Todos lo hemos escrito mil veces:
Task { [weak self] in
await self?.descargarDatos()
}
Usamos [weak self] religiosamente para evitar memory leaks. ✅ Problema resuelto, ¿verdad?
Pues no del todo.
El problema que no sabías que tenías
Imagina esta situación super común:
class PerfilViewController {
func cargarPerfil() {
Task { [weak self] in
let foto = await descargarFotoGigante() // 10 segundos
self?.imagenPerfil.image = foto
}
}
}
El usuario abre el perfil, pero a los 2 segundos se arrepiente y cierra la pantalla.
¿Qué pasa?
- ✅ El ViewController se destruye
- ✅
selfse vuelvenil - ✅ No hay memory leak
- ❌ PERO la descarga sigue corriendo 8 segundos más 🧟
Estás desperdiciando batería, datos y CPU en algo que nadie va a ver.
La demostración: 3 casos reales
Me puse a investigar esto y creé una app de prueba con 3 casos para entender qué estaba pasando realmente.
🔴 Caso A: Memory Leak
El problema clásico que todos conocemos:
class CasoAViewModel: ObservableObject {
private var task: Task<Void, Never>?
func startWork() {
// ❌ NO usa [weak self] - captura fuerte
task = Task {
await self.performWork()
}
}
}
Qué pasa:
- Task retiene → self
- self retiene → task
- Ciclo cerrado ♻️
Mira los logs: después de 22 ticks, NO aparece deinit. El ViewModel nunca se libera. Clásico memory leak.
🔍 Proof en Memory Graph
Si abres el Memory Graph Debugger y navegas varias veces al Caso A, verás esto:
Mira lo que pasa: en el panel izquierdo aparece CasoAViewModel (3) - hay 3 instancias del ViewModel vivas en memoria al mismo tiempo. Cada vez que entras a la vista, se crea una nueva instancia pero nunca se libera porque:
- La Task retiene al ViewModel (captura fuerte)
- El ViewModel retiene a la Task (propiedad
task) - Resultado: Ciclo cerrado que impide que ARC libere la memoria 🔄
🧟 Caso B: Task Zombie (El problema real)
Agregamos [weak self] para evitar el leak:
class CasoBViewModel: ObservableObject {
func startWork() {
// ✅ Usamos [weak self]
Task { [weak self] in
await longWork() // 15 segundos
self?.updateUI()
}
}
}
Mira esto de cerca:
⚡️ Tick 6
💀 deinit llamado - Vista liberada de memoria
⚡️ Tick 7 ← ¿QUÉ? ¡Sigue corriendo!
⚡️ Tick 8
⚡️ Tick 9
...hasta Tick 10
Y aquí está lo interesante:
El ViewModel se destruyó correctamente (deinit aparece). No hay memory leak. ✅
PERO la Task sigue ejecutándose durante 4 ticks más. 🧟
El verdadero problema
Aquí es donde empecé a entender algo importante: [weak self] rompe retain cycles, pero NO cancela el trabajo.
Cuando el ViewModel se destruye, la Task sigue vivita y coleando:
- ❌ La Task sigue consumiendo CPU
- ❌ Sigue usando batería
- ❌ Si era una descarga, sigue descargando
- ❌ Si era un request API, sigue esperando respuesta
Esto es lo que llamo un “Task Zombie”: no hay leak en memoria, pero estás desperdiciando recursos sin darte cuenta.
✅ Caso C: Entendiendo cómo funciona realmente
Después de investigar, descubrí que la solución requiere varias piezas trabajando juntas:
class CasoCViewModel: ObservableObject {
private var task: Task<Void, Never>?
deinit {
// 1. Cancelamos la Task manualmente
task?.cancel()
}
func startWork() {
task = Task { [weak self] in // 2. Seguimos usando [weak self]
// Trabajo principal
for i in 1...10 {
// 3. Checkeamos si nos han cancelado
if Task.isCancelled {
return
}
await self?.performWork()
}
}
}
}
Y en SwiftUI:
.onDisappear {
viewModel.cancelWork() // ← Cancela cuando el usuario cierra la vista
}
¿Por qué tantas piezas?
Al principio me pareció excesivo, pero cada una cumple un rol específico:
[weak self]→ Evita el memory leak (esto ya lo sabíamos)task?.cancel()→ Envía la “señal de cancelación” a la TaskTask.isCancelled→ La Task pregunta “¿me están cancelando?” y se detiene
Cancelar una Task en Swift no la detiene automáticamente. La cancelación es una señal, y el código debe comprobarla y decidir salir. Si no lo hace, la Task seguirá ejecutándose aunque esté “cancelada”.
❌ Cancelada, pero NO cooperativa (Task zombie)
Task {
for i in 1...10 {
print("Working \(i)")
await Task.sleep(for: .seconds(1))
}
}
Aunque llames a .cancel():
- la Task no lo comprueba
- sigue hasta el final
✅ Cancelada y cooperativa
Task {
for i in 1...10 {
if Task.isCancelled {
print("Cancelled, stopping")
return
}
print("Working \(i)")
await Task.sleep(for: .seconds(1))
}
}
Ahora sí:
- recibe la señal
- la detecta
- se detiene
¿Y si tengo recursos que limpiar?
Si tu Task abre conexiones de red, archivos, o cualquier recurso que necesite limpieza explícita, entonces sí usa withTaskCancellationHandler:
task = Task { [weak self] in
await withTaskCancellationHandler {
// Tu trabajo
for i in 1...30 {
if Task.isCancelled { return }
await self?.performWork()
}
} onCancel: {
// Limpia recursos cuando se cancela
stream?.close()
connection?.cancel()
}
}
Pero si no tienes nada que limpiar, no lo necesitas.
UIKit vs SwiftUI
La diferencia principal está en dónde cancelas:
En UIKit:
Cancelas en el deinit de la clase que tiene la Task (normalmente el ViewModel):
class MiViewModel {
private var task: Task<Void, Never>?
deinit {
task?.cancel()
}
}
Cuando el ViewController se destruye, el ViewModel se destruye también, y el deinit cancela la Task.
En SwiftUI:
Cancelas en el .onDisappear de la vista:
.onDisappear {
viewModel.cancelWork()
}
No necesitas el deinit en el ViewModel porque onDisappear ya cancela cuando el usuario cierra la vista.
Ahora sí:
⚡️ Tick 7
👋 Vista desapareció
🛑 Cancelando trabajo manualmente
🧹 Cleanup: liberando recursos...
❌ Trabajo CANCELADO ← Se detuvo aquí
💀 deinit llamado
(FIN - no más ticks)
La Task se detiene inmediatamente cuando cierras la vista. ✅
SwiftUI: cuando .task {} te ahorra todo esto
En SwiftUI, muchas de estas preocupaciones pueden resolverse de forma más sencilla usando el modificador .task {} en la vista.
Las tasks creadas con .task {} están estructuradas y ligadas al ciclo de vida de la vista. Esto significa que SwiftUI las cancela automáticamente cuando la vista desaparece o se recrea, evitando por defecto las “Tasks Zombie” sin necesidad de usar onDisappear.
Ejemplo:
struct PerfilView: View {
@StateObject var viewModel = PerfilViewModel()
let userId: Int
var body: some View {
PerfilUI(state: viewModel.state)
.task {
await viewModel.cargarPerfil(userId: userId)
}
}
}
En este caso:
- La Task se inicia cuando la vista aparece
- Se cancela automáticamente cuando la vista desaparece
- No es necesario guardar la referencia a la Task
- No es necesario cancelar manualmente en onDisappear
Eso sí, la cancelación sigue siendo cooperativa: el código dentro de la Task debe responder correctamente a la cancelación (Task.isCancelled, Task.checkCancellation(), o APIs async que ya cooperen).
Por tanto, en SwiftUI:
- .task {} es la opción recomendada para trabajo ligado a la vista
- Guardar y cancelar Tasks manualmente sigue siendo útil cuando:
- la Task se lanza desde acciones (botones, intents)
- el trabajo no depende estrictamente del ciclo de vida de la vista
- necesitas control explícito (retry, cancel button, etc.)
Las piezas del puzzle
Guardar la referencia a la Task:
private var task: Task<Void, Never>?
Así puedes cancelarla después.
Cancelar cuando ya no la necesitas:
// En UIKit (en la clase que tiene la Task):
deinit {
task?.cancel()
}
// En SwiftUI (en la vista):
.onDisappear {
viewModel.cancelWork()
}
En UIKit cancelas en el deinit de la clase que tiene la Task (ViewModel). En SwiftUI cancelas en .onDisappear de la vista.
La Task tiene que “cooperar” y checkear si la están cancelando:
for i in 1...30 {
if Task.isCancelled {
return // Me detengo aquí
}
await work()
}
Si tienes recursos que limpiar:
Solo si tu Task abre conexiones, archivos u otros recursos que necesiten limpieza explícita:
await withTaskCancellationHandler {
// Tu trabajo
} onCancel: {
// Limpiar recursos
stream?.close()
request?.cancel()
}
Si no tienes nada que limpiar, no lo necesitas.
Mi checklist
¿Tiene sentido que siga corriendo si el usuario cierra la pantalla?
- Si la respuesta es NO → guardo la referencia y cancelo en
deinitoonDisappear - Si la respuesta es SÍ → uso
Task.detached { }(ej: subir una foto en background)
¿La Task hace trabajo en un loop o puede tardar mucho?
- Checkeo
Task.isCancelledperiódicamente para detenerme cuando me cancelen
¿Hay recursos que limpiar? (conexiones de red, archivos abiertos, etc.)
- Uso
withTaskCancellationHandlerpara limpiar recursos cuando me cancelen
Puedes ver el código completo de estos ejemplos en este repo de GitHub.
Recursos
- 📚 Swift Concurrency Documentation
- 🎥 WWDC21: Explore structured concurrency
- 📖 SE-0304: Structured Concurrency
Nos leemos 🫶