Una guida a CloudKit: come sincronizzare i dati utente su dispositivi iOS
Pubblicato: 2022-03-11Al giorno d'oggi, lo sviluppo di moderne applicazioni mobili richiede un piano ben congegnato per mantenere i dati degli utenti sincronizzati su vari dispositivi. Questo è un problema spinoso con molti trucchi e insidie, ma gli utenti si aspettano la funzionalità e si aspettano che funzioni bene.
Per iOS e macOS, Apple fornisce un robusto toolkit, chiamato CloudKit API, che consente agli sviluppatori che prendono di mira le piattaforme Apple di risolvere questo problema di sincronizzazione.
In questo articolo dimostrerò come utilizzare CloudKit per mantenere sincronizzati i dati di un utente tra più client. È destinato a sviluppatori iOS esperti che hanno già familiarità con i framework Apple e con Swift. Farò un tuffo tecnico abbastanza approfondito nell'API CloudKit per esplorare i modi in cui puoi sfruttare questa tecnologia per creare fantastiche app multi-dispositivo. Mi concentrerò su un'applicazione iOS, ma lo stesso approccio può essere utilizzato anche per i client macOS.
Il nostro caso d'uso di esempio è una semplice applicazione di note con una sola nota, a scopo illustrativo. Lungo la strada, darò un'occhiata ad alcuni degli aspetti più complicati della sincronizzazione dei dati basata su cloud, inclusa la gestione dei conflitti e il comportamento incoerente del livello di rete.
Cos'è CloudKit?
CloudKit è basato sul servizio iCloud di Apple. È giusto dire che iCloud ha avuto un inizio un po' difficile. Una transizione goffa da MobileMe, prestazioni scadenti e persino alcuni problemi di privacy hanno frenato il sistema nei primi anni.
Per gli sviluppatori di app, la situazione era ancora peggiore. Prima di CloudKit, il comportamento incoerente e gli strumenti di debug deboli rendevano quasi impossibile fornire un prodotto di alta qualità utilizzando le API iCloud di prima generazione.
Nel tempo, tuttavia, Apple ha affrontato questi problemi. In particolare, dopo il rilascio dell'SDK CloudKit nel 2014, gli sviluppatori di terze parti dispongono di una soluzione tecnica completa e solida per la condivisione dei dati basata su cloud tra dispositivi (incluse applicazioni macOS e persino client basati sul Web).
Poiché CloudKit è profondamente legato ai sistemi operativi e ai dispositivi Apple, non è adatto per applicazioni che richiedono una gamma più ampia di supporto per dispositivi, come client Android o Windows. Per le app destinate alla base di utenti di Apple, tuttavia, fornisce un meccanismo estremamente potente per l'autenticazione degli utenti e la sincronizzazione dei dati.
Configurazione di base di CloudKit
CloudKit organizza i dati tramite una gerarchia di classi: CKContainer
, CKDatabase
, CKRecordZone
e CKRecord
.
Al livello più alto c'è CKContainer
, che incapsula un set di dati CloudKit correlati. Ogni app ottiene automaticamente un CKContainer
predefinito e un gruppo di app può condividere un CKContainer
personalizzato se le impostazioni di autorizzazione lo consentono. Ciò può abilitare alcuni interessanti flussi di lavoro tra applicazioni.
All'interno di ogni CKContainer
sono presenti più istanze di CKDatabase
. CloudKit configura automaticamente ogni app abilitata per CloudKit pronta all'uso per avere un CKDatabase
pubblico (tutti gli utenti dell'app possono vedere tutto) e un CKDatabase
privato (ogni utente vede solo i propri dati). E, a partire da iOS 10, un CKDatabase
condiviso in cui i gruppi controllati dall'utente possono condividere elementi tra i membri del gruppo.
All'interno di un CKDatabase
sono CKRecordZone
e all'interno delle zone CKRecord
s. Puoi leggere e scrivere record, cercare record che soddisfano una serie di criteri e (soprattutto) ricevere notifiche di modifiche a uno qualsiasi dei precedenti.
Per la tua app Note, puoi usare il contenitore predefinito. All'interno di questo contenitore, utilizzerai il database privato (perché desideri che la nota dell'utente venga vista solo da quell'utente) e all'interno del database privato, utilizzerai una zona record personalizzata, che abilita la notifica di specifici registrare le modifiche.
La nota verrà archiviata come un singolo CKRecord
con campi di text
, modified
(DateTime) e version
. CloudKit tiene automaticamente traccia di un valore modified
interno, ma vuoi essere in grado di conoscere l'ora modificata effettiva, inclusi i casi offline, ai fini della risoluzione dei conflitti. Il campo della version
è semplicemente un'illustrazione di buone pratiche per la verifica dell'aggiornamento, tenendo presente che un utente con più dispositivi potrebbe non aggiornare l'app su tutti contemporaneamente, quindi è necessario mettersi sulla difensiva.
Creazione dell'app Note
Presumo che tu abbia una buona padronanza delle basi della creazione di app iOS in Xcode. Se lo desideri, puoi scaricare ed esaminare il progetto di esempio Note App Xcode creato per questo tutorial.
Per i nostri scopi, sarà sufficiente un'applicazione a vista singola contenente un UITextView
con ViewController
come delegato. A livello concettuale, vuoi attivare un aggiornamento del record CloudKit ogni volta che il testo cambia. Tuttavia, in pratica, ha senso utilizzare una sorta di meccanismo di coalescenza delle modifiche, come un timer in background che si attiva periodicamente, per evitare di inviare spam ai server iCloud con troppe piccole modifiche.
L'app CloudKit richiede l'abilitazione di alcuni elementi nel riquadro delle funzionalità di Xcode Target: iCloud (naturalmente), inclusa la casella di controllo CloudKit, le notifiche push e le modalità in background (in particolare, le notifiche remote).
Per la funzionalità CloudKit, ho suddiviso le cose in due classi: una classe CloudKitNoteDatabase
di livello inferiore e una classe CloudKitNote
di livello superiore.
Ma prima, una rapida discussione sugli errori di CloudKit.
Errori di CloudKit
Un'attenta gestione degli errori è assolutamente essenziale per un client CloudKit.
Poiché si tratta di un'API basata sulla rete, è soggetta a tutta una serie di problemi di prestazioni e disponibilità. Inoltre, il servizio stesso deve proteggere da una serie di potenziali problemi, come richieste non autorizzate, modifiche in conflitto e simili.
CloudKit fornisce una gamma completa di codici di errore, con informazioni di accompagnamento, per consentire agli sviluppatori di gestire vari casi limite e, ove necessario, fornire spiegazioni dettagliate all'utente su possibili problemi.
Inoltre, diverse operazioni CloudKit possono restituire un errore come valore di errore singolo o un errore composto indicato al livello superiore come partialFailure
. Viene fornito con un dizionario di CKError
contenuti che meritano un'ispezione più attenta per capire cosa è successo esattamente durante un'operazione composta.
Per aiutare a navigare in parte di questa complessità puoi estendere CKError
con alcuni metodi di supporto.
Si prega di notare che tutto il codice ha commenti esplicativi nei punti chiave.
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) } }
Il CloudKitNoteDatabase
Apple fornisce due livelli di funzionalità nell'SDK CloudKit: funzioni di "convenienza" di alto livello, come fetch()
, save()
e delete()
e costrutti operativi di livello inferiore con nomi ingombranti, come CKModifyRecordsOperation
.
L'API di convenienza è molto più accessibile, mentre l'approccio operativo può intimidire un po'. Tuttavia, Apple esorta vivamente gli sviluppatori a utilizzare le operazioni piuttosto che i metodi di convenienza.
Le operazioni di CloudKit forniscono un controllo superiore sui dettagli di come CloudKit svolge il proprio lavoro e, forse ancora più importante, costringono davvero lo sviluppatore a riflettere attentamente sui comportamenti di rete fondamentali per tutto ciò che CloudKit fa. Per questi motivi, sto usando le operazioni in questi esempi di codice.
La tua classe singleton sarà responsabile di ciascuna di queste operazioni CloudKit che utilizzerai. In effetti, in un certo senso, stai ricreando le API di convenienza. Tuttavia, implementandoli tu stesso in base all'API operativa, ti metti in una buona posizione per personalizzare il comportamento e ottimizzare le risposte di gestione degli errori. Ad esempio, se desideri estendere questa app per gestire più note anziché una sola, potresti farlo più facilmente (e con prestazioni risultanti più elevate) rispetto a quando avessi appena utilizzato le comode API di 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? // ... }
Creazione di una zona personalizzata
CloudKit crea automaticamente una zona predefinita per il database privato. Tuttavia, puoi ottenere più funzionalità se usi una zona personalizzata, in particolare il supporto per il recupero delle modifiche incrementali dei record.
Poiché questo è un primo esempio di utilizzo di un'operazione, ecco un paio di osservazioni generali:
Innanzitutto, tutte le operazioni CloudKit hanno chiusure di completamento personalizzate (e molte hanno chiusure intermedie, a seconda dell'operazione). CloudKit ha la sua classe CKError
, derivata da Error
, ma devi essere consapevole della possibilità che si verifichino anche altri errori. Infine, uno degli aspetti più importanti di qualsiasi operazione è il valore qualityOfService
. A causa della latenza di rete, della modalità aereo e simili, qualityOfService
gestirà internamente i tentativi e simili per le operazioni con una qualità del servizio di "utilità" o inferiore. A seconda del contesto, potresti voler assegnare una qualità del servizio superiore e qualityOfService
queste situazioni da solo.
Una volta impostate, le operazioni vengono passate all'oggetto CKDatabase
, dove verranno eseguite su un thread in background.
// 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) }
Creazione di un abbonamento
Gli abbonamenti sono una delle funzionalità di CloudKit più preziose. Si basano sull'infrastruttura di notifica di Apple per consentire a vari client di ricevere notifiche push quando si verificano determinate modifiche di CloudKit. Queste possono essere normali notifiche push familiari agli utenti iOS (come suoni, banner o badge) oppure in CloudKit possono essere una classe speciale di notifiche chiamata push silenziosi . Questi push silenziosi si verificano interamente senza visibilità o interazione dell'utente e, di conseguenza, non richiedono all'utente di abilitare la notifica push per la tua app, risparmiandoti molti potenziali mal di testa relativi all'esperienza utente come sviluppatore di app.
Il modo per abilitare queste notifiche silenziose consiste nell'impostare la proprietà shouldSendContentAvailable
nell'istanza CKNotificationInfo
, lasciando tutte le impostazioni di notifica tradizionali ( shouldBadge
, soundName
e così via) non impostate.
Nota anche che sto usando un CKQuerySubscription
con un predicato "sempre vero" molto semplice per controllare le modifiche sull'unico (e unico) record di note. In un'applicazione più sofisticata, potresti voler sfruttare il predicato per restringere l'ambito di un particolare CKQuerySubscription
e potresti voler rivedere gli altri tipi di abbonamento disponibili in CloudKit, come CKDatabaseSuscription
.
Infine, osserva che puoi utilizzare un valore memorizzato nella cache UserDefaults
per evitare di salvare inutilmente l'abbonamento più di una volta. Non c'è alcun danno enorme nell'impostarlo, ma Apple consiglia di fare uno sforzo per evitarlo poiché spreca risorse di rete e server.
// 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) }
Caricamento dei record
Recuperare un record per nome è molto semplice. Puoi pensare al nome come alla chiave primaria del record in un semplice senso del database (i nomi devono essere univoci, ad esempio). L'effettivo CKRecordID
è un po' più complicato in quanto include lo zoneID
.
CKFetchRecordsOperation
opera su uno o più record alla volta. In questo esempio, c'è solo un record, ma per l'espandibilità futura, questo è un grande potenziale vantaggio in termini di prestazioni.
// 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) }
Salvataggio dei record
Il salvataggio dei record è, forse, l'operazione più complicata. Il semplice atto di scrivere un record nel database è abbastanza semplice, ma nel mio esempio, con più client, è qui che dovrai affrontare il potenziale problema della gestione di un conflitto quando più client tentano di scrivere sul server contemporaneamente. Per fortuna, CloudKit è esplicitamente progettato per gestire questa condizione. Rifiuta richieste specifiche con un contesto di errore sufficiente nella risposta per consentire a ciascun cliente di prendere una decisione locale e illuminata su come risolvere il conflitto.
Sebbene ciò aggiunga complessità al client, in definitiva è una soluzione di gran lunga migliore rispetto a quando Apple escogita uno dei pochi meccanismi lato server per la risoluzione dei conflitti.
Il progettista dell'app è sempre nella posizione migliore per definire regole per queste situazioni, che possono includere qualsiasi cosa, dall'unione automatica sensibile al contesto alle istruzioni di risoluzione dirette dall'utente. Non diventerò molto elegante nel mio esempio; Sto usando il campo modified
per dichiarare che l'aggiornamento più recente vince. Questo potrebbe non essere sempre il miglior risultato per le app professionali, ma non è male come prima regola e, a questo scopo, serve a illustrare il meccanismo attraverso il quale CloudKit ritrasmette le informazioni di conflitto al client.
Nota che, nella mia applicazione di esempio, questo passaggio di risoluzione dei conflitti avviene nella classe CloudKitNote
, descritta più avanti.
// 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) }
Gestione della notifica dei record aggiornati
Le notifiche CloudKit forniscono i mezzi per scoprire quando i record sono stati aggiornati da un altro client. Tuttavia, le condizioni della rete e i limiti delle prestazioni possono causare l'eliminazione di singole notifiche o l'unione intenzionale di più notifiche in un'unica notifica client. Poiché le notifiche di CloudKit sono basate sul sistema di notifica iOS, devi stare attento a queste condizioni.

Tuttavia, CloudKit ti offre gli strumenti necessari per questo.
Invece di fare affidamento sulle singole notifiche per darti una conoscenza dettagliata di quale modifica rappresenta una singola notifica, usi una notifica per indicare semplicemente che qualcosa è cambiato, quindi puoi chiedere a CloudKit cosa è cambiato dall'ultima volta che hai chiesto. Nel mio esempio, lo faccio usando CKFetchRecordZoneChangesOperation
e CKServerChangeTokens
. I token di modifica possono essere considerati come un segnalibro che ti dice dove ti trovavi prima che si verificasse la sequenza più recente di modifiche.
// 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) }
Ora hai gli elementi costitutivi di basso livello per leggere e scrivere record e per gestire le notifiche delle modifiche ai record.
Diamo ora un'occhiata a un livello costruito su quello per gestire queste operazioni nel contesto di una nota specifica.
La classe CloudKitNote
Per cominciare, è possibile definire alcuni errori personalizzati per proteggere il client dagli interni di CloudKit e un semplice protocollo delegato può informare il client degli aggiornamenti remoti dei dati Note sottostanti.
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... } // … }
Mappatura da CKRecord
a Nota
In Swift, è possibile accedere ai singoli campi di un CKRecord
tramite l'operatore pedice. I valori sono tutti conformi a CKRecordValue
, ma questi, a loro volta, fanno sempre parte di un sottoinsieme specifico di tipi di dati familiari: NSString
, NSNumber
, NSDate
e così via.
Inoltre, CloudKit fornisce un tipo di record specifico per oggetti binari "grandi". Non viene specificato alcun punto di interruzione specifico (si consiglia un massimo di 1 MB in totale per ogni CKRecord
), ma come regola pratica, qualsiasi cosa sembri un elemento indipendente (un'immagine, un suono, un blob di testo) piuttosto che come un campo del database dovrebbe probabilmente essere archiviato come CKAsset
. Questa pratica consente a CloudKit di gestire meglio il trasferimento di rete e l'archiviazione lato server di questi tipi di elementi.
Per questo esempio, utilizzerai CKAsset
per memorizzare il testo della nota. I dati CKAsset
vengono gestiti tramite file temporanei locali contenenti i dati corrispondenti.
// 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) } }
Caricamento di una nota
Il caricamento di una nota è molto semplice. Fai un po' di controllo degli errori necessario, quindi prendi semplicemente i dati effettivi dal CKRecord
e memorizzi i valori nei campi dei membri.
// 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) } }
Salvataggio di una nota e risoluzione di potenziali conflitti
Ci sono un paio di situazioni speciali di cui tenere conto quando si salva una nota.
Prima di tutto, devi assicurarti di partire da un CKRecord
valido. Chiedi a CloudKit se c'è già un record lì e, in caso contrario, crei un nuovo CKRecord
locale da utilizzare per il salvataggio successivo.
Quando chiedi a CloudKit di salvare il record, è qui che potresti dover gestire un conflitto dovuto all'aggiornamento del record da parte di un altro client dall'ultima volta che lo hai recuperato. In previsione di ciò, dividere la funzione di salvataggio in due passaggi. Il primo passaggio esegue una configurazione una tantum in preparazione per la scrittura del record e il secondo passaggio passa il record assemblato alla classe CloudKitNoteDatabase
singleton. Questo secondo passaggio può essere ripetuto in caso di conflitto.
In caso di conflitto, CloudKit fornisce, nel CKError
restituito, tre CKRecord
completi con cui lavorare:
- La versione precedente del record che hai tentato di salvare,
- La versione esatta del record che hai tentato di salvare,
- La versione in possesso del server al momento dell'invio della richiesta.
Osservando i campi modified
di questi record, puoi decidere quale record si è verificato per primo e quindi quali dati conservare. Se necessario, trasferisci il record del server aggiornato a CloudKit per scrivere il nuovo record. Ovviamente, ciò potrebbe causare un altro conflitto (se si verificasse un altro aggiornamento in mezzo), ma poi ripeti semplicemente il processo finché non ottieni un risultato positivo.
In questa semplice applicazione Note, con un singolo utente che passa da un dispositivo all'altro, è probabile che non vedrai troppi conflitti in un senso di "concorrenza in tempo reale". Tuttavia, tali conflitti possono derivare da altre circostanze. Ad esempio, un utente potrebbe aver apportato modifiche su un dispositivo mentre era in modalità aereo e poi aver apportato distrattamente modifiche diverse su un altro dispositivo prima di disattivare la modalità aereo sul primo dispositivo.
Nelle applicazioni di condivisione dei dati basate su cloud, è estremamente importante prestare attenzione a ogni possibile scenario.
// 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) } }
Gestione della notifica di una nota modificata in remoto
Quando arriva una notifica che un record è stato modificato, CloudKitNoteDatabase
eseguirà il lavoro pesante per recuperare le modifiche da CloudKit. In questo caso di esempio, sarà solo un record di note, ma non è difficile vedere come questo possa essere esteso a una gamma di diversi tipi di record e istanze.
Ad esempio, ho incluso un controllo di integrità di base per assicurarmi di aggiornare il record corretto, quindi aggiornare i campi e notificare al delegato che abbiamo nuovi dati.
// 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.
Ecco qua! 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.