wandres.dev
CLOUDKIT Y SINCRONIZACIÓN · datos entre dispositivos

SwiftData con CloudKit: activar, cumplir requisitos y saber qué hace por ti

Activar la sincronización en SwiftData cuesta una línea, y esa es exactamente la razón por la que tantos equipos la activan sin entender qué contrato acaban de firmar. Esta lección examina el espejo de CloudKit desde dentro: los requisitos que el modelo debe cumplir y por qué son consecuencia inevitable del formato de registro, el trabajo que el sistema hace por ti de forma invisible, el que no hará nunca por mucho que lo esperes, y cómo observar un proceso que por diseño no te informa de nada.

⏱ 20 min

Hay pocas funcionalidades en el ecosistema de Apple con una relación tan desigual entre el esfuerzo de activación y la profundidad de sus consecuencias. Poner la sincronización en marcha son unos clics en las capacidades del proyecto y un parámetro en la configuración del modelo; entender qué implica supone aceptar que el esquema local deja de ser tuyo, que la unicidad de tus datos deja de estar garantizada, que tus relaciones cambian de forma y que el momento en que un cambio llega al otro dispositivo se vuelve una variable que no controlas. Nada de eso es un defecto: es el precio estructural de convertir un almacén local en una réplica de un sistema distribuido, y conviene pagarlo con los ojos abiertos.

🎯 Al terminar esta lección sabrás
  • Activar el espejo de CloudKit con la configuración correcta y las capacidades que realmente hacen falta.
  • Justificar cada requisito del modelo a partir de cómo se traduce a un registro, en lugar de memorizarlos.
  • Delimitar con precisión qué resuelve el sistema automáticamente y qué sigue siendo responsabilidad tuya.
  • Observar y depurar un proceso silencioso mediante trazas, panel de control y pruebas con dos dispositivos.

Activar el espejo

La activación tiene tres piezas y las tres son obligatorias. La primera es la capacidad de iCloud con la casilla de CloudKit marcada y un contenedor seleccionado. La segunda es la capacidad de modos en segundo plano con las notificaciones remotas activadas, sin la cual la app no despertará cuando el servidor avise y la sincronización solo ocurrirá mientras esté en pantalla. La tercera es indicar en la configuración del modelo qué base de datos debe usarse. Cuando ese parámetro vale .automatic, SwiftData busca el primer contenedor declarado en los derechos de acceso; nombrarlo de forma explícita es preferible en cuanto la app tiene extensiones o variantes.

@main
struct MiApp: App {
    var body: some Scene {
        WindowGroup { ContentView() }
            .modelContainer(for: [Proyecto.self, Tarea.self])
    }
}

// Control explicito: util cuando hay varios almacenes o pruebas sin red
let config = ModelConfiguration(
    "Principal",
    schema: Schema([Proyecto.self, Tarea.self]),
    isStoredInMemoryOnly: false,
    cloudKitDatabase: .private("iCloud.com.tuequipo.MiApp")
)
let contenedor = try ModelContainer(for: Schema([Proyecto.self, Tarea.self]), configurations: config)

Conviene saber qué ocurre bajo esa línea, porque explica casi todo el comportamiento posterior. SwiftData deriva un esquema de CloudKit a partir de tus clases anotadas, crea en la base privada una zona personalizada propia y usa una tabla de historial local para saber qué falta por subir. Cada entidad se convierte en un tipo de registro con un prefijo reservado, cada propiedad en un campo, y cada relación en una referencia o en un conjunto de referencias. Todo lo que el modelo pida y no quepa en esa traducción provocará un fallo de validación al arrancar el contenedor, no en tiempo de compilación.

⚠️
Desarrollo y producción son dos esquemas distintos

Mientras trabajas en depuración, el esquema se crea solo en el entorno de desarrollo a medida que guardas objetos. La versión que subes a la tienda habla con producción, donde no se crea nada automáticamente: si no has desplegado el esquema desde el panel de control, la app compilada en modo de publicación no sincronizará y no dirá por qué. Desplegar es además aditivo e irreversible, así que revisar los nombres antes de pulsar ese botón es la única oportunidad que tendrás.

Lo que el modelo debe aceptar

Los requisitos parecen arbitrarios hasta que se leen como consecuencias del formato de destino. Un registro de CloudKit es un diccionario sin esquema fuerte en el que cualquier campo puede faltar, porque los dispositivos que lo escriben pueden ejecutar versiones distintas de la app. De ahí se sigue el primer requisito y el que más molesta: toda propiedad debe ser opcional o tener un valor por defecto. No es una limitación de SwiftData, es la única forma de que un registro escrito por una versión anterior siga siendo materializable por la nueva.

☁️

Sin restricción de unicidad

El atributo de unicidad no está soportado. El servidor no puede validar unicidad global de forma barata, y dos dispositivos sin conexión pueden crear el mismo valor a la vez sin que nadie lo impida.

🔗

Relaciones opcionales y con inversa

Toda relación debe ser opcional y declarar su inversa. Un registro puede llegar antes que su pareja, y sin inversa el sistema no sabría reconstruir el otro extremo del enlace.

🧱

Sin borrado denegado

Las reglas de borrado que impiden eliminar un objeto referenciado no tienen equivalente distribuido. Solo caben la anulación y la cascada dentro de la misma zona.

🔐

Cifrado por campo

Un atributo puede marcarse para que su contenido se cifre de extremo a extremo. A cambio deja de ser consultable y no puede usarse en predicados del servidor.

La ausencia de unicidad es la que más consecuencias tiene y la que más equipos descubren tarde. Sin ella, dos dispositivos que crean sin conexión el mismo elemento producen dos objetos distintos que conviven tras la sincronización, y el usuario ve duplicados. La solución no es técnica sino de modelado: se genera el identificador en el cliente de forma determinista cuando el dominio lo permite, se acepta el duplicado y se deduplica al detectarlo, o se rediseña para que la operación sea idempotente. Lo que no funciona es esperar que el sistema lo evite.

@Model
final class Tarea {
    // Cada propiedad con valor por defecto: un registro antiguo puede no traerla
    var identidad: UUID = UUID()
    var titulo: String = ""
    var completada: Bool = false
    var creacion: Date = Date.now

    // Relacion opcional y con inversa declarada en el otro extremo
    var proyecto: Proyecto?

    // Contenido sensible cifrado de extremo a extremo: deja de ser consultable
    @Attribute(.allowsCloudEncryption) var notaPrivada: String = ""

    init(titulo: String) { self.titulo = titulo }
}

Los tipos que no son primitivos merecen una mención aparte. Una enumeración o una estructura conforme a Codable se almacena como un bloque binario, lo que significa que sincroniza sin problema pero resulta opaca para cualquier predicado del servidor y para cualquier consulta del panel de control. Añadir un caso nuevo a esa enumeración es compatible mientras el decodificador tolere valores desconocidos; si no lo tolera, una versión antigua de la app fallará al leer un objeto escrito por una nueva, y ese fallo ocurrirá en el dispositivo del usuario y no en el tuyo.

Qué hace por ti y qué no

Lo que el sistema resuelve es considerable y conviene reconocerlo antes de criticar lo que falta. Crea y mantiene la zona, deriva y publica el esquema en desarrollo, registra las suscripciones necesarias, gestiona los tokens de cambio, agrupa las escrituras en lotes, reintenta con espera cuando el servidor lo pide, sube y baja en segundo plano al recibir avisos silenciosos, y materializa los cambios remotos en el contexto sin que tengas que refrescar nada. Una vista con una consulta declarativa se actualiza sola cuando llega un cambio de otro dispositivo, y ese comportamiento cubre el noventa por ciento de lo que la mayoría de apps necesita.

flowchart LR
a[Contexto local guarda el cambio] --> b[Historial persistente registra la transaccion]
b --> c[Motor de espejo convierte objetos en registros]
c --> d[Zona privada del usuario en CloudKit]
d --> e[Aviso silencioso al resto de dispositivos]
e --> f[Descarga incremental por token de zona]
f --> g[Materializacion en el contexto y refresco de vistas]

Lo que no hace es igual de importante. No garantiza unicidad ni deduplica. No expone ninguna política de resolución de conflictos: aplica una fusión por campo en la que gana la última escritura recibida para cada propiedad por separado, y no puedes sustituirla. No ofrece ningún aviso fiable de que la sincronización ha terminado, porque en un sistema eventualmente consistente esa afirmación no tiene sentido. No sincroniza almacenes marcados como solo en memoria ni configuraciones sin contenedor. Y hasta hoy no expone la compartición entre usuarios con una API propia, de modo que una app que necesite compartir debe bajar a la capa de Core Data o a CloudKit directamente.

💡
Dos almacenes en el mismo contenedor

Un patrón que resuelve muchos casos consiste en declarar dos configuraciones: una sincronizada para los datos del usuario y otra local para cachés, índices de búsqueda, borradores y cualquier cosa cuya replicación no aporte nada. Separar así reduce el tráfico, evita que un fallo de validación en una entidad accesoria impida arrancar el espejo entero y deja claro en el propio código qué información es autoritativa.

Observar un proceso silencioso

La depuración es incómoda porque el sistema está diseñado para no molestar, y eso incluye no informar. El primer instrumento es el argumento de lanzamiento que activa las trazas del motor de espejo: con él aparecen en consola las exportaciones e importaciones, los errores de validación de esquema y los códigos que devuelve el servidor. Es verboso y por eso mismo útil: la primera vez que se lee una sesión completa se entiende el ciclo mejor que con cualquier descripción.

# Argumentos de lanzamiento en el esquema de ejecucion de Xcode
-com.apple.CoreData.CloudKitDebug 1
-com.apple.CoreData.SQLDebug 1
-com.apple.CoreData.Logging.stderr 1
// Observar el historial de eventos del espejo y exponer un estado honesto en la interfaz
NotificationCenter.default.addObserver(
    forName: NSPersistentCloudKitContainer.eventChangedNotification,
    object: nil, queue: .main
) { nota in
    let clave = NSPersistentCloudKitContainer.eventNotificationUserInfoKey
    guard let evento = nota.userInfo?[clave] as? NSPersistentCloudKitContainer.Event else { return }
    if evento.endDate != nil, let error = evento.error {
        registrar("Fallo de tipo \(evento.type): \(error.localizedDescription)")
    }
}

La segunda herramienta es el panel de control del contenedor, que permite inspeccionar registros reales, comprobar si un campo existe con el tipo esperado y consultar los índices declarados. Es también el único sitio donde se ve la diferencia entre lo que crees que subiste y lo que subiste. La tercera, y la única que produce certeza, es el ensayo con dos dispositivos reales con la misma cuenta, porque el simulador exige iniciar sesión en iCloud, no siempre entrega avisos silenciosos con fiabilidad y no reproduce el comportamiento en segundo plano.

Activar la sincronización convierte tu esquema local en un protocolo público versionado

El cambio de naturaleza que casi nadie anticipa no está en la red ni en la latencia, sino en la pérdida de una libertad que se daba por descontada: la de cambiar el modelo de datos a voluntad. En una app local el esquema es privado, existe una sola versión de él en cada instante y una migración lo transforma sin dejar rastro. Al activar el espejo, el esquema pasa a ser un contrato entre versiones simultáneas de tu propia app ejecutándose en dispositivos que no controlas, algunos de los cuales no se actualizarán en meses, y el servidor impone que ese contrato solo pueda crecer. Ahí se explican de golpe todos los requisitos que parecían caprichos: las propiedades son opcionales o con valor por defecto porque una versión antigua escribirá registros sin los campos nuevos; las relaciones son opcionales porque el orden de llegada no está garantizado; la unicidad no existe porque no puede afirmarse en un sistema con particiones y escrituras concurrentes; y no puedes eliminar un campo porque hay clientes vivos que aún lo escriben. La consecuencia práctica es que el diseño del modelo deja de ser una decisión de implementación y se convierte en diseño de API, con su misma disciplina: se nombra con cuidado porque los nombres son permanentes, se añade en lugar de modificar, se tolera lo desconocido al decodificar, y se piensa cada cambio preguntando qué hará con él la versión que ya está instalada. Los equipos que interiorizan esto tratan cada modificación del modelo como una publicación; los que no, descubren el contrato el día que un despliegue de esquema deja fuera de servicio a la mitad de sus usuarios sin posibilidad de revertirlo.

⚔️ Poner el espejo bajo control
  1. Activa la sincronización en un proyecto existente y anota todos los errores de validación que aparecen al arrancar el contenedor antes de corregir ninguno.
  2. Corrige el modelo justificando por escrito cada cambio en términos del registro de destino, no como una regla memorizada.
  3. Crea el mismo elemento en dos dispositivos con la conexión desactivada, restaura la red y documenta exactamente qué ve el usuario.
  4. Separa el almacén en dos configuraciones, una sincronizada y otra local, y mide la diferencia en volumen de subida durante una sesión de uso normal.
  5. Activa las trazas del motor de espejo, provoca un error de esquema deliberado en producción usando una compilación de publicación y describe qué señal recibiría el usuario.