Saltar al contenido
← Todos los posts

Persistencia en iOS: SwiftData, GRDB y Keychain, y por qué Realm ya no es opción

Cómo decidir entre SwiftData, GRDB y Core Data para persistir datos en una app iOS, por qué Realm dejó de ser una opción, por qué el Keychain sobrevive a la desinstalación, y cómo plantear offline-first y sincronización con CloudKit.

Ilustración de tres capas de almacenamiento apiladas, con una caja aparte sellada que representa al Keychain y una nube que sincroniza con la capa principal

En React Native la persistencia era una decisión de una línea: AsyncStorage o MMKV para claves simples, y WatermelonDB, SQLite con Expo o Realm cuando hacía falta una base de datos. En iOS nativo la lista es más corta y la decisión pesa más, porque la base de datos local no es un detalle: en el diseño de la capa de datos de esta serie es la fuente de la que la UI lee. Este post da el criterio para elegir entre SwiftData, GRDB y Core Data, explica por qué Realm salió de la lista, aclara un comportamiento del Keychain que sorprende a todo el que viene de AsyncStorage, y plantea cómo se arma una app offline-first con sincronización, con CloudKit o con un backend propio.

Resumen para perezosos
  • SwiftData para apps pequeñas y solo Apple, con vistas que usan @Query; GRDB cuando hay consultas reales, migraciones que van a evolucionar o sincronización con conflictos; Core Data solo si lo heredas. Realm está descontinuado y no debe entrar en un proyecto nuevo.
  • El Keychain no se borra al desinstalar la app. Un token guardado ahí reaparece si el usuario reinstala; hay que detectar el primer arranque y limpiarlo a propósito.
  • Offline-first es una arquitectura, no una librería: escrituras locales primero, una cola de cambios pendientes, y sincronización con resolución de conflictos explícita. CloudKit la da casi gratis entre dispositivos del mismo usuario; con un backend propio la escribes tú.

En este artículo:

El mapa de opciones frente a React Native

NecesidadReact NativeiOS nativo
Preferencias, flags, valores pequeñosAsyncStorage, MMKVUserDefaults (@AppStorage en SwiftUI)
Tokens, contraseñas, clavesexpo-secure-storeKeychain (Security framework)
Base de datos relacionalexpo-sqlite, WatermelonDBGRDB (SQLite), Core Data
Modelos observables desde la UIRealm, WatermelonDBSwiftData, Core Data
Archivos grandes (imágenes, documentos)expo-file-systemFileManager en Documents o Caches
Sincronización entre dispositivosBackend propioCloudKit, o backend propio

La diferencia que más cambia el diseño: en React Native, Realm y WatermelonDB ofrecían objetos observables que actualizaban la UI al cambiar. En iOS eso lo hacen SwiftData y Core Data con SwiftUI, y GRDB con ValueObservation. No hay que elegir entre “base de datos” y “reactividad”; todas las opciones serias son observables.

SwiftData, GRDB o Core Data: el criterio

SwiftData es el framework de persistencia de Apple para Swift moderno, disponible desde iOS 17. Se declara con macros sobre clases (@Model), se consulta desde SwiftUI con @Query, y usa Core Data por debajo. Es el que aparece en todos los ejemplos de Apple.

import SwiftData

@Model
final class Note {
    var title: String
    var body: String
    var createdAt: Date
    @Relationship(deleteRule: .cascade) var attachments: [Attachment] = []

    init(title: String, body: String) {
        self.title = title
        self.body = body
        self.createdAt = .now
    }
}

struct NotesScreen: View {
    @Query(sort: \Note.createdAt, order: .reverse) private var notes: [Note]
    @Environment(\.modelContext) private var context

    var body: some View {
        List(notes) { note in Text(note.title) }
            .toolbar {
                Button("Nueva") { context.insert(Note(title: "Sin título", body: "")) }
            }
    }
}

Lo que hace bien: cero configuración, integración con SwiftUI, sincronización con CloudKit activable con una opción, migraciones ligeras automáticas. Lo que hace mal o no hace: consultas con joins o agregaciones complejas (#Predicate cubre filtros, no reportes), control fino de transacciones y hilos, y estabilidad entre versiones de iOS, que en sus dos primeros años tuvo cambios de comportamiento que rompieron apps en producción. Sus modelos son clases, no struct, y por lo tanto no son Sendable: cruzarlas entre actores requiere ModelActor y cuidado.

GRDB es una librería de terceros (Gwendal Roué, mantenida desde 2015) que expone SQLite con una API Swift: filas como struct que conforman Codable y FetchableRecord, consultas con un constructor tipado o SQL a mano, migraciones explícitas y versionadas, y ValueObservation para observar el resultado de cualquier consulta.

struct Note: Codable, FetchableRecord, PersistableRecord, Identifiable {
    var id: Int64?
    var title: String
    var body: String
    var createdAt: Date
}

var migrator = DatabaseMigrator()
migrator.registerMigration("v1") { db in
    try db.create(table: "note") { t in
        t.autoIncrementedPrimaryKey("id")
        t.column("title", .text).notNull()
        t.column("body", .text).notNull()
        t.column("createdAt", .datetime).notNull().indexed()
    }
}
try migrator.migrate(dbQueue)

// Observación: emite cada vez que el resultado cambia.
let observation = ValueObservation.tracking { db in
    try Note.order(Column("createdAt").desc).fetchAll(db)
}

Lo que hace bien: rendimiento y control de SQLite completos, modelos como struct (y por lo tanto Sendable), migraciones que tú escribes y versionas, y una API que no depende de SwiftUI, así que la capa de persistencia vive en un package sin importar UI. Lo que cuesta: escribir el esquema a mano, y una dependencia externa, aunque de las más estables del ecosistema.

Core Data es el framework original, de 2005, en Objective-C con API para Swift. Es potente, maduro y verboso. Si heredas un proyecto con Core Data, se mantiene; si empiezas uno nuevo, no hay razón para elegirlo sobre SwiftData (que lo envuelve) o GRDB.

El criterio en forma de decisión:

¿Proyecto heredado con Core Data?

   ├── sí ──► Core Data (y evalúa migrar a SwiftData si iOS 17+)

   └── no

        ¿Consultas complejas, migraciones que evolucionarán,
        sincronización con conflictos, o capa de datos sin SwiftUI?

           ├── sí ──► GRDB

           └── no

                ¿iOS 17+ y las vistas pueden usar @Query directamente?

                   ├── sí ──► SwiftData
                   └── no ──► GRDB

En apps con backend, casi siempre termino en GRDB. En apps locales pequeñas (notas, hábitos, un catálogo que se sincroniza con iCloud), SwiftData ahorra mucho código.

Por qué Realm ya no es opción

Realm fue durante años la alternativa cómoda en los dos mundos: objetos observables, sincronización con Atlas Device Sync, y la misma base de datos en React Native y en Swift. MongoDB anunció en septiembre de 2024 la descontinuación de Atlas Device Sync y de los SDK de Realm, con fin de soporte en septiembre de 2025. El código es abierto y sigue compilando, pero no recibe actualizaciones por nuevas versiones de iOS ni de Swift, y con Swift 6 estricto, un SDK que no se mantiene es un problema que crece con cada release.

Para un proyecto nuevo, la respuesta es no. Para uno existente con Realm, el camino es GRDB (modelos struct, migración de datos escrita a mano) o SwiftData si la app es simple, y en cualquiera de los dos casos la sincronización se resuelve aparte, con CloudKit o con el backend propio. Es trabajo, y conviene hacerlo antes de que un cambio de iOS lo vuelva urgente.

Keychain: lo que sobrevive a la desinstalación

expo-secure-store en iOS usa el Keychain por debajo, así que técnicamente ya lo usabas. Lo que probablemente no sabías es un comportamiento que en nativo te toca manejar: los elementos del Keychain no se borran cuando el usuario desinstala la app. Se borran los datos del contenedor de la app (UserDefaults, archivos, la base de datos), pero el Keychain es un almacén del sistema, indexado por el identificador de la app, y sus entradas persisten hasta que algo las borre.

La consecuencia concreta: un usuario desinstala tu app para “empezar de cero”, la reinstala, y aparece con la sesión iniciada porque el token sigue ahí. O peor, un dispositivo que cambia de dueño con la app desinstalada. El patrón para manejarlo es detectar el primer arranque después de una instalación y limpiar:

enum FirstLaunch {
    private static let key = "hasLaunchedBefore"

    /// Llamar al arrancar, antes de leer cualquier credencial.
    static func resetKeychainIfFreshInstall() {
        let defaults = UserDefaults.standard
        guard !defaults.bool(forKey: key) else { return }
        // UserDefaults sí se borró con la desinstalación; el Keychain no.
        Keychain.deleteAll()
        defaults.set(true, forKey: key)
    }
}

Sobre la API en sí: Security expone funciones en C (SecItemAdd, SecItemCopyMatching) con diccionarios de atributos, y escribir el envoltorio es una tarde que nadie disfruta. Las opciones son escribir un envoltorio de cien líneas una vez y reutilizarlo, o usar una librería fina como KeychainAccess. Cualquiera de las dos; lo que no conviene es guardar tokens en UserDefaults porque “es más fácil”: UserDefaults es un archivo plist sin cifrar dentro del contenedor, legible en un backup sin cifrar.

Dos atributos que importan al guardar:

  • kSecAttrAccessible: cuándo el sistema permite leer el elemento. kSecAttrAccessibleWhenUnlockedThisDeviceOnly es el valor sensato para tokens: solo con el dispositivo desbloqueado, y no se migra a otro dispositivo por backup.
  • Access groups (kSecAttrAccessGroup): para compartir credenciales entre la app y sus extensiones (un widget que necesita el token para pedir datos). Requiere el entitlement de Keychain Sharing.

UserDefaults y archivos: lo pequeño y lo grande

UserDefaults es el reemplazo de AsyncStorage para valores pequeños: preferencias, el último tab visitado, flags de onboarding. En SwiftUI, @AppStorage("showCompleted") private var showCompleted = false lo lee y lo escribe como si fuera @State, con actualización de la vista al cambiar. Dos límites: no es seguro (ya dicho), y no es una base de datos; guardar un array grande de objetos codificados ahí funciona hasta que deja de funcionar.

Para archivos (imágenes descargadas, PDFs, exports), FileManager con dos directorios con semántica distinta:

  • Documents: datos del usuario, se incluyen en el backup de iCloud, el sistema nunca los borra.
  • Caches: datos regenerables, no van al backup, y el sistema puede borrarlos cuando falta espacio. Las imágenes cacheadas de la red van aquí, y el código debe asumir que pueden no estar.

Si guardas en Documents algo que se puede volver a descargar, App Review puede rechazarlo por inflar el backup del usuario. Es una de las guidelines que más se aplica y menos se conoce.

Offline-first: escrituras locales y una cola de cambios

Offline-first no es “cachear las respuestas”. Es que la app funcione completa sin red, incluidas las escrituras, y que sincronice después. El diseño tiene tres piezas, todas sobre la base de datos local:

  1. Toda escritura va primero a la base de datos local. El usuario crea una nota, la nota existe de inmediato, la UI la muestra, sin esperar al servidor.
  2. Cada escritura deja un cambio pendiente en una tabla de cola (pending_change: tipo, entidad, id, payload, fecha). Es el registro de lo que el servidor todavía no sabe.
  3. Un sincronizador drena la cola cuando hay red, en orden, y aplica lo que el servidor devuelve (ids definitivos, versiones, conflictos).
Usuario crea nota


db.write { insert Note(id: local); insert PendingChange(.create, note) }
   │                                       ▲
   ▼                                       │ la UI ya la muestra
Sincronizador (cuando hay red)             │
   │                                       │
   ├── POST /notes ──► ok ──► db.write { update Note(id: remoto); delete PendingChange }

   └── POST /notes ──► conflicto (versión) ──► resolver ──► db.write { ... }

Los conflictos son la parte que ninguna librería resuelve por ti, porque dependen del dominio. Las tres estrategias que uso, en orden de frecuencia:

  • Última escritura gana por campo o por registro, con una marca de tiempo. Sirve para preferencias y datos de una sola persona.
  • El servidor gana y el cliente muestra: el cambio local se descarta y la UI avisa. Sirve para datos compartidos donde el cliente no debería decidir.
  • Fusión por campo: se aplican los dos cambios si tocan campos distintos, y se pide al usuario si tocan el mismo. Sirve para editores colaborativos y es la más cara de implementar.

Cualquiera de las tres necesita que cada registro lleve una versión (un entero o un updatedAt del servidor) y que el servidor la compare al escribir. Si el backend no tiene versiones, no hay sincronización correcta; es lo primero que pido cuando un cliente dice “queremos que funcione sin conexión”.

CloudKit: sincronización entre dispositivos del mismo usuario

Si la app no tiene backend propio y los datos son de un solo usuario (notas, hábitos, colecciones), CloudKit resuelve la sincronización entre iPhone, iPad y Mac con la cuenta de iCloud del usuario, sin servidor tuyo y sin costo hasta cuotas altas. SwiftData lo activa con ModelConfiguration(cloudKitDatabase: .automatic) y una capacidad en el proyecto; Core Data con NSPersistentCloudKitContainer. Con GRDB no hay integración directa; se escribe el sincronizador con CKRecord a mano, o se usa una librería que lo haga.

Las restricciones que hay que aceptar a cambio:

  • Todos los atributos deben ser opcionales o tener valor por defecto, y las relaciones no pueden ser obligatorias, porque los registros pueden llegar en cualquier orden.
  • No hay claves únicas ni restricciones de unicidad: dos dispositivos pueden crear “la misma” entidad sin red y las dos existirán. Si el dominio exige unicidad, hay que deduplicar al recibir.
  • La resolución de conflictos es “última escritura gana” por campo y no se configura. Si necesitas otra cosa, CloudKit no es la herramienta.
  • Solo funciona con cuenta de iCloud, y solo entre plataformas de Apple. Si algún día hay Android o web, los datos están en un lugar al que no puedes llegar.

Para apps personales de un solo usuario, esa lista es aceptable y el ahorro de no tener backend es enorme. Para cualquier cosa con usuarios que comparten datos entre sí, con Android en el plan o con lógica de negocio en el servidor, el backend propio con la cola de cambios de la sección anterior es el camino, y GRDB es la base de datos local que mejor lo soporta.

El error que más he visto en migraciones desde React Native es tratar la base de datos local como una caché opcional y la red como la verdad. En iOS, con SwiftData o GRDB observables, es al revés: la base de datos es la verdad para la UI y la red es un proceso que la actualiza. Cuando ese orden queda claro, offline-first deja de ser una funcionalidad y pasa a ser el estado normal de la app.

Preguntas frecuentes

¿Puedo usar SwiftData y GRDB en la misma app?

Técnicamente sí, pero no lo recomiendo: dos bases de datos son dos esquemas, dos migraciones y dos formas de observar. Elige una. Si dudas entre las dos, GRDB cubre todo lo que SwiftData hace, con más código y sin la integración de @Query.

¿Cómo migro datos de una versión del esquema a otra sin perder lo del usuario?

Con GRDB, cada cambio de esquema es una migración registrada con nombre, que corre una sola vez y en orden. Con SwiftData, los cambios simples (agregar un atributo opcional) son automáticos, y los complejos requieren SchemaMigrationPlan con etapas. En los dos casos, la regla es no editar una migración ya publicada: se agrega una nueva.

¿Los datos de SwiftData o GRDB se cifran?

No por defecto. El contenedor de la app está protegido por el cifrado del dispositivo (Data Protection), que cifra los archivos cuando el dispositivo está bloqueado, y se puede endurecer con FileProtectionType.complete. Para cifrado a nivel de base de datos, GRDB soporta SQLCipher. Para la mayoría de las apps, Data Protection más el Keychain para los secretos es suficiente.

¿Dónde guardo el estado de “el usuario ya vio el onboarding”?

En UserDefaults, con @AppStorage si lo lee una vista. Es el caso de uso exacto de ese almacén: un valor pequeño, no sensible, que se borra con la app.

¿MMKV tiene equivalente en iOS?

MMKV es una librería de Tencent que también existe para iOS nativo, y es más rápida que UserDefaults para escrituras frecuentes. En la práctica, UserDefaults es suficiente para preferencias, y cuando hay escrituras frecuentes de datos estructurados la respuesta es la base de datos, no un almacén de claves más rápido.

Conclusión

La persistencia en iOS tiene menos opciones que en React Native y cada una tiene un lugar claro: UserDefaults para lo pequeño, Keychain para lo secreto, FileManager para lo grande, y una base de datos observable para el modelo. Entre SwiftData y GRDB, la decisión la dan las consultas, las migraciones y la sincronización: simple y solo Apple, SwiftData; cualquier otra cosa, GRDB. Realm salió del mapa. El Keychain sobrevive a la desinstalación y hay que limpiarlo en el primer arranque. Y offline-first es una arquitectura de tres piezas (escritura local, cola de cambios, sincronizador con conflictos explícitos) que CloudKit da hecha para un solo usuario y que con backend propio escribes tú.

Para empezar: elige la base de datos con el diagrama, escribe la primera migración antes que la primera vista, mueve el token al Keychain con limpieza en primer arranque, y si el producto dice “sin conexión”, pide versiones en el backend antes de escribir una línea de sincronización. El siguiente post es la tabla grande: qué framework de Apple reemplaza a cada paquete expo-*, por dominio, y el crash silencioso de permisos que todos cometemos la primera vez.

Seguir leyendo