Tres errores clásicos de concurrencia en la interfaz
La tarea que nadie cancela, la mutación de la UI fuera del actor principal y el await que devuelve un resultado obsoleto: anatomía, síntomas y defensa estructural de los tres fallos más caros.
Los tres fallos de esta lección comparten una propiedad incómoda: ninguno produce un crash reproducible en el simulador. Uno consume batería en silencio, otro corrompe estado de forma intermitente y el tercero enseña datos correctos que corresponden a una pregunta que el usuario ya no está haciendo. Los tres se detectan tarde, en informes vagos y en reseñas de una estrella, y los tres tienen una defensa estructural que cuesta menos que la primera hora de depuración.
- Reconocer la
Taskhuérfana y sustituirla por trabajo estructurado. - Detectar mutaciones de la interfaz fuera del actor principal y su firma de fallo.
- Entender la carrera del
awaittardío y neutralizarla con una guarda de versión. - Adoptar una lista de verificación aplicable a cualquier pantalla.
La tarea que nadie cancela
Escribir Task { } dentro de una vista crea una tarea desligada del ciclo de vida de esa vista. SwiftUI no conoce su existencia, no guarda su handle y no la cancelará jamás. Si el usuario sale de la pantalla, la tarea sigue corriendo: descarga, decodifica y finalmente asigna a un modelo que ya nadie observa.
// mal: nadie cancela esto
.onAppear {
Task { self.datos = await api.cargar() }
}
// bien: SwiftUI cancela al desaparecer
.task {
datos = await api.cargar()
}
El síntoma es difuso porque no hay error visible: solo tráfico de red que no se explica, consumo de batería que se atribuye al sistema y, en listas largas, decenas de tareas simultáneas compitiendo por el pool cooperativo. En dispositivos con poca memoria el desenlace sí es visible: la app la termina el sistema por presión de recursos.
Cuando de verdad necesitas lanzar trabajo desde un callback o un gesto, guarda el handle y cancélalo explícitamente. Y si vas a relanzar, cancela primero:
@State private var envio: Task<Void, Never>?
Button("Enviar") {
envio?.cancel() // no acumules tareas
envio = Task { await enviarFormulario() }
}
.onDisappear { envio?.cancel() }
La regla de decisión es simple: si el resultado solo importa mientras la pantalla exista, .task. Si importa aunque el usuario se vaya, no pertenece a la vista sino a un actor o servicio con vida propia, y ahí es donde debe vivir el handle.
Tocar la interfaz fuera del actor principal
Este es el error que la concurrencia estricta de Swift convirtió en error de compilación, pero que sigue apareciendo en todo el código que puentea con APIs antiguas: delegados, notificaciones, CLLocationManager, callbacks de librerías en C.
// callback que llega en una cola arbitraria
sesion.completionHandler = { resultado in
self.estado = .exito(resultado) // mutación fuera del actor
}
La firma del fallo es característica y por eso conviene reconocerla: no falla donde escribiste, falla después. Un array del modelo se lee a medio mutar durante el dibujo y produce un índice fuera de rango; una vista se actualiza mientras el sistema recorre la jerarquía y aparece un EXC_BAD_ACCESS en pila de SwiftUI, sin una sola línea tuya visible. El informe de fallo apunta al framework y el bug real está tres archivos más allá.
La defensa correcta no es envolver cada asignación en un salto, sino poner la frontera una sola vez, en el borde donde el mundo antiguo entra en el tuyo:
sesion.completionHandler = { resultado in
Task { @MainActor in
self.estado = .exito(resultado)
}
}
Mejor aún: envolver esa API en una función async con withCheckedContinuation y no volver a ver un callback en el resto de la base de código. Una frontera bien puesta elimina la clase entera de errores en lugar de un caso.
sequenceDiagram participant U as Usuario participant V as Vista participant R as Red U->>V: escribe pe V->>R: peticion A U->>V: escribe peli V->>R: peticion B R-->>V: respuesta B rapida V->>V: pinta resultados de peli R-->>V: respuesta A lenta V->>V: pisa con resultados de pe Note over V: la pantalla miente al usuario
El await que llega tarde
El tercer error es el más sutil y el que menos gente sabe nombrar. Un await no es solo una espera: es un punto donde tu código pierde el control y el mundo puede cambiar. Cuando vuelves, las suposiciones que tenías antes de suspender pueden haber caducado.
El caso canónico es el de arriba: dos peticiones en vuelo y la primera respondiendo después de la segunda. La red no garantiza orden, y el resultado es una pantalla que muestra datos correctos de una pregunta obsoleta. Nada ha fallado técnicamente; la app simplemente miente.
Hay una segunda variante, más traicionera, que ocurre incluso con una sola petición: leer estado antes del await y usarlo después.
// mal: el filtro pudo cambiar durante la espera
func aplicar() async {
let filtro = self.filtroActual
let datos = await api.consultar(filtro)
self.resultados = datos // puede no corresponder a filtroActual
}
La defensa estructural es una guarda de versión: un contador o un identificador que se incrementa en cada disparo y se comprueba al volver. Si la generación cambió, tu resultado es obsoleto y se descarta en silencio.
@MainActor
final class BusquedaViewModel {
private var generacion = 0
var resultados: [Item] = []
func buscar(_ termino: String) async {
generacion += 1
let mia = generacion
let datos = (try? await api.buscar(termino)) ?? []
guard mia == generacion else { return } // llegué tarde: me callo
resultados = datos
}
}
La alternativa preferible cuando encaja, y casi siempre encaja, es dejar que el framework lleve la contabilidad: .task(id: termino) cancela la petición anterior antes de lanzar la nueva y hace innecesario el contador. La guarda de versión queda para los casos donde el disparador no es un cambio de estado observable.
La lectura ingenua de await es temporal: aquí espero. La lectura profesional es epistemológica: aquí caduca lo que sé. Antes del await tenías una fotografía coherente del estado; después tienes una fotografía nueva que puede diferir en cualquier detalle, porque entre medias corrieron gestos del usuario, otras tareas, notificaciones del sistema y quizá una recarga completa del modelo. Todo lo que capturaste antes de suspender es, técnicamente, una creencia sin verificar. Los tres errores de esta lección son en el fondo el mismo error visto desde tres ángulos: la tarea sin cancelar no comprueba si su pregunta sigue vigente; la mutación fuera del actor no comprueba si tiene derecho a escribir; el await tardío no comprueba si su respuesta sigue siendo la respuesta. Interioriza esto y dejarás de escribir los tres: cada vez que veas un await, pregúntate qué asumías antes de esa línea y qué te obliga a revalidarlo después.
Lista de verificación
Ciclo de vida
Cada trabajo asíncrono tiene un dueño explícito. Si el dueño es la vista, es .task. Si no, hay un handle guardado y alguien lo cancela.
Aislamiento
Cada mutación de estado observable ocurre en el actor principal, y los puentes con APIs antiguas se cruzan en un solo sitio por API.
Vigencia
Cada resultado que vuelve de un await se valida contra el estado actual antes de publicarse, por generación o por identidad de tarea.
Ninguno de los tres puntos requiere herramientas especiales ni disciplina heroica: son preguntas que se responden leyendo el código en voz alta durante una revisión. Su valor es que convierten fallos intermitentes e irreproducibles —la peor categoría que existe— en decisiones de diseño visibles y discutibles antes de escribir la primera línea.
- Busca en tu proyecto todos los
Task {dentro de vistas y decide, uno a uno, si deberían ser.tasko llevar handle. - Activa la concurrencia estricta y arregla las mutaciones de estado que el compilador señale fuera del actor principal.
- Envuelve una API de callback con
withCheckedContinuationy elimina la frontera manual repetida. - Simula el
awaittardío con retardos artificiales distintos y observa cómo la pantalla muestra datos obsoletos. - Corrígelo primero con una guarda de generación y después con
.task(id:), y argumenta cuál prefieres y por qué.