Índice de contenidos
- Introducción
- Entorno
- De dónde venimos…
- Async/await al rescate
- Transformando a async/await
- Cómo funciona
- Agrupando operaciones
- Conclusiones
1. Introducción
Con la release de Swift 5.5 se introdujo una de las características del lenguaje que los desarrolladores llevábamos más tiempo esperando, async/await. Muy similares al concepto de las suspend functions de Kotlin, nos permite tratar código asíncrono como si fuese síncrono, lo cual aumenta de una forma increíble la legibilidad del código.
2. Entorno
El tutorial está escrito usando el siguiente entorno:
- Hardware: Portátil MacBook Pro 15″ (2.5 GHz Intel Core i7, 16 GB 1600 MHz DDR3)
- Sistema Operativo: macOS 12.1 Monterey
- Xcode 13.2
- Swift 5.5
3. De dónde venimos…
Hasta iOS 13 que se introdujo la librería Combine, todo el API del SDK de iOS empleaba callbacks para las operaciones asíncronas, de forma que por ejemplo para realizar una petición rest tendríamos que utilizar algo como esto.
func requestSomething() { let firstRequest = URLRequest(url: URL(string: "https://jsonplaceholder.typicode.com/posts/10")!) let dataTask = urlSession.dataTask(with: firstRequest) { data, urlResponse, error in print("---FIRST REQUEST----") print(data ?? "No data") print(urlResponse ?? "No response") print(error ?? "No error") let secondRequest = URLRequest(url: URL(string: "https://jsonplaceholder.typicode.com/users/")!) let secondDataTask = urlSession.dataTask(with: secondRequest) { data, urlResponse, error in print("---SECOND REQUEST----") print(data ?? "No data") print(urlResponse ?? "No response") print(error ?? "No error") } secondDataTask.resume() } dataTask.resume() }
El problema de esto es que en cuanto había que encadenar multiples peticiones, acabábamos sufriendo el llamado «callback hell», «pyramid of doom», u otros nombres igual de poco alentadores.
Para solventar esta problemática surgieron varias librerías y soluciones de terceros, como las promesas, RxSwift, etc. Con la llegada de iOS 13 llegaron Swift UI, y puesto que se trata de una capa de presentación declarativo y reactivo, junto a ello llego Combine, una librería reactiva, y lo anterior podía transformarse en algo como esto:
func requestSomethingWithCombine() { let firstRequest = URLRequest(url: URL(string: "https://jsonplaceholder.typicode.com/posts/10")!) let secondRequest = URLRequest(url: URL(string: "https://jsonplaceholder.typicode.com/users/")!) let firstPublisher = urlSession.dataTaskPublisher(for: firstRequest) let secondPublisher = urlSession.dataTaskPublisher(for: secondRequest) Publishers .Zip(firstPublisher, secondPublisher) .sink { completion in print(completion) } receiveValue: { firstResult, secondResult in print(firstResult) print(secondResult) } .store(in: &cancellables) }
El principal «problema» de Combine es que tiene una curva de aprendizaje inicial muy pronunciada, y cuesta cogerle el truco al principio. Ademas la cosa puede ponerse fea muy rápidamente, en estos ejemplos no se están manejando errores ni se depende de valores anteriores para realizar nuevas requests…
4. Async/await al rescate
Con async/await los dos ejemplos anteriores se reducen a esto:
func requestSomething() async throws { let firstRequest = URLRequest(url: URL(string: "https://jsonplaceholder.typicode.com/posts/10")!) let secondRequest = URLRequest(url: URL(string: "https://jsonplaceholder.typicode.com/users/")!) print(try await urlSession.data(for: firstRequest)) print(try await urlSession.data(for: secondRequest)) }
Otra forma de escribir el mismo fragmento seria la siguiente (nota: por algún motivo no funciona en un playground en el momento de escribir este tutorial):
func requestSomething2() async throws { let firstRequest = URLRequest(url: URL(string: "https://jsonplaceholder.typicode.com/posts/10")!) let secondRequest = URLRequest(url: URL(string: "https://jsonplaceholder.typicode.com/users/")!) async let firstResult = urlSession.data(for: firstRequest) async let secondResult = urlSession.data(for: secondRequest) print(try await firstResult) print(try await secondResult) }
En este caso hay una diferencia sutil, mientras el ejemplo anterior realiza la petición de red y espera, en este segundo caso se realizan las peticiones de forma concurrente y se espera al resultado de ambas para continuar.
5. Transformando a async/await
En el caso que tengamos un fragmento de código que utilice callbacks, combine o cualquier otra estrategia para ejecutar código asíncrono, podemos transformarlo a async/await. Por ejemplo, no existe una version async de dataTask en iOS < 15, para esos podemos transformarla de la siguiente forma:
func asyncData(for request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse) { try await withCheckedThrowingContinuation { continuation in let dataTask = urlSession.dataTask(with: request) { data, response, error in if let error = error { continuation.resume(throwing: error) } else if let data = data, let response = response { continuation.resume(returning: (data, response)) } else { continuation.resume(throwing: URLError(.badServerResponse)) } } dataTask.resume() } }
Lo que hace es emplear un patrón visitor de forma que mediante el objeto continuation se indica el resultado de la tarea asíncrona. Hay que tener especial cuidado de cubrir toda la casuística o el hilo del que dependa la tarea no despertará.
6. Cómo funciona
Lo que hace swift internamente es que cada vez que se invoca algo con await, se suspende el hilo y se espera a que la tarea asíncrona finalice. Cuando la tarea de al que depende se completa esta llama a resume y hace que el hilo se despierte. En el punto anterior se puede ver este mecanismo claramente.
7. Agrupando operaciones
Un caso de uso común que se nos puede presentar es tener que realizar nuevas operaciones que dependen de un resultado anterior. Tomemos el siguiente ejemplo, tenemos un endpoint que nos devuelve Posts, con su título y una lista de IDs de comentarios, y por otro lado un endpoint que nos devuelve un comentario concreto por su id. Si queremos obtener todos los posts y sus comentarios podríamos hacer lo siguiente:
struct PostDTO { var title: String var commentIds: [UUID] } struct Comment { var msg: String } struct Post { var title: String var comments: [Comment] } func fetchPostDTOs() async -> [PostDTO] { return [ PostDTO(title: "Post 1", commentIds: [UUID(), UUID(), UUID()]) ] } func fetchComment(uuid: UUID) async -> Comment { return Comment(msg: "Test \(uuid)") } func fetchPostsAndComments() async -> [Post] { let dtos = await fetchPostDTOs() var result: [Post] = [] for dto in dtos { let comments: [Comment] = await withTaskGroup(of: Comment.self) { group in dto.commentIds.forEach { uuid in group.addTask { await fetchComment(uuid: uuid) } } var collect: [Comment] = [] for await comment in group { collect.append(comment) } return collect } result.append(Post(title: dto.title, comments: comments)) } return result }
Con este mecanismo se añaden todas las operaciones a un grupo, que las irá ejecutando en paralelo. Luego es simplemente iterar sobre las operaciones del grupo y esperar a que terminen.
8. Conclusiones
El mecanismo de async/await es una mejora bastante importante del lenguaje. Hace que sea bastante más fácil de seguir el flujo de nuestro código y también lo hace mas legible, y se venía echando en falta y era muy demandado sobre todo después de haber trabajado con las suspend functions de kotlin o con javascript.
De donde sale ese urlSession ? … el código de ejemplo no está bien.
Buenas Vicente, los fragmentos de código son meramente ilustrativos, para este tutorial se utilizo un Playground, asi que el URLSession esta simplemente declarado justo antes que las funciones:
let urlSession = URLSession(configuration: .default)
var cancellables = Set()
Bien podrían ser properties de una clase que albergase esos métodos
Gracias por la información.
Es el primer sitio que veo con este contenido en español