Ejemplo práctico de Async/Await en Swift
Ejemplo práctico de Async/Await en Swift

Callbacks vs Async/Await en Swift

Async/Await en Swift nos permite tener un código mucho más lineal que usando los típicos callbacks (o completion handlers). Con Async/Await podemos mejorar nuestro código para que sea más legible, entendible y fácil de mantener.

SwiftBeta

Tabla de contenido

Callbacks versus 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:

Documentation
This documentation will help you get familiar with the resources of the Rick and Morty API and show you how to make different queries.

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)
    }
}
Model CharacterModel que usaremos para transformar el JSON de la petición HTTPc

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()
    }
}
Creamos nuetras primera petición HTTP con URLSession

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()
        }
    }
}
Llamamos al método executeRequest cuando la vista ContentView aparezca

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 el modelo de la segunda petición HTTP

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()
    }
}
Creamos la segunda petición HTTP con URLSession

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
}
Creamos el modelo de la tercera petición HTTP

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()
    }
}
Creamos la tercera petición HTTP con URLSession

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: "")
    }
}
Finalmente, creamos un modelo que utilizará la vista ContentView para mostrar datos

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()
    }
}
Así es como quedaría el método executeRequest usando Callbacks (CompletionHandlers)

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()
        }
    }
}
Actualizamos la vista ContentView para mostrar los datos del modelo CharacterBasicInfo

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)")
Empezamos a migrar/refactorizar nuestras peticiones HTTP con Aync/Await

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()
    }
}
Al usar Async/Await debemos utilizar Task y await en la vista ContentView

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)")
}
Migramos todas las demás peticiones HTTP a Async/Await en Swift

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)
        }
    }
}
Finalmente, rellenamos el modelo CharacterBasicInfo que se mostrará en la vista ContentView

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.

Si quieres seguir aprendiendo sobre SwiftUI, Swift, Xcode, o cualquier tema relacionado con el ecosistema Apple