wandres.dev
CONCURRENCIA EN LA UI · MainActor y tareas

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.

⏱ 16 min

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.

🎯 Al terminar esta lección sabrás
  • Reconocer la Task huérfana y sustituirla por trabajo estructurado.
  • Detectar mutaciones de la interfaz fuera del actor principal y su firma de fallo.
  • Entender la carrera del await tardí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.

Todo await es una frontera de confianza

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.

⚔️ Caza los tres
  1. Busca en tu proyecto todos los Task { dentro de vistas y decide, uno a uno, si deberían ser .task o llevar handle.
  2. Activa la concurrencia estricta y arregla las mutaciones de estado que el compilador señale fuera del actor principal.
  3. Envuelve una API de callback con withCheckedContinuation y elimina la frontera manual repetida.
  4. Simula el await tardío con retardos artificiales distintos y observa cómo la pantalla muestra datos obsoletos.
  5. Corrígelo primero con una guarda de generación y después con .task(id:), y argumenta cuál prefieres y por qué.