Ein Leitfaden für CloudKit: So synchronisieren Sie Benutzerdaten auf iOS-Geräten
Veröffentlicht: 2022-03-11Heutzutage erfordert die Entwicklung moderner mobiler Anwendungen einen gut durchdachten Plan, um Benutzerdaten auf verschiedenen Geräten synchron zu halten. Dies ist ein heikles Problem mit vielen Fallstricken und Fallstricken, aber die Benutzer erwarten die Funktion und erwarten, dass sie gut funktioniert.
Für iOS und macOS stellt Apple ein robustes Toolkit namens CloudKit API bereit, mit dem Entwickler, die auf Apple-Plattformen abzielen, dieses Synchronisierungsproblem lösen können.
In diesem Artikel zeige ich, wie Sie CloudKit verwenden, um die Daten eines Benutzers zwischen mehreren Clients synchron zu halten. Es richtet sich an erfahrene iOS-Entwickler, die bereits mit Apples Frameworks und mit Swift vertraut sind. Ich werde einen ziemlich tiefen technischen Einblick in die CloudKit-API nehmen, um herauszufinden, wie Sie diese Technologie nutzen können, um großartige Apps für mehrere Geräte zu erstellen. Ich werde mich auf eine iOS-Anwendung konzentrieren, aber der gleiche Ansatz kann auch für macOS-Clients verwendet werden.
Unser Beispielanwendungsfall ist eine einfache Notizanwendung mit nur einer einzigen Notiz zur Veranschaulichung. Nebenbei werfe ich einen Blick auf einige der kniffligeren Aspekte der Cloud-basierten Datensynchronisierung, einschließlich der Konfliktbehandlung und des inkonsistenten Verhaltens der Netzwerkschicht.
Was ist CloudKit?
CloudKit baut auf dem iCloud-Dienst von Apple auf. Es ist fair zu sagen, dass iCloud einen etwas holprigen Start hatte. Ein ungeschickter Übergang von MobileMe, schlechte Leistung und sogar einige Datenschutzbedenken hielten das System in den Anfangsjahren zurück.
Für App-Entwickler war die Situation noch schlimmer. Vor CloudKit machten inkonsistentes Verhalten und schwache Debugging-Tools es fast unmöglich, mit den iCloud-APIs der ersten Generation ein qualitativ hochwertiges Produkt zu liefern.
Im Laufe der Zeit hat Apple diese Probleme jedoch angegangen. Insbesondere nach der Veröffentlichung des CloudKit SDK im Jahr 2014 haben Drittentwickler eine voll funktionsfähige, robuste technische Lösung für den Cloud-basierten Datenaustausch zwischen Geräten (einschließlich macOS-Anwendungen und sogar webbasierten Clients).
Da CloudKit eng mit den Betriebssystemen und Geräten von Apple verbunden ist, eignet es sich nicht für Anwendungen, die eine breitere Palette an Geräteunterstützung erfordern, wie z. B. Android- oder Windows-Clients. Für Apps, die auf die Benutzerbasis von Apple ausgerichtet sind, bietet es jedoch einen äußerst leistungsfähigen Mechanismus für die Benutzerauthentifizierung und Datensynchronisierung.
Grundlegendes CloudKit-Setup
CloudKit organisiert Daten über eine Klassenhierarchie: CKContainer
, CKDatabase
, CKRecordZone
und CKRecord
.
Auf der obersten Ebene befindet sich CKContainer
, der eine Reihe verwandter CloudKit-Daten kapselt. Jede App erhält automatisch einen Standard CKContainer
, und eine Gruppe von Apps kann einen benutzerdefinierten CKContainer
gemeinsam nutzen, wenn die Berechtigungseinstellungen dies zulassen. Das kann einige interessante anwendungsübergreifende Workflows ermöglichen.
In jedem CKContainer
befinden sich mehrere Instanzen von CKDatabase
. CloudKit konfiguriert automatisch jede CloudKit-fähige App standardmäßig so, dass sie eine öffentliche CKDatabase
(alle Benutzer der App können alles sehen) und eine private CKDatabase
(jeder Benutzer sieht nur seine eigenen Daten) hat. Und ab iOS 10 eine gemeinsam genutzte CKDatabase
, in der benutzergesteuerte Gruppen Elemente unter den Mitgliedern der Gruppe teilen können.
Innerhalb einer CKDatabase
befinden sich CKRecordZone
s und innerhalb von Zonen CKRecord
s. Sie können Datensätze lesen und schreiben, Datensätze abfragen, die einer Reihe von Kriterien entsprechen, und (am wichtigsten) Benachrichtigungen über Änderungen an einem der oben genannten Punkte erhalten.
Für Ihre Note-App können Sie den Standardcontainer verwenden. Innerhalb dieses Containers verwenden Sie die private Datenbank (da Sie möchten, dass die Notiz des Benutzers nur von diesem Benutzer gesehen wird) und innerhalb der privaten Datenbank verwenden Sie eine benutzerdefinierte Aufzeichnungszone, die die Benachrichtigung über bestimmte Änderungen aufzeichnen.
Die Notiz wird als einzelner CKRecord
mit text
, modified
(DateTime) und version
gespeichert. CloudKit verfolgt automatisch einen internen modified
Wert, aber Sie möchten die tatsächliche Änderungszeit, einschließlich Offline-Fälle, für Konfliktlösungszwecke kennen. Das version
ist einfach eine Veranschaulichung bewährter Verfahren für Upgrade-Proofing, wobei zu berücksichtigen ist, dass ein Benutzer mit mehreren Geräten Ihre App möglicherweise nicht auf allen gleichzeitig aktualisiert, sodass ein gewisser Schutz geboten ist.
Erstellen der Notiz-App
Ich gehe davon aus, dass Sie die Grundlagen der Erstellung von iOS-Apps in Xcode gut beherrschen. Wenn Sie möchten, können Sie das für dieses Tutorial erstellte Xcode-Beispielprojekt Note App herunterladen und untersuchen.
Für unsere Zwecke reicht eine Single-View-Anwendung aus, die eine UITextView
mit dem ViewController
als Delegate enthält. Auf konzeptioneller Ebene möchten Sie eine CloudKit-Datensatzaktualisierung auslösen, wenn sich der Text ändert. Aus praktischen Gründen ist es jedoch sinnvoll, eine Art Mechanismus zum Zusammenführen von Änderungen zu verwenden, z. B. einen Hintergrund-Timer, der regelmäßig ausgelöst wird, um zu vermeiden, dass die iCloud-Server mit zu vielen kleinen Änderungen überhäuft werden.
Für die CloudKit-App müssen einige Elemente im Funktionsbereich des Xcode-Ziels aktiviert werden: iCloud (natürlich), einschließlich des CloudKit-Kontrollkästchens, Push-Benachrichtigungen und Hintergrundmodi (insbesondere Remote-Benachrichtigungen).
Für die CloudKit-Funktionalität habe ich die Dinge in zwei Klassen unterteilt: Ein CloudKitNoteDatabase
Singleton auf niedrigerer Ebene und eine CloudKitNote
-Klasse auf höherer Ebene.
Aber zuerst eine kurze Diskussion über CloudKit-Fehler.
CloudKit-Fehler
Eine sorgfältige Fehlerbehandlung ist für einen CloudKit-Client absolut unerlässlich.
Da es sich um eine netzwerkbasierte API handelt, ist sie anfällig für eine ganze Reihe von Leistungs- und Verfügbarkeitsproblemen. Außerdem muss der Dienst selbst vor einer Reihe potenzieller Probleme schützen, z. B. vor nicht autorisierten Anforderungen, widersprüchlichen Änderungen und dergleichen.
CloudKit bietet eine vollständige Palette von Fehlercodes mit begleitenden Informationen, damit Entwickler verschiedene Grenzfälle handhaben und dem Benutzer bei Bedarf detaillierte Erläuterungen zu möglichen Problemen geben können.
Außerdem können mehrere CloudKit-Operationen einen Fehler als einzelnen Fehlerwert oder als zusammengesetzten Fehler zurückgeben, der auf der obersten Ebene als partialFailure
. Es wird mit einem Wörterbuch enthaltener CKError
s geliefert, die eine sorgfältigere Untersuchung verdienen, um herauszufinden, was genau während einer zusammengesetzten Operation passiert ist.
Um einen Teil dieser Komplexität zu bewältigen, können Sie CKError
mit einigen Hilfsmethoden erweitern.
Bitte beachten Sie, dass der gesamte Code an den wichtigsten Punkten erläuternde Kommentare enthält.
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) } }
Das CloudKitNoteDatabase
Singleton
Apple stellt im CloudKit SDK zwei Funktionsebenen bereit: Komfortfunktionen auf hoher Ebene wie fetch()
, save()
und delete()
sowie Operationskonstrukte auf niedrigerer Ebene mit umständlichen Namen wie CKModifyRecordsOperation
.
Die Convenience-API ist viel zugänglicher, während der Betriebsansatz etwas einschüchternd sein kann. Apple fordert die Entwickler jedoch dringend auf, die Operationen und nicht die Komfortmethoden zu verwenden.
CloudKit-Vorgänge bieten eine überlegene Kontrolle über die Details, wie CloudKit seine Arbeit erledigt, und zwingen den Entwickler, was vielleicht noch wichtiger ist, wirklich dazu, sorgfältig über das Netzwerkverhalten nachzudenken, das für alles, was CloudKit tut, von zentraler Bedeutung ist. Aus diesen Gründen verwende ich die Operationen in diesen Codebeispielen.
Ihre Singleton-Klasse ist für jede dieser von Ihnen verwendeten CloudKit-Operationen verantwortlich. Tatsächlich erstellen Sie gewissermaßen die Convenience-APIs neu. Indem Sie sie jedoch basierend auf der Operations-API selbst implementieren, versetzen Sie sich in eine gute Position, um das Verhalten anzupassen und Ihre Fehlerbehandlungsreaktionen zu optimieren. Wenn Sie diese App beispielsweise erweitern möchten, um mehrere Notizen statt nur einer zu verarbeiten, können Sie dies einfacher (und mit höherer resultierender Leistung) tun, als wenn Sie nur die Komfort-APIs von Apple verwendet hätten.
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? // ... }
Erstellen einer benutzerdefinierten Zone
CloudKit erstellt automatisch eine Standardzone für die private Datenbank. Sie können jedoch mehr Funktionalität erhalten, wenn Sie eine benutzerdefinierte Zone verwenden, insbesondere Unterstützung für das Abrufen inkrementeller Datensatzänderungen.
Da dies ein erstes Beispiel für die Verwendung einer Operation ist, hier ein paar allgemeine Beobachtungen:
Erstens haben alle CloudKit-Operationen benutzerdefinierte Abschluss-Closures (und viele haben abhängig von der Operation Zwischen-Closures). CloudKit verfügt über eine eigene CKError
-Klasse, die von Error
abgeleitet ist, aber Sie müssen sich der Möglichkeit bewusst sein, dass auch andere Fehler auftreten. Schließlich ist einer der wichtigsten Aspekte jeder Operation der qualityOfService
Wert. Aufgrund von Netzwerklatenz, Flugzeugmodus und dergleichen verarbeitet CloudKit intern Wiederholungen und dergleichen für Vorgänge mit einer qualityOfService
von „Utility“ oder niedriger. Je nach Kontext möchten Sie möglicherweise eine höhere qualityOfService
zuweisen und diese Situationen selbst handhaben.
Einmal eingerichtet, werden Operationen an das CKDatabase
Objekt übergeben, wo sie in einem Hintergrund-Thread ausgeführt werden.
// 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) }
Erstellen eines Abonnements
Abonnements sind eine der wertvollsten CloudKit-Funktionen. Sie bauen auf der Benachrichtigungsinfrastruktur von Apple auf, damit verschiedene Clients Push-Benachrichtigungen erhalten können, wenn bestimmte CloudKit-Änderungen auftreten. Dies können normale Push-Benachrichtigungen sein, die iOS-Benutzern vertraut sind (z. B. Sound, Banner oder Abzeichen), oder in CloudKit können sie eine spezielle Benachrichtigungsklasse sein, die als Silent Pushs bezeichnet wird. Diese stillen Pushs erfolgen vollständig ohne Sichtbarkeit oder Interaktion des Benutzers und erfordern daher nicht, dass der Benutzer Push-Benachrichtigungen für Ihre App aktiviert, was Ihnen als App-Entwickler viele potenzielle Kopfschmerzen bei der Benutzererfahrung erspart.
Sie können diese stillen Benachrichtigungen aktivieren, indem Sie die Eigenschaft shouldSendContentAvailable
in der CKNotificationInfo
Instanz festlegen und dabei alle herkömmlichen Benachrichtigungseinstellungen ( shouldBadge
, soundName
usw.) nicht festlegen.
Beachten Sie auch, dass ich ein CKQuerySubscription
mit einem sehr einfachen „always true“-Prädikat verwende, um auf Änderungen an dem einen (und einzigen) Note-Datensatz zu achten. In einer komplexeren Anwendung möchten Sie möglicherweise das Prädikat nutzen, um den Bereich eines bestimmten CKQuerySubscription
, und Sie möchten möglicherweise die anderen unter CloudKit verfügbaren Abonnementtypen wie CKDatabaseSuscription
.
Beachten Sie schließlich, dass Sie einen zwischengespeicherten UserDefaults
Wert verwenden können, um zu vermeiden, dass das Abonnement unnötigerweise mehr als einmal gespeichert wird. Es schadet nicht, es einzustellen, aber Apple empfiehlt, sich zu bemühen, dies zu vermeiden, da es Netzwerk- und Serverressourcen verschwendet.
// 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) }
Aufzeichnungen laden
Das Abrufen eines Datensatzes nach Namen ist sehr einfach. Sie können sich den Namen als Primärschlüssel des Datensatzes im Sinne einer einfachen Datenbank vorstellen (Namen müssen beispielsweise eindeutig sein). Die tatsächliche CKRecordID
ist etwas komplizierter, da sie die zoneID
enthält.
Die CKFetchRecordsOperation
einen oder mehrere Datensätze gleichzeitig. In diesem Beispiel gibt es nur einen Datensatz, aber für die zukünftige Erweiterbarkeit ist dies ein großer potenzieller Leistungsvorteil.
// 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) }
Aufzeichnungen speichern
Das Speichern von Datensätzen ist vielleicht der komplizierteste Vorgang. Das einfache Schreiben eines Datensatzes in die Datenbank ist recht einfach, aber in meinem Beispiel mit mehreren Clients werden Sie hier mit dem potenziellen Problem konfrontiert, einen Konflikt zu behandeln, wenn mehrere Clients gleichzeitig versuchen, auf den Server zu schreiben. Glücklicherweise ist CloudKit explizit darauf ausgelegt, mit dieser Bedingung umzugehen. Es lehnt bestimmte Anfragen mit genügend Fehlerkontext in der Antwort ab, damit jeder Client eine lokale, aufgeklärte Entscheidung darüber treffen kann, wie der Konflikt gelöst werden soll.
Obwohl dies den Client komplexer macht, ist es letztendlich eine weitaus bessere Lösung, als Apple einen der wenigen serverseitigen Mechanismen zur Konfliktlösung entwickeln zu lassen.
Der App-Designer ist immer am besten in der Lage, Regeln für diese Situationen zu definieren, die alles von der kontextbewussten automatischen Zusammenführung bis hin zu benutzergesteuerten Lösungsanweisungen umfassen können. Ich werde in meinem Beispiel nicht sehr ausgefallen sein; Ich verwende das modified
Feld, um zu erklären, dass das neueste Update gewinnt. Dies ist möglicherweise nicht immer das beste Ergebnis für professionelle Apps, aber es ist nicht schlecht für eine erste Regel und dient zu diesem Zweck dazu, den Mechanismus zu veranschaulichen, mit dem CloudKit Konfliktinformationen an den Client zurückgibt.
Beachten Sie, dass dieser Konfliktlösungsschritt in meiner Beispielanwendung in der CloudKitNote
-Klasse stattfindet, die später beschrieben wird.
// 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) }
Umgang mit Benachrichtigungen über aktualisierte Datensätze
CloudKit-Benachrichtigungen bieten die Möglichkeit, herauszufinden, wann Datensätze von einem anderen Client aktualisiert wurden. Netzwerkbedingungen und Leistungseinschränkungen können jedoch dazu führen, dass einzelne Benachrichtigungen gelöscht werden oder dass mehrere Benachrichtigungen absichtlich zu einer einzigen Client-Benachrichtigung zusammengeführt werden. Da die Benachrichtigungen von CloudKit auf dem iOS-Benachrichtigungssystem aufbauen, müssen Sie nach diesen Bedingungen Ausschau halten.

CloudKit gibt Ihnen jedoch die Werkzeuge an die Hand, die Sie dafür benötigen.
Anstatt sich auf einzelne Benachrichtigungen zu verlassen, um Ihnen detaillierte Informationen darüber zu geben, welche Änderung eine einzelne Benachrichtigung darstellt, verwenden Sie eine Benachrichtigung, um einfach anzugeben, dass sich etwas geändert hat, und dann können Sie CloudKit fragen, was sich geändert hat, seit Sie das letzte Mal gefragt haben. In meinem Beispiel mache ich das mit CKFetchRecordZoneChangesOperation
und CKServerChangeTokens
. Änderungstoken können Sie sich wie ein Lesezeichen vorstellen, das Ihnen mitteilt, wo Sie sich befanden, bevor die letzte Abfolge von Änderungen stattfand.
// 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) }
Sie verfügen jetzt über die Low-Level-Bausteine zum Lesen und Schreiben von Datensätzen und zum Verarbeiten von Benachrichtigungen über Datensatzänderungen.
Sehen wir uns nun eine darauf aufbauende Ebene an, um diese Operationen im Kontext einer bestimmten Notiz zu verwalten.
Die CloudKitNote
Klasse
Für den Anfang können einige benutzerdefinierte Fehler definiert werden, um den Client vor den Interna von CloudKit zu schützen, und ein einfaches Delegiertenprotokoll kann den Client über Remote-Updates der zugrunde liegenden Note-Daten informieren.
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... } // … }
Zuordnung von CKRecord
zu Note
In Swift kann über den Indexoperator auf einzelne Felder eines CKRecord
zugegriffen werden. Die Werte entsprechen alle CKRecordValue
, aber diese gehören wiederum immer zu einer bestimmten Teilmenge bekannter Datentypen: NSString
, NSNumber
, NSDate
und so weiter.
Außerdem bietet CloudKit einen speziellen Datensatztyp für „große“ binäre Objekte. Es wird kein spezifischer Grenzwert angegeben (für jeden CKRecord
wird insgesamt maximal 1 MB empfohlen), aber als Faustregel gilt so ziemlich alles, was sich eher wie ein unabhängiges Element anfühlt (ein Bild, ein Ton, ein Textklecks). ein Datenbankfeld sollte wahrscheinlich als CKAsset
gespeichert werden. Diese Vorgehensweise ermöglicht es CloudKit, die Netzwerkübertragung und die serverseitige Speicherung dieser Art von Elementen besser zu verwalten.
In diesem Beispiel verwenden Sie CKAsset, um den CKAsset
zu speichern. CKAsset
-Daten werden über lokale temporäre Dateien behandelt, die die entsprechenden Daten enthalten.
// 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) } }
Laden einer Notiz
Das Laden einer Notiz ist sehr einfach. Sie führen ein wenig erforderliche Fehlerprüfung durch und rufen dann einfach die tatsächlichen Daten aus dem CKRecord
ab und speichern die Werte in Ihren Mitgliedsfeldern.
// 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) } }
Eine Notiz speichern und potenzielle Konflikte lösen
Beim Speichern einer Notiz sind einige besondere Situationen zu beachten.
Zunächst müssen Sie sicherstellen, dass Sie mit einem gültigen CKRecord
. Sie fragen CloudKit, ob dort bereits ein Datensatz vorhanden ist, und wenn nicht, erstellen Sie einen neuen lokalen CKRecord
, der für die nachfolgende Speicherung verwendet wird.
Wenn Sie CloudKit bitten, den Datensatz zu speichern, müssen Sie hier möglicherweise einen Konflikt behandeln, weil ein anderer Client den Datensatz seit dem letzten Abruf aktualisiert hat. Teilen Sie in Erwartung dessen die Speicherfunktion in zwei Schritte auf. Der erste Schritt führt eine einmalige Einrichtung zur Vorbereitung des Schreibens des Datensatzes durch, und der zweite Schritt übergibt den zusammengestellten Datensatz an die Singleton- CloudKitNoteDatabase
-Klasse. Dieser zweite Schritt kann im Konfliktfall wiederholt werden.
Im Falle eines Konflikts gibt Ihnen CloudKit im zurückgegebenen CKError
drei vollständige CKRecord
s, mit denen Sie arbeiten können:
- Die vorherige Version des Datensatzes, den Sie zu speichern versucht haben,
- Die genaue Version des Datensatzes, den Sie zu speichern versucht haben,
- Die Version, die der Server zum Zeitpunkt des Absendens der Anfrage gespeichert hat.
Indem Sie sich die modified
Felder dieser Datensätze ansehen, können Sie entscheiden, welcher Datensatz zuerst aufgetreten ist und welche Daten daher aufbewahrt werden sollen. Bei Bedarf übergeben Sie dann den aktualisierten Serverdatensatz an CloudKit, um den neuen Datensatz zu schreiben. Natürlich könnte dies zu einem weiteren Konflikt führen (wenn ein anderes Update dazwischen kam), aber dann wiederholen Sie den Vorgang einfach, bis Sie ein erfolgreiches Ergebnis erhalten.
In dieser einfachen Note-Anwendung, bei der ein einzelner Benutzer zwischen den Geräten wechselt, werden Sie wahrscheinlich nicht zu viele Konflikte im Sinne einer „Live-Parallelität“ sehen. Solche Konflikte können sich jedoch aus anderen Umständen ergeben. Beispielsweise kann ein Benutzer im Flugmodus auf einem Gerät Änderungen vorgenommen haben und dann geistesabwesend andere Änderungen auf einem anderen Gerät vorgenommen haben, bevor er den Flugmodus auf dem ersten Gerät deaktiviert hat.
Bei Cloud-basierten Datenaustauschanwendungen ist es äußerst wichtig, nach allen möglichen Szenarien Ausschau zu halten.
// 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) } }
Behandlung einer Benachrichtigung über eine entfernt geänderte Notiz
Wenn eine Benachrichtigung eingeht, dass sich ein Datensatz geändert hat, CloudKitNoteDatabase
die schwere Aufgabe, die Änderungen aus CloudKit abzurufen. In diesem Beispielfall wird es nur ein Notizdatensatz sein, aber es ist nicht schwer zu erkennen, wie dies auf eine Reihe verschiedener Datensatztypen und -instanzen erweitert werden könnte.
Zum Beispiel habe ich eine grundlegende Plausibilitätsprüfung eingefügt, um sicherzustellen, dass ich den richtigen Datensatz aktualisiere, und dann die Felder aktualisiere und den Delegierten benachrichtige, dass wir neue Daten haben.
// 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.
Los geht's! 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.