Saltar al contenido
K
Arquitectura en iOSPaso a paso36 min de lectura · 15 min de máquina Intermedio

El ViewModel y el hilo principal

Al terminar: Escribir el ViewModel y entender el hilo principal.

Al terminar, existirá PartidoViewModel: la clase que lleva el partido en directo, decide qué se ve (uiState), registra los goles con el caso de uso y mueve el reloj con [weak self] para que no se quede retenida en memoria. Tocarás Configuracion.swift (dos cambios pequeños) y escribirás un fichero nuevo en tres tandas.

Importa porque el ViewModel es el centro de MVVM: la pantalla solo lee lo que él expone, y el hilo principal es donde SwiftUI espera que cambie ese estado.

Temas: @MainActor y el hilo principal, un estado calculado, y el error típico de la closure que captura self.

El hilo principal y @MainActor

SwiftUI lee el estado desde el hilo principal, el que dibuja la pantalla; si otro hilo lo cambia mientras la vista lo lee, el resultado es impredecible. @MainActor delante de la clase dice «esto solo se toca desde el hilo principal». Se dice que la clase queda aislada a ese hilo: desde otro sitio, el compilador exige await para llamarla.

En el target de la app, @MainActor ya viene puesto por defecto (lo viste en Tu primera app de iOS), así que aquí es redundante. Se escribe porque dice la intención y porque el target de pruebas no tiene ese ajuste: por eso las pruebas llevan su propio @MainActor. El paquete Torneo, en cambio, no aísla nada por defecto: su código es nonisolated (sin aislar, utilizable desde cualquier hilo).

Un ViewModel, además, expone estado calculado: una propiedad que no guarda nada y construye el uiState a partir de lo que hay. Un ejemplo pequeño, un carrito de la compra, con tres piezas: la propiedad calculada, .init(…) sin el nombre del tipo (Swift lo deduce) y if case let, que mira solo un caso de un Result (el de MVVM: el estado, el repositorio y el caso de uso).

ejemplo mínimo: un ViewModel con estado calculado, .init(…) e if case let (con comentarios)
enum Fallo: Error { case sinStock(String) }              // un fallo esperable
enum Pantalla: Equatable {                              // el estado de la pantalla, como en la lección anterior
    case vacia
    case lista(Resumen)
    struct Resumen: Equatable {
        let total: Int
        let ultimo: String
    }
}
 
@MainActor                                              // solo se toca desde el hilo principal
final class CarritoViewModel {
    private(set) var productos: [String] = []           // la vista lee, solo el ViewModel escribe
 
    // Una propiedad calculada que construye el estado a partir de lo que hay.
    var pantalla: Pantalla {
        guard let ultimo = productos.last else { return .vacia }       // sin productos: vacía
        return .lista(.init(total: productos.count, ultimo: ultimo))   // .init(…) = Pantalla.Resumen(…)
    }
 
    func anadir(_ producto: String) -> Result<Int, Fallo> {
        guard producto != "caviar" else { return .failure(.sinStock(producto)) }
        productos.append(producto)
        return .success(productos.count)
    }
 
    func alTocar(_ producto: String) {
        // if case let: mira solo el caso de fallo y saca su valor.
        if case let .failure(fallo) = anadir(producto) {
            print("no se pudo: \(fallo)")
        }
    }
}
 
let carrito = CarritoViewModel()                        // en un Playground, el nivel superior es del actor principal
print(carrito.pantalla)
carrito.alTocar("pan")
carrito.alTocar("caviar")
print(carrito.pantalla == .lista(.init(total: 1, ultimo: "pan")))
vacia
no se pudo: sinStock("caviar")
true

Una forma corta que verás en el ejercicio y en el ViewModel: si mensajeDeError es un opcional, if let mensajeDeError { … } es lo mismo que if let mensajeDeError = mensajeDeError { … }: dentro de las llaves hay un texto de verdad, sin el «nada».

El nivel superior de un Playground corre en el actor principal (@MainActor y el hilo principal), por eso se puede crear y usar el carrito sin await.

Error típico: la closure guardada que captura self

El ViewModel que vas a escribir usa un Cronometro (el reloj del paquete Torneo que escribiste en Retain cycles): el ViewModel lo guarda, y el reloj guarda una closure en su propiedad alTick que llama al ViewModel. Si esa closure captura self con referencia fuerte, los dos se retienen y el ViewModel se queda en memoria sin ningún aviso.

con el bug: la closure guardada retiene a self
reloj.alTick = { self.alMinuto() }
corregido: [weak self]
reloj.alTick = { [weak self] in self?.alMinuto() }

Con el primero, ViewModel → reloj → closure → ViewModel es un ciclo: ningún contador llega a cero y deinit no sale nunca. La solución es [weak self]: la closure mira al ViewModel sin retenerlo. El interruptor reintroducirElCiclo permite volver a poner el bug a propósito para verlo en Ver un ciclo de retención: deinit y Memory Graph.

Reto · Ejercicio: el pedido de la cafetería

En tu Playground (reutiliza el mismo y borra lo anterior), escribe:

  • un estado de pantalla, PantallaDelPedido, con tres casos que se pueden comparar: vacia, error (con su mensaje) y lista (con el número de cafés);
  • un caso de uso, PedirCafe, que se llama como una función con el nombre de un café y devuelve el café o un fallo «agotado» con su nombre; el moca está agotado;
  • un ViewModel del hilo principal, PedidoViewModel. Guarda los cafés pedidos y un mensaje de error (los dos, solo los cambia él). Tiene un estado pantalla calculado (primero el error, luego vacía, luego la lista) y un método alPedir que usa el caso de uso: si sale bien, añade el café y borra el error; si falla, guarda «No queda moca».

Crea el ViewModel e imprime su pantalla; pide un café solo y un cortado e imprime; pide un moca e imprime. Lo que debe salir:

vacia
lista(2)
error("No queda moca")
Ver solución
solución: el pedido de la cafetería
// Lo que puede mostrar la pantalla del pedido, en un solo valor.
enum PantallaDelPedido: Equatable {
    case vacia
    case error(String)
    case lista(Int)                       // cuántos cafés lleva el pedido
}
 
// El caso de uso: pedir un café. Falla si ese café está agotado.
struct PedirCafe {
    enum Fallo: Error { case agotado(String) }
    let agotados: [String]
 
    func callAsFunction(_ cafe: String) -> Result<String, Fallo> {
        if agotados.contains(cafe) { return .failure(.agotado(cafe)) }
        return .success(cafe)
    }
}
 
// El ViewModel: del hilo principal; la pantalla lee, solo él escribe.
@MainActor
final class PedidoViewModel {
    private(set) var cafes: [String] = []
    private(set) var mensajeDeError: String?
    private let pedirCafe = PedirCafe(agotados: ["moca"])
 
    // Calculada: construye el estado a partir de lo que hay.
    var pantalla: PantallaDelPedido {
        if let mensajeDeError { return .error(mensajeDeError) }
        if cafes.isEmpty { return .vacia }
        return .lista(cafes.count)
    }
 
    func alPedir(_ cafe: String) {
        switch pedirCafe(cafe) {
        case let .success(servido):
            cafes.append(servido)
            mensajeDeError = nil
        case let .failure(.agotado(nombre)):
            mensajeDeError = "No queda \(nombre)"
        }
    }
}
 
let pedido = PedidoViewModel()
print(pedido.pantalla)
pedido.alPedir("solo")
pedido.alPedir("cortado")
print(pedido.pantalla)
pedido.alPedir("moca")
print(pedido.pantalla)

Dos piezas de la solución: if let mensajeDeError { … } es la forma corta de if let que viste arriba, y case let .failure(.agotado(nombre)) abre dos capas a la vez: es un fallo (.failure) y, dentro de él, el caso .agotado, del que saca el nombre.

La última línea es error(…) y no lista(2) porque la propiedad calculada mira primero el error: mientras haya mensaje, la pantalla es la de error aunque ya haya cafés pedidos.

Paso 1 · Dos ajustes en Configuracion.swift

Objetivo: que el ViewModel pueda leer los ajustes de ejecución y llevar un interruptor para fabricar a propósito el fallo de la lección del Memory Graph.

Abre Configuracion.swift (la creaste en ObservableObject y @StateObject) y haz dos cambios:

  1. Añade nonisolated delante de enum Configuracion. En una app de Xcode 26 todo el código es @MainActor por defecto, así que Configuracion.msPorMinuto queda aislado al hilo principal. El ViewModel la lee en los valores por defecto de su init (paso 2) y, sin nonisolated, Xcode lo marca con un aviso amarillo por cada propiedad que lee (serán dos al terminar el paso 2), por ejemplo: main actor-isolated static property 'msPorMinuto' can not be referenced from a nonisolated context. nonisolated quita ese aislamiento y dice «esto se puede leer desde cualquier sitio».
  2. Añade una propiedad más, cicloDelCronometro: un interruptor que se lee de los ajustes con esa misma clave y que, si nadie lo enciende, está apagado y no hace nada.

Intenta escribir tú los dos cambios antes de abrir el bloque.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Configuracion.swift, con comentarios
import Foundation
 
// Ajustes de ejecución de la app. Se leen de `UserDefaults`, y iOS rellena `UserDefaults`
// con los ARGUMENTOS DE LANZAMIENTO que empiezan por guion: `xcrun simctl launch … -msPorMinuto 20`
// (o en el esquema de Xcode, la configuración de cómo se lanza la app, «Arguments Passed On Launch») da `msPorMinuto == 20`.
// Sirve para que las pruebas de UI no esperen 22 segundos por partido.
// nonisolated: se puede leer desde cualquier sitio, también desde valores por defecto de un init
nonisolated enum Configuracion {
    // Cuánto tarda un minuto de partido, en milisegundos (en Kotlin, `MS_POR_MINUTO = 250`).
    static var msPorMinuto: Int {
        let valor = UserDefaults.standard.integer(forKey: "msPorMinuto")
        return valor > 0 ? valor : 250
    }
 
    // Interruptor de la lección «Ver un ciclo de retención»: apagado salvo que se lance con -cicloDelCronometro YES
    static var cicloDelCronometro: Bool {
        UserDefaults.standard.bool(forKey: "cicloDelCronometro")
    }
}

El interruptor se enciende lanzando la app con el argumento -cicloDelCronometro YES, igual que -msPorMinuto: iOS guarda en UserDefaults los argumentos que empiezan por guion. Lo usarás en Ver un ciclo de retención: deinit y Memory Graph.

Paso 2 · El ViewModel: PartidoViewModel.swift

Objetivo: que el partido en directo viva en una clase que decide, sin saber nada de SwiftUI.

El ViewModel es el jefe de cocina. Es lo que antes hacía PartidoEnVivoModelo (que sigue en tu proyecto hasta la lección siguiente), con tres diferencias: recibe un partidoId y un repositorio en vez del partido entero, expone un único uiState y para registrar un gol usa el caso de uso. Lo escribes en tres tandas; crea PartidoViewModel.swift (clic derecho sobre la carpeta Marcador/UI → New File from Template… → Swift File, solo con el target MarcadorApp marcado). Va en Marcador/UI junto a PantallaMarcador, porque prepara lo que esa pantalla muestra.

MarcadorApp
MarcadorApp
Marcador
UI
PartidoViewModel.swiftse crea
El fichero de este paso.

Tanda 1: las propiedades y el init

Las propiedades son las de PartidoEnVivoModelo más el partido (que ahora se pide al repositorio) y mensajeDeError. Todas con private(set): la vista lee, pero solo el ViewModel escribe. Las que ninguna pantalla pinta llevan @ObservationIgnored (@Observable). deinit se ejecuta cuando ARC libera el objeto (ARC) y deja una línea en el registro: es la prueba de que se libera.

Intenta escribirlo tú: PartidoViewModel, una clase del hilo principal, observable y de la que nadie hereda, con lo siguiente:

  • Lo que la vista lee y solo él escribe: el partidoId (que no cambia), el partido y el mensajeDeError (los dos pueden faltar), el minuto (0), si va corriendo (no), el ultimoAviso («ninguno»), la duracion (90) y dos marcadores a 0-0, golesEnVivo (los del guion) y golesAMano (los de los botones).
  • Lo que ninguna pantalla pinta, privado y sin vigilar: el repositorio, el caso de uso registrarGol (creado con ese repositorio), msPorMinuto, el interruptor reintroducirElCiclo, un cronometro (el reloj del paquete Torneo de Retain cycles; vacío al principio, porque se crea al jugar) y eventosDelGuion (una lista vacía al principio).
  • Un inicializador con las etiquetas partidoId, repositorio, msPorMinuto y reintroducirElCiclo (las dos últimas, si no se dan, salen de Configuracion: así las pruebas pueden pasar otras) que lo guarda todo y carga el partido.
  • Al liberarse, anota en el registro «PartidoViewModel LIBERADO».
  • marcador, calculado: los goles del guion más los goles a mano.
  • cargar, privado: pide el partido al repositorio y, si no existe, deja el mensaje «No existe el partido n.º 99» con su id.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Marcador/UI/PartidoViewModel.swift
import Foundation
import Observation
import Torneo
 
@MainActor      // todo lo de esta clase se toca desde el hilo principal
@Observable     // las vistas que lean sus propiedades se redibujan cuando cambian
final class PartidoViewModel {
    let partidoId: Int
 
    // private(set): la vista lee, pero solo el ViewModel escribe
    private(set) var partido: Partido?
    private(set) var mensajeDeError: String?
    private(set) var minuto = 0
    private(set) var corriendo = false
    private(set) var ultimoAviso = "ninguno"
    private(set) var duracion = 90
    private(set) var golesEnVivo = Marcador(local: 0, visitante: 0)   // los del guion del partido
    private(set) var golesAMano = Marcador(local: 0, visitante: 0)    // los de los botones
 
    // Las pantallas no leen estas: no hace falta vigilarlas
    @ObservationIgnored private let repositorio: any PartidosRepositorio
    @ObservationIgnored private let registrarGol: RegistrarGol
    @ObservationIgnored private let msPorMinuto: Int
    @ObservationIgnored private let reintroducirElCiclo: Bool
    @ObservationIgnored private var cronometro: Cronometro?
    @ObservationIgnored private var eventosDelGuion: [EventoDePartido] = []
 
    init(
        partidoId: Int,
        repositorio: any PartidosRepositorio,
        msPorMinuto: Int = Configuracion.msPorMinuto,
        reintroducirElCiclo: Bool = Configuracion.cicloDelCronometro
    ) {
        self.partidoId = partidoId
        self.repositorio = repositorio
        self.registrarGol = RegistrarGol(repositorio: repositorio)
        self.msPorMinuto = msPorMinuto
        self.reintroducirElCiclo = reintroducirElCiclo
        cargar()
    }
 
    deinit {
        Registro.anotar("PartidoViewModel LIBERADO")   // si esta línea no sale al cerrar la pantalla, algo lo retiene
    }
 
    var marcador: Marcador {
        Marcador(local: golesEnVivo.local + golesAMano.local, visitante: golesEnVivo.visitante + golesAMano.visitante)
    }
 
    private func cargar() {
        if let encontrado = repositorio.partido(id: partidoId) {
            partido = encontrado
            mensajeDeError = nil
        } else {
            partido = nil
            mensajeDeError = "No existe el partido n.º \(partidoId)"
        }
    }
}

Cmd+B debe compilar, con Configuracion ya arreglada del paso anterior.

Tanda 2: uiState y alGol

Dos miembros nuevos dentro de la clase, encima de cargar():

  • uiState es una propiedad calculada, como la del carrito: no guarda nada, construye el MarcadorUiState a partir de lo que hay. Si hay mensaje de error, el error; si todavía no hay partido, «cargando»; y si no, el éxito con los siete valores.
  • alGol, que recibe un lado sin etiqueta, es el único punto de entrada de los toques: suma el gol al marcador de los botones y lo registra con el caso de uso en el minuto actual; si el caso de uso devuelve un fallo, lo anota en el registro como «no se pudo registrar el gol:» seguido del fallo.

Intenta escribirlos tú.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
PartidoViewModel.swift, tanda 2 (con comentarios)
    // Lo que ve la pantalla, en un solo valor: primero el error, luego «cargando» y por último los datos
    var uiState: MarcadorUiState {
        if let mensajeDeError { return .error(mensajeDeError) }   // `if let x` es la forma corta de `if let x = x`
        guard let partido else { return .cargando }               // sin partido todavía: cargando
        return .exito(.init(                                      // .init(…) = MarcadorUiState.Exito(…): Swift deduce el tipo
            partidoId: partidoId, partido: partido, marcador: marcador, minuto: minuto,
            corriendo: corriendo, ultimoAviso: ultimoAviso, duracion: duracion
        ))
    }
 
    // La vista avisa de QUÉ ha pasado (un gol de este lado); el ViewModel decide qué hacer
    func alGol(_ lado: Lado) {
        switch lado {   // primero se ve en el marcador...
        case .local: golesAMano = Marcador(local: golesAMano.local + 1, visitante: golesAMano.visitante)
        case .visitante: golesAMano = Marcador(local: golesAMano.local, visitante: golesAMano.visitante + 1)
        }
        // ...y luego se guarda en el repositorio con el caso de uso; si falla, se anota
        if case let .failure(fallo) = registrarGol(partidoId: partidoId, lado: lado, minuto: minuto) {
            Registro.anotar("no se pudo registrar el gol: \(fallo)")
        }
    }

Tanda 3: jugar, prepararReloj y alMinuto

Tres métodos más, al final de la clase (Registro es el que creaste en La app, sus escenas y el paquete Torneo). jugar es el de PartidoEnVivoModelo, con una diferencia: en vez de contar los minutos él mismo, le manda avanzar a un Cronometro, y éste avisa con su closure alTick (la del error típico de arriba). Intenta escribirlos tú:

  1. jugar, asíncrono, que recibe la duración con la etiqueta duracion (fuera se llama duracion:, pero dentro la función la llama nueva, porque duracion ya es una propiedad de la clase: una función puede tener un nombre para quien la llama y otro para su interior), tiene tres partes:
    • Preparar: vuelve a cargar el partido (y sale si no hay), guarda la duración, pone a cero el minuto, los dos marcadores y el aviso («ninguno»), y se queda con los eventos del guion hasta esa duración.
    • Arrancar el reloj: crea un Cronometro, lo guarda, lo prepara y marca que va corriendo.
    • Tantas veces como minutos dure: espera los milisegundos de un minuto y hace avanzar el reloj. Si cancelan la tarea durante la espera, anota «partido CANCELADO en el minuto 12», deja de correr y sale; al terminar, deja de correr y anota «partido TERMINADO en el minuto 90».
  2. prepararReloj, privado, recibe el reloj sin etiqueta y le da una closure que, en cada minuto, llama a alMinuto sin retener al ViewModel; salvo que reintroducirElCiclo esté encendido, y entonces lo retiene (el bug, a propósito).
  3. alMinuto, privado, aplica los goles del guion de ese minuto y actualiza minuto. Cada 15 minutos guarda el aviso con este formato exacto, «minuto 90 con 2-1»: el minuto y el marcador, que se escribe como goles del local, guion y goles del visitante. Las pruebas lo comprueban.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
PartidoViewModel.swift, tanda 3 (con comentarios)
    // La vista la llama desde `.task(id:)`: si la pantalla se va, SwiftUI cancela esta tarea
    func jugar(duracion nueva: Int) async {
        // 1. Preparar
        cargar()
        guard let partido else { return }          // sin partido no hay nada que jugar
        duracion = nueva
        minuto = 0                                 // cada vez empieza de cero
        golesEnVivo = Marcador(local: 0, visitante: 0)
        golesAMano = Marcador(local: 0, visitante: 0)
        ultimoAviso = "ninguno"
        eventosDelGuion = partido.eventos.filter { $0.minuto <= nueva }   // solo los eventos que caben en esta duración
 
        // 2. Arrancar el reloj
        let reloj = Cronometro()                   // el del paquete Torneo: cuenta minutos cuando se lo mandan
        cronometro = reloj
        prepararReloj(reloj)
        corriendo = true
 
        // 3. Un minuto de partido por vuelta
        for _ in 1...nueva {
            do {
                try await Task.sleep(for: .milliseconds(msPorMinuto))   // un minuto de partido
            } catch {                              // cancelaron la tarea: se anota y se sale
                Registro.anotar("partido CANCELADO en el minuto \(minuto)")
                corriendo = false
                return
            }
            reloj.avanzar()                        // suma un minuto y llama a `alTick`
        }
        corriendo = false
        Registro.anotar("partido TERMINADO en el minuto \(minuto)")
    }
 
    // Engancha al reloj la closure que se ejecutará en cada minuto
    private func prepararReloj(_ reloj: Cronometro) {
        if reintroducirElCiclo {
            reloj.alTick = { self.alMinuto() }              // el bug: la closure guardada retiene a self
        } else {
            reloj.alTick = { [weak self] in self?.alMinuto() }   // bien: la closure mira a self sin retenerlo
        }
    }
 
    // Aplica lo que pasa en el minuto que acaba de contar el reloj
    private func alMinuto() {
        guard let reloj = cronometro, let partido else { return }
        let nuevo = reloj.minuto
        for evento in eventosDelGuion where evento.minuto == nuevo {   // los goles del guion de ese minuto
            golesEnVivo = golesEnVivo.despuesDe(evento, en: partido)
        }
        minuto = nuevo
        if nuevo % 15 == 0 { ultimoAviso = "minuto \(nuevo) con \(marcador)" }   // cada 15 minutos, un aviso
    }

Cmd+B debe compilar. La app todavía se comporta como antes: la vista sigue usando PartidoEnVivoModelo, y el ViewModel espera a que la conectes en La pantalla pinta el uiState.

Punto de control

  • Configuracion.swift lleva nonisolated y la propiedad cicloDelCronometro, y existe PartidoViewModel.swift con sus tres tandas.
  • Cmd+B termina en Build Succeeded y sin avisos amarillos (si sale el de main actor-isolated static property, falta el nonisolated).
  • La pantalla del partido se ve y se comporta como antes, porque todavía usa PartidoEnVivoModelo.
  • Cmd+U sigue con las mismas 19 pruebas en verde: 9 unitarias y 10 de interfaz.
  • Sabes decir por qué [weak self] evita que el ViewModel se quede en memoria y qué aporta @MainActor.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.¿Qué garantiza @MainActor delante de PartidoViewModel?

  2. 2.reloj.alTick = { self.alMinuto() } guarda una closure que captura self con referencia fuerte, y el ViewModel guarda el reloj. ¿Qué pasa al cerrar la pantalla?

  3. 3.¿Por qué PartidoViewModel no hace import SwiftUI?

  4. 4.¿Por qué se escribe nonisolated delante de enum Configuracion?

  5. 5.uiState del ViewModel es una propiedad calculada. ¿Qué significa?

  6. 6.En func jugar(duracion nueva: Int), ¿qué es duracion y qué es nueva?

Faltan 6 de 6