La pantalla pinta el uiState
Al terminar: Hacer que la pantalla pinte el uiState del ViewModel.
Al terminar, la pantalla del partido de Marcador no tendrá lógica: pintará un único valor, uiState, y pasará los toques a PartidoViewModel, que decide todo lo demás. Cambiarás cuatro ficheros de la app, borrarás PartidoEnVivoModelo.swift y, en las pruebas, escribirás uno nuevo y limpiarás otro. El partido se verá igual que antes, y al cerrarlo el registro dirá PartidoViewModel LIBERADO.
Importa porque es el momento en que MVVM deja de ser un dibujo: la vista solo lee y avisa, y por eso el ViewModel se puede probar sin pantalla.
Temas: quién crea, posee y libera el ViewModel; un switch sobre el estado dentro de la vista; y las pruebas del ViewModel.
Quién lo crea, lo posee y lo libera
Con un objeto que vive tanto como una pantalla hay tres preguntas:
- Quién lo crea: el
initde la vista, con elpartidoIdy el repositorio.PartidoContenedor(la vista que la envuelve, en el mismo fichero) recoge el repositorio con@Environment(RepositorioEnMemoria.self), que la raíz de la app repartirá con.environment(repositorio), y se lo pasa. - Quién lo posee:
@State. SwiftUI guarda el objeto mientras la vista siga en pantalla y lo conserva aunque elstructde la vista se recree; solo se queda con el de la primera vez. - Quién lo libera: al cerrar la hoja, SwiftUI suelta su
@Statey, si nadie más lo retiene, ARC llama adeinit: el registro dicePartidoViewModel LIBERADO. Si algo más lo retuviera, esa línea no saldría.
Una vista que pinta un estado
La vista ya no decide nada: recibe el estado y lo dibuja. Con un uiState de tres casos, el body es un switch exhaustivo. Tres piezas: Group, que agrupa lo que salga del switch para colgarle modificadores una sola vez; ProgressView(), la ruedecita de carga; y @Environment(\.dismiss), una acción que cierra la pantalla en la que estás (aquí, la hoja) y se llama como una función. \.dismiss es el nombre del dato que se pide al sistema (@Environment rellena la propiedad con él, como \.scenePhase en La app, sus escenas y el paquete Torneo).
import SwiftUI
enum EstadoDelEstante: Equatable {
case cargando
case error(String)
case exito([String])
}
struct EstanteriaView: View {
let estado: EstadoDelEstante
@Environment(\.dismiss) private var cerrar // una acción que quita esta pantalla de su hoja
var body: some View {
Group { // agrupa lo que salga del switch para colgarle modificadores
switch estado { // exhaustivo: un caso por estado
case .cargando:
ProgressView() // la ruedecita de carga
case let .error(mensaje):
VStack {
Text(mensaje).foregroundStyle(.red)
Button("Volver") { cerrar() } // se llama como una función
}
case let .exito(titulos):
contenido(titulos) // el dibujo, en una función aparte
}
}
.padding() // cuelga del Group: vale para los tres casos
}
// Una función que devuelve una vista; recibe los datos ya abiertos.
private func contenido(_ titulos: [String]) -> some View {
let total = titulos.count // una línea antes...
return VStack(alignment: .leading) { // ...obliga a escribir return
Text("\(total) libros").font(.headline)
Text(titulos.joined(separator: ", "))
}
}
}Con .cargando se ve la ruedecita; con .error("Sin red"), el mensaje en rojo y el botón «Volver»; con .exito(["Ana", "Luis"]), «2 libros» y debajo los dos títulos. Compila en un proyecto de iOS.
Reto · Ejercicio: la previsión del tiempo
Crea la subcarpeta EstadosDeCarga dentro de MarcadorApp/Ejercicios (como en El ciclo de vida de UIViewController) y, dentro, el fichero EstadoDelTiempo.swift. Los nombres de tipo no se pueden repetir entre ejercicios ni con los de la app: usa los de este enunciado. Escribe:
- un estado,
EstadoDelTiempo, que se pueda comparar, con tres casos:cargando,error(con su mensaje) yexito(con una lista de textos, uno por día); - una vista,
PrevisionView, que recibe un estado y no decide nada. Mientras carga, la ruedecita; con error, el mensaje en rojo; con éxito, el título «Próximos 3 días» (con el número que toque) y un texto por día, en columna. El dibujo del éxito va en una función aparte.
Escribe tres vistas previas con nombre, una por estado: «Cargando», «Error» (con «Sin conexión») y «Éxito» (con «Lunes: sol», «Martes: lluvia» y «Miércoles: nubes»). Una pieza nueva: #Preview("Nombre") { … } pone nombre a una vista previa, y un fichero puede tener varias. Con el lienzo abierto (Editor → Canvas, como en Primeros pasos con SwiftUI), Xcode enseña una pestaña por vista previa: en «Éxito» debe verse el título «Próximos 3 días» y los tres días debajo.
Ver solución
import SwiftUI
// Lo que puede mostrar la pantalla del tiempo, en un solo valor.
enum EstadoDelTiempo: Equatable {
case cargando
case error(String)
case exito([String]) // un texto por día
}
// Pinta el estado que recibe: no decide nada.
struct PrevisionView: View {
let estado: EstadoDelTiempo
var body: some View {
Group {
switch estado {
case .cargando:
ProgressView()
case let .error(mensaje):
Text(mensaje).foregroundStyle(.red)
case let .exito(dias):
lista(dias)
}
}
.padding()
}
private func lista(_ dias: [String]) -> some View {
let titulo = "Próximos \(dias.count) días"
return VStack(alignment: .leading, spacing: 8) {
Text(titulo).font(.headline)
ForEach(dias, id: \.self) { dia in
Text(dia)
}
}
}
}
#Preview("Cargando") {
PrevisionView(estado: .cargando)
}
#Preview("Error") {
PrevisionView(estado: .error("Sin conexión"))
}
#Preview("Éxito") {
PrevisionView(estado: .exito(["Lunes: sol", "Martes: lluvia", "Miércoles: nubes"]))
}Compila dentro del proyecto sin avisos. El fichero se queda en Ejercicios: no se borra y no afecta a la app.
Paso 1 · Conectar la vista, la raíz y las pruebas
Objetivo: que la pantalla pinte el uiState, que el repositorio llegue a todas partes y que PartidoEnVivoModelo desaparezca. Al terminar, el partido se ve igual que antes, y al cerrarlo el registro dice PartidoViewModel LIBERADO.
La vista
Cambios en PartidoEnDirectoView.swift:
1. El tipo de la propiedad y el init:
@State private var modelo: PartidoEnVivoModelo
// …
init(partido: Partido) {
_modelo = State(wrappedValue: PartidoEnVivoModelo(partido: partido)) @State private var viewModel: PartidoViewModel // la vista es la dueña del ViewModel
// …
init(partidoId: Int, repositorio: any PartidosRepositorio) { // recibe un id y el contrato, no el partido entero
_viewModel = State(wrappedValue: PartidoViewModel(partidoId: partidoId, repositorio: repositorio))2. El resto de la estructura: desde var body: some View { hasta la llave que cierra PartidoEnDirectoView (justo antes del comentario de BotonesDeGolConAccion), sustitúyelo por el bloque de abajo, que empieza con una propiedad nueva, cerrar. El dibujo es el que ya tenías; cambia que ahora está dentro de contenido(_:) y lee datos.… en vez de modelo.….
Intenta escribirlo tú: la propiedad cerrar, que quita la pantalla de su hoja; un body que, según el estado de la pantalla, enseña una ruedecita mientras carga, el mensaje en rojo con un botón «Volver» que cierra la hoja si hay error, y con éxito el dibujo de siempre; y, colgados de todo eso, el lanzamiento del partido con la duración elegida y el contador de pausas de antes. El dibujo va en una función aparte, contenido, que recibe los datos del éxito y lee de ellos el partido, el marcador, el minuto y el aviso; los botones de gol avisan al ViewModel. Y estado, que pasa a ser una función que recibe esos datos y devuelve «Partido terminado», «En juego» o «Sin empezar». Lo que debes ver: el partido igual que antes.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
@Environment(\.dismiss) private var cerrar // quita esta pantalla de la hoja; lo usa el estado de error
var body: some View {
// `switch` exhaustivo sobre el estado: si mañana hay uno más, no compila hasta que lo trates
Group {
switch viewModel.uiState {
case .cargando:
ProgressView() // la ruedecita
case let .error(mensaje):
VStack(spacing: 16) {
Text(mensaje).foregroundStyle(.red)
Button("Volver") { cerrar() }
}
case let .exito(datos):
contenido(datos) // el dibujo de siempre, ahora con los datos del estado
}
}
// Lo que antes colgaba del ScrollView ahora cuelga del Group: la vista sigue lanzando el partido
.task(id: ajustes.duracion) {
await viewModel.jugar(duracion: ajustes.duracion)
}
.onChange(of: fase) { _, nueva in
if nueva == .background { pausas += 1 }
}
}
// El dibujo de antes, en una función que recibe los datos del estado
private func contenido(_ datos: MarcadorUiState.Exito) -> some View {
@Bindable var ajustes = ajustes // sigue haciendo falta para `$ajustes.duracion`
return ScrollView { // `return` explícito: el cuerpo ya no es una sola expresión, tiene una línea antes
VStack(spacing: 24) {
TarjetaDePartido(partido: datos.partido, marcador: datos.marcador)
// Se pasa el método del ViewModel como valor: la vista avisa de QUÉ pasó y no decide nada
BotonesDeGolConAccion(partido: datos.partido, alGol: viewModel.alGol)
VStack(alignment: .leading, spacing: 8) {
Text("Minuto \(datos.minuto)'")
.font(.title)
.monospacedDigit()
.accessibilityIdentifier("minuto")
Text(estado(datos))
.foregroundStyle(.secondary)
.accessibilityIdentifier("estado")
Text("Último aviso (cada 15'): \(datos.ultimoAviso)")
Text("Veces que la pantalla pasó a segundo plano: \(pausas)")
}
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .leading, spacing: 8) {
Text("Duración del partido")
Picker("Duración", selection: $ajustes.duracion) {
ForEach(AjustesModelo.duraciones, id: \.self) { minutos in
Text("\(minutos) min").tag(minutos)
}
}
.pickerStyle(.segmented)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(16)
}
}
// `estado` era una propiedad que leía el modelo; ahora es una función que lee los datos
private func estado(_ datos: MarcadorUiState.Exito) -> String {
// un `if` que da un valor: cada rama es el texto que devuelve la función
if datos.minuto >= datos.duracion { "Partido terminado" }
else if datos.corriendo { "En juego" }
else { "Sin empezar" }
}
}3. PartidoContenedor, al final del fichero, recoge el repositorio y se lo pasa a la vista:
let item: PartidoDeLista
@State private var redibujados = 0
// …
PartidoEnDirectoView(partido: item.partido) let item: PartidoDeLista
@Environment(RepositorioEnMemoria.self) private var repositorio // lo puso la raíz de la app
@State private var redibujados = 0
// …
PartidoEnDirectoView(partidoId: item.id, repositorio: repositorio)La raíz y la lista
Tres ficheros pequeños. En MarcadorApp.swift, el repositorio nace una vez, junto a los ajustes, y se reparte con .environment:
@State private var repositorio = RepositorioEnMemoria() // debajo de `ajustes`; nace una vez
// …
.environment(repositorio) // debajo de `.environment(ajustes)`En ContentView.swift, la lista ya no recibe los partidos:
ListaDePartidosView(partidos: DatosDeEjemplo.partidos) // antes
ListaDePartidosView() // despuésEn ListaDePartidosView.swift, la lista los lee del repositorio, así que cambia la propiedad, el List y la vista previa (que no pasa por MarcadorApp, y hay que darle el repositorio y los ajustes a mano):
let partidos: [PartidoDeLista]
// …
List(partidos) { item in
// …
ListaDePartidosView(partidos: DatosDeEjemplo.partidos) @Environment(RepositorioEnMemoria.self) private var repositorio // al registrar un gol, la fila cambia sola
// …
List(repositorio.partidos) { item in
// …
ListaDePartidosView()
.environment(RepositorioEnMemoria())
.environment(AjustesModelo())Borra el modelo viejo y cambia las pruebas
Borra PartidoEnVivoModelo.swift: clic derecho sobre el fichero → Delete → Move to Trash. Su trabajo ya lo hace el ViewModel. Con él se van las cinco pruebas de MarcadorAppTests que lo usaban, y llegan las siete de PartidoViewModelTests: 9 unitarias − 5 + 7 = 11.
1. En MarcadorAppTests.swift, borra estas cinco funciones de prueba enteras (cada una con el comentario que lleve encima): unModeloSinArrancarSeLibera, jugarAplicaLosGolesDelGuion, unPartidoDeSesentaMinutosIgnoraLosGolesPosteriores, losGolesAManoSeSumanAlDelGuion y cancelarLaTareaDetieneElPartido. Se quedan las otras cuatro: elPaqueteTorneoLlegaALaApp, sumarUnGolCreaOtroMarcador, losDatosDeEjemploTienenDieciochoPartidosConIdUnico y observableAvisaSoloDeLoQueSeLee. Una de las borradas, cancelarLaTareaDetieneElPartido, vuelve enseguida con el mismo nombre, pero ahora prueba al ViewModel.
2. Crea PartidoViewModelTests.swift. Las pruebas copian en espejo las carpetas de la app: prueba lo que hay en Marcador/UI, así que va en MarcadorAppTests/Marcador/UI. Esa carpeta no existe todavía: clic derecho sobre MarcadorAppTests → New Folder → Marcador (la de MarcadorAppTests, no la de MarcadorApp), y dentro de ella otra, UI. Después, clic derecho sobre MarcadorAppTests/Marcador/UI → New File from Template… → Swift File → Next, nombre PartidoViewModelTests, y marca solo el target MarcadorAppTests. Selecciona todo lo que trae la plantilla (Cmd+A) y sustitúyelo por el bloque de abajo. Las siete pruebas, por si quieres intentar escribirlas antes: el partido entero con el reloj a 0 ms (marcador 2-1, minuto 90, aviso «minuto 90 con 2-1»); un partido de 60 minutos que ignora el gol del minuto 80 (1-1); el partido 99, que da el estado de error; un gol a mano que se ve y se guarda en el repositorio; la tarea cancelada, que detiene el partido; y las dos del ciclo, que se explican debajo.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import Testing
import Torneo
@testable import MarcadorApp
// Pruebas del ViewModel con el repositorio en memoria.
// `@MainActor`: el ViewModel lo es, y el target de pruebas no tiene el aislamiento por defecto.
@MainActor
struct PartidoViewModelTests {
// Un repositorio con un solo partido, el de ejemplo (id 1)
private func repositorio() -> RepositorioEnMemoria {
RepositorioEnMemoria(
equipos: [Ejemplo.rayo, Ejemplo.toros],
partidos: [PartidoDeLista(id: 1, partido: Ejemplo.rayoContraToros)]
)
}
@Test func jugarAplicaLosGolesDelGuionYActualizaElEstado() async {
let viewModel = PartidoViewModel(partidoId: 1, repositorio: repositorio(), msPorMinuto: 0)
await viewModel.jugar(duracion: 90)
// `guard case let` mira si el estado es `.exito` y saca sus datos; si no, rellena `else`
guard case let .exito(datos) = viewModel.uiState else {
// `Issue.record` apunta un fallo en la prueba sin parar el programa, como un `#expect` que no se cumple
Issue.record("Se esperaba .exito y salió \(viewModel.uiState)")
return
}
#expect(datos.marcador == Marcador(local: 2, visitante: 1))
#expect(datos.minuto == 90)
#expect(!datos.corriendo)
#expect(datos.ultimoAviso == "minuto 90 con 2-1")
}
@Test func unPartidoDeSesentaMinutosIgnoraElGolDelMinutoOchenta() async {
let viewModel = PartidoViewModel(partidoId: 1, repositorio: repositorio(), msPorMinuto: 0)
await viewModel.jugar(duracion: 60)
#expect(viewModel.marcador == Marcador(local: 1, visitante: 1))
}
@Test func unPartidoQueNoExisteDaUnEstadoDeError() {
let viewModel = PartidoViewModel(partidoId: 99, repositorio: repositorio(), msPorMinuto: 0)
#expect(viewModel.uiState == .error("No existe el partido n.º 99"))
}
@Test func unGolAManoSeVeYSeGuardaEnElRepositorio() {
let repo = repositorio()
let viewModel = PartidoViewModel(partidoId: 1, repositorio: repo, msPorMinuto: 0)
viewModel.alGol(.visitante)
#expect(viewModel.marcador == Marcador(local: 0, visitante: 1))
// El repositorio ya tiene un gol más (el 2-1 de ejemplo pasa a 2-2).
#expect(repo.partido(id: 1)?.marcador() == Marcador(local: 2, visitante: 2))
}
@Test func cancelarLaTareaDetieneElPartido() async {
let viewModel = PartidoViewModel(partidoId: 1, repositorio: repositorio(), msPorMinuto: 20)
let tarea = Task { await viewModel.jugar(duracion: 90) }
try? await Task.sleep(for: .milliseconds(150))
tarea.cancel()
await tarea.value
#expect(viewModel.minuto < 90)
#expect(!viewModel.corriendo)
}
// La prueba del ciclo. Con `[weak self]` el ViewModel se libera en cuanto se suelta...
@Test func elViewModelSeLiberaAlSoltarlo() async {
weak var referencia: PartidoViewModel? // referencia débil: no cuenta para ARC; pasa a nil cuando el objeto se libera
do { // un `do { }` solo es un bloque: lo que se crea dentro desaparece al salir
let viewModel = PartidoViewModel(partidoId: 1, repositorio: repositorio(), msPorMinuto: 0, reintroducirElCiclo: false)
referencia = viewModel
await viewModel.jugar(duracion: 3)
} // el ViewModel sale de su alcance aquí
#expect(referencia == nil) // y se ha liberado
}
// ...y con el ciclo reintroducido, NO: `referencia` sigue viva aunque nadie tenga el ViewModel.
// (Este objeto se queda en memoria hasta que termina el proceso de pruebas: es la fuga.)
@Test func conElCicloReintroducidoElViewModelSeQuedaEnMemoria() async {
weak var referencia: PartidoViewModel?
do {
let viewModel = PartidoViewModel(partidoId: 1, repositorio: repositorio(), msPorMinuto: 0, reintroducirElCiclo: true)
referencia = viewModel
await viewModel.jugar(duracion: 3)
}
#expect(referencia != nil) // sigue vivo: lo retiene su propio cronómetro
}
}Las dos últimas pruebas enseñan el ciclo: la primera comprueba que el ViewModel se libera; la segunda reintroduce el ciclo con reintroducirElCiclo: true y comprueba que no se libera (es la fuga). Una referencia weak no cuenta para ARC: si al terminar el bloque sigue sin ser nil, algo retiene al objeto. Por eso el ViewModel se crea dentro de un do { }: al llegar a la llave final, la variable viewModel deja de existir y solo queda la referencia débil.
Ejecuta y comprueba que el ViewModel se libera
Selecciona el simulador en el selector de destino de la barra superior de Xcode, pulsa Cmd+R, abre un partido, ciérralo (desliza la hoja hacia abajo) y lee el registro con el comando de .task frente a .onAppear:
xcrun simctl spawn booted log show --last 1m --predicate 'subsystem == "com.sebastiannarvaez.marcador"' | grep -E "CANCELADO|LIBERADO"Verás algo como esto; el minuto cambia según cuándo cierres:
partido CANCELADO en el minuto 56
PartidoViewModel LIBERADOSi tocaste «Redibujar el padre», saldrán más líneas LIBERADO, alguna justo al cerrar la hoja: son ViewModels de sobra que SwiftUI descarta, y no cuentan. Para esta comprobación, no lo toques.
Las pruebas: Cmd+U (o Product → Test) lanza todas, las unitarias y las de interfaz (estas arrancan la app en el simulador y tardan casi dos minutos). En el Test navigator (Cmd+6), todo en verde. Para lanzar solo las unitarias, desde la Terminal, en ~/Marcador-Swift/MarcadorApp:
xcodebuild -scheme MarcadorApp -destination 'platform=iOS Simulator,name=iPhone 17' -parallel-testing-enabled NO -only-testing:MarcadorAppTests testEntre las últimas líneas debe salir Test run with 11 tests in 2 suites passed (el Executed 0 tests de XCTest que aparece antes es normal: lo explica Tus primeras pruebas en iOS). -parallel-testing-enabled NO hace que las pruebas corran una tras otra y la salida salga tal cual; sin él, las líneas pueden llevar el nombre de un simulador clonado. Si tu simulador se llama distinto, mira su nombre con xcrun simctl list devices available.
Punto de control
- La pantalla del partido se ve como antes: el minuto avanza, los botones de gol suman y el selector de duración reinicia el partido. Al cerrar la hoja, el registro muestra
PartidoViewModel LIBERADO. - Cmd+U termina con las 21 pruebas en verde: 11 unitarias (7 de
PartidoViewModelTestsy 4 deMarcadorAppTests) y 10 de interfaz. El comando de arriba, que solo lanza las 11 unitarias, termina con** TEST SUCCEEDED **. - En el Project navigator ya no está
PartidoEnVivoModelo.swift, y síMarcadorUiState.swift,PartidosRepositorio.swift,RegistrarGol.swiftyPartidoViewModel.swift; enEjerciciosestáEstadosDeCarga. - Sabes decir qué hace cada pieza: la vista pinta
uiState, el ViewModel decide, el caso de uso aplica las reglas y el repositorio guarda.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Quién crea, quién posee y quién libera el
PartidoViewModelde la pantalla del partido?2.¿Para qué sirve el
Groupalrededor delswitchdelbody?3.Cierras la hoja del partido y en el registro no sale
PartidoViewModel LIBERADO. ¿Qué significa?4.
PartidoEnDirectoViewahora recibe unpartidoIdy un repositorio en vez delPartidoentero. ¿Por qué?5.En la prueba del ciclo, ¿por qué el ViewModel se crea dentro de un
do { }y la referencia esweak?