Un ghid pentru CloudKit: Cum să sincronizați datele utilizatorului pe dispozitivele iOS
Publicat: 2022-03-11În zilele noastre, dezvoltarea de aplicații mobile moderne necesită un plan bine gândit pentru a menține datele utilizatorilor sincronizate pe diferite dispozitive. Aceasta este o problemă spinoasă cu multe probleme și capcane, dar utilizatorii se așteaptă la funcția și se așteaptă să funcționeze bine.
Pentru iOS și macOS, Apple oferă un set de instrumente robust, numit CloudKit API, care permite dezvoltatorilor care vizează platformele Apple să rezolve această problemă de sincronizare.
În acest articol, voi demonstra cum să utilizați CloudKit pentru a menține datele unui utilizator sincronizate între mai mulți clienți. Este destinat dezvoltatorilor iOS cu experiență care sunt deja familiarizați cu cadrele Apple și cu Swift. Voi face o scufundare tehnică destul de profundă în API-ul CloudKit pentru a explora modalități în care puteți folosi această tehnologie pentru a crea aplicații minunate pentru mai multe dispozitive. Mă voi concentra pe o aplicație iOS, dar aceeași abordare poate fi folosită și pentru clienții macOS.
Exemplul nostru de caz de utilizare este o aplicație simplă de note cu doar o singură notă, în scop ilustrativ. Pe parcurs, voi arunca o privire la unele dintre aspectele mai complicate ale sincronizării datelor bazate pe cloud, inclusiv gestionarea conflictelor și comportamentul inconsecvent al stratului de rețea.
Ce este CloudKit?
CloudKit este construit pe deasupra serviciului iCloud de la Apple. Este corect să spunem că iCloud a avut un început puțin dificil. O tranziție stângace de la MobileMe, performanța slabă și chiar unele preocupări legate de confidențialitate au ținut sistemul înapoi în primii ani.
Pentru dezvoltatorii de aplicații, situația a fost și mai rea. Înainte de CloudKit, comportamentul inconsecvent și instrumentele slabe de depanare făceau aproape imposibilă livrarea unui produs de calitate superioară folosind API-urile iCloud de prima generație.
De-a lungul timpului, însă, Apple a abordat aceste probleme. În special, după lansarea SDK-ului CloudKit în 2014, dezvoltatorii terți au o soluție tehnică robustă și complet pentru partajarea datelor bazate pe cloud între dispozitive (inclusiv aplicații macOS și chiar clienți bazați pe web).
Deoarece CloudKit este strâns legat de sistemele de operare și dispozitivele Apple, nu este potrivit pentru aplicațiile care necesită o gamă mai largă de suport pentru dispozitive, cum ar fi clienții Android sau Windows. Cu toate acestea, pentru aplicațiile care sunt direcționate către baza de utilizatori Apple, oferă un mecanism profund puternic pentru autentificarea utilizatorilor și sincronizarea datelor.
Configurare de bază CloudKit
CloudKit organizează datele printr-o ierarhie de clase: CKContainer
, CKDatabase
, CKRecordZone
și CKRecord
.
La nivelul superior se află CKContainer
, care încapsulează un set de date asociate CloudKit. Fiecare aplicație primește automat un CKContainer
implicit, iar un grup de aplicații poate partaja un CKContainer
personalizat dacă setările de permisiune permit. Acest lucru poate permite câteva fluxuri de lucru interesante între aplicații.
În fiecare CKContainer
există mai multe instanțe ale CKDatabase
. CloudKit configurează automat fiecare aplicație activată pentru CloudKit pentru a avea o CKDatabase
publică (toți utilizatorii aplicației pot vedea totul) și o CKDatabase
privată (fiecare utilizator își vede doar propriile date). Și, începând cu iOS 10, o CKDatabase
partajată în care grupurile controlate de utilizatori pot partaja articole între membrii grupului.
Într-o bază de date CKRecordZone
CKDatabase
în zonele CKRecord
. Puteți citi și scrie înregistrări, puteți căuta înregistrări care corespund unui set de criterii și (cel mai important) puteți primi notificări cu privire la modificările la oricare dintre cele de mai sus.
Pentru aplicația Note, puteți utiliza containerul implicit. În acest container, veți folosi baza de date privată (pentru că doriți ca nota utilizatorului să fie văzută numai de acel utilizator) și în baza de date privată, veți folosi o zonă de înregistrare personalizată, care permite notificarea anumitor înregistrează modificări.
Nota va fi stocată ca un singur CKRecord
cu câmpuri text
, modified
(DateTime) și version
. CloudKit urmărește automat o valoare internă modified
, dar doriți să puteți cunoaște timpul real de modificare, inclusiv cazurile offline, în scopul rezolvării conflictelor. Câmpul de version
este pur și simplu o ilustrare a bunei practici pentru verificarea upgrade-ului, ținând cont de faptul că un utilizator cu mai multe dispozitive poate să nu actualizeze aplicația pe toate în același timp, așa că există un anumit apel la apărare.
Construirea aplicației Note
Presupun că vă pricepeți bine la elementele de bază ale creării de aplicații iOS în Xcode. Dacă doriți, puteți descărca și examina exemplul de proiect Note App Xcode creat pentru acest tutorial.
Pentru scopurile noastre, va fi suficientă o singură aplicație de vizualizare care conține un UITextView
cu ViewController
ca delegat. La nivel conceptual, doriți să declanșați o actualizare a înregistrărilor CloudKit ori de câte ori textul se modifică. Cu toate acestea, din punct de vedere practic, este logic să folosiți un fel de mecanism de coalescere a schimbărilor, cum ar fi un temporizator de fundal care se declanșează periodic, pentru a evita spam-ul serverelor iCloud cu prea multe modificări mici.
Aplicația CloudKit necesită activarea câtorva elemente în panoul de capabilități al țintei Xcode: iCloud (în mod firesc), inclusiv caseta de validare CloudKit, notificări push și moduri de fundal (în special, notificări de la distanță).
Pentru funcționalitatea CloudKit, am împărțit lucrurile în două clase: un singleton CloudKitNoteDatabase
de nivel inferior și o clasă CloudKitNote
de nivel superior.
Dar mai întâi, o discuție rapidă despre erorile CloudKit.
Erori CloudKit
Gestionarea atentă a erorilor este absolut esențială pentru un client CloudKit.
Deoarece este un API bazat pe rețea, este susceptibil la o serie întreagă de probleme de performanță și disponibilitate. De asemenea, serviciul în sine trebuie să protejeze împotriva unei game de probleme potențiale, cum ar fi solicitări neautorizate, modificări conflictuale și altele asemenea.
CloudKit furnizează o gamă completă de coduri de eroare, cu informații însoțitoare, pentru a permite dezvoltatorilor să gestioneze diferite cazuri marginale și, acolo unde este necesar, să ofere explicații detaliate utilizatorului despre posibilele probleme.
De asemenea, mai multe operațiuni CloudKit pot returna o eroare ca valoare de eroare unică sau o eroare compusă semnificată la nivelul superior ca partialFailure
. Vine cu un dicționar al CKError
care merită o inspecție mai atentă pentru a afla ce s-a întâmplat exact în timpul unei operațiuni compuse.
Pentru a ajuta la navigarea în parte din această complexitate, puteți extinde CKError
cu câteva metode de ajutor.
Vă rugăm să rețineți că tot codul are comentarii explicative la punctele cheie.
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) } }
CloudKitNoteDatabase
Singleton
Apple oferă două niveluri de funcționalitate în CloudKit SDK: funcții „conveniente” de nivel înalt, cum ar fi fetch()
, save()
și delete()
și constructe de operații de nivel inferior cu nume greoaie, cum ar fi CKModifyRecordsOperation
.
API-ul comod este mult mai accesibil, în timp ce abordarea operațională poate fi puțin intimidantă. Cu toate acestea, Apple îndeamnă cu tărie dezvoltatorii să folosească operațiunile mai degrabă decât metodele comode.
Operațiunile CloudKit oferă un control superior asupra detaliilor despre modul în care CloudKit își desfășoară activitatea și, poate mai important, forțează cu adevărat dezvoltatorul să se gândească cu atenție la comportamentele de rețea esențiale pentru tot ceea ce face CloudKit. Din aceste motive, folosesc operațiunile din aceste exemple de cod.
Clasa ta singleton va fi responsabilă pentru fiecare dintre aceste operațiuni CloudKit pe care le vei folosi. De fapt, într-un fel, recreezi API-urile comode. Dar, implementându-le singur pe baza API-ului Operation, vă puneți într-un loc bun pentru a personaliza comportamentul și a vă ajusta răspunsurile de tratare a erorilor. De exemplu, dacă doriți să extindeți această aplicație pentru a gestiona mai multe note și nu doar una, ați putea face acest lucru mai ușor (și cu o performanță mai mare rezultată) decât dacă ați fi folosit doar API-urile 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? // ... }
Crearea unei zone personalizate
CloudKit creează automat o zonă implicită pentru baza de date privată. Cu toate acestea, puteți obține mai multe funcționalități dacă utilizați o zonă personalizată, în special, suport pentru preluarea modificărilor incrementale ale înregistrărilor.
Deoarece acesta este un prim exemplu de utilizare a unei operații, iată câteva observații generale:
În primul rând, toate operațiunile CloudKit au închideri personalizate de finalizare (și multe au închideri intermediare, în funcție de operațiune). CloudKit are propria sa clasă CKError
, derivată din Error
, dar trebuie să fiți conștient de posibilitatea ca și alte erori să apară. În cele din urmă, unul dintre cele mai importante aspecte ale oricărei operațiuni este valoarea qualityOfService
. Datorită latenței rețelei, modului avion și altele asemenea, qualityOfService
va gestiona intern reîncercări și altele pentru operațiuni la o calitate de „utilitate” sau mai mică. În funcție de context, este posibil să doriți să atribuiți un qualityOfService
mai ridicat și să gestionați singur aceste situații.
Odată configurate, operațiunile sunt transmise obiectului CKDatabase
, unde vor fi executate pe un thread de fundal.
// 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) }
Crearea unui abonament
Abonamentele sunt una dintre cele mai valoroase funcții CloudKit. Acestea se bazează pe infrastructura de notificări a Apple pentru a permite diverșilor clienți să primească notificări push atunci când apar anumite modificări CloudKit. Acestea pot fi notificări push normale, familiare utilizatorilor iOS (cum ar fi sunet, banner sau insignă), sau în CloudKit, pot fi o clasă specială de notificări numită push silențios . Aceste push silențioase au loc în totalitate fără vizibilitatea sau interacțiunea utilizatorului și, prin urmare, nu necesită ca utilizatorul să activeze notificarea push pentru aplicația dvs., scutindu-vă de multe bătăi de cap potențiale ale experienței utilizatorului ca dezvoltator de aplicații.
Modul de a activa aceste notificări silențioase este să setați proprietatea shouldSendContentAvailable
pe instanța CKNotificationInfo
, lăsând în același timp toate setările tradiționale de notificare ( shouldBadge
, soundName
și așa mai departe) nesetate.
Rețineți, de asemenea, că folosesc un CKQuerySubscription
cu un predicat „întotdeauna adevărat” foarte simplu pentru a urmări modificările în singura (și singura) înregistrare de notă. Într-o aplicație mai sofisticată, este posibil să doriți să profitați de predicat pentru a restrânge domeniul de aplicare al unui anumit CKQuerySubscription
și poate doriți să examinați celelalte tipuri de abonament disponibile în CloudKit, cum ar fi CKDatabaseSuscription
.
În cele din urmă, observați că puteți utiliza o valoare din UserDefaults
pentru a evita salvarea inutilă a abonamentului de mai multe ori. Nu există niciun rău enorm în setarea acestuia, dar Apple recomandă să depuneți eforturi pentru a evita acest lucru, deoarece irosește resursele de rețea și 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) }
Încărcare înregistrări
Preluarea unei înregistrări după nume este foarte simplă. Vă puteți gândi la nume ca fiind cheia primară a înregistrării într-un sens simplu de bază de date (numele trebuie să fie unice, de exemplu). CKRecordID
real este un pic mai complicat prin faptul că include zoneID
.
CKFetchRecordsOperation
operează pe una sau mai multe înregistrări simultan. În acest exemplu, există doar o singură înregistrare, dar pentru extinderea viitoare, acesta este un mare potențial beneficiu de performanță.
// 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) }
Salvarea înregistrărilor
Salvarea înregistrărilor este, poate, cea mai complicată operațiune. Simplul act de a scrie o înregistrare în baza de date este destul de simplu, dar în exemplul meu, cu mai mulți clienți, aici te vei confrunta cu potențiala problemă de a gestiona un conflict atunci când mai mulți clienți încearcă să scrie simultan pe server. Din fericire, CloudKit este conceput în mod explicit pentru a gestiona această condiție. Respinge cereri specifice cu suficient context de eroare în răspuns pentru a permite fiecărui client să ia o decizie locală, luminată, despre cum să rezolve conflictul.
Deși acest lucru adaugă complexitate clientului, este în cele din urmă o soluție mult mai bună decât ca Apple să vină cu unul dintre puținele mecanisme de pe partea serverului pentru rezolvarea conflictelor.
Designerul aplicației este întotdeauna în cea mai bună poziție pentru a defini reguli pentru aceste situații, care pot include totul, de la îmbinarea automată în funcție de context până la instrucțiuni de rezoluție direcționate de utilizator. Nu voi deveni foarte elegant în exemplul meu; Folosesc câmpul modified
pentru a declara că cea mai recentă actualizare câștigă. Acesta poate să nu fie întotdeauna cel mai bun rezultat pentru aplicațiile profesionale, dar nu este rău pentru o primă regulă și, în acest scop, servește la ilustrarea mecanismului prin care CloudKit transmite informațiile de conflict înapoi către client.
Rețineți că, în aplicația mea exemplu, acest pas de rezolvare a conflictului are loc în clasa CloudKitNote
, descrisă mai târziu.
// 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) }
Gestionarea notificării înregistrărilor actualizate
Notificările CloudKit oferă mijloacele de a afla când înregistrările au fost actualizate de un alt client. Cu toate acestea, condițiile rețelei și constrângerile de performanță pot face ca notificările individuale să fie abandonate sau mai multe notificări să se unească în mod intenționat într-o singură notificare pentru client. Deoarece notificările lui CloudKit sunt construite pe deasupra sistemului de notificare iOS, trebuie să fiți atenți la aceste condiții.

Cu toate acestea, CloudKit vă oferă instrumentele de care aveți nevoie pentru aceasta.
În loc să vă bazați pe notificări individuale pentru a vă oferi cunoștințe detaliate despre schimbarea pe care o reprezintă o notificare individuală, utilizați o notificare pentru a indica pur și simplu că ceva s-a schimbat și apoi puteți întreba CloudKit ce s-a schimbat de la ultima dată când ați întrebat. În exemplul meu, fac acest lucru folosind CKFetchRecordZoneChangesOperation
și CKServerChangeTokens
. Jetoanele de modificare pot fi considerate ca un marcaj care vă spune unde vă aflați înainte de a avea loc cea mai recentă secvență de modificări.
// 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) }
Acum aveți elementele de bază la nivel inferior pentru a citi și scrie înregistrări și pentru a gestiona notificările privind modificările înregistrărilor.
Să ne uităm acum la un strat construit pe deasupra pentru a gestiona aceste operațiuni în contextul unei note specifice.
Clasa CloudKitNote
Pentru început, pot fi definite câteva erori personalizate pentru a proteja clientul de elementele interne ale CloudKit, iar un simplu protocol delegat poate informa clientul cu privire la actualizările de la distanță ale datelor Note subiacente.
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... } // … }
Maparea de la CKRecord
la notă
În Swift, câmpurile individuale dintr-un CKRecord
pot fi accesate prin intermediul operatorului de indice. Toate valorile sunt conforme cu CKRecordValue
, dar acestea, la rândul lor, sunt întotdeauna unul dintr-un subset specific de tipuri de date familiare: NSString
, NSNumber
, NSDate
și așa mai departe.
De asemenea, CloudKit oferă un tip de înregistrare specific pentru obiectele binare „mari”. Nu este specificat nici un punct de limită specific (se recomandă maximum 1 MB în total pentru fiecare CKRecord
), dar, ca regulă generală, aproape orice se simte ca un element independent (o imagine, un sunet, o bucată de text) mai degrabă decât ca un câmp de bază de date ar trebui probabil să fie stocat ca un CKAsset
. Această practică permite CloudKit să gestioneze mai bine transferul de rețea și stocarea pe server a acestor tipuri de articole.
Pentru acest exemplu, veți folosi CKAsset
pentru a stoca textul notei. Datele CKAsset
sunt gestionate prin fișiere temporare locale care conțin datele corespunzătoare.
// 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) } }
Se încarcă o notă
Încărcarea unei note este foarte simplă. Faceți un pic de verificare a erorilor necesare și apoi pur și simplu preluați datele reale din CKRecord
și stocați valorile în câmpurile 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) } }
Salvarea unei note și rezolvarea unui conflict potențial
Există câteva situații speciale de care trebuie să fii conștient atunci când salvați o notă.
În primul rând, trebuie să vă asigurați că începeți de la un CKRecord
valid. Întrebați CloudKit dacă există deja o înregistrare acolo, iar dacă nu, creați un nou CKRecord
local pe care să îl utilizați pentru salvarea ulterioară.
Când cereți CloudKit să salveze înregistrarea, aici este posibil să trebuiască să gestionați un conflict din cauza actualizării de la un alt client de la ultima dată când ați preluat-o. În așteptarea acestui lucru, împărțiți funcția de salvare în doi pași. Primul pas face o configurare unică în pregătirea pentru scrierea înregistrării, iar al doilea pas transmite înregistrarea asamblată în clasa singleton CloudKitNoteDatabase
. Acest al doilea pas poate fi repetat în cazul unui conflict.
În cazul unui conflict, CloudKit vă oferă, în CKError
returnat, trei CKRecord
-uri complete cu care să lucrați:
- Versiunea anterioară a înregistrării pe care ați încercat să o salvați,
- Versiunea exactă a înregistrării pe care ați încercat să o salvați,
- Versiunea deținută de server în momentul în care ați trimis solicitarea.
Privind câmpurile modified
ale acestor înregistrări, puteți decide care înregistrare a apărut prima și, prin urmare, ce date să păstrați. Dacă este necesar, transmiteți apoi înregistrarea actualizată a serverului către CloudKit pentru a scrie noua înregistrare. Desigur, acest lucru ar putea duce la încă un conflict (dacă a apărut o altă actualizare între ele), dar apoi trebuie doar să repetați procesul până când obțineți un rezultat de succes.
În această aplicație simplă Note, cu un singur utilizator care comută între dispozitive, nu este probabil să vedeți prea multe conflicte în sensul „concurentei live”. Cu toate acestea, astfel de conflicte pot apărea din alte circumstanțe. De exemplu, este posibil ca un utilizator să fi făcut editări pe un dispozitiv în timp ce se afla în modul avion și apoi să fi făcut, distrat, modificări diferite pe alt dispozitiv înainte de a dezactiva modul avion pe primul dispozitiv.
În aplicațiile de partajare a datelor bazate pe cloud, este extrem de important să fii atent la fiecare scenariu posibil.
// 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) } }
Gestionarea notificării unei note modificate de la distanță
Când vine o notificare că o înregistrare s-a schimbat, CloudKitNoteDatabase
se va ocupa de preluarea modificărilor din CloudKit. În acest caz exemplu, va fi doar o înregistrare de notă, dar nu este greu de văzut cum aceasta ar putea fi extinsă la o gamă de tipuri și instanțe de înregistrări diferite.
De exemplu, am inclus o verificare de bază pentru a mă asigura că actualizez înregistrarea corectă, apoi actualizez câmpurile și anunțăm delegatul că avem date noi.
// 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.
Iată! 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.