Callbacks vs Async/Await en Swift
Hoy en SwiftBeta vamos a ver la diferencia entre usar callbacks o Async/Await. Un pequeño dato es que Async/Await fue introducido en Swift 5.5 y vas a ver por qué es un gran avance.
Vamos a crear una app que haga varias peticiones HTTP a la API de Rick and Morty. Haremos 3 peticiones, pero el único requisito es que se deberán hacer una consecutiva de la otra, de esta manera podremos crear un modelo y éste se mostrará en la vista. Para hacerlo vamos a crear un proyecto con el framework SwiftUI
Creamos proyecto en Xcode
Lo primero de todo que vamos hacer es crear nuestro proyecto en Xcode. Cuando lo creemos vamos a crear como interfaz SwiftUI. Y una vez creado, vamos a crear un nuevo fichero, y lo vamos a llamar ViewModel.
Dentro de nuestro ViewModel vamos a crear un método que se va a encargar de realizar varias peticiones HTTP, en total va a llamar a 3 endpoints diferentes:
- Character, vamos a llamar a este endpoint para sacar la información de Rick. Uno de los principales personajes de Rick & Morty.
- Episodes, vamos a llamar a este endpoint para sacar el título del primer episodio en el que aparece Rick
- Location, vamos a llamar a este endpoint para sacar la información de la localización de Rick, queremos saber la dimensión en la que se encuentra.
Lo primero de todo, vamos a crear estas llamadas, estas peticiones HTTP, con callbacks. Para mapear el JSON de cada petición, crearemos un modelo diferente, para empezar vamos a crear una Struct llamada CharacterModel conformando el protocolo Decodable, y para saber qué propiedades necesitamos, vamos a ver la documentación de la API que vamos a usar:
Vamos a crear nuestro modelo:
struct CharacterModel: Decodable {
let id: Int
let name: String
let image: String
let episode: [String]
let locationName: String
let locationURL: String
enum CodingKeys: String, CodingKey {
case id
case name
case image
case episode
case location
case locationURL = "url"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(Int.self, forKey: .id)
self.name = try container.decode(String.self, forKey: .name)
self.image = try container.decode(String.self, forKey: .image)
self.episode = try container.decode([String].self, forKey: .episode)
let locationContainer = try container.nestedContainer(keyedBy: CodingKeys.self, forKey: .location)
self.locationName = try locationContainer.decode(String.self, forKey: .name)
self.locationURL = try locationContainer.decode(String.self, forKey: .locationURL)
}
}
Una vez hemos creado el modelo, vamos a crear nuestra primera petición con nuestro primer callback:
final class ViewModel {
func executeRequest() {
let characterURL = URL(string: "https://rickandmortyapi.com/api/character/1")!
let jsonDecoder = JSONDecoder()
URLSession.shared.dataTask(with: characterURL) { data, response, error in
let characterModel = try! jsonDecoder.decode(CharacterModel.self, from: data!)
print(characterModel)
// TODO
}.resume()
}
}
Para probar que funciona nuestra petición, vamos a crear una instancia en nuestra vista ContentView:
import SwiftUI
struct ContentView: View {
let viewModel = ViewModel()
var body: some View {
VStack {
Image(systemName: "swift")
.resizable()
.scaledToFit()
.frame(height: 120)
.imageScale(.large)
.foregroundColor(.accentColor)
Text("🤩 ¡Suscríbete a SwiftBeta! 🤩")
}
.onAppear {
viewModel.executeRequest()
}
}
}
Al compilar, vemos que la petición se realiza correctamente. Ahora, vamos a crear otra petición, esta peticón nos dará el título del primer episodio donde aparece Rick (miramos la documentación de la API para crear un modelo):
struct EpisodeModel: Decodable {
let id: Int
let name: String
}
Creamos petición HTTP con Callback (CompletionHandler)
Y ahora creamos la petición HTTP dentro de la que hemos creado hace un momento:
final class ViewModel {
func executeRequest() {
let characterURL = URL(string: "https://rickandmortyapi.com/api/character/1")!
let jsonDecoder = JSONDecoder()
URLSession.shared.dataTask(with: characterURL) { data, response, error in
let characterModel = try! jsonDecoder.decode(CharacterModel.self, from: data!)
let firstEpisode = URL(string: characterModel.episode.first!)!
print(characterModel)
URLSession.shared.dataTask(with: firstEpisode) { data, response, error in
let episodeModel = try! jsonDecoder.decode(EpisodeModel.self, from: data!)
print("Episode Model \(episodeModel)")
}.resume()
}.resume()
}
}
Si ahora compilamos la app, vemos que se realiza correctamente la primera llamada y la segunda. Ahora vamos a crear una tercera petición, queremos saber en qué dimensión se encuentra nuestro personaje. Y para saberlo, debemos relizar una petición a la URL de la propiedad locationURL de nuestro CharacterModel.
Primero creamos LocationModel:
struct LocationModel: Decodable {
let id: Int
let name: String
let dimension: String
}
Y a continuación creamos la petición HTTP dentro de la anterior:
final class ViewModel {
func executeRequest() {
let characterURL = URL(string: "https://rickandmortyapi.com/api/character/1")!
let jsonDecoder = JSONDecoder()
URLSession.shared.dataTask(with: characterURL) { data, response, error in
let characterModel = try! jsonDecoder.decode(CharacterModel.self, from: data!)
let firstEpisode = URL(string: characterModel.episode.first!)!
print(characterModel)
URLSession.shared.dataTask(with: firstEpisode) { data, response, error in
let episodeModel = try! jsonDecoder.decode(EpisodeModel.self, from: data!)
let characterLocation = URL(string: characterModel.locationURL)!
print("Episode Model \(episodeModel)")
URLSession.shared.dataTask(with: characterLocation) { data, response, error in
let locationModel = try! jsonDecoder.decode(LocationModel.self, from: data!)
print("Location Model \(locationModel)")
}.resume()
}.resume()
}.resume()
}
}
Ahora vamos a crear un modelo llamado CharacterBasicInfo, este modelo se creará a partir de la información que hemos obtenido de las 3 peticiones HTTP, y tan solo tendrá 3 propiedades.
struct CharacterBasicInfo {
let name: String
let image: URL?
let firstEpisodeTitle: String
let dimension: String
static var empty: Self {
.init(name: "", image: nil, firstEpisodeTitle: "", dimension: "")
}
}
Ahora que ya tenemos el modelo, vamos a:
- Crear una propiedad @Published con el valor de .empty
- Darle un valor en el último callback, en la última petición HTTP que hemos realizado.
Y el resultado sería el siguiente:
final class ViewModel: ObservableObject {
@Published var characterBasicInfo: CharacterBasicInfo = .empty
func executeRequest() {
let characterURL = URL(string: "https://rickandmortyapi.com/api/character/1")!
let jsonDecoder = JSONDecoder()
URLSession.shared.dataTask(with: characterURL) { data, response, error in
let characterModel = try! jsonDecoder.decode(CharacterModel.self, from: data!)
let firstEpisode = URL(string: characterModel.episode.first!)!
print(characterModel)
URLSession.shared.dataTask(with: firstEpisode) { data, response, error in
let episodeModel = try! jsonDecoder.decode(EpisodeModel.self, from: data!)
let characterLocation = URL(string: characterModel.locationURL)!
print("Episode Model \(episodeModel)")
URLSession.shared.dataTask(with: characterLocation) { data, response, error in
let locationModel = try! jsonDecoder.decode(LocationModel.self, from: data!)
print("Location Model \(locationModel)")
DispatchQueue.main.async {
self.characterBasicInfo = .init(name: characterModel.name,
image: URL(string: characterModel.image),
firstEpisodeTitle: episodeModel.name,
dimension: locationModel.dimension)
}
}.resume()
}.resume()
}.resume()
}
}
Ahora vamos hacer que esta información se muestra en nuestro ContentView:
struct ContentView: View {
@StateObject var viewModel = ViewModel()
var body: some View {
VStack {
Image(systemName: "swift")
.resizable()
.scaledToFit()
.frame(height: 120)
.imageScale(.large)
.foregroundColor(.accentColor)
Text("🤩 ¡Suscríbete a SwiftBeta! 🤩")
VStack {
AsyncImage(url: viewModel.characterBasicInfo.image)
Text("Name: \(viewModel.characterBasicInfo.name)")
Text("First Episode: \(viewModel.characterBasicInfo.firstEpisodeTitle)")
Text("Dimension: \(viewModel.characterBasicInfo.dimension)")
}
.padding(.top, 32)
}
.onAppear {
viewModel.executeRequest()
}
}
}
Fíjate que aunque nuestra app funciona, el método de nuestro ViewModel es muy dificil de seguir y de entender. Hemos tenido que realizar las 3 peticiones de forma consecutiva para construir el modelo CharacterBasicInfo, y visualmente, lo que hemos creado con tantos callbacks, se conoce como Pyramid of Doom.
Si tuvieramos que añadir otra petición dentro de la 3ra, se volvería aún más complicado seguir el código y entender qué ocurre.
Migración a Async/Await en Swift
Con Async/Await podemos simplificarlo muchísimo haciendo que sea mucho más fácil de entender y de seguir para todo developer. Vamos a ver cómo refactoriamos este código.
Lo primero de todo que vamos hacer es comentar el código de las 3 peticiones, y vamos a empezar por la primera petición. Voy a crear la primera petición HTTP con Async/Await y luego comento el código:
func executeRequest() async {
let characterURL = URL(string: "https://rickandmortyapi.com/api/character/1")!
let jsonDecoder = JSONDecoder()
let (data, _) = try! await URLSession.shared.data(from: characterURL)
let characterModel = try! jsonDecoder.decode(CharacterModel.self, from: data)
print("Character Model \(characterModel)")
Aquí vamos a fijarnos en varias partes:
- Hemos usado URLSession.shared.data y la URL para realizar una petición HTTP. Un detalle curioso es que no necesitamos llamar al método resume()
- La firma de nuestra función la hemos modificado, ahora tiene la keyword async
- El resultado de la petición la asignamos a una tupa, el primer campo es data y el segundo el response (por simplicidad del video no estamos teniendo en cuenta la gestión de errores)
- La siguiente línea hace el parseo de data a nuestro modelo de dominio.
Si intentamos compilar tenemos un error, debemos modificar nuestra vista ContentView. Dentro del modificador onAppear, lo modificamos de la siguiente manera:
.onAppear {
Task {
await viewModel.executeRequest()
}
}
Vamos a compilar y vamos a ver qué ocurre. Vemos por consola que el modelo characterModel es correcto, el mismo resultado que teniamos con los callbacks. Ahora vamos a continuar, y vamos a realizar las siguientes 2 peticiones:
final class ViewModel: ObservableObject {
@Published var characterBasicInfo: CharacterBasicInfo = .empty
func executeRequest() async {
let characterURL = URL(string: "https://rickandmortyapi.com/api/character/1")!
let jsonDecoder = JSONDecoder()
let (data, _) = try! await URLSession.shared.data(from: characterURL)
let characterModel = try! jsonDecoder.decode(CharacterModel.self, from: data)
print("Character Model \(characterModel)")
let firstEpisode = URL(string: characterModel.episode.first!)!
let (dataFirstEpisode, _) = try! await URLSession.shared.data(from: firstEpisode)
let episodeModel = try! jsonDecoder.decode(EpisodeModel.self, from: dataFirstEpisode)
print("Episode Model \(episodeModel)")
let characterLocation = URL(string: characterModel.locationURL)!
let (dataLocation, _) = try! await URLSession.shared.data(from: characterLocation)
let locationModel = try! jsonDecoder.decode(LocationModel.self, from: dataLocation)
print("Location Model \(locationModel)")
}
Si compilamos, observamos que las 3 peticiones se realizan correctamente, ahora podemos crear el modelo CharacterBasicInfo.
final class ViewModel: ObservableObject {
@Published var characterBasicInfo: CharacterBasicInfo = .empty
func executeRequest() async {
let characterURL = URL(string: "https://rickandmortyapi.com/api/character/1")!
let jsonDecoder = JSONDecoder()
let (data, _) = try! await URLSession.shared.data(from: characterURL)
let characterModel = try! JSONDecoder().decode(CharacterModel.self, from: data)
print("Character Model \(characterModel)")
let firstEpisode = URL(string: characterModel.episode.first!)!
let (dataFirstEpisode, _) = try! await URLSession.shared.data(from: firstEpisode)
let episodeModel = try! JSONDecoder().decode(EpisodeModel.self, from: dataFirstEpisode)
print("Episode Model \(episodeModel)")
let characterLocation = URL(string: characterModel.locationURL)!
let (dataLocation, _) = try! await URLSession.shared.data(from: characterLocation)
let locationModel = try! JSONDecoder().decode(LocationModel.self, from: dataLocation)
print("Location Model \(locationModel)")
DispatchQueue.main.async {
self.characterBasicInfo = .init(name: characterModel.name,
image: URL(string: characterModel.image),
firstEpisodeTitle: episodeModel.name,
dimension: locationModel.dimension)
}
}
}
Como has podido comprobar, tenemos muchas menos líneas que antes y es mucho más fácil de seguir.
Conclusión
Desde siempre hemos usado callbacks en Swift para realizar peticiones asíncronas. Ahora, con la llegada de Async/Await podemos mejorar nuestro código significativamente, haciéndolo más legible, entendible y fácil de mantener.