Przewodnik po CloudKit: Jak synchronizować dane użytkownika na urządzeniach z iOS

Opublikowany: 2022-03-11

W dzisiejszych czasach tworzenie nowoczesnych aplikacji mobilnych wymaga dobrze przemyślanego planu utrzymywania synchronizacji danych użytkownika na różnych urządzeniach. Jest to drażliwy problem z wieloma pułapkami i pułapkami, ale użytkownicy oczekują tej funkcji i oczekują, że będzie działać dobrze.

W przypadku systemów iOS i macOS firma Apple zapewnia solidny zestaw narzędzi o nazwie CloudKit API, który umożliwia programistom atakującym platformy Apple rozwiązanie tego problemu z synchronizacją.

W tym artykule pokażę, jak używać CloudKit, aby zsynchronizować dane użytkownika między wieloma klientami. Jest przeznaczony dla doświadczonych programistów iOS, którzy znają już frameworki Apple i Swift. Zajmę się dość głębokim technicznym zanurzeniem się w API CloudKit, aby zbadać sposoby wykorzystania tej technologii do tworzenia niesamowitych aplikacji na wiele urządzeń. Skoncentruję się na aplikacji na iOS, ale to samo podejście można zastosować również w przypadku klientów macOS.

Nasz przykładowy przypadek użycia to prosta aplikacja do obsługi notatek z tylko jedną notatką w celach ilustracyjnych. Po drodze przyjrzę się niektórym trudniejszym aspektom synchronizacji danych w chmurze, w tym obsłudze konfliktów i niespójnym zachowaniu warstwy sieciowej.

Używanie CloudKit do synchronizacji danych użytkownika między wieloma klientami

Co to jest CloudKit?

CloudKit jest zbudowany na bazie usługi iCloud firmy Apple. Można śmiało powiedzieć, że iCloud zaczęło się trochę niepewnie. Niezręczne przejście z MobileMe, słaba wydajność, a nawet pewne obawy dotyczące prywatności, powstrzymały system we wczesnych latach.

W przypadku twórców aplikacji sytuacja była jeszcze gorsza. Przed CloudKit niespójne zachowanie i słabe narzędzia do debugowania prawie uniemożliwiały dostarczenie produktu najwyższej jakości przy użyciu interfejsów API iCloud pierwszej generacji.

Z czasem jednak Apple rozwiązało te problemy. W szczególności po wydaniu zestawu SDK CloudKit w 2014 r. zewnętrzni programiści mają w pełni funkcjonalne, solidne rozwiązanie techniczne do udostępniania danych w chmurze między urządzeniami (w tym z aplikacjami macOS, a nawet klientami sieciowymi).

Ponieważ CloudKit jest głęboko powiązany z systemami operacyjnymi i urządzeniami Apple, nie nadaje się do aplikacji, które wymagają szerszego zakresu obsługi urządzeń, takich jak klienci Androida lub Windows. W przypadku aplikacji skierowanych do bazy użytkowników Apple zapewnia jednak bardzo potężny mechanizm uwierzytelniania użytkowników i synchronizacji danych.

Podstawowa konfiguracja CloudKit

CloudKit organizuje dane poprzez hierarchię klas: CKContainer , CKDatabase , CKRecordZone i CKRecord .

Na najwyższym poziomie znajduje się CKContainer , który zawiera zestaw powiązanych danych CloudKit. Każda aplikacja automatycznie otrzymuje domyślny CKContainer , a grupa aplikacji może współdzielić niestandardowy CKContainer , jeśli pozwalają na to ustawienia uprawnień. To może umożliwić kilka interesujących przepływów pracy między aplikacjami.

W każdym CKContainer znajduje się wiele wystąpień CKDatabase . CloudKit automatycznie konfiguruje każdą aplikację obsługującą CloudKit, aby mieć publiczną CKDatabase (wszyscy użytkownicy aplikacji widzą wszystko) i prywatną bazę danych CKDatabase (każdy użytkownik widzi tylko własne dane). Oraz, począwszy od iOS 10, współdzieloną bazę danych CKDatabase , w której grupy kontrolowane przez użytkowników mogą udostępniać elementy członkom grupy.

W bazie CKDatabase znajdują się strefy CKRecordZone , aw strefach CKRecord . Możesz czytać i zapisywać rekordy, wyszukiwać rekordy spełniające zestaw kryteriów i (co najważniejsze) otrzymywać powiadomienia o zmianach w którymkolwiek z powyższych.

W przypadku aplikacji Note możesz użyć kontenera domyślnego. W ramach tego kontenera będziesz korzystać z prywatnej bazy danych (ponieważ chcesz, aby notatka użytkownika była widziana tylko przez tego użytkownika), a w ramach prywatnej bazy danych użyjesz niestandardowej strefy rekordów, która umożliwia powiadamianie o określonych zmiany rekordu.

Notatka będzie przechowywana jako pojedynczy CKRecord z polami text , modified (DateTime) i version . CloudKit automatycznie śledzi wewnętrzną modified wartość, ale chcesz mieć możliwość poznania rzeczywistego czasu modyfikacji, w tym przypadków offline, w celu rozwiązywania konfliktów. Pole version jest po prostu ilustracją dobrych praktyk w zakresie sprawdzania aktualizacji, przy czym należy pamiętać, że użytkownik z wieloma urządzeniami może nie aktualizować aplikacji na wszystkich w tym samym czasie, więc istnieje potrzeba obrony.

Tworzenie aplikacji Notatki

Zakładam, że dobrze znasz podstawy tworzenia aplikacji na iOS w Xcode. Jeśli chcesz, możesz pobrać i przeanalizować przykładowy projekt Note App Xcode utworzony na potrzeby tego samouczka.

Dla naszych celów wystarczy pojedyncza aplikacja widoku zawierająca UITextView z ViewController jako jego delegatem. Na poziomie koncepcyjnym chcesz wyzwolić aktualizację rekordu CloudKit za każdym razem, gdy zmieni się tekst. Jednak ze względów praktycznych sensowne jest użycie pewnego rodzaju mechanizmu łączenia zmian, takiego jak zegar w tle, który jest okresowo uruchamiany, aby uniknąć spamowania serwerów iCloud zbyt wieloma drobnymi zmianami.

Aplikacja CloudKit wymaga włączenia kilku elementów w panelu Capabilities of the Xcode Target: iCloud (oczywiście), w tym pola wyboru CloudKit, powiadomień push i trybów tła (w szczególności powiadomień zdalnych).

Dla funkcjonalności CloudKit podzieliłem rzeczy na dwie klasy: Singleton CloudKitNoteDatabase niższego poziomu i klasę CloudKitNote wyższego poziomu.

Ale najpierw krótkie omówienie błędów CloudKit.

Błędy CloudKit

Ostrożna obsługa błędów jest absolutnie niezbędna dla klienta CloudKit.

Ponieważ jest to interfejs API oparty na sieci, jest podatny na wiele problemów z wydajnością i dostępnością. Ponadto sama usługa musi chronić przed szeregiem potencjalnych problemów, takich jak nieautoryzowane żądania, sprzeczne zmiany i tym podobne.

CloudKit zapewnia pełną gamę kodów błędów wraz z towarzyszącymi informacjami, aby umożliwić programistom radzenie sobie z różnymi skrajnymi przypadkami i, w razie potrzeby, szczegółowe wyjaśnienie użytkownikowi możliwych problemów.

Ponadto kilka operacji CloudKit może zwrócić błąd jako pojedynczą wartość błędu lub błąd złożony oznaczony na najwyższym poziomie jako partialFailure . Zawiera Słownik zawartych CKError , które zasługują na dokładniejsze sprawdzenie, aby dowiedzieć się, co dokładnie wydarzyło się podczas operacji złożonej.

Aby ułatwić poruszanie się po tej złożoności, możesz rozszerzyć CKError za pomocą kilku metod pomocniczych.

Należy pamiętać, że cały kod zawiera komentarze wyjaśniające w kluczowych punktach.

 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

Firma Apple udostępnia dwa poziomy funkcjonalności w pakiecie CloudKit SDK: funkcje „wygodne” wysokiego poziomu, takie jak fetch() , save() i delete() , oraz konstrukcje operacyjne niższego poziomu o nieporęcznych nazwach, takie jak CKModifyRecordsOperation .

Interfejs API wygody jest znacznie bardziej dostępny, podczas gdy podejście operacyjne może być nieco onieśmielające. Jednak Apple zdecydowanie zachęca programistów do korzystania z operacji, a nie z wygodnych metod.

Operacje CloudKit zapewniają doskonałą kontrolę nad szczegółami sposobu, w jaki CloudKit wykonuje swoją pracę i, co być może ważniejsze, naprawdę zmuszają programistę do dokładnego przemyślenia zachowań sieciowych, które są kluczowe dla wszystkiego, co robi CloudKit. Z tych powodów używam operacji w tych przykładach kodu.

Twoja klasa singleton będzie odpowiedzialna za każdą z tych operacji CloudKit, z których będziesz korzystać. W pewnym sensie odtwarzasz wygodne interfejsy API. Ale implementując je samodzielnie w oparciu o interfejs API operacji, stawiasz się w dobrym miejscu, aby dostosować zachowanie i dostroić reakcje na błędy. Na przykład, jeśli chcesz rozszerzyć tę aplikację, aby obsługiwała wiele notatek, a nie tylko jedną, możesz to zrobić łatwiej (i z wyższą wydajnością wynikową) niż w przypadku korzystania z wygodnych interfejsów API firmy 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? // ... }

Tworzenie własnej strefy

CloudKit automatycznie tworzy domyślną strefę dla prywatnej bazy danych. Możesz jednak uzyskać więcej funkcji, jeśli używasz strefy niestandardowej, w szczególności obsługi pobierania przyrostowych zmian rekordów.

Ponieważ jest to pierwszy przykład użycia operacji, oto kilka ogólnych obserwacji:

Po pierwsze, wszystkie operacje CloudKit mają niestandardowe zamknięcia uzupełniania (a wiele z nich ma zamknięcia pośrednie, w zależności od operacji). CloudKit ma własną klasę CKError , wywodzącą się z Error , ale musisz mieć świadomość, że pojawiają się również inne błędy. Wreszcie, jednym z najważniejszych aspektów każdej operacji jest wartość qualityOfService . Ze względu na opóźnienie sieci, tryb samolotowy itp. CloudKit wewnętrznie obsłuży ponownych prób itp. w przypadku operacji z qualityOfService „użytecznością” lub niższą. W zależności od kontekstu możesz chcieć przypisać wyższą qualityOfService i samodzielnie poradzić sobie z tymi sytuacjami.

Po skonfigurowaniu operacje są przekazywane do obiektu CKDatabase , gdzie będą wykonywane w wątku w tle.

 // 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) }

Tworzenie subskrypcji

Subskrypcje to jedna z najcenniejszych funkcji CloudKit. Opierają się na infrastrukturze powiadomień Apple, aby umożliwić różnym klientom otrzymywanie powiadomień push, gdy wystąpią pewne zmiany CloudKit. Mogą to być zwykłe powiadomienia push znane użytkownikom iOS (takie jak dźwięk, baner lub znaczek) lub w CloudKit mogą to być specjalne powiadomienia typu ciche push . Te ciche pushy odbywają się całkowicie bez widoczności lub interakcji użytkownika, a w rezultacie nie wymagają od użytkownika włączenia powiadomień push dla Twojej aplikacji, co oszczędza wielu potencjalnych problemów związanych z doświadczeniem użytkownika jako programistom aplikacji.

Sposobem na włączenie tych cichych powiadomień jest ustawienie właściwości shouldSendContentAvailable w instancji CKNotificationInfo , pozostawiając wszystkie tradycyjne ustawienia powiadomień ( shouldBadge , soundName itd.) nieskonfigurowane.

Zauważ też, że używam CKQuerySubscription z bardzo prostym predykatem „zawsze prawda”, aby obserwować zmiany w jednym (i tylko) rekordzie notatki. W bardziej wyrafinowanej aplikacji możesz chcieć skorzystać z predykatu, aby zawęzić zakres określonej CKQuerySubscription , i możesz chcieć przejrzeć inne typy subskrypcji dostępne w CloudKit, takie jak CKDatabaseSuscription .

Na koniec zwróć uwagę, że możesz użyć wartości buforowanej UserDefaults , aby uniknąć niepotrzebnego zapisywania subskrypcji więcej niż raz. Ustawienie go nie zaszkodzi, ale Apple zaleca podjęcie wysiłków, aby tego uniknąć, ponieważ marnuje to zasoby sieciowe i serwerowe.

 // 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) }

Ładowanie rekordów

Pobranie rekordu według nazwy jest bardzo proste. Możesz myśleć o nazwie jako o kluczu podstawowym rekordu w prostym sensie bazy danych (na przykład nazwy muszą być unikatowe). Rzeczywisty CKRecordID jest nieco bardziej skomplikowany, ponieważ zawiera zoneID .

CKFetchRecordsOperation działa na jednym lub kilku rekordach na raz. W tym przykładzie jest tylko jeden rekord, ale ze względu na możliwość przyszłej rozbudowy jest to duża potencjalna korzyść w zakresie wydajności.

 // 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) }

Zapisywanie rekordów

Zapisywanie rekordów jest prawdopodobnie najbardziej skomplikowaną operacją. Prosta czynność zapisania rekordu do bazy danych jest wystarczająco prosta, ale w moim przykładzie, z wieloma klientami, możesz napotkać potencjalny problem z obsługą konfliktu, gdy wielu klientów próbuje jednocześnie pisać do serwera. Na szczęście CloudKit jest wyraźnie zaprojektowany do obsługi tego stanu. Odrzuca określone żądania z wystarczającym kontekstem błędu w odpowiedzi, aby każdy klient mógł podjąć lokalną, świadomą decyzję dotyczącą sposobu rozwiązania konfliktu.

Chociaż zwiększa to złożoność klienta, ostatecznie jest to znacznie lepsze rozwiązanie niż wymyślenie przez Apple jednego z kilku mechanizmów rozwiązywania konfliktów po stronie serwera.

Projektant aplikacji jest zawsze w najlepszej sytuacji, aby zdefiniować reguły dla takich sytuacji, które mogą obejmować wszystko, od kontekstowego automatycznego scalania po instrukcje rozwiązywania problemów kierowane przez użytkownika. Nie będę się zbytnio podobał w moim przykładzie; Używam modified pola, aby zadeklarować, że wygrywa najnowsza aktualizacja. Może to nie zawsze być najlepszy wynik dla profesjonalnych aplikacji, ale nie jest to złe dla pierwszej reguły i służy w tym celu do zilustrowania mechanizmu, za pomocą którego CloudKit przekazuje informacje o konflikcie z powrotem do klienta.

Zauważ, że w mojej przykładowej aplikacji ten krok rozwiązywania konfliktów ma miejsce w klasie CloudKitNote , opisanej później.

 // 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) }

Obsługa powiadomienia o zaktualizowanych zapisach

Powiadomienia CloudKit zapewniają środki, aby dowiedzieć się, kiedy rekordy zostały zaktualizowane przez innego klienta. Jednak warunki sieciowe i ograniczenia wydajności mogą spowodować odrzucenie pojedynczych powiadomień lub celowe połączenie wielu powiadomień w jedno powiadomienie klienta. Ponieważ powiadomienia CloudKit są oparte na systemie powiadomień iOS, musisz zwracać uwagę na te warunki.

Jednak CloudKit zapewnia narzędzia, których potrzebujesz do tego.

Zamiast polegać na indywidualnych powiadomieniach, aby dać ci szczegółową wiedzę na temat zmiany, jaką reprezentuje poszczególne powiadomienie, używasz powiadomienia, aby po prostu wskazać, że coś się zmieniło, a następnie możesz zapytać CloudKit, co się zmieniło od ostatniego pytania. W moim przykładzie robię to za pomocą CKFetchRecordZoneChangesOperation i CKServerChangeTokens . Tokeny zmian można traktować jak zakładkę informującą o tym, gdzie byłeś przed wystąpieniem ostatniej sekwencji zmian.

 // 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) }

Masz teraz bloki konstrukcyjne niskiego poziomu do odczytu i zapisu rekordów oraz do obsługi powiadomień o zmianach rekordów.

Przyjrzyjmy się teraz warstwie zbudowanej na tym, aby zarządzać tymi operacjami w kontekście konkretnej notatki.

Klasa CloudKitNote

Na początek można zdefiniować kilka niestandardowych błędów, aby chronić klienta przed wewnętrznymi elementami CloudKit, a prosty protokół delegata może informować klienta o zdalnych aktualizacjach podstawowych danych Note.

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

Mapowanie z CKRecord do notatki

W Swift do poszczególnych pól w CKRecord można uzyskać dostęp za pomocą operatora indeksu dolnego. Wszystkie wartości są zgodne z CKRecordValue , ale te z kolei zawsze należą do określonego podzbioru znanych typów danych: NSString , NSNumber , NSDate i tak dalej.

Ponadto CloudKit zapewnia określony typ rekordu dla „dużych” obiektów binarnych. Nie określono konkretnego punktu odcięcia (maksymalnie 1 MB jest zalecane dla każdego CKRecord ), ale ogólnie rzecz biorąc, prawie wszystko, co wydaje się być niezależnym elementem (obraz, dźwięk, blob tekstu), a nie jako pole bazy danych prawdopodobnie powinno być przechowywane jako CKAsset . Ta praktyka pozwala CloudKit lepiej zarządzać transferem sieciowym i przechowywaniem tego typu elementów po stronie serwera.

W tym przykładzie użyjesz CKAsset do przechowywania tekstu notatki. Dane CKAsset są obsługiwane przez lokalne pliki tymczasowe zawierające odpowiednie dane.

 // 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) } }

Ładowanie notatki

Ładowanie notatki jest bardzo proste. Wykonujesz trochę wymaganego sprawdzania błędów, a następnie po prostu pobierasz rzeczywiste dane z CKRecord i przechowujesz wartości w polach członkowskich.

 // 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) } }

Zapisywanie notatki i rozwiązywanie potencjalnego konfliktu

Jest kilka specjalnych sytuacji, o których należy pamiętać podczas zapisywania notatki.

Po pierwsze, musisz upewnić się, że zaczynasz od prawidłowego CKRecord . Pytasz CloudKit, czy jest tam już jakiś rekord, a jeśli nie, tworzysz nowy lokalny CKRecord do użycia przy kolejnym zapisie.

Kiedy poprosisz CloudKit o zapisanie rekordu, być może będziesz musiał poradzić sobie z konfliktem z powodu innego klienta aktualizującego rekord od czasu ostatniego jego pobrania. Przewidując to, podziel funkcję zapisywania na dwa kroki. Pierwszy krok wykonuje jednorazową konfigurację w ramach przygotowań do pisania rekordu, a drugi krok przekazuje złożony rekord do pojedynczej klasy CloudKitNoteDatabase . Ten drugi krok można powtórzyć w przypadku konfliktu.

W przypadku konfliktu CloudKit daje w zwróconym CKError trzy pełne CKRecord do pracy z:

  1. Poprzednia wersja rekordu, który próbowałeś zapisać,
  2. Dokładna wersja rekordu, który próbowałeś zapisać,
  3. Wersja posiadana przez serwer w momencie przesłania żądania.

Patrząc na modified pola tych rekordów, możesz zdecydować, który rekord wystąpił jako pierwszy, a zatem, które dane zachować. W razie potrzeby przekazujesz zaktualizowany rekord serwera do CloudKit, aby zapisać nowy rekord. Oczywiście może to spowodować kolejny konflikt (jeśli w międzyczasie pojawi się kolejna aktualizacja), ale po prostu powtarzaj proces, aż uzyskasz pomyślny wynik.

W tej prostej aplikacji Note, w której jeden użytkownik przełącza się między urządzeniami, prawdopodobnie nie zobaczysz zbyt wielu konfliktów w sensie „bieżącej współbieżności”. Jednak takie konflikty mogą wynikać z innych okoliczności. Na przykład użytkownik mógł wprowadzić zmiany na jednym urządzeniu w trybie samolotowym, a następnie w roztargnieniu wprowadzić różne zmiany na innym urządzeniu przed wyłączeniem trybu samolotowego na pierwszym urządzeniu.

W aplikacjach do udostępniania danych opartych na chmurze niezwykle ważne jest, aby zwracać uwagę na każdy możliwy scenariusz.

 // 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) } }

Obsługa powiadomienia o zdalnie zmienionej notatce

Gdy pojawi się powiadomienie, że rekord się zmienił, CloudKitNoteDatabase zajmie się pobieraniem zmian z CloudKit. W tym przykładowym przypadku będzie to tylko jeden rekord notatki, ale nie jest trudno zobaczyć, jak można go rozszerzyć na szereg różnych typów i wystąpień rekordów.

Na przykład dołączyłem podstawowe sprawdzenie poprawności, aby upewnić się, że aktualizuję poprawny rekord, a następnie zaktualizowałem pola i powiadomiłem delegata, że ​​mamy nowe dane.

 // 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.

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

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

Handling the Offline Case

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

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

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

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

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

CloudKit Solves The Synchronization Problem

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

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

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

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

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

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