Ajustes que sobreviven: UserDefaults y @AppStorage
Al terminar: Guardar preferencias con @AppStorage y el torneo en un fichero de Documents.
Al terminar, Marcador se acordará de la duración del partido entre una ejecución y la siguiente. Eliges 60 minutos en el selector, cierras la app del todo, la abres otra vez, y el selector sigue en 60. Lo consigues con @AppStorage, que guarda un ajuste pequeño en UserDefaults. En la lección siguiente guardarás el torneo entero en un fichero.
Importa porque, sin guardar nada, cada arranque parte de cero y el usuario pierde lo que eligió. Y porque elegir el sitio equivocado (un ajuste pequeño en un fichero, un secreto en UserDefaults) es un error de diseño que no da ningún aviso.
Temas: UserDefaults, @AppStorage, el enum sin casos como caja de constantes y el reparto de qué guardar y dónde (Keychain y SwiftData, en un recuadro). Esta lección no instala nada: UserDefaults y @AppStorage vienen con iOS, sin paquetes ni ajustes del proyecto.
Trabajas en tu app, en ~/Marcador-Swift/MarcadorApp: abre MarcadorApp.xcodeproj con Xcode. En el navegador de la izquierda (el Project navigator, Cmd+1) los ficheros de código están en la carpeta MarcadorApp que cuelga del proyecto, repartidos en carpetas por funcionalidad (Marcador, Partidos…), y las pruebas, en MarcadorAppTests y MarcadorAppUITests. Cuando la lección te diga sobre qué carpeta hacer clic derecho, es una de ellas (por ejemplo Marcador/Data).
UserDefaults y @AppStorage
UserDefaults es un diccionario de clave y valor que iOS guarda en disco para tu app. Sirve para ajustes pequeños: un Bool, un Int, un String, un Double, una URL. @AppStorage es un property wrapper (un envoltorio que controla cómo se lee y se escribe una propiedad; viste uno en El JSON de Kotlin en Swift: recursos y errores de lectura, y @State es otro) que lee y escribe una entrada de UserDefaults: lo usas como un @State y, además, sobrevive a cerrar la app.
Un ejemplo mínimo, para probar en cualquier vista:
struct Bienvenida: View {
// La clave "veces" es el nombre de la entrada; el 0, el valor mientras no exista.
@AppStorage("veces") private var veces = 0
var body: some View {
// Cambiar `veces` redibuja la vista (como un @State) y además queda guardado.
Button("Has pulsado \(veces) veces") { veces += 1 }
}
}Lo que se ve: un botón que dice «Has pulsado 0 veces» y, al pulsarlo, «1 veces», «2 veces»… Si cierras la app del todo y la abres otra vez, el número sigue ahí.
Hay una segunda consecuencia, menos visible: cualquier otra vista que declare @AppStorage("veces") lee la misma entrada. Si una la cambia, las demás se redibujan con el valor nuevo. Un dato que no es de una pantalla sino de varias es estado compartido, y @AppStorage es la forma más corta de tenerlo en SwiftUI. Su nombre completo, y cuándo conviene subir un dato hasta ahí, está en Estado de toda la app en SwiftUI.
Un detalle: la clave "veces" es solo un texto. Si en otro sitio la escribes con una errata ("vecez"), no hay error: simplemente apareces ante otra entrada distinta y vacía. Por eso conviene escribir cada clave una sola vez, en una constante, que es lo que hace el apartado siguiente.
Las claves, en una caja con nombre
Para escribir cada clave una sola vez se usa un enum sin ningún caso. Un enum sin casos no se puede crear: sirve solo de «caja con nombre» para agrupar constantes, y static let hace que cada constante pertenezca a la caja y no a un objeto. Con otra app, una tienda de café:
enum Tienda { // sin casos: no se puede crear, solo agrupa
static let nombre = "Café Luna" // static let: pertenece a la caja, no a un objeto
static let mesas = 12
}
print(Tienda.nombre, Tienda.mesas) // se usa escribiendo la caja, un punto y la constanteSalida:
Café Luna 12Qué usar para cada cosa
UserDefaults no sirve para todo. La regla para elegir:
| Qué guardas | Dónde | Por qué |
|---|---|---|
| Un ajuste pequeño (duración, tema) | UserDefaults / @AppStorage | Una línea, sin código de ficheros |
| Un documento entero (el torneo) | Un fichero en Documents | Estructura libre, se copia y se lee entero. Lo ves en la lección siguiente |
| Un secreto (un token, la clave que te da un servidor al iniciar sesión; una contraseña) | Keychain | Cifrado; UserDefaults va en claro |
| Muchos datos con relaciones y búsquedas | SwiftData | Un fichero de texto no lo aguanta |
El Keychain (llavero) es el almacén cifrado de iOS para tokens y contraseñas: lo custodia el sistema, fuera de los ficheros de tu app; en Android lo cubre el Keystore, que viste en Almacenamiento seguro. Marcador no guarda ningún secreto, así que no lo usa.
Reto · Ejercicio: ajustes de lectura que sobreviven
Crea una subcarpeta nueva dentro de Ejercicios, AjustesDeLectura, como en El ciclo de vida de UIViewController, con un fichero, AjustesDeLecturaView.swift. Arriba, un enum sin casos guarda las dos claves y los tamaños que se ofrecen (14, 18 y 22). La vista tiene un interruptor «Modo noche» (en SwiftUI, un Toggle, que se enlaza con un $ a un valor verdadero o falso; apagado si nunca se tocó) y un selector segmentado «Tamaño» (un Picker con estilo segmentado, enlazado también con $; 14 si nunca se tocó), los dos guardados en las preferencias de la app con esas claves. Debajo, una segunda vista, en el mismo fichero, lee las mismas claves por su cuenta (nadie le pasa nada) y muestra dos líneas. Añade la vista previa de la primera. Lo que debe verse al empezar:
Modo noche: desactivado
Tamaño de letra: 14Enciende el interruptor y elige 18: las dos líneas cambian a la vez, aunque la vista de abajo no reciba nada.
Modo noche: activado
Tamaño de letra: 18Puede que el lienzo no conserve los valores al refrescarse, así que no cuentes con ello: lo que importa es que las dos vistas cambien juntas.
Ver solución
import SwiftUI
// Las claves de los dos ajustes, escritas una sola vez.
enum ClavesDeLectura {
static let modoNoche = "lecturaModoNoche"
static let tamano = "lecturaTamano"
static let tamanos = [14, 18, 22]
}
// Los controles: cada uno lee y escribe su entrada de UserDefaults.
struct AjustesDeLecturaView: View {
@AppStorage(ClavesDeLectura.modoNoche) private var modoNoche = false
@AppStorage(ClavesDeLectura.tamano) private var tamano = 14
var body: some View {
VStack(alignment: .leading, spacing: 24) {
Toggle("Modo noche", isOn: $modoNoche)
Picker("Tamaño", selection: $tamano) {
ForEach(ClavesDeLectura.tamanos, id: \.self) { puntos in
Text("\(puntos)").tag(puntos)
}
}
.pickerStyle(.segmented)
MuestraDeLecturaView()
}
.padding()
}
}
// Otra vista que lee las MISMAS claves: nadie le pasa nada y cambia a la vez.
struct MuestraDeLecturaView: View {
@AppStorage(ClavesDeLectura.modoNoche) private var modoNoche = false
@AppStorage(ClavesDeLectura.tamano) private var tamano = 14
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Modo noche: \(modoNoche ? "activado" : "desactivado")")
Text("Tamaño de letra: \(tamano)")
}
}
}
#Preview {
AjustesDeLecturaView()
}Las dos vistas cambian a la vez porque las dos leen la misma entrada de UserDefaults: cuando una la escribe, @AppStorage avisa a la otra. Si pruebas la misma vista dentro de la app, al cerrarla del todo y abrirla otra vez salen los mismos valores que dejaste. El ejercicio se queda en Ejercicios y el proyecto compila con él.
Paso 1 · La duración en @AppStorage
Objetivo: elegir 60 minutos en el selector de un partido, parar la app (Stop, Cmd+.), volver a ejecutarla (Cmd+R) y ver el selector en 60. Hoy la duración vive en AjustesModelo, un objeto en memoria que nace a 90 en cada arranque; al terminar el paso, AjustesModelo habrá desaparecido y su único dato vivirá en UserDefaults.
1.1 Crea Ajustes.swift
Ajustes.swift es un fichero diminuto que guarda en un solo sitio el nombre de cada ajuste. Es la etiqueta del cajón: si la escribes a mano en cien sitios, una errata crea un cajón nuevo y vacío sin avisarte. Es la «caja con nombre» que viste arriba.
Clic derecho sobre la carpeta Marcador/Data (los ajustes son datos de la funcionalidad Marcador, y la carpeta ya existe) → New File from Template… → Swift File → Next. Escribe Ajustes, comprueba que en Targets solo está marcado MarcadorApp (no los de pruebas) y pulsa Create. Alternativa desde la Terminal, que Xcode refleja al instante: touch ~/Marcador-Swift/MarcadorApp/MarcadorApp/Marcador/Data/Ajustes.swift.
Escribe esto en él. import Foundation abre la biblioteca base de Apple (ficheros, URL, fechas, UserDefaults); Xcode la pone sola en cada fichero nuevo y lo normal es dejarla:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import Foundation
// Una «caja con nombre» para los ajustes del usuario: no tiene casos, solo constantes.
enum Ajustes {
// La clave con la que se guarda la duración en `UserDefaults`.
// Se escribe UNA vez aquí y se usa desde el resto de la app.
static let claveDuracion = "duracionDelPartido"
// Las duraciones que ofrece el selector, en minutos.
static let duraciones = [90, 60]
// La que vale si el usuario aún no ha elegido ninguna.
static let duracionPorDefecto = 90
}Compila (Cmd+B): todavía no lo usa nadie.
1.2 Sustituye AjustesModelo por @AppStorage
Abre PartidoEnDirectoView.swift y haz cuatro cambios, uno por cada bloque de código de abajo. El primero, arriba del struct, donde la vista recibía los ajustes del entorno: borra la línea @Environment(AjustesModelo.self) private var ajustes (y sus comentarios) y escribe en su lugar:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// `@AppStorage` lee y escribe `UserDefaults` con la clave de `Ajustes`.
// `duracion` se usa como un `@State`: al cambiar, la vista se redibuja.
@AppStorage(Ajustes.claveDuracion) private var duracion = Ajustes.duracionPorDefectoEl segundo cambia ajustes.duracion por duracion en el .task (las dos veces que aparece):
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
.task(id: ajustes.duracion) {
await viewModel.jugar(duracion: ajustes.duracion)
} .task(id: duracion) {
await viewModel.jugar(duracion: duracion)
}El tercero está en la función contenido(_ datos:): desaparecen dos cosas, @Bindable var ajustes = ajustes, que ya no hace falta (un @AppStorage da su propio Binding con el $), y el return de la línea siguiente. El return se escribió porque había una línea antes del ScrollView; con una sola expresión en la función, Swift la devuelve solo, y por eso sobra:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
@Bindable var ajustes = ajustes // sigue haciendo falta para `$ajustes.duracion`
return ScrollView { // `return` explícito: el cuerpo ya no es una sola expresión, tiene una línea antes ScrollView {El cuarto está en el selector: el Binding y la lista de duraciones cambian de dueño:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Picker("Duración", selection: $ajustes.duracion) {
ForEach(AjustesModelo.duraciones, id: \.self) { minutos in Picker("Duración", selection: $duracion) {
ForEach(Ajustes.duraciones, id: \.self) { minutos in1.3 Borra AjustesModelo
Borra el fichero y arregla los tres sitios que aún nombran AjustesModelo. Si compilas antes de hacerlo, Xcode los marca en rojo con cannot find 'AjustesModelo' in scope: es la lista de lo que falta.
- Clic derecho sobre
AjustesModelo.swift→ Delete → Move to Trash. - En
MarcadorApp.swift, borra la línea@State private var ajustes = AjustesModelo()(con su comentario) y la línea.environment(ajustes). Se queda solo el repositorio:.environment(repositorio). - En
ListaDePartidosView.swift, al final, la vista previa (#Preview) pasaba unAjustesModelo: borra la línea.environment(AjustesModelo()). - En
MarcadorAppTests.swift, la pruebaobservableAvisaSoloDeLoQueSeLeeusabaAjustesModelosolo como ejemplo de objeto observable. Pon una clase mínima encima delstructy cambia la prueba para que use esa clase. Encima de@MainActor struct MarcadorAppTests:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// Un objeto `@Observable` mínimo, solo para esta prueba de observación.
@Observable final class Contador { var valor = 0 } @Test func observableAvisaSoloDeLoQueSeLee() {
let contador = Contador()
let avisos = Mutex(0)
withObservationTracking {
_ = contador.valor
} onChange: {
avisos.withLock { $0 += 1 }
}
contador.valor = 60
#expect(avisos.withLock { $0 } == 1)
#expect(contador.valor == 60)
}Compila con Cmd+B: debe terminar con Build Succeeded y sin errores en el panel de avisos (Issues, Cmd+5).
1.4 Pruébalo
Ejecuta la app (Cmd+R) en un iPhone del simulador, toca un partido de la lista y elige 60 min en el selector. Para la app con Stop (Cmd+.) y ejecútala otra vez (Cmd+R). Abre el mismo partido: el selector sigue en 60. Antes, la duración volvía a 90 en cada arranque.
Una advertencia para las pruebas: ahora la duración elegida se queda guardada en el simulador, y el simulador conserva sus datos de una ejecución a otra, también cuando pruebas con Cmd+U o con xcodebuild. Si alguna prueba de interfaz falla justo después de este paso, vuelve a elegir 90 min en el selector y repite; algunas pruebas esperan la duración de siempre. Para dejar el simulador como nuevo, borra la app desde él (mantén pulsado su icono) o, con el simulador apagado, ejecuta xcrun simctl erase all, que borra los datos de todos tus simuladores.
Punto de control
- Elige 60 min en un partido, para la app (Cmd+.) y ejecútala otra vez (Cmd+R): el selector sigue en 60.
- No queda ninguna referencia a
AjustesModelo: Cmd+B compila sin errores, y en el Project navigator estáAjustes.swift. - Las pruebas de la app pasan. Cmd+U lanza las 25: 12 unitarias y 13 de interfaz, las mismas que al empezar.
Siguiente: Guardar el torneo en un fichero guarda el torneo entero y te enseña a mirar el sandbox por dentro.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.¿Qué hace
@AppStorage("duracion") private var duracion = 90en una vista?2.Necesitas guardar el token de acceso de un usuario. ¿Dónde va?
3.¿Cuándo tiene sentido pasar de un JSON en
Documentsa SwiftData?4.¿Por qué la clave de
@AppStorageva en una constante (Ajustes.claveDuracion) y no escrita a mano en cada sitio?