fileStorage: estructuras grandes en disco, con escritura diferida
Cuando lo que hay que conservar no es una preferencia sino una colección entera, `appStorage` deja de ser una opción y aparece `fileStorage`: un fichero JSON tratado como si fuera una variable del estado. Esta lección examina el contrato completo de esa clave —carga perezosa al primer acceso, escritura diferida y agrupada, vaciado al pasar a segundo plano, vigilancia del fichero para detectar cambios externos— y expone con precisión su punto ciego: qué ocurre exactamente cuando el fichero existe pero no se puede decodificar.
Una preferencia es un escalar y cabe en una lista de propiedades; un catálogo de notas, una bandeja de borradores o una caché de artículos descargados no caben, y meterlos ahí por comodidad hipoteca cada arranque de la app. fileStorage ofrece el siguiente escalón del mismo modelo mental: sigues declarando una propiedad en el State, sigues leyéndola sin ceremonia, sigues mutándola con withLock, pero debajo hay un fichero JSON en el sistema de ficheros y, sobre todo, hay una política de escritura que no es la ingenua. Entender esa política —cuándo se escribe, cuándo no, y qué se pierde si el proceso muere en el hueco— es la diferencia entre usar la clave y confiar en ella.
- Distinguir con criterio qué estructuras pertenecen a
fileStoragey cuáles siguen siendo asunto deappStorage. - Declarar una colección persistida en JSON y elegir el directorio correcto según su valor para el usuario.
- Explicar la escritura diferida: agrupación temporal, vaciado al pasar a segundo plano y coste de reescribir el fichero entero.
- Anticipar el fallo de decodificación y por qué su comportamiento por defecto es silencioso.
Declarar un fichero como si fuera una variable
La forma es idéntica a la de la lección anterior; lo que cambia es la clave y, con ella, el sustrato.
@ObservableState
struct State: Equatable {
@Shared(.fileStorage(.documentsDirectory.appending(component: "notas.json")))
var notas: IdentifiedArrayOf<Nota> = []
}
struct Nota: Codable, Equatable, Identifiable, Sendable {
let id: UUID
var titulo: String
var cuerpo: String
var actualizada: Date
}
El requisito nuevo es Codable: la clave usa un codificador y un decodificador JSON por omisión, así que el valor completo tiene que saber ir y volver de una representación textual. A partir de ahí todo se comporta como ya sabes. La lectura del fichero ocurre de forma perezosa la primera vez que alguien accede a la propiedad, no en un efecto ni en una acción de arranque, de modo que la vista ya se pinta con los datos de la sesión anterior en su primer fotograma. Si el fichero no existe, se usa el valor por defecto de la declaración y no se escribe nada hasta la primera mutación real.
Como antes, la cadena de la ruta no debe repetirse por el proyecto. Se extrae a una clave con nombre y defecto propios.
extension SharedKey where Self == FileStorageKey<IdentifiedArrayOf<Nota>>.Default {
static var notas: Self {
Self[.fileStorage(.documentsDirectory.appending(component: "notas.json")), default: []]
}
}
@Shared(.notas) var notas
Y la mutación conserva su gesto explícito, con una consecuencia nueva que conviene interiorizar desde el principio: cada withLock no provoca una escritura, provoca la programación de una escritura.
case let .notaEditada(id, cuerpo):
state.$notas.withLock { $0[id: id]?.cuerpo = cuerpo }
return .none
Escritura diferida: por qué no se guarda al instante
Si cada pulsación de tecla en un editor de texto reescribiera un fichero de dos megabytes, la app tendría que elegir entre saltarse fotogramas o vaciar la batería. fileStorage evita ese dilema agrupando las escrituras: tras una mutación arranca una ventana corta —del orden de un segundo— durante la cual las mutaciones siguientes no generan trabajo adicional, y al cerrarse la ventana se serializa una sola vez el valor actual. Cien pulsaciones en un segundo producen una escritura, no cien.
La ventana introduce un hueco en el que la memoria va por delante del disco, y ese hueco se cierra por tres caminos distintos.
Por vencimiento
Al expirar la ventana de agrupación se escribe el último valor. Es el camino normal, invisible para el usuario.
Por segundo plano
Cuando la app deja de estar activa se fuerza el vaciado inmediato, porque el sistema puede matar el proceso en cualquier momento a partir de ahí.
Por terminación
La notificación de cierre también dispara el vaciado, de modo que la salida ordenada nunca pierde el último cambio.
Por vigilancia inversa
La clave observa además el fichero: si otro proceso lo reescribe, el nuevo contenido entra en el estado y la vista se redibuja.
Queda un cuarto camino, el que no se cierra: la muerte abrupta del proceso mientras la ventana está abierta. Un fallo por acceso a memoria inválida, un cierre forzado por el usuario o un desalojo por presión de memoria en primer plano se llevan por delante lo que aún no se escribió. Es un compromiso consciente y correcto para la inmensa mayoría de las apps, pero deja de serlo en cuanto lo que se está editando es irreemplazable. Para esos casos la respuesta no es reducir la ventana hasta cero —eso reintroduce el problema del rendimiento— sino subir un escalón de sustrato: una base de datos transaccional, que es exactamente el territorio de la cuarta lección de este nivel.
No hay escritura parcial: cambiar un carácter de una nota reserializa la colección completa. Con centenares de elementos es imperceptible; con decenas de miles empieza a notarse en cada ventana de vaciado. La señal de alarma es sencilla de reconocer: si te sorprendes deseando poder guardar solo el elemento que cambió, es que tu problema ya no es un fichero sino una base de datos.
Dónde vive el fichero, y qué significa cada sitio
La ruta no es un detalle de fontanería: elige quién puede leer el fichero, si se respalda en la nube y si el sistema puede borrarlo cuando le falte espacio.
| Ubicación | Se respalda | El sistema puede purgarlo | Uso adecuado |
|---|---|---|---|
| Directorio de documentos | sí | no | datos creados por el usuario que dolería perder |
| Directorio de soporte de la app | sí | no | estado interno que el usuario no debe ver ni editar |
| Directorio de cachés | no | sí, cuando falte espacio | contenido derivado y reconstruible desde la red |
| Contenedor de grupo de app | según configuración | no | lo que comparten la app, un widget y las extensiones |
La regla que ordena la tabla es una pregunta única: si este fichero desapareciera esta noche, ¿podría la app reconstruirlo sin molestar al usuario? Si la respuesta es sí, pertenece a cachés y ponerlo en documentos infla la copia de seguridad de todos tus usuarios con datos que nadie echará de menos. Si la respuesta es no, jamás debe estar en cachés, por muy caché que parezca su contenido.
Para casos con exigencias propias, la clave admite sustituir la pareja de codificación completa. Sirve para cambiar de formato, para ajustar la representación de las fechas o para envolver el valor en algo que se pueda versionar.
extension SharedKey where Self == FileStorageKey<IdentifiedArrayOf<Nota>>.Default {
static var notas: Self {
Self[
.fileStorage(
.applicationSupportDirectory.appending(component: "notas.json"),
decode: { try JSONDecoder().decode(IdentifiedArrayOf<Nota>.self, from: $0) },
encode: { try JSONEncoder().encode($0) }
),
default: []
]
}
}
flowchart TD M[Mutacion con withLock] --> B[Ventana de agrupacion abierta] B --> M2[Mas mutaciones no anaden trabajo] B --> T[Vence la ventana] A[App pasa a segundo plano] --> F[Vaciado inmediato] T --> W[Escribe el fichero entero] F --> W W --> D[JSON en disco] D --> V[Vigilancia detecta cambio externo] V --> S[El valor compartido se actualiza] style W fill:#a6e3a1,color:#11111b style D fill:#89b4fa,color:#11111b
El punto ciego: cuando el fichero está pero no se deja leer
Hay un caso que merece una sección propia porque su comportamiento por omisión es contraintuitivo. Si el fichero existe pero la decodificación lanza —porque el formato cambió, porque una escritura quedó truncada, porque alguien editó el JSON a mano— la clave no propaga el error: cae al valor por defecto de la declaración. Desde dentro de la app, un fichero corrupto y un fichero inexistente son indistinguibles: en ambos casos la colección aparece vacía.
Las consecuencias se encadenan rápido. El usuario abre la app y ve cero notas. Crea una para probar. Esa mutación programa una escritura, y la escritura reemplaza el fichero ilegible por uno perfectamente legible que contiene una sola nota. El original, con sus doscientas, ha dejado de existir sin que se registrara un solo error.
Es la elección correcta para el caso corriente —una app que no arranca es peor que una app que arranca vacía— pero es inaceptable sin salvaguardas cuando el contenido es del usuario. El mínimo defendible es un decode que, ante el fallo, copie el fichero ilegible a un nombre de cuarentena antes de rendirse, de modo que exista algo que recuperar mañana. La quinta lección de este nivel convierte esa salvaguarda en una estrategia de versionado completa.
Todo el vocabulario tradicional de la persistencia es de transporte: se carga, se guarda, se vuelca, se sincroniza. Son verbos de traslado, y presuponen dos lugares distintos —la memoria y el disco— entre los cuales una copia viaja de ida y vuelta bajo la supervisión de alguien. Ese alguien era, hasta ahora, el programador, y toda la fragilidad de la persistencia clásica nace de ahí: cualquier traslado puede olvidarse, duplicarse o llegar tarde, y ninguna firma de tipos puede impedirlo. fileStorage disuelve la dualidad. No hay dos lugares con una copia yendo y viniendo, hay un solo valor cuyo modo de existencia incluye tener una huella en el sistema de ficheros, igual que otros valores tienen la suya en una página de memoria virtual que a veces está en RAM y a veces en la partición de intercambio. Nadie escribe código para sincronizar una variable con su página de intercambio, porque el paginador es invisible y su corrección no es asunto del programa. La escritura diferida es exactamente eso: un paginador para el estado del usuario, con su propia política de vaciado y su propia ventana de riesgo. Y como todo paginador, solo puede ser invisible mientras se conozca su contrato. Por eso esta lección insiste tanto en el hueco de la ventana y en la caída silenciosa al valor por defecto: no son detalles de implementación que la abstracción tape, son los dos únicos sitios donde la abstracción tiene fugas, y saber dónde están es la condición para poder olvidarse del resto.
- Migra a
@Shared(.fileStorage)una colección que hoy persistas a mano, extrae la ruta a una clave con nombre y elige el directorio según la pregunta de la reconstruibilidad. - Instrumenta el
encodecon un registro por consola. Escribe rápido en un campo de texto y cuenta cuántas escrituras reales se producen frente a cuántas mutaciones hiciste. - Manda la app a segundo plano en mitad de una ráfaga de edición y comprueba que el vaciado se adelanta.
- Cierra la app de forma abrupta con una mutación recién hecha y determina empíricamente cuánto se pierde. Escribe ese número en la documentación de tu feature.
- Corrompe el fichero a mano, arranca, observa la colección vacía y verifica lo peor: que la primera mutación destruye el original. Después escribe el
decodeque pone el fichero ilegible en cuarentena y repite el experimento.