Ver un ciclo de retención: deinit y Memory Graph
Al terminar: Ver con deinit y con el Memory Graph Debugger que un ViewModel no se libera, y reconocer el ciclo.
Al terminar esta lección sabrás encontrar en Xcode un objeto que no se libera y ver quién lo mantiene vivo. Usarás dos herramientas: la línea PartidoViewModel LIBERADO del registro, que te dice si se libera, y el Memory Graph Debugger, que dibuja los objetos vivos de tu app y las flechas fuertes entre ellos, y te dice por qué no. Lo harás sobre el PartidoViewModel de Marcador, al que le vas a quitar el [weak self] a propósito encendiendo el interruptor que escribiste en El ViewModel y el hilo principal. Además escribirás una prueba de interfaz que reproduce la fuga sin el depurador; el código de la app no cambia.
Importa porque un ciclo de retención no da error ni aviso: la app funciona, y cada pantalla que abres deja un objeto en memoria para siempre. Temas: el Memory Graph Debugger y los argumentos de lanzamiento de un esquema.
El bug
Con el interruptor encendido, prepararReloj guarda esta closure en lugar de la que lleva [weak self]:
reloj.alTick = { self.alMinuto() }El síntoma: abres un partido, lo cierras, y el registro no dice PartidoViewModel LIBERADO. Ahora vas a ver por qué, con una herramienta que te dice qué objeto sujeta a cuál.
El Memory Graph Debugger
El grafo de memoria es una foto de todos los objetos que la app tiene vivos en ese instante: cada objeto es un nodo y cada referencia fuerte entre dos objetos, una flecha. Se hace con la app en marcha, pausándola un momento para dibujar la foto.
Un esquema (scheme) guarda cómo Xcode lanza la app, incluidos los argumentos que recibe. iOS guarda en UserDefaults, el almacén de ajustes pequeños de la app, cada argumento que empieza por guion seguido de un valor, y Configuracion lo lee desde ahí.
Un ciclo mínimo, en pequeño
Antes de verlo en Marcador, el mismo ciclo con una cafetera, para leer en una pantalla qué sale y qué no. Pruébalo en tu Playground (reutiliza el mismo y borra lo anterior).
// El temporizador guarda una closure: lo que hará al sonar.
final class Temporizador {
var alSonar: (() -> Void)?
}
// La cafetera guarda su temporizador.
final class Cafetera {
let temporizador = Temporizador()
init() {
// La closure usa `self` (la cafetera) con referencia FUERTE.
// Resultado: la cafetera sujeta al temporizador, el temporizador a la closure
// y la closure a la cafetera. Un círculo: nadie puede soltar primero.
temporizador.alSonar = { self.avisar() }
}
func avisar() { print("¡Café listo!") }
// Se ejecuta cuando ARC libera la cafetera.
deinit { print("Cafetera liberada") }
}
func prepararUnCafe() {
_ = Cafetera() // se crea y nadie la guarda: al acabar la función debería liberarse
}
prepararUnCafe()
print("fin")Lo que sale:
finFalta Cafetera liberada: la cafetera sigue viva, atrapada en su propio círculo. Con { [weak self] in self?.avisar() } la closure mira a la cafetera sin sujetarla y sale:
Cafetera liberada
finReto · Ejercicio: encuentra tu propio ciclo
En tu Playground (reutiliza el mismo y borra lo anterior), escribe dos clases:
Reloj, con una propiedad opcional,alTick, que guarda una closure sin parámetros ni resultado.Pantalla, que tiene su propio reloj y, al crearse, guarda enalTickuna closure que llama a un método suyo,marcar, capturando a la pantalla de forma fuerte. Cuando se libera, imprimePantalla LIBERADA.
Escribe una función, probar, que cree tres pantallas sin guardarlas en ningún sitio. Llámala y, después, imprime fin.
-
Ejecuta. Lo que sale:
fin¿Por qué no sale
Pantalla LIBERADA? -
Describe el ciclo que habría en el Memory Graph (qué objeto apunta a cuál).
-
Arréglalo. Lo que debe salir:
Pantalla LIBERADA Pantalla LIBERADA Pantalla LIBERADA fin
Ver solución
final class Reloj {
var alTick: (() -> Void)?
}
final class Pantalla {
let reloj = Reloj()
init() {
reloj.alTick = { self.marcar() } // arreglo: { [weak self] in self?.marcar() }
}
func marcar() { print("tick") }
deinit { print("Pantalla LIBERADA") }
}
func probar() {
_ = Pantalla()
_ = Pantalla()
_ = Pantalla()
}
probar()
print("fin")- No sale
Pantalla LIBERADA:Pantallaguarda aRelojy el reloj guarda una closure que capturaself. - Filtrando por
Pantallaverías tres instancias y las flechasPantalla → Reloj → closure → Pantalla. - Con
{ [weak self] in self?.marcar() }la closure ya no retiene a la pantalla: salen tresPantalla LIBERADAantes defin.
Paso 1: enciende el interruptor
Objetivo: lanzar Marcador con -cicloDelCronometro YES y comprobar en el registro que el ViewModel ya no se libera.
Para pasar el argumento desde Xcode (los nombres de los menús pueden variar un poco según tu versión):
- Abre el menú Product → Scheme → Edit Scheme…
- Elige Run en la columna izquierda y la pestaña Arguments.
- En Arguments Passed On Launch pulsa el botón
+y escribe una sola entrada:-cicloDelCronometro YES. - Cierra el cuadro con Close.
Ejecuta con Cmd+R desde Xcode, abre un partido, ciérralo (desliza la hoja hacia abajo) y lee el registro enseguida:
xcrun simctl spawn booted log show --last 1m --predicate 'subsystem == "com.sebastiannarvaez.marcador"' | grep -E "CANCELADO|LIBERADO"Esta vez sale solo partido CANCELADO en el minuto …: la tarea del partido se cancela al cerrar la hoja, pero PartidoViewModel LIBERADO no aparece. Algo retiene al ViewModel.
Dos cosas pueden hacer que el registro engañe:
- No toques «Redibujar el padre» (el botón de arriba de la hoja). Cada toque crea un ViewModel de sobra que SwiftUI descarta al instante, y ese sobrante también escribe
PartidoViewModel LIBERADOaunque el del partido siga retenido: a veces justo al cerrar la hoja, como si fuera el del partido. El que importa es el que sale justo después deCANCELADO. --last 1mmira solo el último minuto. Con--last 5mentrarían líneas de ejecuciones anteriores, que también pueden traer suLIBERADO; si tardas más de un minuto en leer, repite la prueba (abrir, cerrar, leer).
Paso 2: ver el ciclo
- Con la app todavía en ejecución desde Xcode (el depurador tiene que estar conectado: si abres la app a mano en el simulador, no hay grafo), abre y cierra dos partidos más: tres en total.
- En la barra de depuración, la franja con botones pequeños que hay justo encima de la consola, en la parte inferior de Xcode (a la izquierda están pausa/continuar y los de avanzar paso a paso), busca el icono de tres círculos pequeños unidos por líneas, Debug Memory Graph. Si no lo encuentras, es lo mismo que la entrada del menú Debug → Debug Workflow que empieza por «View Memory Graph». Xcode pausa la app y dibuja el grafo.
- En el panel izquierdo, que ahora muestra los objetos vivos agrupados por tipo, hay abajo un campo de filtro. Escribe
PartidoViewModel. - Aparecen tres instancias, una por partido abierto. Selecciona una.
- En el lienzo, la zona central, cada objeto es un recuadro con el nombre de su tipo y cada flecha es una referencia fuerte: sale del objeto que sujeta y llega al sujetado. Sigue las flechas desde
PartidoViewModel: así se lee el ciclo, dibujado debajo de esta lista. - Pulsa el botón de parar de Xcode para terminar la sesión.
Lo que debes ver en el lienzo, con los nombres de las propiedades que sujetan:
PartidoViewModel ──cronometro──▶ Cronometro ──alTick──▶ closure
▲ │
└──────────────── (captura a self) ─────────────────┘La flecha sale de PartidoViewModel, llega a Cronometro, sigue a la closure y vuelve al mismo PartidoViewModel: ese círculo cerrado es el ciclo, y por eso ARC nunca baja el contador a cero. Xcode puede marcar además esos objetos con un signo de exclamación morado, que indica una posible fuga.
Paso 3: comprobar el arreglo
- Quita el argumento: en el esquema (Product → Scheme → Edit Scheme…, Run, Arguments), desmarca la casilla de
-cicloDelCronometro YESo bórralo con-. - Ejecuta con Cmd+R, abre y cierra tres partidos. El registro vuelve a mostrar
PartidoViewModel LIBERADOcada vez. - Abre el Memory Graph como antes con el filtro
PartidoViewModel: la lista sale vacía, siempre que todos los partidos estén cerrados. Un partido que sigues teniendo abierto sí aparece, y es correcto.
Paso 4: la fuga, sin el depurador
Objetivo. Una prueba de interfaz que abre un partido y lo cierra, y que con una variable de entorno reintroduce el ciclo. Así ves la fuga en el registro sin el depurador ni tocar el esquema.
Piezas nuevas de este paso.
- Una variable de entorno es un dato con nombre (
CICLO=YES) que se le pasa a un programa al lanzarlo, sin tocar su código.TEST_RUNNER_CICLO=YESdelante dexcodebuildhace que la prueba la reciba comoCICLO:xcodebuildquita el prefijoTEST_RUNNER_. ProcessInfo.processInfo.environment: el diccionario con las variables de entorno de la prueba.entorno["CICLO"]danilsi no existe, y el operador??pone un valor de reserva (entorno["CICLO"] ?? "NO"es «lo que digaCICLO, oNOsi no hay»).entorno["ESPERA"].flatMap(Double.init):ESPERAsería un número de segundos, escrito como texto.flatMap(Double.init)lo convierte en número, o danilsi la variable no existe o no es un número. En esta lección no la usas: queda ahí para dejar la app viva unos segundos más cuando quieras observarla.-only-testing:Objetivo/Clase/prueba: el argumento dexcodebuildque ejecuta solo la prueba que le nombres, con su objetivo, su clase y su método.
En MarcadorAppUITests.swift, añade esta prueba dentro de la clase, debajo de la última que tienes. Abre el partido 1 con minutos de 100 ms y el argumento -cicloDelCronometro en NO, salvo que la variable CICLO diga otra cosa; espera a que exista el texto minuto, deja pasar dos segundos, cierra la hoja deslizándola (como en .task frente a .onAppear) y comprueba que vuelve a ver el partido 1 en la lista. Si hay una variable ESPERA, deja la app viva esos segundos (ya sabes qué hace esa línea por las piezas de arriba).
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// Abre un partido y lo cierra; con `TEST_RUNNER_ESPERA` la app se queda viva esos segundos más.
// `TEST_RUNNER_CICLO=YES` reintroduce el ciclo del cronómetro (para verlo en el Memory Graph).
@MainActor
func testAbreYCierraUnPartidoParaMedirLaMemoria() throws {
let app = XCUIApplication()
let entorno = ProcessInfo.processInfo.environment
app.launchArguments = ["-msPorMinuto", "100", "-cicloDelCronometro", entorno["CICLO"] ?? "NO"]
app.launch()
app.buttons["partido-1"].tap()
XCTAssertTrue(app.staticTexts["minuto"].waitForExistence(timeout: 10))
Thread.sleep(forTimeInterval: 2)
// Se desliza la hoja de arriba abajo para cerrarla.
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15))
.press(forDuration: 0.1, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98)))
XCTAssertTrue(app.buttons["partido-1"].waitForExistence(timeout: 5))
if let segundos = entorno["ESPERA"].flatMap(Double.init) {
Thread.sleep(forTimeInterval: segundos)
}
}Ejecútala con el ciclo, desde la Terminal, y lee el registro nada más acabar:
cd ~/Marcador-Swift/MarcadorApp
TEST_RUNNER_CICLO=YES xcodebuild -scheme MarcadorApp -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:MarcadorAppUITests/MarcadorAppUITests/testAbreYCierraUnPartidoParaMedirLaMemoria test
xcrun simctl spawn booted log show --last 2m --predicate 'subsystem == "com.sebastiannarvaez.marcador"' | grep -E "CANCELADO|LIBERADO"Verás solo la cancelación, sin LIBERADO (el minuto depende de cuándo se cerró la hoja):
partido CANCELADO en el minuto 57Ahora sin el ciclo: la misma orden sin TEST_RUNNER_CICLO=YES. El ViewModel se libera:
partido CANCELADO en el minuto 56
PartidoViewModel LIBERADOSi el registro trae líneas de la ejecución anterior, fíjate en las últimas. Y un aviso: al terminar xcodebuild … test, el simulador puede quedarse apagado, y entonces simctl spawn booted … responde que no hay ninguno arrancado. Arráncalo con xcrun simctl boot "iPhone 17" y repite la lectura: el registro se conserva. Si tu simulador no se llama iPhone 17, usa el nombre de uno de los que ves con xcrun simctl list devices available en las dos órdenes de este paso.
Cmd+U ejecuta ahora 22 pruebas: 11 unitarias y 11 de interfaz.
Lo que te llevas
- Un objeto que no se libera se ve en el registro (falta su
deinit) y en el Memory Graph, que dibuja las flechas fuertes. - Un argumento de lanzamiento como
-cicloDelCronometro YESllega a la app porUserDefaults, sin cambiar código, y se pone en el esquema. - Esta lección deja una prueba de interfaz nueva; el código de la app no cambia, y el esquema queda sin el argumento.
Punto de control
- Con
-cicloDelCronometro YESen el esquema, el registro muestrapartido CANCELADO …sinPartidoViewModel LIBERADO, y el Memory Graph dibuja el ciclo. - Sin el argumento, el registro muestra
PartidoViewModel LIBERADOal cerrar cada partido. - La prueba nueva con
TEST_RUNNER_CICLO=YESda el registro sinLIBERADO, y sin la variable, con él. - Cmd+U termina en
** TEST SUCCEEDED **con 22 pruebas: 11 unitarias y 11 de interfaz.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.Buscando la fuga con
-cicloDelCronometro YES, ¿por qué no conviene tocar «Redibujar el padre»?2.Cierras un partido y el registro no muestra
PartidoViewModel LIBERADO. ¿Qué indica?3.Con
-cicloDelCronometro YESabres y cierras un partido tres veces y filtras porPartidoViewModelen el Memory Graph. ¿Qué ves?4.¿Qué cambia
[weak self]en la closure dealTick?5.¿Para qué existe el argumento de lanzamiento
-cicloDelCronometro YESen Marcador?6.¿Qué hace
TEST_RUNNER_CICLO=YESdelante dexcodebuild … test?