MVVM: el estado, el repositorio y el caso de uso
Al terminar: Escribir un ViewModel con @MainActor y un caso de uso, con el ciclo del cronómetro ya evitado con [weak self].
Al terminar, la app tendrá tres ficheros nuevos que todavía nadie usa: MarcadorUiState (todo lo que la pantalla puede mostrar, en un solo valor), PartidosRepositorio (la puerta a los datos) y RegistrarGol (el caso de uso, con cuatro reglas, las mismas que en Android). La app se verá y se comportará igual que ahora, y las pruebas seguirán en verde.
Importa porque una pantalla que mezcla dibujar, calcular y guardar no se puede probar sin dibujarla. En las dos lecciones siguientes escribirás el ViewModel y conectarás la pantalla; esta pone los cimientos. Con ella empieza el módulo «Arquitectura en iOS»: MVVM, navegación, persistencia, permisos y notificaciones, pruebas y, al final, la red con sus capas. (Los permisos y las notificaciones van aquí y no al principio, como en Android, porque en iOS el aviso de gol lo da el ViewModel, que se escribe en este módulo.)
Temas: MVVM en SwiftUI, un enum como estado de pantalla, un contrato (protocolo) para los datos y un caso de uso.
MVVM: quién hace qué
MVVM (Model, View, ViewModel) es un patrón que reparte el trabajo de una pantalla en tres piezas. No es una API de Apple: SwiftUI no lo exige, es una forma de ordenar el código.
Vista (SwiftUI) ──eventos──▶ ViewModel ──llama──▶ caso de uso / repositorio ──▶ datos
▲ │
└──── estado (uiState) ──────┘| Pieza | Sabe de | No sabe de |
|---|---|---|
| Model | Los datos y las reglas del dominio: en Marcador, Partido, Equipo y Marcador del paquete Torneo | Pantallas, toques, SwiftUI |
| Vista | Dibujar el uiState y traducir toques en llamadas | Reglas del fútbol, de dónde salen los datos |
| ViewModel | Qué se ve y en qué orden; cuándo pedir datos | SwiftUI: no hace import SwiftUI |
| Repositorio | Dónde viven los datos | Pantallas |
El Model ya lo tienes: son los tipos del paquete Torneo que escribiste en las lecciones de Swift. El repositorio y el caso de uso (la acción con sus reglas) son piezas que MVVM suele añadir entre el ViewModel y el Model.
Vas a montarlo de dentro hacia fuera, un fichero cada vez, y la app seguirá compilando en todo momento: primero el estado, el repositorio y el caso de uso (esta lección), después el ViewModel (El ViewModel y el hilo principal) y, por último, conectas la vista (La pantalla pinta el uiState). Antes, las tres piezas de hoy en ejemplos pequeños, con datos que no son de Marcador.
Un estado que no admite combinaciones raras
La pantalla de partido puede estar cargando, haber fallado o tener datos. Con variables sueltas (cargando, hayError, datos…) caben combinaciones imposibles, como «cargando» y «error» a la vez. Un enum (un tipo con un número cerrado de casos) con valores asociados (cada caso puede llevar datos) obliga a estar en un estado cada vez.
enum EstadoDeGoleadores: Equatable { // Equatable: dos estados se pueden comparar con ==
case cargando // un caso sin datos
case error(String) // un caso que lleva un texto
case exito([String]) // un caso que lleva una lista
}
func texto(_ estado: EstadoDeGoleadores) -> String {
switch estado { // el switch tiene que cubrir TODOS los casos
case .cargando: "Cargando…"
case let .error(mensaje): "Error: \(mensaje)" // `let` saca el valor que lleva
case let .exito(nombres): nombres.joined(separator: ", ") // une los textos poniendo una coma y un espacio entre cada par
}
}
print(texto(.cargando))
print(texto(.error("sin red")))
print(texto(.exito(["Ana", "Luis"])))Cargando…
Error: sin red
Ana, LuisEl switch es exhaustivo: si añades un caso al enum, no compila hasta que lo trates. Es la sealed interface de Kotlin de UiState en Marcador: el ViewModel.
Una pieza más: el caso de éxito suele llevar varios datos. Se agrupan en un struct que vive dentro del enum, aquí con un pedido de restaurante:
enum EstadoDelPedido: Equatable {
case preparando
case listo(Detalle) // el caso lleva un struct con todos los datos
// Un struct DENTRO del enum: desde fuera se llama EstadoDelPedido.Detalle.
struct Detalle: Equatable {
let mesa: Int // let: una foto del momento, no cambia
let platos: [String]
}
}
let estado = EstadoDelPedido.listo(.init(mesa: 4, platos: ["sopa"])) // .init(…): Swift sabe que es un Detalle
print(estado == .listo(EstadoDelPedido.Detalle(mesa: 4, platos: ["sopa"])))trueUn contrato para los datos
Un repositorio es el único sitio que sabe de dónde salen los datos. Para que nadie dependa de ese detalle, se define primero un contrato: un protocolo que dice qué se puede pedir, sin decir cómo. Luego una clase lo cumple.
protocol X: AnyObject:AnyObjectobliga a que solo lo adopten clases, que se comparten por referencia (Delegados y tareas lo usó para los delegados). Si el repositorio fuera unstruct, cada pantalla tendría su copia, y un cambio hecho en una no se vería en la otra.{ get }: la propiedad del contrato solo se puede leer desde fuera.any X: «algún tipo que cumple el contrato» (some y any): da igual si viene de memoria, de un fichero o de un falso en las pruebas.@Observable(@Observable) en la clase: las vistas que leen sus propiedades se redibujan solas cuando cambian.
import Observation
struct Nota: Identifiable {
let id: Int
let texto: String
}
// El contrato: solo clases (AnyObject), con propiedades de solo lectura ({ get }).
protocol NotasRepositorio: AnyObject {
var notas: [Nota] { get }
func nota(id: Int) -> Nota?
func corregir(id: Int, texto: String)
}
@Observable
final class NotasEnMemoria: NotasRepositorio {
private(set) var notas: [Nota] // cumple el { get }: desde fuera solo se lee
init(notas: [Nota] = [Nota(id: 1, texto: "pan"), Nota(id: 2, texto: "leche")]) { // datos de ejemplo por defecto
self.notas = notas
}
func nota(id: Int) -> Nota? {
notas.first { $0.id == id } // la primera con ese id, o nil
}
func corregir(id: Int, texto: String) {
guard let indice = notas.firstIndex(where: { $0.id == id }) else { return } // sin nota, no hace nada
notas[indice] = Nota(id: id, texto: texto) // se sustituye el elemento de esa posición por uno nuevo
}
}
let repo: any NotasRepositorio = NotasEnMemoria() // any: cualquier tipo que cumpla el contrato
repo.corregir(id: 2, texto: "café")
print(repo.nota(id: 2)?.texto ?? "-") // la nota 2 ya dice «café»
print(repo.nota(id: 9)?.texto ?? "-") // la 9 no existe: sale el «-» de reservacafé
-Un caso de uso que se llama como una función
Un caso de uso es una acción con sentido para el negocio («registrar un gol»), con sus reglas, en un sitio que no es ni la vista ni el repositorio. Dos piezas nuevas para escribirlo:
func callAsFunction(…): deja llamar a una instancia como si fuera una función,registrarGol(partidoId: 1, …). Es eloperator fun invokedelRegistrarGolde Kotlin.Result<Éxito, Fallo>: el resultado de algo que puede fallar,.success(valor)o.failure(fallo). Los fallos esperables (no existe el partido, el equipo está vacío) viajan como valores, no como excepciones (registrar lanza errores: throws tipado y Result).
struct Doblar {
func callAsFunction(_ numero: Int) -> Int { numero * 2 } // este método especial convierte la instancia en «llamable»
}
let doblar = Doblar()
print(doblar(21)) // se llama la instancia, no un método con nombre42enum FalloDeStock: Error { case agotado(String) } // el fallo esperable, como valor
func sacar(_ producto: String) -> Result<String, FalloDeStock> {
if producto == "caviar" { return .failure(.agotado(producto)) } // no hay: devuelve el fallo
return .success(producto) // sí hay: devuelve el producto
}
for producto in ["pan", "caviar"] {
switch sacar(producto) { // el switch separa los dos caminos
case let .success(sacado): print("sacado: \(sacado)")
case let .failure(fallo): print("no se pudo: \(fallo)")
}
}sacado: pan
no se pudo: agotado("caviar")Un caso de uso vale la pena cuando hay una regla que no es de la vista ni del repositorio, y sobra cuando solo reenvía la llamada. Aquí RegistrarGol tiene regla (qué jugador marca). Más adelante, con la red, verás los dos casos juntos en Casos de uso: cuándo sí y cuándo sobran.
Reto · Ejercicio: un estado que no compila hasta que lo tratas
En tu Playground (reutiliza el mismo y borra lo anterior), escribe un enum, EstadoDeSubida, con los casos esperando, subiendo (con el porcentaje, un entero), fallo (con el motivo, un texto) y hecha. Escribe también una función, texto, que reciba un estado y devuelva un texto para cada caso: «En cola», «Subiendo: 40 %» (con el porcentaje que toque), «Fallo:» seguido del motivo, y «Subida completa».
-
Imprime el texto de una subida que va por el 40 %. Lo que debe salir:
Subiendo: 40 % -
Añade el caso
canceladaal enum sin tocar la función. ¿Qué te dice Xcode y por qué es mejor que tener un booleano por estado?
Ver solución
enum EstadoDeSubida {
case esperando, hecha
case subiendo(Int)
case fallo(String)
}
func texto(_ estado: EstadoDeSubida) -> String {
switch estado {
case .esperando: "En cola"
case let .subiendo(porcentaje): "Subiendo: \(porcentaje) %"
case let .fallo(motivo): "Fallo: \(motivo)"
case .hecha: "Subida completa"
}
}
print(texto(.subiendo(40)))Al añadir cancelada, Xcode marca el switch con el error «Switch must be exhaustive» (tiene que cubrir todos los casos) y sugiere añadir el que falta. Con un Bool por estado, uno nuevo no rompe nada y el olvido llega a producción; con el enum te obliga el compilador, y los estados son excluyentes.
Dónde va cada fichero: funcionalidades y Core
Hasta ahora, cada lección te decía en qué carpeta crear cada fichero; esta explica el criterio. Una funcionalidad es una parte de la app que el usuario reconoce como una cosa: el marcador, la lista de partidos, los goleadores. Cada una lleva sus propias carpetas por capas: UI (lo que se ve), Domain (las reglas) y Data (de dónde salen los datos). La regla, en una frase: va en Core lo que no pertenece a ninguna funcionalidad, aunque lo usen varias; si es de una, se queda en ella aunque otras lo lean. Los partidos son de Partidos, aunque los lean varias funcionalidades; la red, los errores y los avisos no son de ninguna, y van en Core. Dentro de Core las subcarpetas se llaman por lo que hacen (Network, Notifications) o Domain si son reglas o errores: no son capas.
Las apps de ejemplo de Apple (Backyard Birds, Food Truck) también se reparten por funcionalidades y sacan el modelo y los datos a un paquete aparte; en Marcador ese paquete es Torneo, y todo lo demás vive en MarcadorApp.
| Carpeta | En Marcador |
|---|---|
Marcador/UI | PantallaMarcador, PartidoEnDirectoView, PartidoEnVivoModelo; en esta lección MarcadorUiState, y luego (próximamente) PartidoViewModel |
Marcador/Domain | Lado; en esta lección RegistrarGol, y luego (próximamente) Directo |
Marcador/Data | AjustesModelo (luego Ajustes) |
Partidos/UI y Partidos/Data | ListaDePartidosView; DatosDeEjemplo y, en esta lección, el repositorio de partidos |
Goleadores/UI | (próximamente) la pantalla de goleadores |
Core/* | (próximamente) Network, Notifications y Domain: lo que no es de ninguna funcionalidad. Las crearás cuando haga falta |
La raíz de MarcadorApp (la cáscara: lo que une todo) | MarcadorApp, ContentView, Configuracion, Registro, y luego (próximamente) Destino |
Laboratorio y Ejercicios | Lo que escribes para aprender: no son funcionalidades de la app, y por eso no llevan capas |
Algunas de esas carpetas ya existen en tu proyecto; las demás irán apareciendo, cada una cuando haga falta. Las pruebas copian esa estructura en espejo: lo que prueba un fichero de Marcador/Domain va en MarcadorAppTests/Marcador/Domain. La única excepción es MarcadorAppTests.swift, el que trae la plantilla, que se queda en la raíz de MarcadorAppTests.
Paso 1 · El estado de la pantalla: MarcadorUiState.swift
Objetivo: un tipo que describa todo lo que la pantalla puede mostrar, en un solo valor.
Los ficheros están en el Project navigator (el panel izquierdo de Xcode), dentro de la carpeta MarcadorApp del código (la que tiene MarcadorApp.swift). Para crear uno en una carpeta: clic derecho sobre esa carpeta → New File from Template… → Swift File → Next, escribe el nombre y pulsa Create. Deja marcado solo el target MarcadorApp (los de pruebas, sin marcar). Este paso va en Marcador/UI, porque es lo que la pantalla del marcador lee. Si prefieres la Terminal, touch ~/Marcador-Swift/MarcadorApp/MarcadorApp/Marcador/UI/Nombre.swift hace lo mismo (las carpetas ya existen): la carpeta está sincronizada con el proyecto y Xcode ve el fichero solo.
Este fichero es la ficha que la vista leerá para saber qué dibujar. Créalo con el nombre MarcadorUiState; con el contenido de plantilla ya compila.
Intenta escribirlo tú: MarcadorUiState, los tres estados posibles de la pantalla: cargando; error, con su mensaje; y exito, con un Exito, un struct que vive dentro del mismo tipo y guarda, sin que cambien, partidoId, partido, marcador, minuto, corriendo, ultimoAviso y duracion. Los dos tipos se pueden comparar, y el fichero necesita importar Torneo, porque Partido y Marcador viven en el paquete. Compila con Cmd+B: debe terminar en Build Succeeded.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import Torneo // de aquí vienen Partido y Marcador
// Todo lo que la pantalla puede mostrar, en UN solo valor.
enum MarcadorUiState: Equatable { // Equatable: dos estados se pueden comparar con ==
case cargando // todavía no hay datos
case error(String) // algo falló; lleva el mensaje para el usuario
case exito(Exito) // todo bien; lleva los datos que se pintan
// Los datos de la pantalla lista. Está dentro del enum: fuera se llama MarcadorUiState.Exito
struct Exito: Equatable {
let partidoId: Int // let: es una foto del momento, no cambia
let partido: Partido
let marcador: Marcador
let minuto: Int
let corriendo: Bool
let ultimoAviso: String
let duracion: Int
}
}Paso 2 · El repositorio: PartidosRepositorio.swift
Objetivo: que nadie más en la app sepa de dónde salen los partidos.
El repositorio es la despensa del restaurante: un contrato (un protocolo) que dice qué se puede pedir, y una clase que lo cumple con los datos de ejemplo. Crea PartidosRepositorio.swift como el fichero anterior, pero con clic derecho sobre Partidos/Data: el repositorio da los datos de los partidos, y ahí ya está DatosDeEjemplo. El protocolo y la clase que lo cumple comparten fichero, así que van juntos en esa carpeta.
La raíz de la app creará una sola instancia, y el ViewModel y la lista compartirán esa misma: si cada uno tuviera su copia, un gol registrado en una no se vería en la otra. Por eso el contrato es AnyObject.
Un dato que toda la app comparte, con una sola instancia que varias pantallas leen, tiene nombre: estado de toda la app. Lo verás con ese nombre, y con un caso nuevo (el partido que sigue en juego aunque salgas de su pantalla), en Estado de toda la app en SwiftUI.
Intenta escribirlo tú. El protocolo PartidosRepositorio, solo para clases, pide equipos y partidos (los dos solo para leer), partido, que con la etiqueta id devuelve ese partido o nada si no existe, y registrarGol, con las etiquetas partidoId y gol. La clase RepositorioEnMemoria, observable y de la que nadie hereda, lo cumple: solo ella cambia sus partidos, y si no se le dan otros, se crea con los datos de ejemplo, los 18 partidos de Listas en SwiftUI y los cuatro equipos del torneo del mismo fichero. Registrar un gol sustituye ese partido de la lista por una copia con el gol añadido; si el partido no existe, no hace nada. Cmd+B debe compilar.
Nombres que ya existen y que necesitas (los escribiste en lecciones anteriores):
PartidoDeLista(id:partido:): una fila de la lista, con suidy suPartido(Listas en SwiftUI).DatosDeEjemplo.torneo.equiposyDatosDeEjemplo.partidos: los datos de ejemplo.partido.registrando(gol): devuelve una copia delPartidocon el gol añadido (Enums: el partido de eventos).
Usa justo los equipos de DatosDeEjemplo: Equipo es una clase, y con las clases == pregunta si son el mismo objeto, no si se llaman igual. Contar los goles de un equipo compara equipos, así que un Equipo recién creado con el mismo nombre no contaría ningún gol (struct frente a class).
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import Foundation
import Observation
import Torneo
// El contrato: qué se puede pedir a los datos, sin decir de dónde salen.
protocol PartidosRepositorio: AnyObject { // AnyObject: solo clases; todos comparten LA MISMA instancia
var equipos: [Equipo] { get } // { get }: solo se puede leer
var partidos: [PartidoDeLista] { get }
func partido(id: Int) -> Partido? // nil si ese partido no existe
func registrarGol(partidoId: Int, gol: EventoDePartido)
}
// La implementación de hoy: los datos de ejemplo, en memoria.
@Observable // las vistas que leen `partidos` se redibujan cuando cambia
final class RepositorioEnMemoria: PartidosRepositorio {
let equipos: [Equipo]
private(set) var partidos: [PartidoDeLista] // solo esta clase la modifica
// Los valores por defecto son los datos de ejemplo; las pruebas pasarán otros
init(equipos: [Equipo] = DatosDeEjemplo.torneo.equipos, partidos: [PartidoDeLista] = DatosDeEjemplo.partidos) {
self.equipos = equipos
self.partidos = partidos
}
func partido(id: Int) -> Partido? {
partidos.first { $0.id == id }?.partido // el primero con ese id; el `?.` cubre que no haya ninguno
}
func registrarGol(partidoId: Int, gol: EventoDePartido) {
guard let indice = partidos.firstIndex(where: { $0.id == partidoId }) else { return } // sin partido, no hace nada
// Un Partido es un valor: se sustituye por una copia con el gol añadido
partidos[indice] = PartidoDeLista(id: partidoId, partido: partidos[indice].partido.registrando(gol))
}
}Paso 3 · El caso de uso: RegistrarGol.swift
Objetivo: meter las reglas de «registrar un gol» en un sitio que no es ni la vista ni el repositorio.
Éste tiene cuatro reglas: el partido tiene que existir, el equipo tiene que tener jugadores, marca el jugador que toca por turno y, si todo va bien, se guarda el gol. Crea RegistrarGol.swift con clic derecho sobre Marcador/Domain, donde ya está Lado: es una regla del marcador y no sabe de pantallas.
Intenta escribirlo tú: RegistrarGol, un struct con un repositorio (cualquiera que cumpla el contrato) y sus fallos esperables, Fallo, un error que se puede comparar con == (Equatable) con dos casos: noExisteElPartido, con el id, y equipoSinJugadores, con el nombre del equipo. Se llama como una función, con las etiquetas partidoId, lado y minuto, y devuelve el gol o el fallo. Las reglas, en orden: el partido tiene que existir; el equipo del lado que marca tiene que tener jugadores; marca el jugador al que le toca por turno (los goles que ya lleva el equipo, dando la vuelta a la plantilla); y el gol se guarda en el repositorio y se devuelve. Cmd+B debe compilar.
Nombres que ya existen y que necesitas:
Lado, con.localy.visitante: elenumque creaste en @Binding y que hasta hoy no usaba nadie.equipo.plantilla, la lista de jugadores, yequipo.nombre.partido.golesDe(equipo): cuántos goles lleva ese equipo (Enums: el partido de eventos).EventoDePartido.gol(minuto:jugador:equipo:): el evento de un gol, con esas tres etiquetas.
Para «dar la vuelta» a la plantilla se usa el resto de la división (%): con 3 jugadores y 4 goles marcados, 4 % 3 es 1 y toca el segundo.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import Torneo
// El caso de uso: una acción del usuario con sentido para el fútbol.
struct RegistrarGol {
let repositorio: any PartidosRepositorio // cualquier cosa que cumpla el contrato
enum Fallo: Error, Equatable { // los fallos esperables, como valores
case noExisteElPartido(Int)
case equipoSinJugadores(String)
}
// callAsFunction permite escribir `registrarGol(partidoId: 1, …)` como si la instancia fuera una función
func callAsFunction(partidoId: Int, lado: Lado, minuto: Int) -> Result<EventoDePartido, Fallo> {
guard let partido = repositorio.partido(id: partidoId) else {
return .failure(.noExisteElPartido(partidoId)) // sin partido no hay gol
}
let equipo = lado == .local ? partido.local : partido.visitante
guard !equipo.plantilla.isEmpty else { return .failure(.equipoSinJugadores(equipo.nombre)) }
// Quién marca: el jugador que toca por turno (los goles que lleva el equipo, el resto de dividir entre el tamaño de la plantilla)
let jugador = equipo.plantilla[partido.golesDe(equipo) % equipo.plantilla.count]
let gol = EventoDePartido.gol(minuto: minuto, jugador: jugador, equipo: equipo)
repositorio.registrarGol(partidoId: partidoId, gol: gol) // se guarda
return .success(gol) // y se devuelve para quien lo pidió
}
}Cmd+B debe compilar. Los tres ficheros son nuevos y nadie los usa todavía: la app se comporta igual que antes.
Punto de control
- Existen
MarcadorUiState.swift,PartidosRepositorio.swiftyRegistrarGol.swiften sus carpetas (Marcador/UI,Partidos/DatayMarcador/Domain), y Cmd+B termina enBuild Succeeded. - La app se ve y se comporta igual que antes:
PartidoEnVivoModelosigue haciendo el trabajo. - Cmd+U sigue en verde con las mismas 19 pruebas: 9 unitarias y 10 de interfaz.
- Sabes decir qué hace cada pieza: el estado dice qué se ve, el repositorio guarda los datos y el caso de uso aplica las reglas.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.En MVVM, ¿qué le toca hacer a la vista de SwiftUI?
2.¿Por qué
MarcadorUiStatees unenumcon valores asociados y no dosBool(«cargando» y «hayError»)?3.¿Para qué sirve
callAsFunctionenRegistrarGol?4.¿Por qué el protocolo
PartidosRepositoriose declaraAnyObject?5.En
RegistrarGol, ¿por qué se cuentan los goles con losEquipodeDatosDeEjemploy no con otros recién creados con el mismo nombre?