Un guide de CloudKit : Comment synchroniser les données utilisateur sur les appareils iOS
Publié: 2022-03-11De nos jours, le développement d'applications mobiles modernes nécessite un plan bien pensé pour synchroniser les données des utilisateurs sur différents appareils. Il s'agit d'un problème épineux avec de nombreux pièges et pièges, mais les utilisateurs attendent la fonctionnalité et s'attendent à ce qu'elle fonctionne bien.
Pour iOS et macOS, Apple fournit une boîte à outils robuste, appelée API CloudKit, qui permet aux développeurs ciblant les plates-formes Apple de résoudre ce problème de synchronisation.
Dans cet article, je vais vous montrer comment utiliser CloudKit pour synchroniser les données d'un utilisateur entre plusieurs clients. Il est destiné aux développeurs iOS expérimentés qui connaissent déjà les frameworks d'Apple et Swift. Je vais faire une plongée technique assez approfondie dans l'API CloudKit pour explorer les moyens d'exploiter cette technologie pour créer de superbes applications multi-appareils. Je vais me concentrer sur une application iOS, mais la même approche peut également être utilisée pour les clients macOS.
Notre exemple de cas d'utilisation est une application de note simple avec une seule note, à des fins d'illustration. En cours de route, j'examinerai certains des aspects les plus délicats de la synchronisation des données basée sur le cloud, notamment la gestion des conflits et le comportement incohérent de la couche réseau.
Qu'est-ce que CloudKit ?
CloudKit est construit sur le service iCloud d'Apple. Il est juste de dire qu'iCloud a pris un départ un peu difficile. Une transition maladroite depuis MobileMe, des performances médiocres et même des problèmes de confidentialité ont freiné le système au cours des premières années.
Pour les développeurs d'applications, la situation était encore pire. Avant CloudKit, un comportement incohérent et des outils de débogage faibles rendaient presque impossible la livraison d'un produit de qualité supérieure à l'aide des API iCloud de première génération.
Au fil du temps, cependant, Apple a résolu ces problèmes. En particulier, après la sortie du SDK CloudKit en 2014, les développeurs tiers disposent d'une solution technique complète et robuste pour le partage de données basé sur le cloud entre les appareils (y compris les applications macOS et même les clients Web.)
Étant donné que CloudKit est profondément lié aux systèmes d'exploitation et aux appareils d'Apple, il ne convient pas aux applications qui nécessitent une prise en charge plus large de l'appareil, comme les clients Android ou Windows. Pour les applications destinées à la base d'utilisateurs d'Apple, cependant, il fournit un mécanisme extrêmement puissant pour l'authentification des utilisateurs et la synchronisation des données.
Configuration de base de CloudKit
CloudKit organise les données via une hiérarchie de classes : CKContainer
, CKDatabase
, CKRecordZone
et CKRecord
.
Au niveau supérieur se trouve CKContainer
, qui encapsule un ensemble de données CloudKit associées. Chaque application obtient automatiquement un CKContainer
par défaut, et un groupe d'applications peut partager un CKContainer
personnalisé si les paramètres d'autorisation le permettent. Cela peut permettre des workflows inter-applications intéressants.
Dans chaque CKContainer
se trouvent plusieurs instances de CKDatabase
. CloudKit configure automatiquement chaque application compatible CloudKit prête à l'emploi pour avoir une CKDatabase
publique (tous les utilisateurs de l'application peuvent tout voir) et une CKDatabase
privée (chaque utilisateur ne voit que ses propres données). Et, à partir d'iOS 10, une CKDatabase
partagée où les groupes contrôlés par l'utilisateur peuvent partager des éléments entre les membres du groupe.
Dans une CKDatabase
se trouvent des CKRecordZone
s, et dans les zones CKRecord
s. Vous pouvez lire et écrire des enregistrements, rechercher des enregistrements correspondant à un ensemble de critères et (surtout) recevoir une notification des modifications apportées à l'un des éléments ci-dessus.
Pour votre application Note, vous pouvez utiliser le conteneur par défaut. Dans ce conteneur, vous allez utiliser la base de données privée (parce que vous voulez que la note de l'utilisateur ne soit vue que par cet utilisateur) et dans la base de données privée, vous allez utiliser une zone d'enregistrement personnalisée, qui permet la notification de enregistrer les modifications.
La note sera stockée sous la forme d'un seul CKRecord
avec les champs text
, modified
(DateTime) et version
. CloudKit suit automatiquement une valeur modified
interne, mais vous souhaitez pouvoir connaître l'heure de modification réelle, y compris les cas hors ligne, à des fins de résolution de conflits. Le champ de version
est simplement une illustration de bonnes pratiques pour la mise à niveau, en gardant à l'esprit qu'un utilisateur avec plusieurs appareils peut ne pas mettre à jour votre application sur tous en même temps, il y a donc un appel à la défensive.
Construire l'application Note
Je suppose que vous maîtrisez bien les bases de la création d'applications iOS dans Xcode. Si vous le souhaitez, vous pouvez télécharger et examiner l'exemple de projet Note App Xcode créé pour ce didacticiel.
Pour nos besoins, une application à vue unique contenant un UITextView
avec le ViewController
comme délégué suffira. Au niveau conceptuel, vous souhaitez déclencher une mise à jour d'enregistrement CloudKit chaque fois que le texte change. Cependant, en pratique, il est logique d'utiliser une sorte de mécanisme de fusion des changements, comme un minuteur d'arrière-plan qui se déclenche périodiquement, pour éviter de spammer les serveurs iCloud avec trop de petits changements.
L'application CloudKit nécessite que quelques éléments soient activés dans le volet des fonctionnalités de la cible Xcode : iCloud (naturellement), y compris la case à cocher CloudKit, les notifications push et les modes d'arrière-plan (en particulier, les notifications à distance).
Pour la fonctionnalité CloudKit, j'ai divisé les éléments en deux classes : un singleton CloudKitNoteDatabase
niveau inférieur et une classe CloudKitNote
de niveau supérieur.
Mais d'abord, une discussion rapide sur les erreurs CloudKit.
Erreurs CloudKit
Une gestion soigneuse des erreurs est absolument essentielle pour un client CloudKit.
Puisqu'il s'agit d'une API basée sur le réseau, elle est sensible à toute une série de problèmes de performances et de disponibilité. En outre, le service lui-même doit protéger contre une série de problèmes potentiels, tels que les demandes non autorisées, les modifications conflictuelles, etc.
CloudKit fournit une gamme complète de codes d'erreur, accompagnés d'informations, pour permettre aux développeurs de gérer divers cas extrêmes et, si nécessaire, de fournir des explications détaillées à l'utilisateur sur les problèmes éventuels.
En outre, plusieurs opérations CloudKit peuvent renvoyer une erreur sous la forme d'une valeur d'erreur unique ou une erreur composée signifiée au niveau supérieur comme partialFailure
. Il est livré avec un dictionnaire des CKError
contenus qui méritent une inspection plus approfondie pour comprendre ce qui s'est exactement passé lors d'une opération composée.
Pour vous aider à naviguer dans cette complexité, vous pouvez étendre CKError
avec quelques méthodes d'assistance.
Veuillez noter que tout le code comporte des commentaires explicatifs aux points clés.
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) } }
Le singleton CloudKitNoteDatabase
Apple fournit deux niveaux de fonctionnalité dans le SDK CloudKit : des fonctions « pratiques » de haut niveau, telles que fetch()
, save()
et delete()
, et des constructions d'opérations de niveau inférieur avec des noms encombrants, telles que CKModifyRecordsOperation
.
L'API de commodité est beaucoup plus accessible, tandis que l'approche de l'opération peut être un peu intimidante. Cependant, Apple exhorte fortement les développeurs à utiliser les opérations plutôt que les méthodes de commodité.
Les opérations CloudKit offrent un contrôle supérieur sur les détails de la façon dont CloudKit fait son travail et, peut-être plus important encore, obligent vraiment le développeur à réfléchir attentivement aux comportements du réseau au cœur de tout ce que fait CloudKit. Pour ces raisons, j'utilise les opérations de ces exemples de code.
Votre classe singleton sera responsable de chacune de ces opérations CloudKit que vous utiliserez. En fait, dans un sens, vous recréez les API de commodité. Mais, en les implémentant vous-même sur la base de l'API d'opération, vous vous placez en bonne position pour personnaliser le comportement et ajuster vos réponses de gestion des erreurs. Par exemple, si vous souhaitez étendre cette application pour gérer plusieurs notes plutôt qu'une seule, vous pouvez le faire plus facilement (et avec des performances plus élevées) que si vous veniez d'utiliser les API pratiques d'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? // ... }
Création d'une zone personnalisée
CloudKit crée automatiquement une zone par défaut pour la base de données privée. Cependant, vous pouvez obtenir plus de fonctionnalités si vous utilisez une zone personnalisée, notamment la prise en charge de la récupération des modifications d'enregistrement incrémentielles.
Puisqu'il s'agit d'un premier exemple d'utilisation d'une opération, voici quelques observations générales :
Tout d'abord, toutes les opérations CloudKit ont des fermetures d'achèvement personnalisées (et beaucoup ont des fermetures intermédiaires, selon l'opération). CloudKit a sa propre classe CKError
, dérivée de Error
, mais vous devez être conscient de la possibilité que d'autres erreurs surviennent également. Enfin, l'un des aspects les plus importants de toute opération est la valeur qualityOfService
. En raison de la latence du réseau, du mode avion, etc., CloudKit gérera en interne les tentatives et autres pour les opérations à une qualityOfService
de service de « utilité » ou inférieure. Selon le contexte, vous souhaiterez peut-être attribuer une qualité de service supérieure et qualityOfService
vous-même ces situations.
Une fois configurées, les opérations sont transmises à l'objet CKDatabase
, où elles seront exécutées sur un thread d'arrière-plan.
// 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) }
Créer un abonnement
Les abonnements sont l'une des fonctionnalités les plus précieuses de CloudKit. Ils s'appuient sur l'infrastructure de notification d'Apple pour permettre à divers clients de recevoir des notifications push lorsque certaines modifications CloudKit se produisent. Il peut s'agir de notifications push normales familières aux utilisateurs d'iOS (comme un son, une bannière ou un badge), ou dans CloudKit, il peut s'agir d'une classe spéciale de notification appelée push silencieux . Ces poussées silencieuses se produisent entièrement sans visibilité ou interaction de l'utilisateur et, par conséquent, ne nécessitent pas que l'utilisateur active la notification push pour votre application, ce qui vous évite de nombreux maux de tête potentiels en tant que développeur d'applications.
La façon d'activer ces notifications silencieuses consiste à définir la propriété shouldSendContentAvailable
sur l'instance CKNotificationInfo
, tout en laissant tous les paramètres de notification traditionnels ( shouldBadge
, soundName
, etc.) non définis.
Notez également que j'utilise un CKQuerySubscription
avec un prédicat "toujours vrai" très simple pour surveiller les modifications sur le seul (et unique) enregistrement Note. Dans une application plus sophistiquée, vous souhaiterez peut-être tirer parti du prédicat pour réduire la portée d'un CKQuerySubscription
particulier, et vous souhaiterez peut-être revoir les autres types d'abonnement disponibles sous CloudKit, tels que CKDatabaseSuscription
.
Enfin, notez que vous pouvez utiliser une valeur mise en cache UserDefaults
pour éviter d'enregistrer inutilement l'abonnement plusieurs fois. Il n'y a pas de mal à le configurer, mais Apple recommande de faire un effort pour éviter cela car cela gaspille les ressources du réseau et du serveur.
// 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) }
Chargement des enregistrements
Récupérer un enregistrement par son nom est très simple. Vous pouvez considérer le nom comme la clé primaire de l'enregistrement dans un sens de base de données simple (les noms doivent être uniques, par exemple). Le CKRecordID
réel est un peu plus compliqué en ce sens qu'il inclut le zoneID
.
L'opération CKFetchRecordsOperation
fonctionne sur un ou plusieurs enregistrements à la fois. Dans cet exemple, il n'y a qu'un seul enregistrement, mais pour une évolutivité future, il s'agit d'un excellent avantage potentiel en termes de performances.
// 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) }
Enregistrement des enregistrements
La sauvegarde des enregistrements est peut-être l'opération la plus compliquée. Le simple fait d'écrire un enregistrement dans la base de données est assez simple, mais dans mon exemple, avec plusieurs clients, c'est là que vous serez confronté au problème potentiel de gestion d'un conflit lorsque plusieurs clients tentent d'écrire simultanément sur le serveur. Heureusement, CloudKit est explicitement conçu pour gérer cette condition. Il rejette les demandes spécifiques avec suffisamment de contexte d'erreur dans la réponse pour permettre à chaque client de prendre une décision locale et éclairée sur la manière de résoudre le conflit.
Bien que cela ajoute de la complexité au client, c'est finalement une bien meilleure solution que de demander à Apple de proposer l'un des quelques mécanismes côté serveur pour la résolution des conflits.
Le concepteur d'application est toujours le mieux placé pour définir des règles pour ces situations, qui peuvent inclure tout, de la fusion automatique sensible au contexte aux instructions de résolution dirigées par l'utilisateur. Je ne vais pas devenir très fantaisiste dans mon exemple; J'utilise le champ modified
pour déclarer que la mise à jour la plus récente l'emporte. Ce n'est peut-être pas toujours le meilleur résultat pour les applications professionnelles, mais ce n'est pas mauvais pour une première règle et, à cette fin, sert à illustrer le mécanisme par lequel CloudKit transmet les informations de conflit au client.
Notez que, dans mon exemple d'application, cette étape de résolution de conflit se produit dans la classe CloudKitNote
, décrite plus loin.
// 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) }
Gestion de la notification des enregistrements mis à jour
Les notifications CloudKit permettent de savoir quand des enregistrements ont été mis à jour par un autre client. Cependant, les conditions du réseau et les contraintes de performances peuvent entraîner la suppression de notifications individuelles ou la fusion intentionnelle de plusieurs notifications en une seule notification client. Étant donné que les notifications de CloudKit sont construites au-dessus du système de notification iOS, vous devez être à l'affût de ces conditions.

Cependant, CloudKit vous donne les outils dont vous avez besoin pour cela.
Plutôt que de compter sur des notifications individuelles pour vous donner une connaissance détaillée de ce que représente une notification individuelle, vous utilisez une notification pour indiquer simplement que quelque chose a changé, puis vous pouvez demander à CloudKit ce qui a changé depuis la dernière fois que vous avez demandé. Dans mon exemple, je le fais en utilisant CKFetchRecordZoneChangesOperation
et CKServerChangeTokens
. Les jetons de modification peuvent être considérés comme un signet vous indiquant où vous étiez avant la séquence de modifications la plus récente.
// 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) }
Vous disposez maintenant des blocs de construction de bas niveau pour lire et écrire des enregistrements, et pour gérer les notifications de modifications d'enregistrement.
Examinons maintenant une couche construite au-dessus de cela pour gérer ces opérations dans le contexte d'une note spécifique.
La classe CloudKitNote
Pour commencer, quelques erreurs personnalisées peuvent être définies pour protéger le client des éléments internes de CloudKit, et un simple protocole délégué peut informer le client des mises à jour à distance des données de note sous-jacentes.
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... } // … }
Mappage de CKRecord
à Note
Dans Swift, les champs individuels d'un CKRecord
sont accessibles via l'opérateur d'indice. Les valeurs sont toutes conformes à CKRecordValue
, mais celles-ci, à leur tour, font toujours partie d'un sous-ensemble spécifique de types de données familiers : NSString
, NSNumber
, NSDate
, etc.
De plus, CloudKit fournit un type d'enregistrement spécifique pour les "grands" objets binaires. Aucun point de coupure spécifique n'est spécifié (un maximum de 1 Mo au total est recommandé pour chaque CKRecord
), mais en règle générale, à peu près tout ce qui ressemble à un élément indépendant (une image, un son, une goutte de texte) plutôt que comme un champ de base de données devrait probablement être stocké en tant que CKAsset
. Cette pratique permet à CloudKit de mieux gérer le transfert réseau et le stockage côté serveur de ces types d'éléments.
Pour cet exemple, vous utiliserez CKAsset
pour stocker le texte de la note. Les données CKAsset
sont gérées via des fichiers temporaires locaux contenant les données correspondantes.
// 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) } }
Charger une note
Le chargement d'une note est très simple. Vous faites un peu de vérification d'erreur requise, puis récupérez simplement les données réelles du CKRecord
et stockez les valeurs dans vos champs de membre.
// 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) } }
Enregistrer une note et résoudre un conflit potentiel
Il y a quelques situations particulières à prendre en compte lorsque vous enregistrez une note.
Tout d'abord, vous devez vous assurer que vous partez d'un CKRecord
valide. Vous demandez à CloudKit s'il y a déjà un enregistrement, et si ce n'est pas le cas, vous créez un nouveau CKRecord
local à utiliser pour la sauvegarde suivante.
Lorsque vous demandez à CloudKit de sauvegarder l'enregistrement, c'est là que vous devrez peut-être gérer un conflit en raison d'un autre client mettant à jour l'enregistrement depuis la dernière fois que vous l'avez récupéré. En prévision de cela, divisez la fonction de sauvegarde en deux étapes. La première étape effectue une configuration unique en vue de l'écriture de l'enregistrement, et la deuxième étape transmet l'enregistrement assemblé à la classe singleton CloudKitNoteDatabase
. Cette deuxième étape peut être répétée en cas de conflit.
En cas de conflit, CloudKit vous donne, dans le CKError
renvoyé, trois CKRecord
complets avec lesquels travailler :
- La version précédente de l'enregistrement que vous avez essayé de sauvegarder,
- La version exacte de l'enregistrement que vous avez tenté de sauvegarder,
- La version détenue par le serveur au moment où vous avez soumis la demande.
En examinant les champs modified
de ces enregistrements, vous pouvez décider quel enregistrement s'est produit en premier, et donc quelles données conserver. Si nécessaire, vous transmettez ensuite l'enregistrement de serveur mis à jour à CloudKit pour écrire le nouvel enregistrement. Bien sûr, cela pourrait entraîner un autre conflit (si une autre mise à jour intervenait), mais vous répétez simplement le processus jusqu'à ce que vous obteniez un résultat réussi.
Dans cette simple application Note, avec un seul utilisateur passant d'un appareil à l'autre, vous ne risquez pas de voir trop de conflits dans un sens de "concurrence en direct". Cependant, de tels conflits peuvent découler d'autres circonstances. Par exemple, un utilisateur peut avoir effectué des modifications sur un appareil alors qu'il était en mode avion, puis avoir effectué différentes modifications sur un autre appareil avant de désactiver le mode avion sur le premier appareil.
Dans les applications de partage de données basées sur le cloud, il est extrêmement important d'être à l'affût de tous les scénarios possibles.
// 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) } }
Gestion de la notification d'une note modifiée à distance
Lorsqu'une notification arrive indiquant qu'un enregistrement a été modifié, CloudKitNoteDatabase
fera le gros du travail en récupérant les modifications à partir de CloudKit. Dans cet exemple, il ne s'agira que d'un seul enregistrement de note, mais il n'est pas difficile de voir comment cela pourrait être étendu à une gamme de types et d'instances d'enregistrement différents.
À titre d'exemple, j'ai inclus une vérification de base pour m'assurer que je mets à jour le bon enregistrement, puis mettre à jour les champs et informer le délégué que nous avons de nouvelles données.
// 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.
Voilà! 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.