Saltar al contenido
K
Arquitectura en iOSPaso a paso38 min de lectura · 8 min de máquina Avanzado

Dos fuentes: el almacén y el repositorio

Al terminar: Guardar lo que llega de la red en disco y construir el repositorio donde el disco manda y la red refresca.

Esta lección y la siguiente, Dos fuentes: la pantalla y las pruebas, son una pareja. Al terminar las dos, los árbitros de Marcador se guardarán en el disco: la pantalla pintará siempre lo guardado, al instante y también sin internet, y la red solo refrescará esa lista. Si la red falla, verás un aviso naranja encima de la lista y la lista no se vaciará.

Hoy haces la primera mitad: aprendes la idea de las dos fuentes y escribes las dos piezas nuevas, el almacén que guarda los árbitros en el disco y el repositorio que junta disco y red. La pantalla todavía no las usa: la app se ve igual que antes, y eso es lo correcto. La lección siguiente las conecta.

Importa porque una app que solo funciona con internet se queda en blanco en el metro, en un ascensor o con el servidor caído. Casi todas las apps reales tienen dos fuentes de datos (una local y otra remota) y una sola puerta para pedirlas. Temas: la fuente de la verdad local, leer frente a refrescar(), el almacén en un fichero JSON, un repositorio con dos fuentes y cuándo conviene tener varios repositorios o varios servicios.

La libreta manda y el teléfono avisa

Hasta ahora la pantalla de árbitros pedía la lista a internet cada vez que se abría. Mientras tarda, ves una ruedecita; si no hay conexión, ves un error y ningún árbitro. Aunque los hubieras visto hace un minuto.

Hay otra forma de ordenarlo: la lista vive en el disco y la pantalla siempre la lee de allí. La red ya no es de donde se lee, sino de donde se renueva lo guardado.

A esa idea se la llama fuente de la verdad (en inglés, single source of truth): de todas las copias de un dato, una manda, y es la que pinta la pantalla. Aquí manda el disco. La red es la segunda fuente: solo sirve para actualizar la primera.

El camino de un refresco tiene cuatro paradas, siempre en el mismo orden:

servidor  →  modelo  →  disco  →  la propiedad que lee la pantalla
 (DTO)      (Arbitro)  (arbitros.json)   (arbitros)

El servidor devuelve DTO (los tipos con la forma del JSON, de Red: HTTP, JSON y URLSession); el repositorio los convierte en modelos, los guarda y, solo entonces, cambia la lista que lee la pantalla. Si el servidor falla, nada de lo que viene después ocurre: el disco y la pantalla se quedan como estaban.

Un ejemplo con otros datos: el menú del día

Una cafetería apunta el menú en una pizarra (la fuente local) y puede pedirlo a su proveedor por internet (la fuente remota). La clase Menu es el repositorio: dos fuentes, una sola puerta.

ejemplo mínimo: dos fuentes y una puerta (con comentarios)
struct Plato: Equatable {
    let nombre: String
}
 
// La fuente local: lo apuntado en la pizarra. Empieza con un plato.
final class Pizarra {
    var apuntados: [Plato] = [Plato(nombre: "Lentejas")]
}
 
enum FalloDeLinea: Error { case sinLinea }
 
// La fuente remota: tarda y puede fallar. `hayLinea` apaga la conexión a mano.
final class Proveedor {
    var hayLinea = true
    func menuDeHoy() async throws -> [Plato] {
        guard hayLinea else { throw FalloDeLinea.sinLinea }   // sin línea: lanza el error
        return [Plato(nombre: "Paella"), Plato(nombre: "Flan")]
    }
}
 
// El repositorio: recibe las dos fuentes y las junta detrás de una puerta.
final class Menu {
    private let pizarra: Pizarra
    private let proveedor: Proveedor
 
    init(pizarra: Pizarra, proveedor: Proveedor) {
        self.pizarra = pizarra
        self.proveedor = proveedor
    }
 
    // LEER: siempre lo apuntado. No espera, no falla.
    // Es una propiedad calculada: no guarda nada, calcula su valor cada vez que se lee.
    var platos: [Plato] { pizarra.apuntados }
 
    // REFRESCAR: pide al proveedor y, si llega bien, lo apunta. `try await` lanza si falla,
    // y entonces la línea de debajo NO se ejecuta: la pizarra queda como estaba.
    func refrescar() async throws {
        let nuevos = try await proveedor.menuDeHoy()
        pizarra.apuntados = nuevos
    }
}
 
let proveedor = Proveedor()
let menu = Menu(pizarra: Pizarra(), proveedor: proveedor)
print("1. se ve:", menu.platos.map(\.nombre))        // antes de ir a la red
 
try await menu.refrescar()                            // `await` suelto: sirve en el Playground
print("2. se ve:", menu.platos.map(\.nombre))
 
proveedor.hayLinea = false                            // se corta internet
do {
    try await menu.refrescar()
} catch {
    print("3. falló:", error)
}
print("4. se ve:", menu.platos.map(\.nombre))        // la lista NO se vació
1. se ve: ["Lentejas"]
2. se ve: ["Paella", "Flan"]
3. falló: sinLinea
4. se ve: ["Paella", "Flan"]

Fíjate en la línea 1: la pantalla ya tiene algo que enseñar antes de que la red conteste. Y en la 4: el fallo no vació nada.

Dos detalles del código. menu.platos.map(\.nombre) convierte la lista de platos en la lista de sus nombres: \.nombre es una ruta de clave (key path), una forma corta de decir «de cada elemento, su nombre», como viste en Colecciones y key paths. Y var platos: [Plato] { … } es una propiedad calculada, de Propiedades: calculadas, didSet y lazy: no guarda nada, mira la pizarra cada vez que se lee.

Cuántos repositorios y cuántos servicios

Dos preguntas que salen en cuanto la app crece:

¿Un repositorio o varios? Uno por tipo de dato con su propio dueño y su propio ritmo. Los partidos y los árbitros son datos distintos: los partidos cambian con cada gol y se editan en la app; los árbitros llegan del servidor y cambian poco. Cada uno tiene su repositorio (RepositorioDePartidos, RepositorioDeArbitros). Meterlos en uno solo obligaría a las pantallas a depender de cosas que no usan.

¿Un servicio o varios? Un servicio por servidor. Si un día los árbitros vinieran de otro servidor, tendrías dos servicios (por ejemplo, ServicioJSONPlaceholder y otro), cada uno con su dirección, y podrían compartir la misma URLSession. Los repositorios no cambian: siguen pidiendo «dame los árbitros» a un protocolo.

Reto · Ejercicio: el periódico con dos fuentes

En tu Playground (reutiliza el mismo y borra lo anterior), escribe un periódico que lee de dos fuentes, con la misma forma que el menú del día. Necesitas cuatro piezas:

  • Noticia, con un titular (un texto); se puede comparar con ==.
  • Archivo, la fuente local: una clase con las noticias guardadas, que empiezan siendo una sola, «Noticia guardada».
  • Servidor, la fuente remota: una clase con una función asíncrona que puede fallar y devuelve las últimas noticias. La primera vez que la llaman devuelve una noticia, «Noticia v1»; a partir de la segunda lanza un error propio, sinConexion (un enum que cumple Error, como FalloDeLinea). Tendrá que contar cuántas veces la han llamado.
  • Periodico, el repositorio: recibe el archivo y el servidor, tiene pantalla (las noticias guardadas, solo para leer) y una función asíncrona refrescar que no lanza nada hacia fuera: si el servidor responde, guarda lo que dé y devuelve true; si el servidor falla, devuelve false y deja lo guardado tal como estaba.

Crea un periódico y, en este orden, imprime la pantalla, el resultado de refrescar, la pantalla, el resultado de refrescar otra vez y la pantalla. Lo que debe salir:

pantalla: ["Noticia guardada"]
refrescar: true
pantalla: ["Noticia v1"]
refrescar: false
pantalla: ["Noticia v1"]
Ver solución
solución: el periódico con dos fuentes
struct Noticia: Equatable {
    let titular: String
}
 
final class Archivo {
    var guardadas = [Noticia(titular: "Noticia guardada")]
}
 
enum FalloDeRed: Error {
    case sinConexion
}
 
final class Servidor {
    private var llamadas = 0
 
    func ultimas() async throws -> [Noticia] {
        llamadas += 1
        if llamadas > 1 { throw FalloDeRed.sinConexion }
        return [Noticia(titular: "Noticia v1")]
    }
}
 
final class Periodico {
    private let archivo: Archivo
    private let servidor: Servidor
 
    init(archivo: Archivo, servidor: Servidor) {
        self.archivo = archivo
        self.servidor = servidor
    }
 
    var pantalla: [Noticia] { archivo.guardadas }
 
    func refrescar() async -> Bool {
        do {
            archivo.guardadas = try await servidor.ultimas()   // si lanza, salta al catch y no se guarda nada
            return true
        } catch {
            return false
        }
    }
}
 
let periodico = Periodico(archivo: Archivo(), servidor: Servidor())
print("pantalla: \(periodico.pantalla.map(\.titular))")
print("refrescar: \(await periodico.refrescar())")
print("pantalla: \(periodico.pantalla.map(\.titular))")
print("refrescar: \(await periodico.refrescar())")
print("pantalla: \(periodico.pantalla.map(\.titular))")
pantalla: ["Noticia guardada"]
refrescar: true
pantalla: ["Noticia v1"]
refrescar: false
pantalla: ["Noticia v1"]

La última línea es la importante: el segundo refresco falló y aun así la pantalla conserva «Noticia v1». Esa es la ventaja de que el disco mande.

Paso 1 · El almacén de los árbitros: AlmacenDeArbitros.swift

Objetivo: que Arbitro se pueda guardar como JSON y un fichero nuevo que guarda y lee la lista de árbitros en Documents/arbitros.json. Todavía no lo usa nadie.

MarcadorApp (proyecto)
MarcadorApp
Arbitros
Domain
Arbitro.swiftse modifica
Data
AlmacenDeArbitros.swiftse crea
En el Project navigator: un fichero cambia y otro se crea.

Primero, un cambio de una palabra. Para escribir una lista de árbitros en el disco hay que convertirla en JSON y, al leerla, volverla a convertir; eso lo hace Codable, que ya conoces de Codable: guardar y cargar el torneo. En Arbitro.swift, añade Codable a los protocolos que cumple Arbitro; el resto del fichero no cambia:

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Arbitros/Domain/Arbitro.swift
// `Codable`: además se guarda en el disco (`arbitros.json`) y se vuelve a leer.
struct Arbitro: Identifiable, Codable, Equatable {

Ahora el almacén. Es de los datos de los árbitros, así que va en Arbitros/Data, junto al repositorio: clic derecho sobre MarcadorApp/Arbitros/Data → New File from Template… → Swift File → nombre AlmacenDeArbitros (solo el target MarcadorApp marcado). Es el AlmacenDelTorneo de Guardar el torneo en un fichero, con otro fichero y otra lista.

Intenta escribirlo tú: un struct, AlmacenDeArbitros, que guarda la dirección del fichero, url; tiene predeterminado (el de la app de verdad: arbitros.json en Documents); guardar, que recibe una lista de árbitros y la escribe como JSON de forma que una escritura a medias no estropee el fichero anterior; cargar, que devuelve la lista guardada, o una lista vacía si el fichero no existe o está roto (esta vez no lanza error); y borrar, que lo borra sin quejarse si no existe.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Arbitros/Data/AlmacenDeArbitros.swift
import Foundation
 
// LA FUENTE LOCAL de los árbitros: la última lista que llegó del servidor, en `Documents/arbitros.json`.
// Es la misma idea que `AlmacenDelTorneo`, con un fichero propio.
struct AlmacenDeArbitros {
    let url: URL
 
    static let predeterminado = AlmacenDeArbitros(url: .documentsDirectory.appending(path: "arbitros.json"))
 
    func guardar(_ arbitros: [Arbitro]) throws {
        // `.atomic`: si la app muere a mitad, queda el fichero viejo entero.
        try JSONEncoder().encode(arbitros).write(to: url, options: .atomic)
    }
 
    // Si el fichero no existe (aún no se bajó nada) o está roto, una lista vacía.
    func cargar() -> [Arbitro] {
        guard let datos = try? Data(contentsOf: url) else { return [] }
        return (try? JSONDecoder().decode([Arbitro].self, from: datos)) ?? []
    }
 
    func borrar() {
        try? FileManager.default.removeItem(at: url)
    }
}

Piezas que ya conoces: .documentsDirectory, .appending(path:), .atomic y try?. Lo nuevo es que JSONEncoder().encode(arbitros) convierte una lista de árbitros en JSON, y decode([Arbitro].self, …) la lee de vuelta. Compila con Cmd+B: Build Succeeded.

Paso 2 · El repositorio de dos fuentes: RepositorioDeArbitros.swift

Objetivo: el repositorio nuevo, que junta el disco y el servidor. Todavía no lo usa nadie y todavía no cumple el contrato ArbitrosRepositorio: eso es de la lección siguiente.

MarcadorApp (proyecto)
MarcadorApp
Arbitros
Data
RepositorioDeArbitros.swiftse crea
En el Project navigator: un fichero nuevo.

Créalo igual que el almacén, con el nombre RepositorioDeArbitros. El que ya tienes, RepositorioDeArbitrosEnRed, no se toca: la pantalla sigue usándolo hasta la lección siguiente, y por eso el proyecto compila.

Intenta escribirlo tú: una clase final, RepositorioDeArbitros, observable (@Observable). Guarda arbitros (se lee desde fuera, solo ella lo cambia), y recibe en su init un servicio y un almacén opcional (si no se da, nil: vive solo en memoria, para las vistas previas). Al crearse, arbitros parte de lo que haya en el almacén, o de una lista vacía. refrescar() es asíncrona y puede lanzar un error: pide los DTO al servicio, los convierte en Arbitro, los guarda en el almacén (si falla al guardar, lo anota con Registro.anotar pero no rompe el refresco), y por último cambia arbitros. Si el servicio lanza un error, ese error sale tal cual y no se toca nada. No le pongas todavía : ArbitrosRepositorio: ese contrato pide arbitros() como función, y aquí arbitros es una propiedad.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Arbitros/Data/RepositorioDeArbitros.swift
import Foundation
import Observation
 
// DOS FUENTES, UNA PUERTA: el disco manda y la red refresca.
//
// La pantalla lee `arbitros`, que sale SIEMPRE de lo guardado: al abrir la app se ve al instante,
// con red o sin ella. `refrescar()` sigue el camino servidor → modelo → disco → `arbitros`. Si la red
// falla, lanza el error y lo guardado sigue ahí: la lista no se vacía.
//
// `@Observable`: cuando `refrescar()` cambia `arbitros`, la pantalla se redibuja sola.
@Observable
final class RepositorioDeArbitros {
    private(set) var arbitros: [Arbitro]
 
    @ObservationIgnored private let servicio: any ServicioDelTorneo
    @ObservationIgnored private let almacen: AlmacenDeArbitros?
 
    // Con `almacen: nil` (las vistas previas) no se guarda nada: vive solo en memoria.
    init(servicio: any ServicioDelTorneo, almacen: AlmacenDeArbitros? = nil) {
        self.servicio = servicio
        self.almacen = almacen
        self.arbitros = almacen?.cargar() ?? []   // lo último guardado, sin esperar a la red
    }
 
    func refrescar() async throws {
        let nuevos = try await servicio.arbitros().map(Arbitro.init(dto:))   // servidor → modelo
        do {
            try almacen?.guardar(nuevos)                                     // → disco
        } catch {
            Registro.anotar("no se pudo guardar arbitros.json: \(error)")
        }
        arbitros = nuevos                                                   // → la pantalla
        Registro.anotar("árbitros REFRESCADOS: \(nuevos.count)")
    }
}

Cuatro detalles:

  • try await servicio.arbitros().map(Arbitro.init(dto:)) es una sola línea con tres pasos: pide los DTO, espera, y convierte cada uno con Arbitro.init(dto:) (el map de Red en Marcador: la lista de árbitros). Si el servicio lanza, la línea entera lanza y las siguientes no se ejecutan.
  • almacen?.guardar(nuevos) lleva ? porque el almacén es opcional. Si es nil, no hace nada.
  • do/catch alrededor de guardar: no poder escribir en el disco es un fallo menor que no debe impedir que la pantalla se actualice con lo que acaba de llegar.
  • @ObservationIgnored delante de servicio y almacen dice «no vigiles esta propiedad». Son let y no cambian nunca, así que no hay nada que vigilar. Lo que sí se vigila es arbitros: cuando refrescar() lo cambia, avisa a cualquier vista que lo haya leído para pintar. Da igual por cuántas capas pase esa lectura: la pantalla de mañana lo leerá a través de su ViewModel y se redibujará igual, porque lo que cuenta es qué propiedad se lee, no por dónde.

Compila con Cmd+B: Build Succeeded. Ejecuta las pruebas con Cmd+U: no has tocado ninguna, así que siguen las mismas. La app, de momento, se ve igual que antes.

Punto de control

  • Existen AlmacenDeArbitros.swift y RepositorioDeArbitros.swift, Arbitro cumple Codable, y RepositorioDeArbitrosEnRed.swift sigue en el proyecto: la pantalla de árbitros funciona igual que antes.
  • Cmd+B termina en Build Succeeded, y Cmd+U da 46 pruebas en verde: 30 unitarias y 16 de interfaz. Si dejaste ejercicios con pruebas en tu proyecto, como la taquilla de XCTest, ves tres más (49): los ejercicios suman pruebas propias. Hoy no se ha añadido ninguna.
  • Sabes decir por qué la pantalla pintará lo guardado en el disco y no lo que dé la red, y qué diferencia hay entre leer arbitros y llamar a refrescar().

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.En la pantalla de árbitros con dos fuentes, ¿de dónde sale siempre la lista que se pinta?

  2. 2.El servidor falla en medio de un refrescar(). ¿Qué le pasa a la lista que ya se estaba viendo?

  3. 3.Los partidos vienen de la app y los árbitros de un servidor. ¿Cuántos repositorios conviene tener?

  4. 4.En RepositorioDeArbitros, ¿por qué fallar al guardar en el disco solo se anota y no rompe el refresco?

  5. 5.En RepositorioDeArbitros, ¿qué diferencia hay entre arbitros y refrescar()?

Faltan 5 de 5