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

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 init de la vista, con el partidoId y 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 el struct de la vista se recree; solo se queda con el de la primera vez.
  • Quién lo libera: al cerrar la hoja, SwiftUI suelta su @State y, si nadie más lo retiene, ARC llama a deinit: el registro dice PartidoViewModel 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).

ejemplo mínimo: Group con un switch, ProgressView, dismiss y una función que devuelve una vista (con comentarios)
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) y exito (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
MarcadorApp/Ejercicios/EstadosDeCarga/EstadoDelTiempo.swift (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.

MarcadorApp (proyecto)
MarcadorApp
Marcador
UI
PartidoEnDirectoView.swiftse modifica
PartidoEnVivoModelo.swiftse borra
MarcadorApp.swiftse modifica
ContentView.swiftse modifica
Partidos
UI
ListaDePartidosView.swiftse modifica
MarcadorAppTests
Marcador
UI
PartidoViewModelTests.swiftse crea
MarcadorAppTests.swiftse borran cinco pruebas
En la app, cuatro ficheros cambian y uno desaparece; en las pruebas, uno nuevo y uno que cambia.

La vista

Cambios en PartidoEnDirectoView.swift:

1. El tipo de la propiedad y el init:

PartidoEnDirectoView.swift: antes
    @State private var modelo: PartidoEnVivoModelo
    // …
    init(partido: Partido) {
        _modelo = State(wrappedValue: PartidoEnVivoModelo(partido: partido))
PartidoEnDirectoView.swift: después
    @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
PartidoEnDirectoView.swift: body, contenido y estado (con comentarios)
    @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:

PartidoContenedor: antes
    let item: PartidoDeLista
    @State private var redibujados = 0
    // …
            PartidoEnDirectoView(partido: item.partido)
PartidoContenedor: después
    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:

MarcadorApp.swift: líneas nuevas
    @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:

ContentView.swift: antes y después
ListaDePartidosView(partidos: DatosDeEjemplo.partidos)   // antes
ListaDePartidosView()                                    // después

En 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):

ListaDePartidosView.swift: antes
    let partidos: [PartidoDeLista]
    // …
        List(partidos) { item in
    // …
    ListaDePartidosView(partidos: DatosDeEjemplo.partidos)
ListaDePartidosView.swift: después
    @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
MarcadorAppTests/Marcador/UI/PartidoViewModelTests.swift
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 LIBERADO

Si 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 test

Entre 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 PartidoViewModelTests y 4 de MarcadorAppTests) 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.swift y PartidoViewModel.swift; en Ejercicios está 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. 1.¿Quién crea, quién posee y quién libera el PartidoViewModel de la pantalla del partido?

  2. 2.¿Para qué sirve el Group alrededor del switch del body?

  3. 3.Cierras la hoja del partido y en el registro no sale PartidoViewModel LIBERADO. ¿Qué significa?

  4. 4.PartidoEnDirectoView ahora recibe un partidoId y un repositorio en vez del Partido entero. ¿Por qué?

  5. 5.En la prueba del ciclo, ¿por qué el ViewModel se crea dentro de un do { } y la referencia es weak?

Faltan 5 de 5