Red en Marcador: la lista de árbitros
Al terminar: Cargar los árbitros desde internet y pintarlos con los estados de carga, éxito y error.
Al terminar, Marcador tendrá un botón «Árbitros» en la barra de la lista de Partidos. Al tocarlo se abre una pantalla que pide a internet los diez árbitros de la liga, enseña una ruedecita mientras espera y, si algo falla, un mensaje «No hay conexión» con un botón «Reintentar». Es la primera vez que el estado «cargando» de tu ViewModel es de verdad: antes se ponía y se quitaba al instante, porque los datos estaban dentro de la app.
Importa porque es el patrón de casi cualquier pantalla que depende de un servidor: tres estados (cargando, éxito, error), un repositorio que trae los datos y un ViewModel que decide qué se ve. Lo ya conocido de MVVM se aplica sin cambios; lo nuevo es lo que la red trae consigo.
Temas: un repositorio cuya única fuente es la red; el estado cargando / error / éxito con una espera real; ContentUnavailableView; un botón en la barra con .toolbar; y probar el error sin conexión. Nivel 2: usar.
Un repositorio que solo tiene la red
Para el ViewModel, el repositorio es la puerta a los datos. Esta vez detrás de la puerta no hay una lista en memoria, sino el servicio de red. El repositorio hace dos cosas: pedir los DTO al servicio y convertirlos en modelos. Así el ViewModel y la pantalla solo conocen Arbitro, con nombres en español, y nunca el DTO.
Con una sola fuente, sin red no hay datos: la lista queda vacía. En Dos fuentes: el almacén y el repositorio lo arreglarás guardando en el disco; hoy toca ver la pantalla fallar para saber manejar el fallo.
El estado: cargando, error, éxito
El estado de la pantalla es un enum con tres casos, UNO a la vez, como el de MVVM. Lo nuevo es que ahora cargando dura de verdad y error ocurre de verdad. Un ejemplo con una biblioteca, en el que el ViewModel pide libros a un repositorio de mentira que puede fallar:
import Foundation
// Una biblioteca que presta libros.
// `CustomStringConvertible` (de Protocolos y extensiones) tiene un solo requisito, `description`:
// el texto que `print` escribe para el valor. Aquí solo sirve para que la salida sea corta.
struct Libro: Equatable, CustomStringConvertible {
let titulo: String
var description: String { titulo }
}
// Los tres estados posibles de la pantalla: UNO cada vez.
enum EstadoDeLibros: Equatable {
case cargando
case error(String)
case exito([Libro])
}
// El contrato: quien pide libros no sabe si vienen de internet o de un cajón.
protocol LibrosRepositorio {
func libros() async throws -> [Libro]
}
// Un repositorio de mentira: devuelve dos libros o falla, según le digas.
struct LibrosDeMentira: LibrosRepositorio {
let falla: Bool
struct Fallo: Error {}
func libros() async throws -> [Libro] {
if falla { throw Fallo() }
return [Libro(titulo: "Dune"), Libro(titulo: "Ubik")]
}
}
final class LibrosViewModel {
private(set) var estado: EstadoDeLibros = .cargando // empieza cargando
private let repositorio: any LibrosRepositorio // any: cualquier cosa que cumpla el contrato
init(repositorio: any LibrosRepositorio) {
self.repositorio = repositorio
}
func cargar() async {
estado = .cargando
do {
// Si el repositorio lanza un error, salta directo al `catch`.
estado = .exito(try await repositorio.libros())
} catch {
estado = .error("No se pudieron cargar")
}
}
}
let bueno = LibrosViewModel(repositorio: LibrosDeMentira(falla: false))
print(bueno.estado) // todavía no ha cargado nada
await bueno.cargar()
print(bueno.estado)
let malo = LibrosViewModel(repositorio: LibrosDeMentira(falla: true))
await malo.cargar()
print(malo.estado)cargando
exito([Dune, Ubik])
error("No se pudieron cargar")Para probarlo no hace falta internet: el repositorio de mentira es el mismo truco de los falsos de Tests en iOS: falsos y espías. any LibrosRepositorio es el any de some y any, y CustomStringConvertible el de Protocolos y extensiones. El enum del estado es exhaustivo: el switch que lo lee tiene que cubrir los tres casos o no compila, así que no puedes olvidar pintar uno.
Tres piezas de pantalla nuevas
La vista de los árbitros es como la de La pantalla pinta el uiState (un switch sobre el estado), con tres piezas que no habías usado:
ProgressView("Buscando árbitros…"): la ruedecita que ya conoces, con un texto debajo.ContentUnavailableView: la pantalla vacía de iOS (desde iOS 17), con icono, título, descripción y botones. Se usa cuando no hay nada que enseñar: sin conexión, sin resultados… Su forma larga lleva tres bloques entre llaves, cada uno con su etiqueta: el primero, sin etiqueta, es el título con icono (Label);description:es el texto de ayuda yactions:son los botones. Es la sintaxis de los cierres que ya conoces, repetida con nombres..buttonStyle(.borderedProminent)(aparece en la vista): un botón con el fondo de color, para el botón principal de la pantalla..toolbar { … }: la barra de arriba. Lo que pones dentro aparece en ella; unNavigationLink(value:)allí es un botón que apila una pantalla, igual que una fila de la lista.
Reto · Ejercicio: el estado de una pantalla de recetas
En tu Playground (reutiliza el mismo y borra lo anterior; ahí await va suelto, como en Red: HTTP, JSON y URLSession), escribe lo necesario para que una pantalla de recetas pase de cargando a éxito o a error:
- Una receta con nombre y minutos, que se pueda comparar y que al imprimirse salga como «Tortilla (20 min)» (con
CustomStringConvertible, como elLibrodel ejemplo). - Un estado con tres casos: cargando, error (con un mensaje) y éxito (con la lista de recetas).
- Un contrato,
RecetasRepositorio, con una función asíncrona que puede fallar y devuelve las recetas. - Un repositorio de mentira que, según se le diga al crearlo, devuelve dos recetas, Tortilla (20 min) y Gazpacho (15 min), o falla con un error propio.
- Un
RecetasViewModelque empieza en cargando, tiene una funcióncargary, si el repositorio falla, deja el estado en error con el mensaje «No hay conexión». - Crea un
ViewModelcon el repositorio que funciona e imprime su estado antes y después de cargar; crea otro con el que falla e imprime también su estado antes y después de cargar.
Lo que debe salir:
cargando
exito([Tortilla (20 min), Gazpacho (15 min)])
cargando
error("No hay conexión")Ver solución
struct Receta: Equatable, CustomStringConvertible {
let nombre: String
let minutos: Int
var description: String { "\(nombre) (\(minutos) min)" }
}
enum EstadoDeRecetas: Equatable {
case cargando
case error(String)
case exito([Receta])
}
protocol RecetasRepositorio {
func recetas() async throws -> [Receta]
}
struct RecetasDeMentira: RecetasRepositorio {
let falla: Bool
struct SinRed: Error {}
func recetas() async throws -> [Receta] {
if falla { throw SinRed() }
return [Receta(nombre: "Tortilla", minutos: 20), Receta(nombre: "Gazpacho", minutos: 15)]
}
}
final class RecetasViewModel {
private(set) var estado: EstadoDeRecetas = .cargando
private let repositorio: any RecetasRepositorio
init(repositorio: any RecetasRepositorio) {
self.repositorio = repositorio
}
func cargar() async {
estado = .cargando
do {
estado = .exito(try await repositorio.recetas())
} catch {
estado = .error("No hay conexión")
}
}
}
let conRed = RecetasViewModel(repositorio: RecetasDeMentira(falla: false))
print(conRed.estado)
await conRed.cargar()
print(conRed.estado)
let sinRed = RecetasViewModel(repositorio: RecetasDeMentira(falla: true))
print(sinRed.estado)
await sinRed.cargar()
print(sinRed.estado)El estado de cada ViewModel empieza en cargando antes de llamar a cargar(); por eso cargando sale dos veces, una por cada ViewModel. Cada catch convierte el fallo del repositorio en un mensaje que la pantalla puede enseñar tal cual.
Paso 1 · El modelo y el contrato: Arbitro.swift
Objetivo: el tipo que pintará la pantalla, con su traducción desde el DTO, y el contrato del repositorio.
Los árbitros son una funcionalidad nueva y cada capa va en su carpeta: Domain (el modelo), Data (de dónde salen los datos) y UI (la pantalla). Clic derecho sobre la carpeta MarcadorApp (la de dentro, donde está MarcadorApp.swift) → New Folder → Arbitros; luego, clic derecho sobre Arbitros → New Folder → Domain, y lo mismo para Data y UI. Las otras dos las usarás un poco más abajo. Crea Arbitro.swift en Domain: clic derecho sobre Domain → New File from Template… → Swift File. Con la Terminal: mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorApp/Arbitros/{Domain,Data,UI} && touch ~/Marcador-Swift/MarcadorApp/MarcadorApp/Arbitros/Domain/Arbitro.swift. El protocolo del repositorio va en ese mismo fichero y por eso vive en Domain; la clase que lo cumple irá en Data.
Dos ideas antes de escribir.
- El
init(dto:)va en unaextension, fuera delstruct. Unstructrecibe gratis uninitcon todos sus campos (Arbitro(id:nombre:ciudad:)). Si escribes tu propioinitdentro delstruct, Swift deja de dar el gratuito; en unaextensionconservas los dos. Las pruebas de más abajo llaman alinitde los tres campos, y por eso el tuyo va fuera (Protocolos y extensiones cuenta las extensiones). : AnyObjecten el protocolo quiere decir «solo lo cumplen clases» (nostruct): el mismoAnyObjectde Delegados y tareas. Lo pedimos porque el falso de las pruebas será una clase cuyo contenido cambia mientras elViewModello tiene, y los dos tienen que compartir la misma instancia.
Intenta escribirlo tú: Arbitro, con número, nombre y ciudad, que se identifica por su número y se puede comparar. Una extensión con un init(dto:) que lo construye a partir de un ArbitroDTO (la ciudad está dentro de la dirección). Y ArbitrosRepositorio, un protocolo solo para clases, con una función asíncrona que puede fallar, arbitros(), que devuelve una lista de Arbitro.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// EL MODELO: lo que la app entiende por «árbitro», con nombres en español y solo lo que usa.
// La pantalla pinta `Arbitro`, nunca el DTO: si mañana el servidor cambia un nombre, solo cambia
// la traducción de abajo.
struct Arbitro: Identifiable, Equatable {
let id: Int
let nombre: String
let ciudad: String
}
extension Arbitro {
// De DTO a modelo: el ÚNICO sitio que conoce los dos.
init(dto: ArbitroDTO) {
self.init(id: dto.id, nombre: dto.name, ciudad: dto.address.city)
}
}
// EL REPOSITORIO de los árbitros: la puerta por la que el ViewModel los pide, sin saber de dónde salen.
protocol ArbitrosRepositorio: AnyObject {
func arbitros() async throws -> [Arbitro]
}Paso 2 · El repositorio de red: RepositorioDeArbitrosEnRed.swift
Objetivo: el repositorio cuya única fuente es el servicio de red. Va en Arbitros/Data: es de donde salen los datos.
Antes de escribir. Pasar de DTO a modelo es un map: lista.map { Arbitro(dto: $0) } crea un Arbitro por cada elemento (el $0 de Closures con listas). Cuando el cierre solo llama a una función con su argumento, se puede dar el nombre de la función en vez del cierre: lista.map(Arbitro.init(dto:)) es lo mismo, más corto. Arbitro.init(dto:) es el init visto como una función.
Intenta escribirlo tú: RepositorioDeArbitrosEnRed, una clase final (que no se hereda), cumple el contrato, recibe en su init un servicio (cualquier cosa que cumpla ServicioDelTorneo) y, en arbitros(), pide los DTO al servicio y los convierte en Arbitro con map. Si el servicio lanza un error, lo deja pasar.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// Un repositorio cuya ÚNICA fuente es la red: pide los DTO al servicio y los convierte en modelos.
// Sin red no hay árbitros (más adelante se guardarán en el disco).
final class RepositorioDeArbitrosEnRed: ArbitrosRepositorio {
private let servicio: any ServicioDelTorneo
init(servicio: any ServicioDelTorneo) {
self.servicio = servicio
}
func arbitros() async throws -> [Arbitro] {
try await servicio.arbitros().map(Arbitro.init(dto:))
}
}map(Arbitro.init(dto:)) aplica la traducción a cada elemento de la lista (lo mismo que map { Arbitro(dto: $0) }). try await espera la respuesta y deja pasar el error: no se atrapa aquí, porque quien decide qué hacer con él es el ViewModel.
Paso 3 · El ViewModel: ArbitrosViewModel.swift
Objetivo: el enum de estado y el ViewModel que lo gobierna. Va en Arbitros/UI, con la pantalla que lo lee.
Intenta escribirlo tú: ArbitrosUiState, un enum que se puede comparar, con cargando, error (con un texto) y exito (con la lista de árbitros). ArbitrosViewModel, una clase final con @MainActor y @Observable, como PartidoViewModel, con un uiState que solo él cambia (empieza en cargando), un repositorio recibido por el init, una función cargar() y una función reintentar() que vuelve a cargar. Si el repositorio falla, cargar() anota el fallo con Registro.anotar (el de La app, sus escenas y el paquete Torneo) y pone el estado en error con «No hay conexión».
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import Foundation
import Observation
// LO QUE VE LA PANTALLA DE ÁRBITROS. Esta vez «cargando» es de verdad: la red tarda.
enum ArbitrosUiState: Equatable {
case cargando
case error(String)
case exito([Arbitro])
}
@MainActor
@Observable
final class ArbitrosViewModel {
private(set) var uiState: ArbitrosUiState = .cargando
@ObservationIgnored private let repositorio: any ArbitrosRepositorio
init(repositorio: any ArbitrosRepositorio) {
self.repositorio = repositorio
}
// La lanza la vista con `.task` al aparecer.
func cargar() async {
uiState = .cargando
do {
let arbitros = try await repositorio.arbitros()
uiState = .exito(arbitros)
} catch {
Registro.anotar("árbitros: no se pudieron cargar (\(error))")
uiState = .error("No hay conexión")
}
}
// El botón «Reintentar»: vuelve a pedirlos.
func reintentar() async {
await cargar()
}
}Paso 4 · La pantalla: ArbitrosView.swift
Objetivo: la vista que pinta cada estado. Va en Arbitros/UI, junto a su ViewModel.
Intenta escribirla tú: ArbitrosView recibe su ViewModel en el init y lo guarda en un @State (con State(wrappedValue:), como en La pantalla pinta el uiState). Su cuerpo es un switch sobre el uiState: ProgressView("Buscando árbitros…") si carga; un ContentUnavailableView con el mensaje, el icono wifi.slash, la descripción «Comprueba la conexión y vuelve a intentarlo.» y un botón «Reintentar» si hay error; y una List con «nombre · ciudad» por árbitro si hay éxito. Lleva el título «Árbitros» en la barra y lanza cargar() con .task. Un detalle: un body tiene que devolver una vista, y un switch suelto no lo es; por eso va envuelto en un Group { … }, que agrupa lo que haya dentro en una sola vista para que el título y el .task de debajo se apliquen al estado que toque.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import SwiftUI
// La pantalla de los árbitros: pinta el `uiState` del ViewModel, como la del partido.
struct ArbitrosView: View {
@State private var viewModel: ArbitrosViewModel
init(viewModel: ArbitrosViewModel) {
_viewModel = State(wrappedValue: viewModel)
}
var body: some View {
Group {
switch viewModel.uiState {
case .cargando:
ProgressView("Buscando árbitros…")
case let .error(mensaje):
// `ContentUnavailableView`: la pantalla vacía de iOS, con icono, texto y acciones.
ContentUnavailableView {
Label(mensaje, systemImage: "wifi.slash")
} description: {
Text("Comprueba la conexión y vuelve a intentarlo.")
} actions: {
Button("Reintentar") {
Task { await viewModel.reintentar() }
}
.buttonStyle(.borderedProminent)
}
case let .exito(arbitros):
List(arbitros) { arbitro in
// «Leanne Graham · Gwenborough»
Text("\(arbitro.nombre) · \(arbitro.ciudad)")
}
.listStyle(.plain)
}
}
.navigationTitle("Árbitros")
.navigationBarTitleDisplayMode(.inline)
// Arranca al aparecer la pantalla y se cancela si se va antes de terminar.
.task { await viewModel.cargar() }
}
}Task { await viewModel.reintentar() } crea una tarea desde un botón, que no es async; es la misma Task { … } de async/await y Task. Y .navigationBarTitleDisplayMode(.inline) deja el título pequeño en el centro de la barra.
Paso 5 · El botón «Árbitros» en la lista
Objetivo: que desde la lista de Partidos se llegue a la pantalla.
Dos cambios pequeños. El primero, un caso nuevo en Destino.swift, el fichero de NavigationStack:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
enum Destino: Hashable {
case partido(Int) // el id del partido (viaja en la clave, en un solo sitio)
case goleadores
case arbitros // la lista de árbitros (botón «Árbitros» de la barra de Partidos)
}El resto del fichero no cambia: solo se añade la línea de arbitros.
El segundo, en ListaDePartidosView.swift: un botón en la barra y el destino que construye la pantalla. Dentro del NavigationStack, sustituye lo que hay desde .listStyle(.plain) hasta el cierre de .navigationDestination por este bloque (añade el .toolbar y el caso .arbitros):
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
.listStyle(.plain)
.navigationTitle("Partidos")
// Un botón en la barra de arriba que empuja la pantalla de árbitros.
.toolbar {
NavigationLink("Árbitros", value: Destino.arbitros)
}
// UN solo sitio decide qué pantalla corresponde a cada destino.
.navigationDestination(for: Destino.self) { destino in
switch destino {
case let .partido(id):
PartidoContenedor(id: id)
case .goleadores:
GoleadoresView()
case .arbitros:
// OJO: la pantalla crea aquí, a escondidas, el servicio y el repositorio. Funciona,
// pero cada vez que se abre nace un repositorio nuevo. Se arregla con la inyección.
ArbitrosView(viewModel: ArbitrosViewModel(
repositorio: RepositorioDeArbitrosEnRed(servicio: ServicioJSONPlaceholder())
))
}
}El resto del fichero no cambia.
Fíjate en el comentario «a escondidas»: la lista crea ella misma el servicio, el repositorio y el ViewModel. Funciona, pero cada vez que abres la pantalla nacen piezas nuevas y no hay forma de cambiarlas en las pruebas. Es un problema a propósito: se arregla en Inyección de dependencias en todas las capas.
Ejecuta con Cmd+R y toca «Árbitros» arriba a la derecha: tras la ruedecita, diez filas, de «Leanne Graham · Gwenborough» a «Clementina DuBuque · Lebsackbury».
Paso 6 · Las pruebas
Objetivo: probar el ViewModel sin red, con un repositorio falso.
Primero, el falso. En Dobles.swift (el de Tests en iOS: falsos y espías, en MarcadorAppTests/Core) añade import Foundation arriba del todo, porque el falso usa URLError, y la clase nueva al final:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import Foundation
import Torneo
@testable import MarcadorApp
// … lo que ya tenías no cambia …
// El repositorio de árbitros falso: devuelve lo que le digas (una lista o un fallo) y cuenta las llamadas.
final class ArbitrosRepositorioFalso: ArbitrosRepositorio {
var respuesta: Result<[Arbitro], URLError>
private(set) var llamadas = 0
init(respuesta: Result<[Arbitro], URLError>) {
self.respuesta = respuesta
}
func arbitros() async throws -> [Arbitro] {
llamadas += 1
return try respuesta.get()
}
}Result<[Arbitro], URLError> es el Result de MVVM: el estado, el repositorio y el caso de uso: guarda una lista (.success) o un fallo (.failure), y try respuesta.get() devuelve la lista o lanza el fallo. Después, el fichero de pruebas, ArbitrosViewModelTests.swift. Las pruebas copian las carpetas de la app, y este ViewModel está en Arbitros/UI: clic derecho sobre MarcadorAppTests → New Folder → Arbitros, y dentro otra New Folder → UI; después, clic derecho sobre UI → New File from Template… → Swift File, con solo el target MarcadorAppTests marcado. Con la Terminal: mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorAppTests/Arbitros/UI && touch ~/Marcador-Swift/MarcadorApp/MarcadorAppTests/Arbitros/UI/ArbitrosViewModelTests.swift.
Intenta escribirlas tú, en una estructura @MainActor con Swift Testing:
pasaDeCargandoAExito: con un repositorio falso que devuelve a Leanne Graham, el estado empieza en cargando y, trascargar(), es éxito con ella.sinRedSaleElErrorYReintentarVuelveACargar: con un repositorio falso que falla conURLError(.notConnectedToInternet), trascargar()el estado es error con «No hay conexión»; después el falso pasa a devolver a Leanne, se llama areintentar()y el estado es éxito, con el repositorio llamado dos veces.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import Foundation
import Testing
@testable import MarcadorApp
// El ViewModel de los árbitros con un repositorio FALSO: sin red, sin esperas y sin servidor.
@MainActor
struct ArbitrosViewModelTests {
private let leanne = Arbitro(id: 1, nombre: "Leanne Graham", ciudad: "Gwenborough")
@Test func pasaDeCargandoAExito() async {
let repositorio = ArbitrosRepositorioFalso(respuesta: .success([leanne]))
let viewModel = ArbitrosViewModel(repositorio: repositorio)
#expect(viewModel.uiState == .cargando)
await viewModel.cargar()
#expect(viewModel.uiState == .exito([leanne]))
}
@Test func sinRedSaleElErrorYReintentarVuelveACargar() async {
let repositorio = ArbitrosRepositorioFalso(respuesta: .failure(URLError(.notConnectedToInternet)))
let viewModel = ArbitrosViewModel(repositorio: repositorio)
await viewModel.cargar()
#expect(viewModel.uiState == .error("No hay conexión"))
// Vuelve la red: «Reintentar» pide otra vez la lista.
repositorio.respuesta = .success([leanne])
await viewModel.reintentar()
#expect(viewModel.uiState == .exito([leanne]))
#expect(repositorio.llamadas == 2)
}
}Lánzalas con Cmd+U.
Punto de control
- Cmd+B termina en
Build Succeeded, y Cmd+U da 43 pruebas en verde: 27 unitarias y 16 de interfaz. Las dos nuevas sonpasaDeCargandoAExitoysinRedSaleElErrorYReintentarVuelveACargar. Si dejaste el ejercicio de la taquilla enMarcadorAppTests/Ejercicios, salen 46: los ejercicios con pruebas suman a la cuenta. - En el simulador, el botón «Árbitros» aparece arriba a la derecha de la lista de Partidos y abre la pantalla con 10 filas, la primera «Leanne Graham · Gwenborough».
- Con el wifi del Mac apagado, la pantalla enseña «No hay conexión» y el botón «Reintentar»; con el wifi encendido otra vez, «Reintentar» trae la lista.
- El ejercicio de las recetas del Playground imprime las cuatro líneas.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Qué hace
RepositorioDeArbitrosEnRedcon lo que le devuelve el servicio?2.¿Por qué el estado de la pantalla es un
enumconcargando,erroryexitoy no tresBool?3.El botón «Reintentar» llama a
Task { await viewModel.reintentar() }. ¿Por qué hace faltaTask?4.En las pruebas de
ArbitrosViewModelno se toca internet. ¿Cómo se consigue?5.La lista de Partidos crea el servicio y el repositorio dentro del
case .arbitros. ¿Qué problema tiene?