Email copiado — support@tuurt.com
Cargando experiencia
ios · 02 de septiembre de 2026 · 8 min

Structured Concurrency en Swift: migrar callbacks en una app iOS sin romper la UI

Reemplazar completion handlers por async/await no es solo un cambio de sintaxis: es atar la vida de cada tarea al ciclo de vida de la vista. Repasamos Task, async let, TaskGroup y el bug de un Task suelto que actualizaba una pantalla que ya no existía.

Por Equipo Tuurt

Structured Concurrency en Swift: migrar callbacks en una app iOS sin romper la UI

Migrar una app iOS de completion handlers a async/await suena, en el papel, como un cambio mecánico: reemplazar cada closure de cierre por una función async y seguir. En la práctica, la parte que sale mal no es la sintaxis, es el ciclo de vida. Un completion handler que se dispara después de que la vista que lo originó ya desapareció es un bug conocido: en el peor caso algo intenta actualizar una UI que ya no está, y comprobar que el weak self siga vivo llevaba años siendo parte del vocabulario de cualquier equipo iOS. Con Task, ese mismo error tiene una forma distinta y, sin conocer structured concurrency de verdad, más fácil de introducir sin darse cuenta.

Por qué esto no es solo "azúcar sintáctico"

La promesa de async/await que más se vende es la legibilidad: código asíncrono que se lee como código síncrono, sin pirámides de closures anidados. Esa parte es real, pero no es la que cambia el comportamiento de la app. Lo que cambia el comportamiento es la cancelación estructurada: en GCD, cancelar una operación en curso es responsabilidad explícita de quien la lanzó, y si nadie la implementó, la operación sigue corriendo aunque ya no le importe a nadie el resultado. Con Task, la cancelación se propaga por la jerarquía: si un Task padre se cancela, sus tareas hijas -las creadas dentro de un TaskGroup o con async let- reciben esa cancelación automáticamente. Eso es structured concurrency: la vida de una tarea asíncrona queda atada a la vida de la tarea que la creó, no flotando libre en el sistema.

Migrar sin entender esto lleva a un resultado peor que quedarse con completion handlers: código con la sintaxis nueva pero el mismo comportamiento no estructurado de antes, sin ninguna de las dos ventajas.

El caso simple: envolver una API de completion handler existente

No todo se reescribe de una vez. La mayoría de los SDKs de terceros que seguimos usando -frameworks de analítica, algún SDK de pagos, wrappers viejos sobre URLSession escritos antes de Swift Concurrency- siguen exponiendo completion handlers. La forma correcta de integrarlos no es reescribir el SDK, es envolver la llamada una sola vez con withCheckedThrowingContinuation:

func fetchUserProfile(id: String) async throws -> UserProfile {
    try await withCheckedThrowingContinuation { continuation in
        legacyAPIClient.fetchProfile(userId: id) { result in
            switch result {
            case .success(let profile):
                continuation.resume(returning: profile)
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
    }
}

El detalle que cuesta más caro pasar por alto aquí es que resume tiene que llamarse exactamente una vez por cada camino de ejecución. Si el completion handler original puede, en algún caso límite, llamarse dos veces -un bug real que encontramos en un SDK de terceros, que reintentaba internamente y a veces disparaba el callback dos veces tras un timeout- el runtime de Swift lo detecta en tiempo de ejecución y produce un crash con un mensaje explícito. Es un fallo incómodo de encontrar en producción, pero preferible a la alternativa silenciosa: sin ese chequeo, dos resume habrían dejado el continuation en un estado indefinido sin ningún aviso.

Async let y TaskGroup: paralelismo real, no solo azúcar

El patrón de completion handlers que más código feo generaba era pedir dos o tres recursos en paralelo y esperar a que todos terminaran antes de continuar, normalmente con DispatchGroup:

let group = DispatchGroup()
var profile: UserProfile?
var avatar: UIImage?

group.enter()
fetchUserProfile(id: id) { result in
    profile = try? result.get()
    group.leave()
}

group.enter()
fetchAvatar(id: id) { image in
    avatar = image
    group.leave()
}

group.notify(queue: .main) {
    // usar profile y avatar
}

Con async let, el mismo paralelismo queda expresado sin estado mutable compartido ni un DispatchGroup que hay que balancear a mano:

async let profile = fetchUserProfile(id: id)
async let avatar = fetchAvatar(id: id)

let (loadedProfile, loadedAvatar) = try await (profile, avatar)

Ambas llamadas arrancan de inmediato y corren en paralelo; el await conjunto espera a las dos. Cuando el número de tareas paralelas no se conoce en tiempo de compilación -por ejemplo, descargar una miniatura por cada elemento de una lista cuyo tamaño depende de la respuesta del servidor- async let no alcanza porque exige declarar cada tarea por separado. Ahí es donde entra TaskGroup:

func fetchThumbnails(for items: [GalleryItem]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: UIImage.self) { group in
        for item in items {
            group.addTask {
                try await self.downloadThumbnail(url: item.thumbnailURL)
            }
        }

        var images: [UIImage] = []
        for try await image in group {
            images.append(image)
        }
        return images
    }
}

Si una sola descarga lanza un error, withThrowingTaskGroup cancela automáticamente el resto de las tareas del grupo antes de propagar el error hacia arriba. Con DispatchGroup esa cancelación había que escribirla a mano, y en la práctica casi nunca se escribía: un elemento fallido dejaba a los demás descargando igual, para un resultado que de todas formas se iba a descartar.

Lo que no funcionó: Task sin atarlo al ciclo de vida de la vista

El primer intento de migración que hicimos en una pantalla de detalle de producto reemplazó un completion handler por esto, dentro de viewDidLoad:

override func viewDidLoad() {
    super.viewDidLoad()
    Task {
        let details = try await productService.fetchDetails(id: productId)
        self.updateUI(with: details)
    }
}

Compilaba, funcionaba en el caso feliz, y pasó la revisión de código sin que nadie marcara el problema. El problema apareció en pruebas manuales: si el usuario abría la pantalla de detalle y la cerraba rápido -antes de que fetchDetails terminara- el Task seguía corriendo. Cuando la petición de red finalmente respondía, updateUI se ejecutaba sobre una vista ya fuera de la jerarquía visible. No siempre crasheaba: la mayoría de las veces solo desperdiciaba una petición de red y un poco de CPU actualizando algo que nadie iba a ver, pero en un caso encontramos un crash real porque updateUI asumía que un IBOutlet seguía enlazado, y en ese punto del ciclo de vida ya no lo estaba.

La causa era que ese Task nunca estuvo atado a nada: es un Task no estructurado, creado suelto en viewDidLoad, sin relación con la vida del controller más allá de que self quedaba capturado fuerte dentro de la closure. Nada cancelaba esa tarea cuando la vista desaparecía.

La corrección fue guardar una referencia al Task y cancelarlo explícitamente:

private var detailsTask: Task<Void, Never>?

override func viewDidLoad() {
    super.viewDidLoad()
    detailsTask = Task {
        do {
            let details = try await productService.fetchDetails(id: productId)
            self.updateUI(with: details)
        } catch {
            // manejar error de forma explícita
        }
    }
}

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    detailsTask?.cancel()
}

Cancelar el Task no interrumpe fetchDetails a mitad de la petición de red por sí solo: URLSession respeta la cancelación cooperativa, así que si fetchDetails está implementado sobre URLSession.data(for:), la cancelación sí corta la petición en curso. Pero si en algún punto del código hay un bucle o un cómputo largo, hace falta comprobar Task.isCancelled explícitamente para que la cancelación tenga efecto real ahí dentro, en vez de asumir que basta con llamar a .cancel() en la referencia externa.

En SwiftUI el mismo problema se resuelve de forma más directa con el modificador .task, que ata automáticamente la vida de la tarea a la vida de la vista y la cancela sola cuando la vista desaparece, sin necesidad de guardar la referencia a mano:

struct ProductDetailView: View {
    let productId: String
    @State private var details: ProductDetails?

    var body: some View {
        content
            .task {
                details = try? await productService.fetchDetails(id: productId)
            }
    }
}

MainActor en vez de DispatchQueue.main.async

El otro cambio que generó dudas fue dónde poner las actualizaciones de UI. El patrón viejo era terminar cada completion handler con DispatchQueue.main.async. La forma equivalente y más segura en Swift Concurrency es anotar con @MainActor la función o la propiedad que toca UI, en vez de acordarse de envolver cada actualización individual:

@MainActor
func updateUI(with details: ProductDetails) {
    titleLabel.text = details.name
    priceLabel.text = details.formattedPrice
}

El compilador, no una convención de equipo, es quien impide que updateUI se llame fuera del main actor. Eso convierte un error que antes solo se detectaba en tiempo de ejecución -o ni siquiera, si el bug era intermitente- en un error de compilación si alguien intenta llamarla desde un contexto que no lo garantiza.

Dónde trazamos la línea de la migración

No convertimos a async/await código que ya funciona solo porque es posible hacerlo. Migramos las pantallas donde el anidamiento de completion handlers hacía difícil razonar sobre el orden de ejecución o sobre qué pasaba si una de las llamadas fallaba a mitad de camino, que es exactamente donde structured concurrency reduce bugs reales. Donde queda una API de completion handler aislada y estable, la envolvemos una vez con withCheckedThrowingContinuation en el borde del sistema y dejamos que el resto de la app hable en async/await, en vez de mezclar ambos estilos pantalla por pantalla sin un criterio consistente sobre cuál manda.

swift async-await ios concurrencia
← Volver al blog