Una guía para CloudKit: cómo sincronizar datos de usuario entre dispositivos iOS

Publicado: 2022-03-11

En estos días, el desarrollo de aplicaciones móviles modernas requiere un plan bien pensado para mantener los datos de los usuarios sincronizados en varios dispositivos. Este es un problema espinoso con muchas trampas y escollos, pero los usuarios esperan que la función funcione bien.

Para iOS y macOS, Apple proporciona un sólido conjunto de herramientas, llamado CloudKit API, que permite a los desarrolladores que apuntan a las plataformas de Apple resolver este problema de sincronización.

En este artículo, demostraré cómo usar CloudKit para mantener sincronizados los datos de un usuario entre varios clientes. Está destinado a desarrolladores de iOS experimentados que ya están familiarizados con los marcos de trabajo de Apple y con Swift. Voy a realizar una inmersión técnica bastante profunda en la API de CloudKit para explorar formas en que puede aprovechar esta tecnología para crear aplicaciones increíbles para múltiples dispositivos. Me centraré en una aplicación de iOS, pero el mismo enfoque también se puede usar para clientes de macOS.

Nuestro caso de uso de ejemplo es una aplicación de nota simple con una sola nota, con fines ilustrativos. En el camino, echaré un vistazo a algunos de los aspectos más complicados de la sincronización de datos basada en la nube, incluido el manejo de conflictos y el comportamiento inconsistente de la capa de red.

Uso de CloudKit para sincronizar datos de usuario entre varios clientes

¿Qué es CloudKit?

CloudKit se basa en el servicio iCloud de Apple. Es justo decir que iCloud tuvo un comienzo un poco difícil. Una transición torpe de MobileMe, el bajo rendimiento e incluso algunos problemas de privacidad frenaron el sistema en los primeros años.

Para los desarrolladores de aplicaciones, la situación era aún peor. Antes de CloudKit, el comportamiento inconsistente y las herramientas de depuración débiles hacían que fuera casi imposible entregar un producto de alta calidad utilizando las API de iCloud de primera generación.

Con el tiempo, sin embargo, Apple ha abordado estos problemas. En particular, tras el lanzamiento de CloudKit SDK en 2014, los desarrolladores externos tienen una solución técnica robusta y con todas las funciones para compartir datos basados ​​en la nube entre dispositivos (incluidas aplicaciones macOS e incluso clientes basados ​​en la web).

Dado que CloudKit está profundamente ligado a los sistemas operativos y dispositivos de Apple, no es adecuado para aplicaciones que requieren una gama más amplia de compatibilidad con dispositivos, como los clientes de Android o Windows. Sin embargo, para las aplicaciones que están dirigidas a la base de usuarios de Apple, proporciona un mecanismo profundamente poderoso para la autenticación de usuarios y la sincronización de datos.

Configuración básica de CloudKit

CloudKit organiza los datos a través de una jerarquía de clases: CKContainer , CKDatabase , CKRecordZone y CKRecord .

En el nivel superior está CKContainer , que encapsula un conjunto de datos de CloudKit relacionados. Cada aplicación obtiene automáticamente un CKContainer predeterminado y un grupo de aplicaciones puede compartir un CKContainer personalizado si la configuración de permisos lo permite. Eso puede habilitar algunos flujos de trabajo entre aplicaciones interesantes.

Dentro de cada CKContainer hay varias instancias de CKDatabase . CloudKit configura automáticamente todas las aplicaciones habilitadas para CloudKit listas para usar para tener una CKDatabase pública (todos los usuarios de la aplicación pueden ver todo) y una CKDatabase privada (cada usuario ve solo sus propios datos). Y, a partir de iOS 10, una CKDatabase compartida donde los grupos controlados por el usuario pueden compartir elementos entre los miembros del grupo.

Dentro de una CKDatabase hay CKRecordZone s, y dentro de las zonas CKRecord s. Puede leer y escribir registros, consultar registros que coincidan con un conjunto de criterios y (lo más importante) recibir notificaciones de cambios en cualquiera de los anteriores.

Para su aplicación Note, puede usar el contenedor predeterminado. Dentro de este contenedor, utilizará la base de datos privada (porque desea que la nota del usuario solo la vea ese usuario) y dentro de la base de datos privada, utilizará una zona de registro personalizada, que permite la notificación de información específica. registrar cambios.

La nota se almacenará como un solo CKRecord con campos de text , modified (fecha y hora) y de version . CloudKit realiza un seguimiento automático de un valor modified interno, pero desea poder conocer el tiempo modificado real, incluidos los casos fuera de línea, para fines de resolución de conflictos. El campo de version es simplemente una ilustración de una buena práctica para la prueba de actualización, teniendo en cuenta que un usuario con varios dispositivos puede no actualizar su aplicación en todos ellos al mismo tiempo, por lo que es necesario estar a la defensiva.

Construyendo la aplicación de notas

Supongo que tiene un buen manejo de los conceptos básicos de la creación de aplicaciones iOS en Xcode. Si lo desea, puede descargar y examinar el proyecto Note App Xcode de ejemplo creado para este tutorial.

Para nuestros propósitos, será suficiente una aplicación de vista única que contenga un UITextView con ViewController como su delegado. En el nivel conceptual, desea activar una actualización de registro de CloudKit siempre que cambie el texto. Sin embargo, como cuestión práctica, tiene sentido usar algún tipo de mecanismo de combinación de cambios, como un temporizador de fondo que se activa periódicamente, para evitar enviar spam a los servidores de iCloud con demasiados cambios pequeños.

La aplicación CloudKit requiere que se habiliten algunos elementos en el Panel de capacidades del objetivo Xcode: iCloud (naturalmente), incluida la casilla de verificación CloudKit, las notificaciones automáticas y los modos en segundo plano (específicamente, las notificaciones remotas).

Para la funcionalidad de CloudKit, he dividido las cosas en dos clases: un singleton de CloudKitNoteDatabase nivel inferior y una clase de CloudKitNote de nivel superior.

Pero primero, una discusión rápida sobre los errores de CloudKit.

Errores de CloudKit

El manejo cuidadoso de errores es absolutamente esencial para un cliente de CloudKit.

Dado que es una API basada en red, es susceptible a una gran cantidad de problemas de rendimiento y disponibilidad. Además, el servicio en sí mismo debe proteger contra una variedad de posibles problemas, como solicitudes no autorizadas, cambios en conflicto y similares.

CloudKit proporciona una gama completa de códigos de error, con información adjunta, para permitir a los desarrolladores manejar varios casos extremos y, cuando sea necesario, brindar explicaciones detalladas al usuario sobre posibles problemas.

Además, varias operaciones de CloudKit pueden devolver un error como un valor de error único o un error compuesto indicado en el nivel superior como partialFailure . Viene con un Diccionario de CKError contenidos que merecen una inspección más cuidadosa para descubrir qué sucedió exactamente durante una operación compuesta.

Para ayudar a navegar parte de esta complejidad, puede extender CKError con algunos métodos auxiliares.

Tenga en cuenta que todo el código tiene comentarios explicativos en los puntos clave.

 import CloudKit extension CKError { public func isRecordNotFound() -> Bool { return isZoneNotFound() || isUnknownItem() } public func isZoneNotFound() -> Bool { return isSpecificErrorCode(code: .zoneNotFound) } public func isUnknownItem() -> Bool { return isSpecificErrorCode(code: .unknownItem) } public func isConflict() -> Bool { return isSpecificErrorCode(code: .serverRecordChanged) } public func isSpecificErrorCode(code: CKError.Code) -> Bool { var match = false if self.code == code { match = true } else if self.code == .partialFailure { // This is a multiple-issue error. Check the underlying array // of errors to see if it contains a match for the error in question. guard let errors = partialErrorsByItemID else { return false } for (_, error) in errors { if let cke = error as? CKError { if cke.code == code { match = true break } } } } return match } // ServerRecordChanged errors contain the CKRecord information // for the change that failed, allowing the client to decide // upon the best course of action in performing a merge. public func getMergeRecords() -> (CKRecord?, CKRecord?) { if code == .serverRecordChanged { // This is the direct case of a simple serverRecordChanged Error. return (clientRecord, serverRecord) } guard code == .partialFailure else { return (nil, nil) } guard let errors = partialErrorsByItemID else { return (nil, nil) } for (_, error) in errors { if let cke = error as? CKError { if cke.code == .serverRecordChanged { // This is the case of a serverRecordChanged Error // contained within a multi-error PartialFailure Error. return cke.getMergeRecords() } } } return (nil, nil) } }

El CloudKitNoteDatabase

Apple proporciona dos niveles de funcionalidad en el SDK de CloudKit: funciones de "conveniencia" de alto nivel, como fetch() , save() y delete() , y construcciones de operación de nivel inferior con nombres complicados, como CKModifyRecordsOperation .

La API de conveniencia es mucho más accesible, mientras que el enfoque de operación puede ser un poco intimidante. Sin embargo, Apple insta encarecidamente a los desarrolladores a utilizar las operaciones en lugar de los métodos de conveniencia.

Las operaciones de CloudKit brindan un control superior sobre los detalles de cómo CloudKit hace su trabajo y, quizás lo que es más importante, realmente obligan al desarrollador a pensar detenidamente sobre los comportamientos de la red que son fundamentales para todo lo que hace CloudKit. Por estas razones, estoy usando las operaciones en estos ejemplos de código.

Su clase singleton será responsable de cada una de estas operaciones de CloudKit que utilizará. De hecho, en cierto sentido, está recreando las API de conveniencia. Pero, al implementarlos usted mismo en función de Operation API, se coloca en un buen lugar para personalizar el comportamiento y ajustar sus respuestas de manejo de errores. Por ejemplo, si desea extender esta aplicación para manejar varias notas en lugar de solo una, puede hacerlo más fácilmente (y con un mayor rendimiento resultante) que si solo usara las API de conveniencia de Apple.

 import CloudKit public protocol CloudKitNoteDatabaseDelegate { func cloudKitNoteRecordChanged(record: CKRecord) } public class CloudKitNoteDatabase { static let shared = CloudKitNoteDatabase() private init() { let zone = CKRecordZone(zoneName: "note-zone") zoneID = zone.zoneID } public var delegate: CloudKitNoteDatabaseDelegate? public var zoneID: CKRecordZoneID? // ... }

Creación de una zona personalizada

CloudKit crea automáticamente una zona predeterminada para la base de datos privada. Sin embargo, puede obtener más funciones si utiliza una zona personalizada, en particular, la compatibilidad con la obtención de cambios de registros incrementales.

Dado que este es un primer ejemplo del uso de una operación, aquí hay un par de observaciones generales:

En primer lugar, todas las operaciones de CloudKit tienen cierres de finalización personalizados (y muchas tienen cierres intermedios, según la operación). CloudKit tiene su propia clase CKError , derivada de Error , pero debe tener en cuenta la posibilidad de que también se presenten otros errores. Finalmente, uno de los aspectos más importantes de cualquier operación es el qualityOfService la calidad del servicio. Debido a la latencia de la red, el modo avión y demás, CloudKit manejará internamente los reintentos y demás para operaciones con una qualityOfService de servicio de "utilidad" o inferior. Según el contexto, es posible que desee asignar una calidad de servicio más qualityOfService y manejar estas situaciones usted mismo.

Una vez configuradas, las operaciones se pasan al objeto CKDatabase , donde se ejecutarán en un subproceso en segundo plano.

 // Create a custom zone to contain our note records. We only have to do this once. private func createZone(completion: @escaping (Error?) -> Void) { let recordZone = CKRecordZone(zoneID: self.zoneID!) let operation = CKModifyRecordZonesOperation(recordZonesToSave: [recordZone], recordZoneIDsToDelete: []) operation.modifyRecordZonesCompletionBlock = { _, _, error in guard error == nil else { completion(error) return } completion(nil) } operation.qualityOfService = .utility let container = CKContainer.default() let db = container.privateCloudDatabase db.add(operation) }

Crear una suscripción

Las suscripciones son una de las características más valiosas de CloudKit. Se basan en la infraestructura de notificaciones de Apple para permitir que varios clientes reciban notificaciones automáticas cuando se producen ciertos cambios en CloudKit. Estas pueden ser notificaciones push normales familiares para los usuarios de iOS (como sonido, pancarta o insignia), o en CloudKit, pueden ser una clase especial de notificación llamada notificaciones push silenciosas . Estos envíos silenciosos ocurren completamente sin la visibilidad o interacción del usuario y, como resultado, no requieren que el usuario habilite las notificaciones de envío para su aplicación, lo que le ahorra muchos dolores de cabeza potenciales relacionados con la experiencia del usuario como desarrollador de aplicaciones.

La manera de habilitar estas notificaciones silenciosas es establecer la propiedad shouldSendContentAvailable en la instancia de CKNotificationInfo , mientras deja todas las configuraciones de notificación tradicionales ( shouldBadge , soundName , etc.) sin establecer.

Tenga en cuenta también que estoy usando una CKQuerySubscription con un predicado "siempre verdadero" muy simple para observar los cambios en el único (y único) registro de Nota. En una aplicación más sofisticada, es posible que desee aprovechar el predicado para reducir el alcance de una CKQuerySubscription en particular, y es posible que desee revisar los otros tipos de suscripción disponibles en CloudKit, como CKDatabaseSuscription .

Finalmente, observe que puede usar un valor en caché de UserDefaults para evitar guardar innecesariamente la suscripción más de una vez. No hay mucho daño en configurarlo, pero Apple recomienda hacer un esfuerzo para evitarlo, ya que desperdicia recursos de la red y del servidor.

 // Create the CloudKit subscription we'll use to receive notification of changes. // The SubscriptionID lets us identify when an incoming notification is associated // with the query we created. public let subscription private let subscriptionSavedKey = "ckSubscriptionSaved" public func saveSubscription() { // Use a local flag to avoid saving the subscription more than once. let alreadySaved = UserDefaults.standard.bool(forKey: subscriptionSavedKey) guard !alreadySaved else { return } // If you wanted to have a subscription fire only for particular // records you can specify a more interesting NSPredicate here. // For our purposes we'll be notified of all changes. let predicate = NSPredicate(value: true) let subscription = CKQuerySubscription(recordType: "note", predicate: predicate, subscriptionID: subscriptionID, options: [.firesOnRecordCreation, .firesOnRecordDeletion, .firesOnRecordUpdate]) // We set shouldSendContentAvailable to true to indicate we want CloudKit // to use silent pushes, which won't bother the user (and which don't require // user permission.) let notificationInfo = CKNotificationInfo() notificationInfo.shouldSendContentAvailable = true subscription.notificationInfo = notificationInfo let operation = CKModifySubscriptionsOperation(subscriptionsToSave: [subscription], subscriptionIDsToDelete: []) operation.modifySubscriptionsCompletionBlock = { (_, _, error) in guard error == nil else { return } UserDefaults.standard.set(true, forKey: self.subscriptionSavedKey) } operation.qualityOfService = .utility let container = CKContainer.default() let db = container.privateCloudDatabase db.add(operation) }

Cargando Registros

Obtener un registro por nombre es muy sencillo. Puede pensar en el nombre como la clave principal del registro en un sentido simple de base de datos (los nombres deben ser únicos, por ejemplo). El CKRecordID real es un poco más complicado porque incluye el zoneID .

CKFetchRecordsOperation opera en uno o más registros a la vez. En este ejemplo, solo hay un registro, pero para la capacidad de expansión futura, este es un gran beneficio potencial de rendimiento.

 // Fetch a record from the iCloud database public func loadRecord(name: String, completion: @escaping (CKRecord?, Error?) -> Void) { let recordID = CKRecordID(recordName: name, zoneID: self.zoneID!) let operation = CKFetchRecordsOperation(recordIDs: [recordID]) operation.fetchRecordsCompletionBlock = { records, error in guard error == nil else { completion(nil, error) return } guard let noteRecord = records?[recordID] else { // Didn't get the record we asked about? // This shouldn't happen but we'll be defensive. completion(nil, CKError.unknownItem as? Error) return } completion(noteRecord, nil) } operation.qualityOfService = .utility let container = CKContainer.default() let db = container.privateCloudDatabase db.add(operation) }

Guardar registros

Guardar registros es, quizás, la operación más complicada. El simple acto de escribir un registro en la base de datos es bastante sencillo, pero en mi ejemplo, con varios clientes, aquí es donde enfrentará el problema potencial de manejar un conflicto cuando varios clientes intentan escribir en el servidor al mismo tiempo. Afortunadamente, CloudKit está diseñado explícitamente para manejar esta condición. Rechaza solicitudes específicas con suficiente contexto de error en la respuesta para permitir que cada cliente tome una decisión local e informada sobre cómo resolver el conflicto.

Aunque esto agrega complejidad al cliente, en última instancia es una solución mucho mejor que hacer que Apple presente uno de los pocos mecanismos del lado del servidor para la resolución de conflictos.

El diseñador de la aplicación siempre está en la mejor posición para definir reglas para estas situaciones, que pueden incluir todo, desde la fusión automática según el contexto hasta las instrucciones de resolución dirigidas por el usuario. No me voy a poner muy elegante en mi ejemplo; Estoy usando el campo modified para declarar que gana la actualización más reciente. Es posible que este no sea siempre el mejor resultado para las aplicaciones profesionales, pero no está mal como primera regla y, para este propósito, sirve para ilustrar el mecanismo por el cual CloudKit devuelve la información del conflicto al cliente.

Tenga en cuenta que, en mi aplicación de ejemplo, este paso de resolución de conflictos ocurre en la clase CloudKitNote , que se describe más adelante.

 // Save a record to the iCloud database public func saveRecord(record: CKRecord, completion: @escaping (Error?) -> Void) { let operation = CKModifyRecordsOperation(recordsToSave: [record], recordIDsToDelete: []) operation.modifyRecordsCompletionBlock = { _, _, error in guard error == nil else { guard let ckerror = error as? CKError else { completion(error) return } guard ckerror.isZoneNotFound() else { completion(error) return } // ZoneNotFound is the one error we can reasonably expect & handle here, since // the zone isn't created automatically for us until we've saved one record. // create the zone and, if successful, try again self.createZone() { error in guard error == nil else { completion(error) return } self.saveRecord(record: record, completion: completion) } return } // Lazy save the subscription upon first record write // (saveSubscription is internally defensive against trying to save it more than once) self.saveSubscription() completion(nil) } operation.qualityOfService = .utility let container = CKContainer.default() let db = container.privateCloudDatabase db.add(operation) }

Manejo de notificaciones de registros actualizados

Las notificaciones de CloudKit proporcionan los medios para saber cuándo otro cliente ha actualizado los registros. Sin embargo, las condiciones de la red y las restricciones de rendimiento pueden hacer que se eliminen notificaciones individuales o que múltiples notificaciones se fusionen intencionalmente en una sola notificación de cliente. Dado que las notificaciones de CloudKit se basan en el sistema de notificación de iOS, debe estar atento a estas condiciones.

Sin embargo, CloudKit le brinda las herramientas que necesita para esto.

En lugar de depender de notificaciones individuales para brindarle un conocimiento detallado de qué cambio representa una notificación individual, usa una notificación para indicar simplemente que algo ha cambiado y luego puede preguntarle a CloudKit qué ha cambiado desde la última vez que preguntó. En mi ejemplo, hago esto usando CKFetchRecordZoneChangesOperation y CKServerChangeTokens . Los tokens de cambio se pueden considerar como un marcador que le dice dónde estaba antes de que ocurriera la secuencia de cambios más reciente.

 // Handle receipt of an incoming push notification that something has changed. private let serverChangeTokenKey = "ckServerChangeToken" public func handleNotification() { // Use the ChangeToken to fetch only whatever changes have occurred since the last // time we asked, since intermediate push notifications might have been dropped. var changeToken: CKServerChangeToken? = nil let changeTokenData = UserDefaults.standard.data(forKey: serverChangeTokenKey) if changeTokenData != nil { changeToken = NSKeyedUnarchiver.unarchiveObject(with: changeTokenData!) as! CKServerChangeToken? } let options = CKFetchRecordZoneChangesOptions() options.previousServerChangeToken = changeToken let optionsMap = [zoneID!: options] let operation = CKFetchRecordZoneChangesOperation(recordZoneIDs: [zoneID!], optionsByRecordZoneID: optionsMap) operation.fetchAllChanges = true operation.recordChangedBlock = { record in self.delegate?.cloudKitNoteRecordChanged(record: record) } operation.recordZoneChangeTokensUpdatedBlock = { zoneID, changeToken, data in guard let changeToken = changeToken else { return } let changeTokenData = NSKeyedArchiver.archivedData(withRootObject: changeToken) UserDefaults.standard.set(changeTokenData, forKey: self.serverChangeTokenKey) } operation.recordZoneFetchCompletionBlock = { zoneID, changeToken, data, more, error in guard error == nil else { return } guard let changeToken = changeToken else { return } let changeTokenData = NSKeyedArchiver.archivedData(withRootObject: changeToken) UserDefaults.standard.set(changeTokenData, forKey: self.serverChangeTokenKey) } operation.fetchRecordZoneChangesCompletionBlock = { error in guard error == nil else { return } } operation.qualityOfService = .utility let container = CKContainer.default() let db = container.privateCloudDatabase db.add(operation) }

Ahora tiene los componentes básicos de bajo nivel para leer y escribir registros y para manejar las notificaciones de cambios en los registros.

Veamos ahora una capa construida sobre eso para administrar estas operaciones en el contexto de una nota específica.

La clase CloudKitNote

Para empezar, se pueden definir algunos errores personalizados para proteger al cliente de las funciones internas de CloudKit, y un protocolo de delegado simple puede informar al cliente sobre actualizaciones remotas de los datos subyacentes de Note.

 import CloudKit enum CloudKitNoteError : Error { case noteNotFound case newerVersionAvailable case unexpected } public protocol CloudKitNoteDelegate { func cloudKitNoteChanged(note: CloudKitNote) } public class CloudKitNote : CloudKitNoteDatabaseDelegate { public var delegate: CloudKitNoteDelegate? private(set) var text: String? private(set) var modified: Date? private let recordName = "note" private let version = 1 private var noteRecord: CKRecord? public init() { CloudKitNoteDatabase.shared.delegate = self } // CloudKitNoteDatabaseDelegate call: public func cloudKitNoteRecordChanged(record: CKRecord) { // will be filled in below... } // … }

Mapeo de CKRecord a Nota

En Swift, se puede acceder a campos individuales en un CKRecord a través del operador de subíndice. Todos los valores se ajustan a CKRecordValue , pero estos, a su vez, son siempre uno de un subconjunto específico de tipos de datos familiares: NSString , NSNumber , NSDate , etc.

Además, CloudKit proporciona un tipo de registro específico para objetos binarios "grandes". No se especifica un punto de corte específico (se recomienda un máximo de 1 MB en total para cada CKRecord ), pero como regla general, casi cualquier cosa que se sienta como un elemento independiente (una imagen, un sonido, una gota de texto) en lugar de como un campo de base de datos probablemente debería almacenarse como CKAsset . Esta práctica permite que CloudKit administre mejor la transferencia de red y el almacenamiento del lado del servidor de este tipo de elementos.

Para este ejemplo, utilizará CKAsset para almacenar el texto de la nota. Los datos de CKAsset se manejan a través de archivos temporales locales que contienen los datos correspondientes.

 // Map from CKRecord to our native data fields private func syncToRecord(record: CKRecord) -> (String?, Date?, Error?) { let version = record["version"] as? NSNumber guard version != nil else { return (nil, nil, CloudKitNoteError.unexpected) } guard version!.intValue <= self.version else { // Simple example of a version check, in case the user has // has updated the client on another device but not this one. // A possible response might be to prompt the user to see // if the update is available on this device as well. return (nil, nil, CloudKitNoteError.newerVersionAvailable) } let textAsset = record["text"] as? CKAsset guard textAsset != nil else { return (nil, nil, CloudKitNoteError.noteNotFound) } // CKAsset data is stored as a local temporary file. Read it // into a String here. let modified = record["modified"] as? Date do { let text = try String(contentsOf: textAsset!.fileURL) return (text, modified, nil) } catch { return (nil, nil, error) } }

Cargando una nota

Cargar una nota es muy sencillo. Realiza un poco de verificación de errores requerida y luego simplemente obtiene los datos reales del CKRecord y almacena los valores en sus campos de miembro.

 // Load a Note from iCloud public func load(completion: @escaping (String?, Date?, Error?) -> Void) { let noteDB = CloudKitNoteDatabase.shared noteDB.loadRecord(name: recordName) { (record, error) in guard error == nil else { guard let ckerror = error as? CKError else { completion(nil, nil, error) return } if ckerror.isRecordNotFound() { // This typically means we just haven't saved it yet, // for example the first time the user runs the app. completion(nil, nil, CloudKitNoteError.noteNotFound) return } completion(nil, nil, error) return } guard let record = record else { completion(nil, nil, CloudKitNoteError.unexpected) return } let (text, modified, error) = self.syncToRecord(record: record) self.noteRecord = record self.text = text self.modified = modified completion(text, modified, error) } }

Guardar una nota y resolver posibles conflictos

Hay un par de situaciones especiales que debe tener en cuenta cuando guarda una nota.

En primer lugar, debe asegurarse de que está comenzando desde un CKRecord válido. Le pregunta a CloudKit si ya hay un registro allí, y si no, crea un nuevo CKRecord local para usarlo para el guardado posterior.

Cuando le pide a CloudKit que guarde el registro, es posible que tenga que manejar un conflicto debido a que otro cliente actualizó el registro desde la última vez que lo buscó. Anticipándose a esto, divida la función de guardar en dos pasos. El primer paso realiza una configuración única en preparación para escribir el registro, y el segundo paso pasa el registro ensamblado a la clase CloudKitNoteDatabase . Este segundo paso puede repetirse en caso de conflicto.

En caso de conflicto, CloudKit le brinda, en el CKError devuelto, tres CKRecord completos para trabajar con:

  1. La versión anterior del registro que intentó guardar,
  2. La versión exacta del registro que intentó guardar,
  3. La versión que tenía el servidor en el momento en que envió la solicitud.

Al observar los campos modified de estos registros, puede decidir qué registro ocurrió primero y, por lo tanto, qué datos conservar. Si es necesario, pasa el registro del servidor actualizado a CloudKit para escribir el nuevo registro. Por supuesto, esto podría resultar en otro conflicto (si se produjera otra actualización), pero luego simplemente repita el proceso hasta que obtenga un resultado exitoso.

En esta sencilla aplicación Note, con un solo usuario cambiando entre dispositivos, no es probable que vea demasiados conflictos en un sentido de "concurrencia en vivo". Sin embargo, tales conflictos pueden surgir de otras circunstancias. Por ejemplo, un usuario puede haber realizado ediciones en un dispositivo mientras estaba en modo avión y luego, sin darse cuenta, realizó diferentes ediciones en otro dispositivo antes de desactivar el modo avión en el primer dispositivo.

En las aplicaciones de intercambio de datos basadas en la nube, es extremadamente importante estar atento a todos los escenarios posibles.

 // Save a Note to iCloud. If necessary, handle the case of a conflicting change. public func save(text: String, modified: Date, completion: @escaping (Error?) -> Void) { guard let record = self.noteRecord else { // We don't already have a record. See if there's one up on iCloud let noteDB = CloudKitNoteDatabase.shared noteDB.loadRecord(name: recordName) { record, error in if let error = error { guard let ckerror = error as? CKError else { completion(error) return } guard ckerror.isRecordNotFound() else { completion(error) return } // No record up on iCloud, so we'll start with a // brand new record. let recordID = CKRecordID(recordName: self.recordName, zoneID: noteDB.zoneID!) self.noteRecord = CKRecord(recordType: "note", recordID: recordID) self.noteRecord?["version"] = NSNumber(value:self.version) } else { guard record != nil else { completion(CloudKitNoteError.unexpected) return } self.noteRecord = record } // Repeat the save attempt now that we've either fetched // the record from iCloud or created a new one. self.save(text: text, modified: modified, completion: completion) } return } // Save the note text as a temp file to use as the CKAsset data. let tempDirectory = NSTemporaryDirectory() let tempFileName = NSUUID().uuidString let tempFileURL = NSURL.fileURL(withPathComponents: [tempDirectory, tempFileName]) do { try text.write(to: tempFileURL!, atomically: true, encoding: .utf8) } catch { completion(error) return } let textAsset = CKAsset(fileURL: tempFileURL!) record["text"] = textAsset record["modified"] = modified as NSDate saveRecord(record: record) { updated, error in defer { try? FileManager.default.removeItem(at: tempFileURL!) } guard error == nil else { completion(error) return } guard !updated else { // During the save we found another version on the server side and // the merging logic determined we should update our local data to match // what was in the iCloud database. let (text, modified, syncError) = self.syncToRecord(record: self.noteRecord!) guard syncError == nil else { completion(syncError) return } self.text = text self.modified = modified // Let the UI know the Note has been updated. self.delegate?.cloudKitNoteChanged(note: self) completion(nil) return } self.text = text self.modified = modified completion(nil) } } // This internal saveRecord method will repeatedly be called if needed in the case // of a merge. In those cases, we don't have to repeat the CKRecord setup. private func saveRecord(record: CKRecord, completion: @escaping (Bool, Error?) -> Void) { let noteDB = CloudKitNoteDatabase.shared noteDB.saveRecord(record: record) { error in guard error == nil else { guard let ckerror = error as? CKError else { completion(false, error) return } let (clientRec, serverRec) = ckerror.getMergeRecords() guard let clientRecord = clientRec, let serverRecord = serverRec else { completion(false, error) return } // This is the merge case. Check the modified dates and choose // the most-recently modified one as the winner. This is just a very // basic example of conflict handling, more sophisticated data models // will likely require more nuance here. let clientModified = clientRecord["modified"] as? Date let serverModified = serverRecord["modified"] as? Date if (clientModified?.compare(serverModified!) == .orderedDescending) { // We've decided ours is the winner, so do the update again // using the current iCloud ServerRecord as the base CKRecord. serverRecord["text"] = clientRecord["text"] serverRecord["modified"] = clientModified! as NSDate self.saveRecord(record: serverRecord) { modified, error in self.noteRecord = serverRecord completion(true, error) } } else { // We've decided the iCloud version is the winner. // No need to overwrite it there but we'll update our // local information to match to stay in sync. self.noteRecord = serverRecord completion(true, nil) } return } completion(false, nil) } }

Manejo de la notificación de una nota modificada de forma remota

Cuando llega una notificación de que un registro ha cambiado, CloudKitNoteDatabase hará el trabajo pesado de obtener los cambios de CloudKit. En este caso de ejemplo, solo será un registro de nota, pero no es difícil ver cómo esto podría extenderse a un rango de diferentes tipos de registros e instancias.

A modo de ejemplo, incluí una verificación de cordura básica para asegurarme de que estoy actualizando el registro correcto y luego actualizo los campos y notifico al delegado que tenemos nuevos datos.

 // CloudKitNoteDatabaseDelegate call: public func cloudKitNoteRecordChanged(record: CKRecord) { if record.recordID == self.noteRecord?.recordID { let (text, modified, error) = self.syncToRecord(record: record) guard error == nil else { return } self.noteRecord = record self.text = text self.modified = modified self.delegate?.cloudKitNoteChanged(note: self) } }

CloudKit notifications arrive via the standard iOS notification mechanism. Thus, your AppDelegate should call application.registerForRemoteNotifications in didFinishLaunchingWithOptions and implement didReceiveRemoteNotification . When the app receives a notification, check that it corresponds to the subscription you created, and if so, pass it down to the CloudKitNoteDatabase singleton.

 func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { let dict = userInfo as! [String: NSObject] let notification = CKNotification(fromRemoteNotificationDictionary: dict) let db = CloudKitNoteDatabase.shared if notification.subscriptionID == db.subscriptionID { db.handleNotification() completionHandler(.newData) } else { completionHandler(.noData) } }

Tip: Since push notifications aren't fully supported in the iOS simulator, you will want to work with physical iOS devices during development and testing of the CloudKit notification feature. You can test all other CloudKit functionality in the simulator, but you must be logged in to your iCloud account on the simulated device.

¡Ahí tienes! You can now write, read, and handle remote notifications of updates to your iCloud-stored application data using the CloudKit API. More importantly, you have a foundation for adding more advanced CloudKit functionality.

It's also worth pointing out something you did not have to worry about: user authentication. Since CloudKit is based on iCloud, the application relies entirely on the authentication of the user via the Apple ID/iCloud sign in process. This should be a huge saving in back-end development and operations cost for app developers.

Handling the Offline Case

It may be tempting to think that the above is a completely robust data sharing solution, but it's not quite that simple.

Implicit in all of this is that CloudKit may not always be available. Users may not be signed in, they may have disabled CloudKit for the app, they may be in airplane mode—the list of exceptions goes on. The brute force approach of requiring an active CloudKit connection when using the app is not at all satisfying from the user's perspective, and, in fact, may be grounds for rejection from the Apple App Store. So, an offline mode must be carefully considered.

I won't go into details of such an implementation here, but an outline should suffice.

The same note fields for text and modified datetime can be stored locally in a file via NSKeyedArchiver or the like, and the UI can provide near full functionality based on this local copy. It is also possible to serialize CKRecords directly to and from local storage. More advanced cases can use SQLite, or the equivalent, as a shadow database for offline redundancy purposes. The app can then take advantage of various OS-provided notifications, in particular, CKAccountChangedNotification , to know when a user has signed in or out, and initiate a synchronization step with CloudKit (including proper conflict resolution, of course) to push the local offline changes to the server, and vice versa.

Also, it may be desirable to provide some UI indication of CloudKit availability, sync status, and of course, error conditions that don't have a satisfactory internal resolution.

CloudKit Solves The Synchronization Problem

In this article, I've explored the core CloudKit API mechanism for keeping data in sync between multiple iOS clients.

Note that the same code will work for macOS clients as well, with slight adjustments for differences in how notifications work on that platform.

CloudKit provides much more functionality on top of this, especially for sophisticated data models, public sharing, advanced user notification features, and more.

Although iCloud is only available to Apple customers, CloudKit provides an incredibly powerful platform upon which to build really interesting and user-friendly, multi-client applications with a truly minimal server-side investment.

To dig deeper into CloudKit, I strongly recommend taking the time to view the various CloudKit presentations from each of the last few WWDCs and follow along with the examples they provide.

Related: Swift Tutorial: An Introduction to the MVVM Design Pattern