Retain Cycle es una situación en ARC donde dos o más objetos se referencian entre sí a través de referencias fuertes, formando un bucle cerrado. Según la Apple Memory Management Guide, 2026, un retain cycle bloquea la liberación de todos los objetos en el ciclo porque cada uno tiene un retain count ≥ 1. A diferencia de una fuga de memoria en GC, un retain cycle garantiza que los objetos permanezcan vivos mientras al menos un participante externo del ciclo esté vivo — e incluso después de perder todas las referencias externas, si el ciclo está aislado.
Puntos clave
Retain Cycle es una situación en la que dos o más objetos se poseen mutuamente a través de referencias fuertes, creando un grafo de dependencias cerrado. ARC no puede liberar ninguno de estos objetos porque el retain count de cada uno es siempre ≥ 1: el objeto A retiene a B, B retiene a A, y sus contadores nunca llegan a cero.
El problema ocurre exclusivamente en sistemas con conteo de referencias (ARC, MRR). En Garbage Collection, el recolector determina la inalcanzabilidad a través del grafo de referencias desde el conjunto raíz — los ciclos no son un obstáculo. En ARC, sin embargo, un ciclo equivale a una fuga, porque la liberación determinista mediante conteo no puede resolver dependencias circulares.
Según la WWDC 2012 Session 406, retain cycle es la causa más común de fugas de memoria en aplicaciones Objective-C y Swift. Escenarios típicos: relaciones padre-hijo con delegados, closures que capturan self y arquitecturas en capas con relaciones bidireccionales.
Examinemos escenarios clásicos de retain cycle que todo desarrollador iOS encuentra. Comprender estos patrones es la base para escribir código seguro con ARC.
Escenario clásico: un objeto padre (por ejemplo, UIViewController) crea un objeto hijo y se convierte en su delegado. Si ambos usan referencias fuertes, se produce un retain cycle. La solución — el delegado debe ser weak.
// ERROR: retain cycle a través de delegate fuerte
protocol ChildDelegate: AnyObject { }
class ParentVC: UIViewController, ChildDelegate {
var child: ChildVC?
func showChild() {
child = ChildVC()
child?.delegate = self // Parent → Child (strong)
} // Child → Parent (strong mediante delegate)
} // ⚠️ ¡Retain cycle!
class ChildVC: UIViewController {
var delegate: ChildDelegate? // ❌ strong por defecto
}
// SOLUCIÓN: delegate weak
class ChildVC: UIViewController {
weak var delegate: ChildDelegate? // ✅ weak — no retiene
}
En el ejemplo, ParentVC mantiene una referencia fuerte a ChildVC a través de su propiedad child. ChildVC mantiene una referencia fuerte a ParentVC a través de delegate. El ciclo está cerrado. Solución: weak var delegate — la referencia no aumenta el retain count, y ParentVC puede liberarse.
NSTimer es una fuente clásica de retain cycles. El timer retiene su target (normalmente self), y el target retiene el timer a través de una propiedad. Incluso si el timer es de un solo uso, no se liberará hasta que se llame a invalidate. Solución: llamar siempre a timer.invalidate() en deinit o viewDidDisappear.
En arquitecturas con propiedad en cascada (coordinadores, enrutadores), suelen producirse ciclos de múltiples pasos: Coordinator → ViewController → ViewModel → Coordinator (a través de un callback). Cada referencia fuerte en la cadena debe elegirse conscientemente — una referencia weak en cualquier eslabón rompe el ciclo.
Las closures en Swift capturan variables externas por referencia fuerte. Si una closure se almacena como propiedad de un objeto (por ejemplo, un completion handler) y captura self, se crea un retain cycle: self → closure → self.
Esta es la fuente más común de retain cycles en el desarrollo moderno con Swift. Ocurre implícitamente — un desarrollador puede no notar la captura de self en una closure, especialmente cuando se usa sintaxis abreviada sin self explícito.
class DownloadService {
var onComplete: ((Data) -> Void)?
var result: Data?
func startDownload() {
// ❌ Retain cycle: self → onComplete → self
onComplete = { data in
self.result = data
self.notifyUI()
}
// ✅ Solución: capture list con weak self
onComplete = { [weak self] data in
guard let self else { return }
self.result = data
self.notifyUI()
}
}
func notifyUI() { }
}
Una lista de captura [weak self] crea una referencia débil a self dentro de la closure. Si DownloadService se libera antes de que la closure se ejecute, self se vuelve nil, y el código sale de forma segura mediante guard. Este es un patrón estándar para closures asíncronas en Swift — debe usarse siempre que una closure se almacene como propiedad.
unowned self es una alternativa a weak self cuando se garantiza que self vive más que la closure. Ejemplo: closures síncronas que se ejecutan inmediatamente (sorted, filter). En esos casos self definitivamente está vivo, y unowned es seguro. Sin embargo, unowned falla al acceder a un objeto liberado — por lo tanto, weak se considera la opción segura por defecto.
Detectar retain cycles en una etapa temprana es crítico para el rendimiento de la aplicación. Revisemos las principales herramientas y técnicas para identificar referencias cíclicas en el desarrollo iOS.
Xcode Memory Debugger (Debug Memory Graph) es una herramienta visual que muestra el grafo de objetos en memoria con sus referencias. Un retain cycle aparece como una cadena cerrada de flechas fuertes. Para iniciarlo: haga clic en el botón Debug Memory Graph en el panel Debug area mientras la aplicación se ejecuta. Cada objeto se muestra con su tipo, dirección y lista de referencias.
Instruments Leaks es un profiler para la detección automática de fugas. Registra las asignaciones y analiza el grafo de referencias en tiempo real. Detecta no solo retain cycles, sino también referencias olvidadas, ViewControllers no liberados y otras fugas. Leaks señala el objeto exacto y la cadena de retención.
El método más simple es agregar un print en el deinit de cada clase clave. Si deinit no se llama cuando se espera que el objeto se destruya, hay un retain cycle. Este método no requiere herramientas y es efectivo para el diagnóstico inicial.
| Herramienta | Tipo | Cuándo usarla |
|---|---|---|
| Memory Debugger | Grafo visual | Verificación manual tras la navegación |
| Instruments Leaks | Análisis automatizado | Pruebas de regresión, CI |
| deinit print | Registro manual | Desarrollo, code review |
| Malloc Scribble | Flag de runtime | Depuración de use-after-free |
Enfoque recomendado: use registro de deinit durante el desarrollo, Memory Debugger durante las pruebas manuales e Instruments Leaks en el pipeline de CI/CD para la detección automatizada de regresiones de fugas.
Prevenir retain cycles es más fácil que corregirlos en producción. Aquí hay algunas reglas que minimizan el riesgo de referencias cíclicas.
Todos los delegados y dataSources deben ser weak. Esta regla está incorporada en UIKit: todos los protocolos de delegados en el SDK de Apple se declaran con propiedades weak (UITableView.delegate, UICollectionView.dataSource). Para sus propios protocolos, use weak var delegate: MyDelegate? y herede el protocolo de AnyObject.
Cualquier closure que se almacene como propiedad (completion handler, callback) y capture self debe usar [weak self] en la lista de captura. La excepción son las closures que se ejecutan inmediatamente y no se almacenan (sorted, map, filter). Para ellas, unowned self es seguro.
En arquitecturas complejas (VIPER, Coordinators, Redux), rastree la dirección de las referencias fuertes. El propietario mantiene una referencia fuerte al subordinado, pero el subordinado debe referenciar al propietario solo a través de weak o unowned. El flujo de datos unidireccional simplifica la gestión de referencias.
// Ejemplo: verificación con registro de deinit
class BaseViewController: UIViewController {
deinit {
print("✅ \(type(of: self)) deallocated")
}
}
// Uso: todos los ViewController heredan de BaseViewController
class ProfileVC: BaseViewController {
var viewModel: ProfileViewModel?
var onLogout: (() -> Void)?
override func viewDidLoad() {
super.viewDidLoad()
onLogout = { [weak self] in
self?.dismiss(animated: true)
}
}
}
// Al cerrar ProfileVC esperamos "✅ ProfileVC deallocated" en la consola
Una clase base con registro de deinit proporciona retroalimentación instantánea. Si el mensaje no aparece cuando se espera que la pantalla se cierre, hay un retain cycle en esta clase. Agregue esta práctica a la plantilla del proyecto para todos los ViewControllers.
Preguntas frecuentes
Retain cycle es un problema específico de ARC donde un bucle cerrado de referencias fuertes bloquea la liberación. En GC, el recolector analiza la accesibilidad desde el conjunto raíz, no los contadores de referencia — por lo tanto, los ciclos no son fugas. En ARC, sin embargo, cualquier ciclo aislado es una fuga garantizada.
Una referencia weak no aumenta el retain count de un objeto. Si reemplaza una de las referencias fuertes en un ciclo con weak, el retain count de cada objeto puede llegar a cero. Después de que el objeto se libera, la referencia weak se establece automáticamente a nil, evitando el acceso a memoria liberada.
Sí, un retain cycle puede incluir cualquier número de objetos: A → B → C → A. Para romperlo, solo necesita romper un eslabón del ciclo — reemplace cualquier referencia fuerte con weak o unowned. Las herramientas muestran el grafo completo, no solo pares de objetos.
GCD (Grand Central Dispatch) no almacena la closure después de su ejecución. El DispatchWorkItem se ejecuta y se libera, incluso si la closure captura self. Un retain cycle solo ocurre cuando una closure se almacena como propiedad (completion handler en una clase), no cuando se pasa a una cola.
Instruments Leaks no siempre encuentra retain cycles temporales (que duran segundos) ni referencias cíclicas en objetos C/C++ a través de bridging. Para una verificación completa, use Memory Debugger manualmente junto con el registro de deinit de todos los objetos clave en la escena.
Resumen
Desarrollaremos una aplicación móvil llave en mano
IT Sectr crea aplicaciones para iOS y Android para startups y empresas desde 2017. Le asesoraremos y le propondremos la mejor solución.
Lea también