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

El modelo de CloudKit: contenedores, bases de datos, zonas y registros

CloudKit no es una base de datos remota con una API encima: es un modelo de replicación cuyas cuatro capas —contenedor, base de datos, zona y registro— deciden quién ve un dato, con quién se comparte, qué se escribe de forma atómica y qué puede descargarse de forma incremental. Esta lección recorre esas capas como decisiones de arquitectura y no como vocabulario, porque casi todo problema grave en un proyecto con CloudKit nace de haber elegido mal en una de ellas y descubrirlo cuando ya hay datos de usuarios.

⏱ 20 min

Hay una forma de leer CloudKit que lo convierte en un catálogo de clases y otra que lo convierte en un modelo de sistema distribuido. La primera se aprende en una tarde y produce aplicaciones que fallan de maneras difíciles de explicar; la segunda exige entender que cada uno de los cuatro niveles de la jerarquía existe para responder a una pregunta distinta y solo a esa. El contenedor responde a quién pertenece el espacio de nombres. La base de datos responde a quién puede ver el dato y contra qué cuota se cobra. La zona responde a qué se escribe de golpe y qué se descarga en incrementos. El registro responde a qué se versiona y qué se detecta como conflicto. Confundir esos cuatro planos es el origen de la mayoría de los rediseños tardíos, y ninguno de los cuatro admite una corrección barata una vez que hay datos en producción.

🎯 Al terminar esta lección sabrás
  • Situar el contenedor en el ciclo de vida de la app y comprender la naturaleza aditiva del esquema en producción.
  • Elegir entre base privada, pública y compartida entendiendo el modelo de cuota y de visibilidad de cada una.
  • Usar zonas personalizadas como unidad real de atomicidad y de sincronización incremental por token.
  • Modelar registros, referencias y activos conociendo los límites del servicio y el papel de los metadatos de sistema.

El contenedor y sus tres bases de datos

Un contenedor es un espacio de nombres identificado por una cadena del estilo iCloud.com.tuequipo.MiApp, ligado al perfil de aprovisionamiento mediante un derecho de acceso y propiedad del equipo de desarrollo, no de la app. Esa distinción importa más de lo que parece: varias apps del mismo equipo pueden compartir un contenedor, y esa es la única forma limpia de que una app principal y su extensión, o una versión de iPhone y otra de Mac, vean los mismos datos. Cambiar el identificador del contenedor después del lanzamiento equivale a abandonar todos los datos existentes.

Cada contenedor vive en dos entornos, desarrollo y producción, con esquemas independientes. En desarrollo el esquema se crea solo: guardar un registro con un campo nuevo lo añade al tipo. En producción no ocurre nada de eso; el esquema se despliega explícitamente desde el panel de control y esa operación es estrictamente aditiva. Se pueden añadir tipos, campos e índices; no se pueden eliminar ni cambiar de tipo. Un campo mal nombrado o mal tipado que llega a producción se queda ahí para siempre, y esa asimetría es la razón por la que el esquema de CloudKit merece la misma revisión que una API pública.

🔒

Base privada

Los datos de cada usuario, invisibles para ti incluso como desarrollador. La cuota se descuenta del iCloud del propio usuario, de modo que escala sin coste para ti. Es donde vive casi todo.

🌐

Base pública

Legible por cualquiera, incluso sin sesión iniciada, y escribible por quien crea cada registro. La cuota la pagas tú y crece con la base de usuarios. Catálogos, contenidos editoriales, marcadores.

🤝

Base compartida

No almacena nada propio: es la ventana a las zonas que otros usuarios te han cedido. Solo aparece cuando aceptas una invitación y desaparece cuando dejas de participar.

La elección entre esas tres no es reversible en la práctica, porque mover un registro de una base a otra significa borrarlo y volver a crearlo con otro identificador, con lo que se pierden las referencias que apuntaban a él y el historial de cambios que los clientes tenían sincronizado. Conviene por tanto decidirla con un criterio explícito: si el dato pertenece a una persona, base privada; si el dato es el mismo para todo el mundo y tú lo publicas, base pública; y si el dato pertenece a una persona pero otras deben poder verlo o editarlo, base privada del propietario más compartición, nunca base pública con un filtro.

ℹ️
La cuota es una decisión de arquitectura

La base pública consume el espacio y el ancho de banda asociados a tu cuenta de desarrollador, con límites que escalan con el número de usuarios activos pero que se agotan si alguien decide subir vídeos. La base privada consume el iCloud del usuario y por eso es gratuita para ti a cualquier escala. Un diseño que mete en la base pública lo que podría vivir en la privada funciona perfectamente con mil usuarios y se convierte en una factura con un millón.

La zona como unidad de atomicidad

Dentro de la base privada existe siempre una zona por defecto, y trabajar en ella es la decisión más común y más cara del principiante. Esa zona no admite escrituras atómicas, de modo que una operación con varios registros puede aplicarse a medias; y no admite descargas incrementales por token, lo que obliga a consultar en lugar de a sincronizar. Las zonas personalizadas sí ofrecen ambas cosas, y crear una cuesta una línea. La regla es simple: cualquier app que aspire a sincronizar de verdad crea su propia zona antes de escribir el primer registro.

flowchart TB
c[Contenedor iCloud.com.equipo.MiApp] --> pr[Base privada del usuario]
c --> pu[Base publica con cuota del desarrollador]
c --> sh[Base compartida con lo que otros ceden]
pr --> z0[Zona por defecto sin atomicidad ni token]
pr --> z1[Zona personalizada con atomicidad y token]
z1 --> r[Registros con metadatos de sistema]
z1 --> s[Objeto de comparticion asociado a la raiz]
sh --> z2[Una zona espejo por invitacion aceptada]

La atomicidad de una zona personalizada significa que una operación de modificación se aplica entera o no se aplica en absoluto, siempre que todos los registros implicados vivan en la misma zona. Esa garantía es la que permite que una factura y sus líneas, o una tarea y sus adjuntos, nunca lleguen al otro dispositivo a medio construir. No existe ninguna forma de obtener atomicidad entre zonas distintas, ni entre bases de datos, y ese límite debe gobernar el diseño: todo lo que deba viajar junto tiene que vivir junto.

let db = CKContainer(identifier: "iCloud.com.tuequipo.MiApp").privateCloudDatabase
let zonaID = CKRecordZone.ID(zoneName: "Principal", ownerName: CKCurrentUserDefaultName)

// Crear la zona una sola vez y recordar que ya existe
_ = try await db.modifyRecordZones(saving: [CKRecordZone(zoneID: zonaID)], deleting: [])

// Escritura atomica: o entran los tres o no entra ninguno
let (guardados, _) = try await db.modifyRecords(
    saving: [factura, linea1, linea2],
    deleting: [],
    savePolicy: .ifServerRecordUnchanged,
    atomically: true
)

La segunda función de la zona es servir de unidad de sincronización. El servidor mantiene por zona un CKServerChangeToken que representa una posición en el historial de cambios; entregándoselo de vuelta, el cliente recibe únicamente lo ocurrido desde entonces. Ese token es el verdadero estado de la réplica local y merece tratarse como tal: se persiste junto a los datos, se invalida si el servidor lo declara caducado y jamás se comparte entre zonas. Una app que guarda bien sus tokens sincroniza en milisegundos; una que los pierde vuelve a bajar el mundo entero.

⚠️
Las referencias no son claves foráneas

Una CKRecord.Reference con acción deleteSelf borra el registro dependiente cuando desaparece su padre, pero solo dentro de la misma zona y sin ninguna garantía transaccional adicional. No existe integridad referencial: nada impide que quede una referencia apuntando a un identificador que ya no existe, y el cliente debe estar preparado para encontrársela. Modelar con esa idea desde el principio evita reconstrucciones costosas.

Registros, referencias y activos

Un CKRecord es un diccionario débilmente tipado con un tipo textual, un identificador compuesto por un nombre y una zona, y un bloque de metadatos que el servidor controla: la etiqueta de cambio, las fechas de creación y modificación, y los identificadores de quien lo creó y lo modificó. Los campos admiten cadenas, números, fechas, datos binarios, listas homogéneas, ubicaciones, referencias y activos. No hay esquema fuerte en el cliente: la validación ocurre en el servidor la primera vez que un campo aparece, y a partir de ahí su tipo queda fijado.

El límite práctico que más sorprende es el tamaño: un registro completo no debe superar el orden de un megabyte, y cualquier contenido binario apreciable debe viajar como CKAsset, que se sube y se descarga por separado y llega al cliente como un fichero temporal en disco. Un activo pertenece al registro que lo contiene y se borra con él, pero su transferencia no es atómica respecto de los campos: puede existir el registro y estar el activo aún descargándose. Ese detalle obliga a que la interfaz tolere un estado intermedio en el que el dato existe pero su imagen todavía no.

let tarea = CKRecord(recordType: "Tarea", recordID: .init(recordName: UUID().uuidString, zoneID: zonaID))
tarea["titulo"] = "Revisar el esquema"
tarea["completada"] = 0 as Int64
tarea["proyecto"] = CKRecord.Reference(recordID: proyectoID, action: .deleteSelf)
tarea["adjunto"] = CKAsset(fileURL: urlEnDisco)

// Los metadatos de sistema permiten reconstruir el registro sin volver a bajarlo
let buffer = NSMutableData()
let codificador = NSKeyedArchiver(requiringSecureCoding: true)
tarea.encodeSystemFields(with: codificador)
codificador.finishEncoding()
persistirLocalmente(metadatos: buffer as Data)

Ese último fragmento contiene la técnica que separa una integración ingenua de una robusta. La etiqueta de cambio de un registro es lo único que permite escribir con detección de conflicto, y vive dentro de los metadatos de sistema. Si el cliente guarda solo los campos y reconstruye el registro desde cero al modificarlo, pierde la etiqueta y se ve obligado a escribir pisando lo que hubiera en el servidor. Persistir los metadatos codificados junto a la copia local es lo que convierte cada escritura en una operación optimista verificable, que es la base de todo lo que se verá en la lección sobre conflictos.

Consultar, paginar y observar

Las consultas se expresan con un predicado sobre un tipo de registro, pero con una restricción que no existe en una base de datos local: cada campo debe declararse explícitamente como consultable, ordenable o buscable en el esquema, y en producción esa declaración se despliega igual que el resto. Un predicado sobre un campo sin índice no devuelve un conjunto vacío, devuelve un error. Además, los resultados llegan paginados mediante un cursor opaco, y el número de elementos por página lo decide el servidor según su carga, no tu configuración.

let consulta = CKQuery(recordType: "Tarea", predicate: NSPredicate(format: "completada == 0"))
consulta.sortDescriptors = [NSSortDescriptor(key: "creacion", ascending: false)]

var (coincidencias, cursor) = try await db.records(matching: consulta, inZoneWith: zonaID)
while let siguiente = cursor {
    let pagina = try await db.records(continuingMatchFrom: siguiente)
    coincidencias += pagina.matchResults
    cursor = pagina.queryCursor
}

// Suscripcion de zona: el servidor avisa y el cliente decide que baja
let sub = CKRecordZoneSubscription(zoneID: zonaID, subscriptionID: "zona-principal")
let info = CKSubscription.NotificationInfo()
info.shouldSendContentAvailable = true          // aviso silencioso, sin interrumpir
sub.notificationInfo = info
_ = try await db.modifySubscriptions(saving: [sub], deleting: [])

Conviene además distinguir dos operaciones que se parecen y no lo son. Consultar recorre el estado actual y responde a la pregunta de qué hay ahora que cumpla un criterio; sincronizar recorre el historial desde un token y responde a la pregunta de qué ha cambiado desde la última vez. La primera sirve para búsquedas y para poblar la primera pantalla de una base pública; la segunda es la que mantiene una réplica. Una app que sincroniza consultando periódicamente funciona en la demostración, gasta batería y ancho de banda de forma proporcional al tamaño total de los datos, y se vuelve inviable en cuanto un usuario acumula unos miles de registros.

Las suscripciones son el mecanismo por el que el servidor deja de ser pasivo, y hay tres formas que responden a necesidades distintas: por consulta, cuando interesa un subconjunto concreto y el servidor debe evaluar un predicado; por zona, cuando interesa saber que algo cambió sin importar qué; y por base de datos, que es la única que avisa de la aparición de zonas nuevas y por tanto la imprescindible en la base compartida. En todos los casos el aviso correcto es silencioso: llega sin molestar al usuario, la app despierta en segundo plano, pide los cambios con su token y actualiza el almacén local. Una notificación no es el dato, es solo la señal de que hay que ir a buscarlo.

CloudKit no es una base de datos remota: es un protocolo de replicación con un servidor autoritativo

El error conceptual que arruina más integraciones consiste en tratar CloudKit como un almacén al que se consulta, cuando su diseño entero presupone que el cliente mantiene una réplica local y que el servicio existe para reconciliarla. Se ve en cada detalle de la API si se lee con esa clave: el token de cambio por zona no es una optimización sino la representación explícita del estado de esa réplica; la etiqueta de cambio por registro no es un metadato administrativo sino un número de versión que habilita el control de concurrencia optimista; la atomicidad por zona no es una comodidad sino la definición de la unidad de consistencia del sistema; y la propia existencia de un límite de tamaño por registro empuja hacia un grano fino que hace la reconciliación tratable. De ahí se deriva una consecuencia práctica que ordena todo lo demás: la pregunta correcta al diseñar no es qué consultas necesito, sino cuál es mi unidad de consistencia y cuál es mi unidad de sincronización, porque ambas son la zona y ambas se fijan antes de escribir la primera línea. Los equipos que empiezan por las consultas acaban con datos repartidos por la zona por defecto, sin atomicidad, bajando el mundo entero en cada arranque y descubriendo a los seis meses que la corrección exige una migración de todos los usuarios. Los que empiezan por la unidad de consistencia escriben menos código y descubren que la mitad de los problemas que temían nunca llegan a existir, porque una decisión estructural bien tomada elimina clases enteras de fallo en lugar de gestionarlas.

📝
Lo esencial

Cuatro niveles con cuatro responsabilidades: el contenedor es el espacio de nombres del equipo y su esquema de producción es aditivo para siempre; la base de datos decide visibilidad y cuota, y la elección es irreversible en la práctica; la zona es la única unidad de atomicidad y de sincronización incremental por token, de modo que la zona por defecto casi nunca sirve; y el registro es la unidad de versionado, cuyos metadatos de sistema hay que persistir si se quiere escribir sin pisar. Las consultas exigen índices declarados y llegan paginadas, y las suscripciones silenciosas avisan de que hay que sincronizar, no traen el dato.

⚔️ Levantar el modelo con criterio
  1. Declara el contenedor, escribe un registro de prueba en desarrollo y comprueba en el panel de control cómo aparece el tipo; después intenta cambiar el tipo de un campo y documenta qué te impide el sistema.
  2. Crea una zona personalizada, escribe tres registros relacionados de forma atómica y provoca un fallo en uno de ellos para verificar que ninguno de los tres queda guardado.
  3. Repite el mismo experimento en la zona por defecto y anota la diferencia de comportamiento observada.
  4. Implementa la persistencia de los metadatos de sistema de cada registro y el almacenamiento del token de zona, y mide cuántos bytes bajas en el segundo arranque frente al primero.
  5. Registra una suscripción de zona con aviso silencioso, comprueba que la app despierta en segundo plano y describe por escrito qué haría tu código si la notificación se perdiera.