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

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) ──────┘
PiezaSabe deNo sabe de
ModelLos datos y las reglas del dominio: en Marcador, Partido, Equipo y Marcador del paquete TorneoPantallas, toques, SwiftUI
VistaDibujar el uiState y traducir toques en llamadasReglas del fútbol, de dónde salen los datos
ViewModelQué se ve y en qué orden; cuándo pedir datosSwiftUI: no hace import SwiftUI
RepositorioDónde viven los datosPantallas

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.

ejemplo mínimo: un estado que no admite combinaciones raras (con comentarios)
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, Luis

El 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:

ejemplo mínimo: un struct anidado como valor asociado (con comentarios)
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"])))
true

Un 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: AnyObject obliga a que solo lo adopten clases, que se comparten por referencia (Delegados y tareas lo usó para los delegados). Si el repositorio fuera un struct, 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.
ejemplo mínimo: un contrato AnyObject y una clase @Observable que lo cumple (con comentarios)
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 reserva
café
-

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 el operator fun invoke del RegistrarGol de 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).
ejemplo mínimo: callAsFunction (con comentarios)
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 nombre
42
ejemplo mínimo: un Result con éxito y con fallo (con comentarios)
enum 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».

  1. Imprime el texto de una subida que va por el 40 %. Lo que debe salir:

    Subiendo: 40 %
  2. Añade el caso cancelada al enum sin tocar la función. ¿Qué te dice Xcode y por qué es mejor que tener un booleano por estado?

Ver solución
solución: un estado de subida
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.

CarpetaEn Marcador
Marcador/UIPantallaMarcador, PartidoEnDirectoView, PartidoEnVivoModelo; en esta lección MarcadorUiState, y luego (próximamente) PartidoViewModel
Marcador/DomainLado; en esta lección RegistrarGol, y luego (próximamente) Directo
Marcador/DataAjustesModelo (luego Ajustes)
Partidos/UI y Partidos/DataListaDePartidosView; 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 EjerciciosLo 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.

MarcadorApp (proyecto)
MarcadorApp
Marcador
UI
MarcadorUiState.swiftse crea
El fichero de este paso, en el Project navigator.

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
MarcadorApp/Marcador/UI/MarcadorUiState.swift
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.

MarcadorApp (proyecto)
MarcadorApp
Partidos
Data
PartidosRepositorio.swiftse crea
El fichero de este paso.

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 su id y su Partido (Listas en SwiftUI).
  • DatosDeEjemplo.torneo.equipos y DatosDeEjemplo.partidos: los datos de ejemplo.
  • partido.registrando(gol): devuelve una copia del Partido con 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
MarcadorApp/Partidos/Data/PartidosRepositorio.swift
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.

MarcadorApp (proyecto)
MarcadorApp
Marcador
Domain
RegistrarGol.swiftse crea
El fichero de este paso.

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 .local y .visitante: el enum que creaste en @Binding y que hasta hoy no usaba nadie.
  • equipo.plantilla, la lista de jugadores, y equipo.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
MarcadorApp/Marcador/Domain/RegistrarGol.swift
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.swift y RegistrarGol.swift en sus carpetas (Marcador/UI, Partidos/Data y Marcador/Domain), y Cmd+B termina en Build Succeeded.
  • La app se ve y se comporta igual que antes: PartidoEnVivoModelo sigue 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. 1.En MVVM, ¿qué le toca hacer a la vista de SwiftUI?

  2. 2.¿Por qué MarcadorUiState es un enum con valores asociados y no dos Bool («cargando» y «hayError»)?

  3. 3.¿Para qué sirve callAsFunction en RegistrarGol?

  4. 4.¿Por qué el protocolo PartidosRepositorio se declara AnyObject?

  5. 5.En RegistrarGol, ¿por qué se cuentan los goles con los Equipo de DatosDeEjemplo y no con otros recién creados con el mismo nombre?

Faltan 5 de 5