Los memory leaks son de esos errores que no dan la cara. La app no se cierra, los tests pasan y todo parece ir bien… hasta que alguien la usa un buen rato y empieza a ir lenta, a gastar batería o, directamente, iOS la cierra por falta de memoria.
Swift 6 te protege de muchos problemas de concurrencia, pero no de esto: ARC sigue dependiendo de cómo conectes tus objetos. En este artículo vemos de dónde salen los retain cycles, cuándo usar weak y cuándo unowned, y cómo detectarlos con Xcode antes de que los encuentren tus usuarios.
¿Cómo funciona ARC (Automatic Reference Counting) en Swift 6?
A diferencia de lenguajes como Java o C#, que emplean un recolector de basura (Garbage Collector) en segundo plano, Swift utiliza el conteo automático de referencias (ARC).
ARC funciona bajo un principio simple pero riguroso:
- Cada vez que creas una nueva instancia de una clase (tipo por referencia), ARC reserva un bloque de memoria para almacenar la información de dicha instancia.
- ARC lleva la cuenta de cuántas propiedades, constantes o variables mantienen una referencia fuerte (strong reference) hacia esa instancia.
- Mientras el contador de referencias sea al menos 1, la instancia se mantendrá viva en memoria.
- En el momento exacto en que el contador llega a 0, se ejecuta el método
deinitdel objeto y, a continuación, su memoria se libera.
En Swift 6, el compilador es mucho más estricto garantizando la seguridad en el acceso concurrente a la memoria (evitando condiciones de carrera o data races), pero la regla fundamental de ARC sigue intacta: si dos objetos se apuntan mutuamente con referencias fuertes, ninguno se liberará jamás.
Retain Cycles y Memory Leaks: El enemigo silencioso de tu app
Un Memory Leak (fuga de memoria) ocurre cuando un fragmento de memoria asignado ya no es accesible por la aplicación, pero el sistema operativo no puede recuperarlo porque su contador de referencias nunca llega a cero.
La causa más común de una fuga de memoria en Swift es un Retain Cycle (ciclo de retención). Esto sucede cuando el Objeto A mantiene una referencia fuerte hacia el Objeto B, y simultáneamente el Objeto B mantiene una referencia fuerte hacia el Objeto A.
¿Cuáles son las consecuencias de un ciclo de retención?
- Consumo desmedido de RAM: Vistas o controladores enteros quedan atrapados en memoria incluso después de haberse cerrado.
- Comportamiento inesperado: Tareas asíncronas, observadores o temporizadores continúan ejecutándose en segundo plano sobre instancias zombies.
- Crashes por OOM (Out Of Memory): El sistema operativo termina el proceso de la aplicación cuando supera el límite de memoria asignado.
Rompiendo ciclos: La diferencia clave entre weak y unowned
Para evitar que dos objetos creen un ciclo de retención, Swift nos ofrece dos modificadores de referencia: weak y unowned. Ambos permiten apuntar a una instancia sin incrementar su contador de referencias ARC.
1. Referencias weak (Débiles)
Una referencia declarada como weak no protege al objeto de ser desasignado por ARC. Cuando el objeto al que apunta se destruye, ARC automáticamente establece la variable en nil.
- Naturaleza Opcional: Una referencia
weaksiempre debe declararse como opcional (Optional) y como variable mutable (var). - Seguridad: Es la opción más segura contra caídas de la app, ya que nunca apuntará a una dirección de memoria inválida (dangling pointer).
class PerfilUsuario {
let nombre: String
weak var delegado: PerfilDelegado? // Referencia débil (PerfilDelegado debe ser un protocolo AnyObject)
init(nombre: String) {
self.nombre = nombre
}
deinit {
print("PerfilUsuario \(nombre) liberado de memoria.")
}
}
2. Referencias unowned (Sin dueño)
Al igual que weak, una referencia unowned no incrementa el contador de referencias. Sin embargo, tiene una diferencia crítica: asume que el objeto al que apunta nunca será nil durante el tiempo de vida de la referencia.
- No es opcional por defecto: Se utiliza habitualmente como una variable o constante no opcional.
- Riesgo elevado: Si intentas acceder a una referencia
unowneddespués de que el objeto original ha sido destruido, tu aplicación sufrirá un crash instantáneo en tiempo de ejecución. - Uso ideal: Relaciones donde dos objetos tienen el mismo ciclo de vida o donde el objeto secundario no puede existir sin el principal (por ejemplo, una tarjeta de crédito ligada de por vida a un cliente).
class Cliente {
let nombre: String
var tarjeta: TarjetaCredito?
init(nombre: String) { self.nombre = nombre }
deinit { print("\(nombre) liberado") }
}
class TarjetaCredito {
let numero: String
unowned let titular: Cliente // la tarjeta nunca vive más que su cliente
init(numero: String, titular: Cliente) {
self.numero = numero
self.titular = titular
}
deinit { print("Tarjeta \(numero) liberada") }
}
var ana: Cliente? = Cliente(nombre: "Ana")
ana?.tarjeta = TarjetaCredito(numero: "**** 4242", titular: ana!)
ana = nil // se liberan los dos objetos: no hay ciclo
Resumen comparativo: ¿Cuándo usar cada una?
A continuación, una guía rápida para tomar la decisión correcta al diseñar tus tipos en Swift:
- Usa
weakcuando:- La referencia puede convertirse en
nilen algún momento del ciclo de vida de la aplicación. - El otro objeto tiene una vida útil más corta.
- Implementas el patrón Delegado (Delegation Pattern).
- Capturas
selfdentro de cierres (closures) que escapan y pueden sobrevivir al llamador.
- La referencia puede convertirse en
- Usa
unownedcuando:- Ambos objetos se crean al mismo tiempo y se destruyen al mismo tiempo.
- Estás completamente seguro de que la referencia jamás será
nilmientras sea invocada. - Deseas evitar el desenvolver (unwrap) constantes opcionales por comodidad de sintaxis, siempre asumiendo el riesgo técnico.
Casos de uso comunes y cómo solucionarlos
Caso 1: El patrón Delegate
El error clásico al implementar delegados es declararlos como fuertes. Para corregirlo, siempre declara los protocolos de delegado como restringidos a clases (AnyObject) y la propiedad como weak.
protocol DescargaDelegate: AnyObject {
func descargaFinalizada()
}
class GestorDescargas {
weak var delegate: DescargaDelegate? // Evita el retain cycle
}
Caso 2: Closures y listas de captura (Capture Lists)
Los closures en Swift son tipos por referencia. Si una clase guarda un closure en una propiedad, y ese closure invoca a self, ambos se mantendrán vivos mutuamente.
Para solucionarlo, utilizamos listas de captura:
class ServicioRed {
var alFinalizar: (() -> Void)?
func ejecutarPeticion() {
// Usamos [weak self] dentro de la lista de captura
alFinalizar = { [weak self] in
guard let self else { return }
self.actualizarInterfaz()
}
}
func actualizarInterfaz() {
print("Datos cargados correctamente")
}
}
Swift 6: Concurrencia y el ciclo de vida de las Tasks
En Swift 6, con la activación total del compilador de concurrencia segura, surgen nuevas consideraciones de memoria:
- Captura implícita en tareas no estructuradas: Un bloque
Task { ... }creado dentro de un objeto es una tarea no estructurada y capturaselfde forma fuerte (por eso el compilador no te obliga a escribirself.). El objeto seguirá vivo hasta que la tarea termine. - Tareas de larga duración: Si lanzas una tarea asíncrona que tarda mucho tiempo (o un bucle infinito en un
AsyncSequence) y capturasselffuertemente, ese controlador o modelo no se liberará hasta que la tarea finalice o sea cancelada. - Buenas prácticas: Utiliza
[weak self]explícitamente en tareas no estructuradas si no necesitas que la vista siga viva mientras la petición se procesa, y compruebaguard let self else { return }al reanudar. Para tareas ligadas a una vista de SwiftUI, el modificador.tasklas cancela automáticamente al desaparecer la vista.
Un ejemplo típico es un temporizador que guarda su tarea en una propiedad. Aquí sí habría un ciclo (el objeto retiene la tarea y la tarea retiene al objeto), así que capturamos self de forma débil y lo recuperamos solo dentro de cada vuelta del bucle:
@MainActor
final class CronometroViewModel {
private var tarea: Task<Void, Never>?
var segundos = 0
func iniciar() {
tarea = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(1))
// Se recupera self en cada vuelta y se suelta al final de la iteración
guard let self else { return }
self.segundos += 1
}
}
}
func detener() {
tarea?.cancel()
tarea = nil
}
}
Ojo con escribir guard let self antes del bucle: el objeto quedaría retenido mientras dure la tarea y volveríamos al problema. Llama a detener(), por ejemplo desde .onDisappear, cuando ya no necesites el cronómetro.
Herramientas imprescindibles en Xcode para detectar leaks
Prevenir con buenas prácticas de arquitectura es el primer paso, pero revisar la app con herramientas es lo que de verdad los encuentra. Xcode incluye herramientas profesionales integradas:
- Memory Graph Debugger: El depurador visual más rápido. Al presionar el ícono de los tres círculos interconectados en la barra de depuración de Xcode, puedes congelar la app y visualizar qué objetos están vivos, sus referencias y si Xcode detecta fugas automáticas (marcadas con un ícono de advertencia púrpura).
- Instruments (Herramienta Leaks): Permite ejecutar tu aplicación mientras se analiza la memoria en tiempo real, registrando cada asignación y mostrando la traza de llamadas (backtrace) del lugar donde se creó el objeto que se ha quedado sin liberar.
Errores típicos que conviene evitar
- Delegados fuertes. Un
var delegatesinweakes el ciclo clásico entre una vista y su controlador. - Closures guardadas que usan self. Si la closure se guarda en una propiedad del propio objeto, necesita
[weak self]. - Timer y NotificationCenter. Un
Timercontarget: selfo un observador con bloque mantienen vivo el objeto si no los invalidas. - Tareas guardadas en propiedades. Una
Taskde larga duración que capturaselfretrasa su liberación hasta que termina.
Conclusión
No hace falta obsesionarse con [weak self] en cada closure: si no se guarda en ninguna parte, no hay ciclo. Donde sí hay que pararse a pensar es en delegados, closures que guardas en propiedades, temporizadores y tareas de larga duración.
Un hábito sencillo que funciona: añade un print en el deinit de tus ViewModels y controladores mientras desarrollas. Si cierras una pantalla y no ves el mensaje, tienes un ciclo. Y antes de cada versión, dale una vuelta con el Memory Graph Debugger.
Artículos relacionados: Tutorial: Cómo debugear Memory Leaks en SwiftUI · Swift Concurrency: Guía Completa de Task, Async/Await y Actors · Guía de supervivencia para Swift 6: Cómo migrar un proyecto grande sin romper el compilador