¿Cómo perfilas memoria en iOS — qué te muestra Instruments realmente sobre un ciclo de retención?
Dos objetos que se mantienen con referencias fuertes entre sí nunca llegan a cero — Instruments convierte ese leak invisible en un grafo, no en una suposición.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo perfilas memoria en iOS — qué te muestra Instruments realmente sobre un ciclo de retención?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Por qué ocurren los ciclos de retención
La gestión de memoria en iOS es ARC (Automatic Reference Counting): cada objeto lleva un contador de retención, que se incrementa con cada referencia fuerte hacia él y se decrementa cuando esa referencia desaparece; el objeto se desaloja en el instante en que el contador llega a cero. Un ciclo de retención ocurre cuando dos (o más) objetos se mantienen con referencias fuertes entre sí, directamente o mediante una cadena —cada uno mantiene el contador del otro por encima de cero para siempre, así que ninguno se desaloja jamás, incluso después de que toda referencia externa al par haya desaparecido. Esta es la clásica trampa de delegate/closure:
final class ProfileViewController: UIViewController {private let imageLoader = ImageLoader()override func viewDidLoad() {super.viewDidLoad()// self es capturado fuertemente por defecto dentro del closure...imageLoader.onComplete = { image inself.avatarView.image = image // ...y self mantiene a imageLoader fuertemente.}imageLoader.load(url: profileImageURL)}}final class ImageLoader {var onComplete: ((UIImage) -> Void)?// Mantener el closure mantiene vivo lo que este capturó mientras// esta instancia de ImageLoader esté viva.}
ProfileViewController es dueño de imageLoader con una referencia fuerte. El closure asignado a imageLoader.onComplete captura self fuertemente por defecto. Entonces imageLoader mantiene vivo al view controller, y el view controller mantiene vivo al loader —un ciclo de dos objetos. Incluso después de que el usuario sale de esta pantalla y se descarta toda referencia de navegación, el par sigue residente en memoria. La solución es una lista de captura que rompa un lado del ciclo, usualmente [weak self]:
imageLoader.onComplete = { [weak self] image inself?.avatarView.image = image}
El mismo patrón aplica a propiedades delegate (weak var delegate: SomeDelegate?) y a suscripciones de NotificationCenter/Combine que capturan self sin [weak self].
#Qué te muestra Instruments realmente
Leaks (una plantilla en Instruments) muestrea el heap a lo largo del tiempo y marca específicamente las asignaciones que se volvieron inalcanzables desde cualquier cadena de retención a partir de una raíz —leaks genuinos en sentido estricto. Es preciso pero limitado: un ciclo de retención donde ambos objetos siguen siendo técnicamente alcanzables desde alguna raíz (por ejemplo, ambos siguen referenciados desde un singleton de larga vida) no siempre aparece como un "leak" clásico, por lo cual la herramienta más útil en el día a día suele ser el gráfico de objetos vivos del instrumento Allocations, filtrado por el nombre de clase de tu view controller, observado a través de un ciclo de navegar-hacia-dentro/navegar-hacia-afuera. Si el conteo de instancias de ProfileViewController sube en uno cada vez que empujas y sacas esa pantalla y nunca vuelve a bajar, tenés un problema de retención incluso si Leaks se mantiene en silencio.
La herramienta más directa es el Memory Graph Debugger, integrado en el propio Xcode (el ícono que parece tres círculos conectados en la barra de debug). Pausás la ejecución, lo abrís, y renderiza el grafo de objetos vivo —cada objeto asignado y la referencia (flecha) que lo mantiene vivo. Un ciclo de retención aparece como un bucle literal cerrado de flechas moradas entre dos nodos, y hacer clic en cualquiera de los dos objetos muestra el backtrace exacto de dónde se creó esa referencia que retiene. Esta es la herramienta que convierte "sospecho un leak" en "acá está la línea exacta que creó la referencia fuerte que lo causa", que es lo que realmente le pasarías a un compañero en un comentario de PR.
#Un flujo de trabajo que podés describir de punta a punta
unowned es la otra herramienta en esta caja, y la distinción digna de entrevista es cuándo recurrir a ella en vez de weak: weak siempre es opcional y se vuelve nil de forma segura una vez que el objeto referenciado se desaloja, lo cual es correcto siempre que la referencia pueda legítimamente sobrevivir al objeto que captura (un delegate que podría reasignarse o destruirse primero). unowned asegura que el objeto capturado nunca puede ser nil durante el tiempo en que se usa —más barato (sin envoltura opcional) pero un crash, no un nil elegante, si esa suposición alguna vez es incorrecta. unowned pertenece a relaciones con un tiempo de vida estrictamente garantizado —un objeto hijo que referencia de vuelta a un padre garantizado a sobrevivirlo— nunca como una forma de silenciar una advertencia del compilador sin verificar esa garantía.
#Por qué esto importa más allá del leak en sí
Un view controller retenido pero invisible no es solo memoria desperdiciada —su deinit nunca se ejecuta, así que cualquier cosa que debía limpiarse al desalojarse (observadores, timers, tareas de red activas) sigue corriendo contra una pantalla que el usuario cree haber abandonado, que es cómo terminás con actualizaciones de UI retrasadas en la pantalla equivocada, o un crash por un callback que dispara contra una vista que lógicamente ya no debería existir. Por esto deinit con un print o un breakpoint sigue siendo una verificación legítima de primera línea y bajo esfuerzo antes incluso de recurrir a Instruments: si navegás fuera y nunca ves disparar deinit, ya sabés que tenés un problema de ciclo de vida antes de abrir cualquier profiler.