wandres.dev
UIKIT · lo que sigue vivo

El ciclo de vida de un UIViewController

El controlador es el objeto que estructura toda aplicación imperativa de iOS, y su ciclo de vida es un contrato con garantías precisas que casi nadie enuncia bien. Esta lección recorre la secuencia completa —construcción, carga de la vista, aparición, layout, desaparición y destrucción—, explica qué se puede afirmar en cada punto y qué no, y usa ese contrato para iluminar por contraste la diferencia real entre un modelo con identidad de objeto y uno donde la vista es un valor que se vuelve a evaluar.

⏱ 19 min

Quien viene de SwiftUI suele leer la lista de métodos de ciclo de vida de un UIViewController como una curiosidad histórica: seis o siete funciones con nombres parecidos que hay que memorizar para pasar una entrevista. Esa lectura pierde lo importante. El ciclo de vida no es una lista de ganchos, es un contrato temporal: una secuencia de puntos en los que el sistema garantiza que ciertas cosas ya han ocurrido y otras todavía no, y todo el arte de programar en UIKit consiste en colocar cada trabajo en el punto donde sus precondiciones se cumplen y su coste es asumible. Entender ese contrato tiene además un premio inesperado para quien programa en declarativo, porque explica por qué SwiftUI no tiene equivalente a la mayoría de estos métodos y por qué los dos que sí ofrece se comportan de un modo que a menudo desconcierta.

🎯 Al terminar esta lección sabrás
  • Reconstruir la secuencia completa del ciclo de vida y saber qué garantiza cada punto.
  • Decidir con criterio dónde colocar la configuración, la carga de datos y el trabajo dependiente del tamaño.
  • Entender la contención de controladores y por qué la jerarquía de controladores importa tanto como la de vistas.
  • Traducir cada método imperativo a su equivalente declarativo, o justificar por qué no lo tiene.

El objeto que estructura la app

Un UIViewController es tres cosas a la vez, y confundirlas es el origen de la mitad de los malentendidos. Es el propietario de una vista raíz y de su subárbol. Es un nodo en una jerarquía paralela de controladores, que el sistema mantiene por separado de la jerarquía de vistas. Y es el interlocutor del sistema para todo lo que trasciende una vista concreta: rotación, barras de navegación, presentación modal, restauración de estado, apariencia de la barra de estado y clases de tamaño.

Esa segunda faceta es la que sorprende a quien llega desde SwiftUI. Existen dos árboles simultáneos que se corresponden pero no coinciden: el de vistas, que es denso y contiene cada botón y cada etiqueta, y el de controladores, que es escaso y solo contiene las unidades de pantalla. Cuando un controlador contiene a otro hay que declararlo explícitamente, porque de lo contrario el hijo queda huérfano en el árbol de controladores y deja de recibir eventos de ciclo de vida aunque su vista se dibuje perfectamente.

// Contencion: la vista sola no basta, hay que registrar la relacion
func incrustar(_ hijo: UIViewController) {
    addChild(hijo)
    view.addSubview(hijo.view)
    hijo.view.frame = view.bounds
    hijo.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
    hijo.didMove(toParent: self)
}

Ese fragmento de cuatro líneas resume el modelo entero. La llamada de alta comunica al padre que asuma la propiedad, la inserción de la vista construye el árbol visual, y la notificación final cierra la transacción para que el hijo empiece a recibir su ciclo de vida. Omitir la primera o la última produce el bug más difícil de diagnosticar de UIKit: una pantalla que se ve bien y en la que nada funciona.

💡
El equivalente declarativo de la contención

En SwiftUI la contención es implícita: escribir una vista dentro de otra ya establece la relación completa, y no hay ningún registro que olvidar. Ese es exactamente el tipo de error de clase entera que el modelo declarativo elimina por construcción, y verlo desde el lado imperativo hace apreciar mejor qué se ganó en la transición.

La secuencia y sus garantías

La secuencia canónica tiene una forma fija y merece recorrerse afirmando en cada punto qué es cierto. La construcción del objeto ocurre primero y ahí la vista todavía no existe; tocarla en el inicializador la fuerza a cargarse antes de tiempo, que es un error clásico y silencioso. Después llega la carga, donde el sistema pide la vista raíz y donde un controlador que no use archivos de interfaz puede construirla a mano. Inmediatamente después se ejecuta el método de carga completada, que corre exactamente una vez y es el lugar de la configuración estructural: crear subvistas, instalar restricciones, suscribirse a lo que haga falta.

flowchart TB
a[init y configuracion de dependencias] --> b[loadView construye la vista raiz]
b --> c[viewDidLoad una sola vez]
c --> d[viewWillAppear antes de cada aparicion]
d --> e[viewIsAppearing con geometria ya valida]
e --> f[viewWillLayoutSubviews y viewDidLayoutSubviews]
f --> g[viewDidAppear transicion terminada]
g --> h[viewWillDisappear y viewDidDisappear]
h --> i[deinit cuando nadie lo retiene]

El bloque de aparición es donde se concentran los matices. El método previo se invoca antes de cada presentación, no una sola vez, y por tanto todo lo que se ponga ahí se ejecutará también al volver de una pantalla apilada; es el sitio de lo que debe refrescarse, no de lo que debe construirse. La geometría en ese instante todavía no es fiable: el sistema aún no ha resuelto el layout definitivo y los límites de la vista pueden ser los de la escena anterior o los del archivo de interfaz.

Ese hueco lo cerró Apple en iOS 17 con un método intermedio, retrocompatible hasta iOS 13, que corre después de que la vista esté en la jerarquía y con la geometría ya calculada, pero antes de que la transición termine. Es el punto correcto para desplazar una tabla a una fila concreta, para ajustar un desplazamiento inicial o para cualquier cosa que dependa del tamaño real y que hecha más tarde produzca un salto visible.

override func viewIsAppearing(_ animated: Bool) {
    super.viewIsAppearing(animated)
    // Aqui bounds ya es correcto y la transicion aun no ha terminado
    tabla.scrollToRow(at: filaInicial, at: .middle, animated: false)
}

El bloque de layout es distinto en naturaleza porque no se ejecuta una vez por aparición sino tantas veces como el sistema lo necesite: al rotar, al cambiar el teclado, al modificar una restricción, al ajustar un tamaño de texto. Poner trabajo caro ahí es la receta más eficaz para arruinar el rendimiento de una pantalla, y es un error frecuente precisamente porque el método parece un lugar cómodo donde todo está ya medido.

⚠️
Tres afirmaciones falsas que cuestan tardes enteras

Que la carga completada se ejecuta cuando la pantalla es visible: no, puede ocurrir mucho antes y a veces para una vista que nunca se muestra. Que la aparición ocurre una sola vez: no, ocurre cada vez que la pantalla vuelve al frente. Y que la geometría es fiable en el método previo a aparecer: no lo es, y confiar en ella produce saltos que solo se reproducen en algunos dispositivos.

La desaparición cierra el ciclo y tiene una asimetría importante: no está garantizado que ocurra antes de la destrucción en todos los caminos, porque un controlador puede ser liberado sin haber aparecido nunca. Por eso la limpieza de recursos que debe suceder sí o sí —cancelar tareas, retirar observadores, invalidar temporizadores— pertenece al destructor y no al método de desaparición, que sirve para lo reversible, como pausar un vídeo o guardar la posición del scroll.

Imperativo frente a declarativo

La comparación con SwiftUI no consiste en emparejar métodos, porque el emparejamiento es casi siempre falso. Consiste en entender que los dos sistemas responden a preguntas distintas. UIKit pregunta cuándo cambia el estado de un objeto que persiste; SwiftUI pregunta cuál es la descripción de la interfaz dado el estado actual. El primero tiene ciclo de vida porque hay una entidad con identidad que atraviesa el tiempo; el segundo tiene identidad de vista, que es otra cosa.

🧱

viewDidLoad

No tiene equivalente porque no hay un momento de construcción único: una vista de SwiftUI se evalúa muchas veces. Lo más cercano es el trabajo dentro de un task con identidad estable o la inicialización perezosa del modelo.

👁️

viewWillAppear

Se aproxima con onAppear, pero la semántica difiere: se dispara cuando la vista entra en el árbol de renderizado, que no siempre coincide con hacerse visible, y en contenedores perezosos puede adelantarse o repetirse.

📐

Los metodos de layout

No existen y no deberían existir: en SwiftUI el tamaño se negocia y se lee con GeometryReader o con el sistema de preferencias, en lugar de reaccionar a un evento posterior a la medición.

Hay un punto de fricción que conviene nombrar porque genera muchas preguntas mal planteadas. Quien busca en SwiftUI un gancho que le avise de que la pantalla ya está visible suele estar intentando resolver un problema imperativo con herramientas declarativas: quiere cargar datos en un instante concreto. La formulación correcta no es cuándo cargo, sino de qué estado depende esta vista y cómo se produce ese estado. La diferencia parece retórica y no lo es: la primera pregunta lleva a código frágil que depende del orden de los eventos, y la segunda lleva a código que funciona igual aunque el orden cambie.

El modelo de propiedad

Queda una dimensión que las listas de métodos ignoran y que es la que de verdad diferencia a quien sabe UIKit: quién retiene a quién. Un controlador presentado es retenido por quien lo presenta; un hijo es retenido por su padre; una vista es retenida por su supervista. Cuando esas relaciones se cierran en ciclo, el destructor nunca corre y la pantalla sigue viva, invisible, consumiendo memoria y a veces reaccionando a notificaciones que ya no le corresponden.

// El patron que evita el ciclo de retencion mas comun
servicio.observar { [weak self] datos in
    guard let self else { return }
    self.aplicar(datos)
}

deinit {
    tarea?.cancel()
    NotificationCenter.default.removeObserver(self)
}

Este es probablemente el aprendizaje más transferible de toda la lección, porque el mismo problema existe en SwiftUI aunque esté mucho mejor escondido. Un objeto observable retenido por un cierre que a su vez lo captura produce exactamente la misma fuga, solo que sin un destructor evidente donde ponerle un punto de interrupción. Quien ha depurado ciclos de retención en UIKit reconoce el síntoma de inmediato; quien nunca lo ha hecho tarda semanas en sospecharlo.

El ciclo de vida existe porque hay identidad, y la identidad es el verdadero eje de la comparación

La forma más profunda de leer esta lección no es como un inventario de métodos sino como una investigación sobre qué significa que algo persista. En UIKit una pantalla es un objeto: nace, ocupa una dirección de memoria, atraviesa estados, recibe mensajes en un orden y muere. Todos los métodos de ciclo de vida son consecuencia directa de esa ontología, porque cuando algo dura en el tiempo hace falta un vocabulario para hablar de los momentos de su duración. En SwiftUI una vista es un valor: se construye, se compara, se descarta y se vuelve a construir muchas veces por segundo, y preguntarle a un valor cuándo apareció es tan raro como preguntarle a un número cuándo nació. Lo que persiste en el modelo declarativo no es la vista sino el estado, y por eso el vocabulario cambió de eventos a dependencias: en lugar de decir haz esto cuando ocurra aquello, se dice esto vale esto otro mientras aquello sea cierto. Ahora bien, la identidad no desapareció, se desplazó. Sigue existiendo con toda su fuerza en el modelo, en el ciclo de vida de las tareas asociadas a una vista, y en las estructuras internas que el motor conserva entre evaluaciones; simplemente dejó de estar en el sitio donde uno la busca. Por eso quien estudia el ciclo de vida de UIKit no está aprendiendo una API obsoleta, está aprendiendo a ver el eje sobre el que giran los dos modelos: dónde se coloca la identidad, quién la gestiona y qué precio se paga en cada caso. Con ese eje claro, los fenómenos raros de SwiftUI —una vista que se reconstruye entera, un estado que se pierde al mover una fila, una tarea que se cancela sola— dejan de ser anomalías y se convierten en consecuencias predecibles.

📝
Lo esencial

El ciclo de vida es un contrato temporal, no una lista de ganchos. La carga completada ocurre una vez y sirve para construir; la aparición ocurre muchas y sirve para refrescar; el método intermedio de iOS 17 es el primer punto con geometría fiable; los métodos de layout se ejecutan sin límite y no admiten trabajo caro; el destructor es el único sitio garantizado para la limpieza definitiva. La contención de controladores exige registrar la relación además de insertar la vista. Y la comparación honesta con SwiftUI no es método a método: es identidad de objeto frente a identidad de estado.

⚔️ Reconstruir el contrato midiéndolo
  1. Escribe un controlador que registre por consola cada método de ciclo de vida con marca de tiempo y navega hacia él y de vuelta tres veces; anota qué se repite y qué no.
  2. Imprime los límites de la vista en el método previo a aparecer, en el intermedio de iOS 17 y en el posterior al layout, en iPhone y en iPad; explica las diferencias.
  3. Incrusta un controlador hijo omitiendo a propósito la llamada de alta y comprueba qué eventos deja de recibir aunque su vista se dibuje.
  4. Provoca un ciclo de retención capturando el controlador con fuerza en un cierre y confirma con Instruments que el destructor nunca se ejecuta.
  5. Escribe la misma pantalla en SwiftUI y anota, para cada método del original, si tiene equivalente, si es innecesario o si el problema se plantea de otra forma.