Saltar al contenido
K
iOS con SwiftUIPaso a paso38 min de lectura · 8 min de máquina Básico

El ciclo de vida de UIViewController

Al terminar: Ver cada callback de un UIViewController y compararlo con el de la Activity.

Al terminar, el controlador de Marcador anotará en el registro cada aviso que le da iOS (viewDidLoad, viewWillAppear, viewDidAppear…) y, al final, su liberación con deinit, que es de Swift y no de UIKit. Una prueba de interfaz abrirá y cerrará la hoja por ti. Es el mismo experimento que hiciste con la Activity de Android, ahora en el otro sistema.

Importa porque entender el ciclo de vida de un UIViewController te dice cuándo hacer cada cosa en cualquier pantalla de iOS, y porque es una pregunta habitual en entrevistas de iOS.

Los avisos de un UIViewController

El sistema avisa a tu pantalla en cada momento importante de su vida. Una comparación con los avisos de la Activity (cada aviso, también llamado callback, es un método que el sistema llama por ti en un momento concreto). Es una simplificación: las dos listas no significan lo mismo, y la tabla sirve para orientarte, no para traducir mecánicamente.

Activity (Android)UIViewController (iOS)Qué avisa
onCreateviewDidLoadLa vista se ha creado; una sola vez
onStartviewWillAppearVa a hacerse visible
onResumeviewDidAppearYa es visible y recibe toques
onPauseviewWillDisappearVa a dejar de verse
onStopviewDidDisappearYa no se ve
(sin equivalente directo)viewWillLayoutSubviewsVa a colocar las vistas de nuevo; se llama muchas veces (al rotar, al cambiar el teclado…)
onDestroydeinitARC libera el objeto; no es un aviso de UIKit

Son seis avisos de UIKit (viewDidLoad, los cuatro will/did y viewWillLayoutSubviews) y el deinit de Swift. Tres diferencias que importan:

  • Los pares de iOS son will/did: avisan antes y después del cambio. Android avisa una vez.
  • Rotar no destruye el controlador. En Android, rotar destruye y recrea la Activity. En iOS solo cambia el tamaño de la vista y se vuelve a maquetar (viewWillLayoutSubviews, que se llama muchas veces).
  • deinit es de Swift, no de UIKit. Corre cuando ARC libera el objeto, no cuando «se cierra la pantalla». Si nunca sale en el registro, algo lo está reteniendo (lo viste en ARC).

Un aviso con override y super

Todos los avisos se escriben igual: se redefine el método con override y lo primero es llamar a super con el mismo parámetro, como en la herencia de la lección anterior. Aquí, con otro aviso que UIKit también tiene, viewIsAppearing (no lo escribas en tu clase):

ejemplo mínimo: un aviso con override y super (con comentarios)
    // override: redefine un aviso que UIViewController ya tiene; el sistema lo llama solo.
    override func viewIsAppearing(_ animated: Bool) {   // animated: si el cambio se anima (true o false)
        super.viewIsAppearing(animated)                 // primero, lo de la clase madre, con el mismo parámetro
        Registro.anotar("viewIsAppearing(animated: \(animated))")   // después, lo tuyo: una línea en el registro
    }

Cada vez que el sistema avise de ese momento, el registro recibe una línea con ese texto: viewIsAppearing(animated: true) o viewIsAppearing(animated: false), según el valor que traiga.

Tu carpeta de ejercicios

Los ejercicios de iOS que no caben en un Playground se hacen dentro del proyecto, en una carpeta Ejercicios con una subcarpeta por ejercicio. Se compilan con la app, pero ninguna pantalla los muestra, y no se borran: se acumulan y el proyecto compila siempre con ellos. Créala una vez:

  1. En el Project navigator, clic derecho sobre la carpeta MarcadorApp (la que contiene MarcadorApp.swift, no el icono azul) → New Folder, y de nombre Ejercicios.
  2. Clic derecho sobre Ejercicios → New Folder, OrdenDeLosAvisos.
  3. Clic derecho sobre OrdenDeLosAvisos → New File from Template… → Swift File → Next, de nombre OrdenDeLosAvisosViewController, y Create.

Si el menú de tu Xcode se llama distinto, hazlo desde la Terminal: mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorApp/Ejercicios/OrdenDeLosAvisos y touch ~/Marcador-Swift/MarcadorApp/MarcadorApp/Ejercicios/OrdenDeLosAvisos/OrdenDeLosAvisosViewController.swift; Xcode los muestra solos. Todo lo que pongas ahí compila junto con la app, así que cada ejercicio usa nombres propios que no se repitan con los de la app ni con los de otro ejercicio.

Reto · Ejercicio: el orden de los avisos

En OrdenDeLosAvisosViewController.swift, escribe una pantalla de UIKit con fondo amarillo y una etiqueta centrada que, en vez de escribir en el registro, se escribe a sí misma: cada aviso que recibe (viewDidLoad, viewWillAppear y viewDidAppear) añade su nombre en una línea nueva de la etiqueta. Añade al final del fichero una vista previa que muestre la pantalla. Lo que debe verse, centrado sobre amarillo, en la vista previa (menú Editor → Canvas; si dice que está en pausa, pulsa Resume):

viewDidLoad
viewWillAppear
viewDidAppear

Pistas con lo ya visto: el fichero empieza con import SwiftUI (para la vista previa) y import UIKit; la vista previa se escribe #Preview { OrdenDeLosAvisosViewController() }; la etiqueta y su colocación son las de la lección anterior; el texto de una etiqueta es opcional, así que para añadirle algo hay que darle un valor de reserva con ?? (Opcionales); y el fondo amarillo de UIKit es el color del sistema .systemYellow. Si el lienzo no te enseña la vista, Cmd+B te dice igualmente si compila.

Ver solución
MarcadorApp/Ejercicios/OrdenDeLosAvisos/OrdenDeLosAvisosViewController.swift
import SwiftUI   // para #Preview
import UIKit
 
// Una pantalla de UIKit que escribe en su propia etiqueta cada aviso que recibe.
final class OrdenDeLosAvisosViewController: UIViewController {
    private let etiqueta = UILabel()
 
    // Añade una línea al texto de la etiqueta.
    private func apuntar(_ aviso: String) {
        etiqueta.text = (etiqueta.text ?? "") + aviso + "\n"
    }
 
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemYellow
        etiqueta.numberOfLines = 0
        etiqueta.font = .preferredFont(forTextStyle: .title2)
        etiqueta.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(etiqueta)
        NSLayoutConstraint.activate([
            etiqueta.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            etiqueta.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        ])
        apuntar("viewDidLoad")
    }
 
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        apuntar("viewWillAppear")
    }
 
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        apuntar("viewDidAppear")
    }
}
 
// La vista previa acepta un controlador de UIKit directamente.
#Preview {
    OrdenDeLosAvisosViewController()
}

Cada aviso añade su nombre al texto que ya había, con un salto de línea detrás; ?? "" cubre el caso de que la etiqueta todavía no tenga texto. Los tres llegan en el orden de la tabla: viewDidLoad, viewWillAppear y viewDidAppear.

Paso 1 · Los seis avisos y el que no lo es

Objetivo: que el controlador de Marcador anote cada aviso del sistema y también su propia liberación, con un identificador que distinga una instancia de otra.

MarcadorApp (proyecto)
MarcadorApp
Laboratorio
CicloDeVidaViewController.swiftse modifica: los avisos
Registro.swiftse modifica: una palabra
Se modifican dos ficheros que ya tienes.

Tres ayudas dentro de la clase, antes de los avisos. La primera, una propiedad calculada (private var identificador: String { … }: no guarda valor, lo calcula cada vez que la lees) que devuelve un número que identifica esta instancia del controlador (escrito con cifras y letras, solo para distinguirlo); así, si abres la hoja dos veces, distingues las dos en el registro. Te la damos hecha, porque usa piezas que esta lección no explica. La segunda, maquetaciones, cuenta cuántas veces coloca el sistema las vistas. La tercera, anotar, un atajo que antepone el nombre del controlador y el identificador a cada mensaje. Añade las tres al principio de la clase, justo debajo de la llave de apertura:

CicloDeVidaViewController.swift: identificador y atajo
    // Un número que identifica esta instancia (como el `identityHashCode` de la Activity).
    private var identificador: String { String(UInt(bitPattern: ObjectIdentifier(self).hashValue), radix: 16) }
 
    // Cuántas veces ha colocado el sistema las vistas; `viewWillLayoutSubviews` lo cuenta.
    private var maquetaciones = 0
 
    // Un atajo: antepone el nombre del controlador y el identificador a cada mensaje.
    private func anotar(_ mensaje: String) {
        Registro.anotar("Ciclo CicloDeVidaViewController@\(identificador) \(mensaje)")
    }

Ahora los avisos. Intenta escribirlos tú, con el patrón del ejemplo de arriba: viewDidLoad pasa a anotar con el atajo, y debajo, los cinco avisos restantes, cada uno con primero lo de la clase madre y luego lo tuyo. Los cuatro will/did anotan su nombre y si el cambio se anima; viewWillLayoutSubviews cuenta las maquetaciones y solo anota la primera; y viewDidDisappear añade cuántas hubo. Lo que debes ver en la consola al abrir y cerrar la hoja (el número tras la @ cambia en cada ejecución):

Ciclo CicloDeVidaViewController@3da651cb68e9ef08 viewDidLoad
Ciclo CicloDeVidaViewController@3da651cb68e9ef08 viewWillAppear(animated: false)
Ciclo CicloDeVidaViewController@3da651cb68e9ef08 viewWillLayoutSubviews (primera de varias)
Ciclo CicloDeVidaViewController@3da651cb68e9ef08 viewDidAppear(animated: true)
Ciclo CicloDeVidaViewController@3da651cb68e9ef08 viewWillDisappear(animated: true)
Ciclo CicloDeVidaViewController@3da651cb68e9ef08 viewDidDisappear(animated: true), maquetaciones: 1

Cuando termines, abre el bloque y compara.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
CicloDeVidaViewController.swift: solo la clase, con los seis avisos
import SwiftUI   // para el puente `UIViewControllerRepresentable`
import UIKit
 
final class CicloDeVidaViewController: UIViewController {
 
    // Un número que identifica esta instancia.
    private var identificador: String { String(UInt(bitPattern: ObjectIdentifier(self).hashValue), radix: 16) }
 
    // Cuántas veces ha colocado el sistema las vistas.
    private var maquetaciones = 0
 
    // Antepone el nombre del controlador y el identificador a cada mensaje.
    private func anotar(_ mensaje: String) {
        Registro.anotar("Ciclo CicloDeVidaViewController@\(identificador) \(mensaje)")
    }
 
    // Aviso 1: la vista se ha creado. Una sola vez.
    override func viewDidLoad() {
        super.viewDidLoad()
        anotar("viewDidLoad")
        view.backgroundColor = .systemBackground
        // … aquí sigue la etiqueta de la lección anterior, sin cambios …
    }
 
 
    // Aviso 2: va a hacerse visible. `animated` dice si el cambio se anima.
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        anotar("viewWillAppear(animated: \(animated))")
    }
 
    // Aviso 3: va a colocar las vistas. Se llama muchas veces: solo se cuentan y se anota la primera.
    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        maquetaciones += 1
        if maquetaciones == 1 { anotar("viewWillLayoutSubviews (primera de varias)") }
    }
 
    // Aviso 4: ya es visible y recibe toques.
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        anotar("viewDidAppear(animated: \(animated))")
    }
 
    // Aviso 5: va a dejar de verse.
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        anotar("viewWillDisappear(animated: \(animated))")
    }
 
    // Aviso 6: ya no se ve. Aquí también se dice cuántas veces se colocaron las vistas.
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        anotar("viewDidDisappear(animated: \(animated)), maquetaciones: \(maquetaciones)")
    }
}
 
// … el puente `CicloDeVidaView` de la lección anterior sigue al final del fichero, sin cambios …

Ejecuta con Cmd+R, abre la hoja y ciérrala. En la consola salen seis líneas, en este orden: viewDidLoad, viewWillAppear, viewWillLayoutSubviews, viewDidAppear, viewWillDisappear y viewDidDisappear. Falta el séptimo mensaje: el del deinit.

deinit y por qué Registro pasa a nonisolated

deinit { … } es un bloque que Swift ejecuta cuando libera el objeto. Añádelo al final de la clase, debajo de viewDidDisappear:

CicloDeVidaViewController.swift: deinit
    // Swift lo ejecuta cuando ARC libera el objeto. No es un callback de UIKit.
    deinit {
        Registro.anotar("Ciclo CicloDeVidaViewController deinit")
    }

Pulsa Cmd+B: compila. Pero hay un fallo escondido, y la lección te lo enseña antes de que te muerda. En Tu primera app de iOS viste que el ajuste Default Actor Isolation = MainActor convierte todo el código de la app en @MainActor: solo debe ejecutarse en el hilo principal (el que dibuja la interfaz). Eso incluye Registro. Pero el deinit de una clase @MainActor no corre en el actor principal: corre en el hilo que suelta la última referencia, y Swift no puede garantizar cuál. Un deinit que llama a una función @MainActor rompe la regla, aunque hoy no se note.

El proyecto está en modo Swift 5, que lo deja pasar sin avisar. Para ver lo que dirá el compilador cuando el proyecto pase a Swift 6, provócalo:

  1. Selecciona el proyecto → target MarcadorApp → pestaña Build Settings y busca Swift Language Version.
  2. Cámbialo a Swift 6 y pulsa Cmd+B.

Xcode marca la línea de Registro.anotar dentro de deinit con este error (el texto exacto puede variar un poco con tu versión de Xcode):

error: call to main actor-isolated static method 'anotar' in a synchronous nonisolated context

Vuelve a dejar Swift Language Version en Swift 5. El arreglo es nonisolated delante de enum Registro: «este tipo no pertenece a ningún actor, se puede llamar desde cualquier hilo». Es la única línea que cambia en Registro.swift:

Registro.swift: nonisolated, la única línea que cambia
// `nonisolated`: `Registro` se puede llamar desde cualquier hilo, también desde un `deinit`.
nonisolated enum Registro {
    // …el resto del enum no cambia…
}

Cmd+B otra vez: Build Succeeded.

Paso 2 · Las pruebas

Objetivo: una prueba de interfaz que abre la hoja y la cierra por ti, y una ayuda para adjuntar capturas de pantalla a los resultados.

MarcadorApp (proyecto)
MarcadorAppUITests
Ayudas.swiftse crea (con Copiar fichero)
MarcadorAppUITests.swiftse modifica
Un fichero se crea en el grupo de pruebas de interfaz y otro se modifica.

Ayudas.swift. Es un fichero mecánico que no enseña nada nuevo, así que no lo escribes: te lo da el botón Copiar fichero, que ya usaste en Clases y objetos. Copia el contenido al portapapeles; no escribe nada en tu proyecto. El orden es siempre el mismo: crear el fichero, pulsar el botón, seleccionar todo (Cmd+A) y pegar (Cmd+V).

Créalo: clic derecho sobre la carpeta MarcadorAppUITests (no sobre MarcadorApp) → New File from Template… → Swift File → Next, nombre Ayudas. Si el asistente pregunta por el target, marca solo MarcadorAppUITests. Contiene una función, capturar, que guarda una captura de la pantalla y la adjunta al resultado de la prueba. La usarán las pruebas de interfaz de las próximas lecciones. Su firma, para que sepas llamarla:

capturar: firma y uso (resumen)
// En una prueba: `app` es la app que se prueba; `nombre` es el nombre de la captura en el informe.
func capturar(_ app: XCUIApplication, _ nombre: String)
 
capturar(app, "hoja-abierta")   // adjunta una captura llamada «hoja-abierta» al resultado
Ayudas.swift

MarcadorAppUITests.swift. Ya existe, con tu prueba de arranque. La prueba nueva, testAbreYCierraElControlador, hace lo que harías tú con el dedo, y esta vez te la damos hecha porque usa piezas que todavía no has visto. Las de siempre están en la tabla de XCUITest; las nuevas, aquí:

  • app.staticTexts["etiquetaDelControlador"]: busca un texto por su nombre interno (el accessibilityIdentifier de tu etiqueta) o por lo que muestra; acepta las dos cosas.
  • app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)): un punto de la pantalla, dado en coordenadas normalizadas: de 0 a 1 en cada eje, donde dx 0 es el borde izquierdo y 1 el derecho, y dy 0 es arriba y 1 abajo. CGVector(dx:dy:) es el par de números. Así el punto vale en cualquier tamaño de iPhone.
  • punto.press(forDuration: 0.1, thenDragTo: otroPunto): pone el dedo en un punto durante una décima de segundo y lo arrastra hasta el otro: aquí, de arriba de la hoja hasta abajo del todo, que la cierra.

La prueba arranca la app, toca el botón «Ciclo de vida (UIKit)», espera a que aparezca la etiqueta, guarda una captura con capturar, arrastra la hoja hacia abajo y comprueba que el botón vuelve a estar en pantalla. Añádela debajo de testArrancaYMuestraElMarcador, sin tocar esa:

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppUITests.swift: la prueba nueva, debajo de la de arranque
    // … aquí termina `testArrancaYMuestraElMarcador`, sin cambios:
    //         XCTAssertTrue(app.staticTexts["Marcador"].waitForExistence(timeout: 5))
    //     }
 
    // Abre la hoja con el UIViewController y la cierra arrastrándola hacia abajo.
    // Sirve de prueba y de «mano» para ver el registro del ciclo de vida.
    @MainActor
    func testAbreYCierraElControlador() throws {
        let app = XCUIApplication()
        app.launch()
        app.buttons["Ciclo de vida (UIKit)"].tap()
        XCTAssertTrue(app.staticTexts["etiquetaDelControlador"].waitForExistence(timeout: 5))
        capturar(app, "controlador")
        // Se arrastra desde arriba de la hoja hasta abajo del todo.
        let inicio = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15))
        inicio.press(forDuration: 0.1, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98)))
        XCTAssertTrue(app.buttons["Ciclo de vida (UIKit)"].waitForExistence(timeout: 5))
    }
}   // esta llave cierra la clase: ya la tenías

Sirve de prueba y de «mano»: abrir y cerrar la hoja a mano es lo que genera el ciclo que vas a leer en el registro.

Paso 3 · Ver el ciclo

Pulsa Cmd+U para pasar todas las pruebas (la de la hoja abre y cierra el controlador por ti), o ejecuta la app con Cmd+R, toca el botón y arrastra la hoja hacia abajo. El registro se lee desde la Terminal (el simulador tiene que estar arrancado):

xcrun simctl spawn booted log show --last 2m --predicate 'subsystem == "com.sebastiannarvaez.marcador"' --style compact

Cada línea empieza por la hora, el nombre del proceso y [com.sebastiannarvaez.marcador:app] (el subsystem y la category de tu Logger); lo que sigue es lo tuyo. Antes de estas líneas puede salir la scenePhase: inactive -> active del arranque de la app, y si acabas de pasar las pruebas, también las de esos otros arranques, con otro número de proceso: las tuyas son las siete seguidas que empiezan por Ciclo. Verás algo como esto, ya sin ese principio (el número tras @ cambia en cada ejecución):

Ciclo CicloDeVidaViewController@3da651cb68e9ef08 viewDidLoad
Ciclo CicloDeVidaViewController@3da651cb68e9ef08 viewWillAppear(animated: false)
Ciclo CicloDeVidaViewController@3da651cb68e9ef08 viewWillLayoutSubviews (primera de varias)
Ciclo CicloDeVidaViewController@3da651cb68e9ef08 viewDidAppear(animated: true)
Ciclo CicloDeVidaViewController@3da651cb68e9ef08 viewWillDisappear(animated: true)
Ciclo CicloDeVidaViewController@3da651cb68e9ef08 viewDidDisappear(animated: true), maquetaciones: 1
Ciclo CicloDeVidaViewController deinit

Dos detalles que sorprenden. viewWillAppear llega con animated: false y viewDidAppear con animated: true: el valor lo decide quien presenta la pantalla, y aquí es SwiftUI. Y deinit sale justo después de viewDidDisappear: al cerrar la hoja, SwiftUI suelta el controlador y ARC lo libera.

Punto de control

  • Desde ~/Marcador-Swift/MarcadorApp, xcodebuild -scheme MarcadorApp -destination 'platform=iOS Simulator,name=iPhone 17' build test termina con ** TEST SUCCEEDED ** (con el simulador que tengas: cambia iPhone 17 por un nombre de xcrun simctl list devices available): cuatro pruebas, una unitaria y tres de interfaz.
  • Al tocar «Ciclo de vida (UIKit)» sube una hoja con «Un UIViewController (mira el registro)».
  • Al cerrar la hoja, el registro muestra los siete mensajes de arriba, en ese orden, y el último es deinit.
  • Registro.swift empieza por nonisolated enum Registro y Ayudas.swift está en el grupo MarcadorAppUITests.
  • Tienes la carpeta Ejercicios con la subcarpeta OrdenDeLosAvisos y su controlador, y el proyecto compila con ella.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Cuál de estos NO es un callback de UIKit, aunque aparezca en el registro del ciclo de vida?

  2. 2.¿Por qué Registro pasa a nonisolated?

  3. 3.El usuario gira el iPhone con el controlador en pantalla. ¿Qué ocurre?

  4. 4.¿Cuál es la relación entre onResume de Android y viewDidAppear de iOS?

  5. 5.Abres la hoja del controlador y la cierras. ¿En qué orden salen los avisos en el registro?

Faltan 5 de 5