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).
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")
trueUna 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.
reloj.alTick = { self.alMinuto() }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) ylista(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 estadopantallacalculado (primero el error, luego vacía, luego la lista) y un métodoalPedirque 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
// 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:
- Añade
nonisolateddelante deenum Configuracion. En una app de Xcode 26 todo el código es@MainActorpor defecto, así queConfiguracion.msPorMinutoqueda aislado al hilo principal. El ViewModel la lee en los valores por defecto de suinit(paso 2) y, sinnonisolated, 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.nonisolatedquita ese aislamiento y dice «esto se puede leer desde cualquier sitio». - 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
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.
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), elpartidoy elmensajeDeError(los dos pueden faltar), elminuto(0), si vacorriendo(no), elultimoAviso(«ninguno»), laduracion(90) y dos marcadores a 0-0,golesEnVivo(los del guion) ygolesAMano(los de los botones). - Lo que ninguna pantalla pinta, privado y sin vigilar: el
repositorio, el caso de usoregistrarGol(creado con ese repositorio),msPorMinuto, el interruptorreintroducirElCiclo, uncronometro(el reloj del paqueteTorneode Retain cycles; vacío al principio, porque se crea al jugar) yeventosDelGuion(una lista vacía al principio). - Un inicializador con las etiquetas
partidoId,repositorio,msPorMinutoyreintroducirElCiclo(las dos últimas, si no se dan, salen deConfiguracion: 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
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():
uiStatees una propiedad calculada, como la del carrito: no guarda nada, construye elMarcadorUiStatea 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
// 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ú:
jugar, asíncrono, que recibe la duración con la etiquetaduracion(fuera se llamaduracion:, pero dentro la función la llamanueva, porqueduracionya 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».
prepararReloj, privado, recibe el reloj sin etiqueta y le da una closure que, en cada minuto, llama aalMinutosin retener al ViewModel; salvo quereintroducirElCicloesté encendido, y entonces lo retiene (el bug, a propósito).alMinuto, privado, aplica los goles del guion de ese minuto y actualizaminuto. 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
// 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.swiftllevanonisolatedy la propiedadcicloDelCronometro, y existePartidoViewModel.swiftcon sus tres tandas.- Cmd+B termina en
Build Succeededy sin avisos amarillos (si sale el demain actor-isolated static property, falta elnonisolated). - 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.¿Qué garantiza
@MainActordelante dePartidoViewModel?2.
reloj.alTick = { self.alMinuto() }guarda una closure que capturaselfcon referencia fuerte, y el ViewModel guarda el reloj. ¿Qué pasa al cerrar la pantalla?3.¿Por qué
PartidoViewModelno haceimport SwiftUI?4.¿Por qué se escribe
nonisolateddelante deenum Configuracion?5.
uiStatedel ViewModel es una propiedad calculada. ¿Qué significa?6.En
func jugar(duracion nueva: Int), ¿qué esduraciony qué esnueva?