CloudKit 指南:如何在 iOS 設備之間同步用戶數據

已發表: 2022-03-11

如今,現代移動應用程序開發需要一個深思熟慮的計劃,以使用戶數據在各種設備之間保持同步。 這是一個棘手的問題,存在許多陷阱和陷阱,但用戶期望該功能,並期望它運行良好。

對於 iOS 和 macOS,Apple 提供了一個強大的工具包,稱為 CloudKit API,它允許針對 Apple 平台的開發人員解決這個同步問題。

在本文中,我將演示如何使用 CloudKit 在多個客戶端之間保持用戶數據同步。 它適用於已經熟悉 Apple 框架和 Swift 的有經驗的 iOS 開發人員。 我將對 CloudKit API 進行相當深入的技術研究,以探索利用這項技術製作出色的多設備應用程序的方法。 我將專注於 iOS 應用程序,但同樣的方法也可以用於 macOS 客戶端。

出於說明目的,我們的示例用例是一個簡單的筆記應用程序,只有一個筆記。 在此過程中,我將了解基於雲的數據同步的一些棘手方面,包括衝突處理和不一致的網絡層行為。

使用 CloudKit 在多個客戶端之間同步用戶數據

什麼是 CloudKit?

CloudKit 建立在 Apple 的 iCloud 服務之上。 公平地說,iCloud 的起步有點坎坷。 從 MobileMe 的笨拙過渡、糟糕的性能,甚至是一些隱私問題,都使該系統在早年停滯不前。

對於應用程序開發人員來說,情況更糟。 在 CloudKit 之前,不一致的行為和弱的調試工具使得使用第一代 iCloud API 交付高質量的產品幾乎是不可能的。

然而,隨著時間的推移,Apple 已經解決了這些問題。 特別是在 2014 年 CloudKit SDK 發布之後,第三方開發者擁有了一個功能齊全、強大的技術解決方案來實現設備(包括 macOS 應用程序甚至基於 Web 的客戶端)之間基於雲的數據共享。

由於 CloudKit 與 Apple 的操作系統和設備密切相關,因此它不適合需要更廣泛設備支持的應用程序,例如 Android 或 Windows 客戶端。 但是,對於針對 Apple 用戶群的應用程序,它為用戶身份驗證和數據同步提供了非常強大的機制。

基本 CloudKit 設置

CloudKit 通過類的層次結構組織數據: CKContainerCKDatabaseCKRecordZoneCKRecord

頂層是CKContainer ,它封裝了一組相關的 CloudKit 數據。 每個應用都會自動獲得一個默認的CKContainer ,如果權限設置允許,一組應用可以共享一個自定義的CKContainer 。 這可以啟用一些有趣的跨應用程序工作流。

每個CKContainer中有多個CKDatabase實例。 CloudKit 自動配置每個支持 CloudKit 的應用程序開箱即用,以擁有一個公共CKDatabase (應用程序的所有用戶都可以看到所有內容)和一個私有CKDatabase (每個用戶只能看到自己的數據)。 並且,從 iOS 10 開始,一個共享的CKDatabase ,用戶控制的組可以在該組的成員之間共享項目。

CKDatabase中有CKRecordZone ,在區域中有CKRecord 。 您可以讀取和寫入記錄,查詢符合一組條件的記錄,並且(最重要的是)接收上述任何更改的通知。

對於您的 Note 應用,您可以使用默認容器。 在這個容器中,您將使用私有數據庫(因為您希望用戶的註釋只被該用戶看到),在私有數據庫中,您將使用自定義記錄區域,它可以通知特定的記錄變化。

Note 將存儲為帶有textmodified (DateTime) 和version字段的單個CKRecord 。 CloudKit 自動跟踪內部modified值,但您希望能夠知道實際修改時間,包括離線情況,以解決衝突。 version字段只是對升級驗證的良好實踐的說明,請記住,擁有多個設備的用戶可能不會同時在所有設備上更新您的應用程序,因此需要採取防禦措施。

構建筆記應用程序

我假設您對在 Xcode 中創建 iOS 應用程序的基礎知識有很好的掌握。 如果您願意,可以下載並檢查為本教程創建的示例 Note App Xcode 項目。

對於我們的目的,一個包含UITextViewViewController作為其委託的單一視圖應用程序就足夠了。 在概念級別,您希望在文本更改時觸發 CloudKit 記錄更新。 但是,實際上,使用某種更改合併機制是有意義的,例如定期觸發的後台計時器,以避免向 iCloud 服務器發送過多微小更改的垃圾郵件。

CloudKit 應用程序需要在 Xcode Target 的 Capabilities Pane 上啟用一些項目:iCloud(自然),包括 CloudKit 複選框、推送通知和背景模式(特別是遠程通知)。

對於 CloudKit 功能,我將其分為兩個類:較低級別的CloudKitNoteDatabase單例和較高級別的CloudKitNote類。

但首先,快速討論一下 CloudKit 錯誤。

CloudKit 錯誤

仔細的錯誤處理對於 CloudKit 客戶端來說是絕對必要的。

由於它是基於網絡的 API,因此容易受到一系列性能和可用性問題的影響。 此外,服務本身必須防止一系列潛在問題,例如未經授權的請求、衝突更改等。

CloudKit 提供了完整的錯誤代碼以及相關信息,以允許開發人員處理各種邊緣情況,並在必要時向用戶提供有關可能問題的詳細解釋。

此外,多個 CloudKit 操作可以將錯誤作為單個錯誤值或在頂層表示為partialFailure的複合錯誤返回。 它帶有一個包含CKError的字典,值得更仔細地檢查以找出在復合操作期間到底發生了什麼。

為了幫助解決這種複雜性,您可以使用一些輔助方法擴展CKError

請注意所有代碼的關鍵點都有解釋性註釋。

 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單例

Apple 在 CloudKit SDK 中提供了兩個級別的功能:高級“便利”功能,例如fetch()save()delete() ,以及具有繁瑣名稱的低級操作構造,例如CKModifyRecordsOperation

便捷的 API 更易於訪問,而操作方法可能有點嚇人。 但是,Apple 強烈敦促開發人員使用操作而不是便捷方法。

CloudKit 操作對 CloudKit 如何工作的細節提供了卓越的控制,也許更重要的是,它確實迫使開發人員仔細考慮對 CloudKit 所做的一切至關重要的網絡行為。 由於這些原因,我使用這些代碼示例中的操作。

您的單例類將負責您將使用的每個 CloudKit 操作。 事實上,從某種意義上說,您正在重新創建便利 API。 但是,通過基於操作 API 自己實現它們,您可以將自己置於自定義行為和調整錯誤處理響應的好地方。 例如,如果您想擴展此應用程序以處理多個便箋,而不僅僅是一個,那麼您可以比僅使用 Apple 的便捷 API 更容易(並獲得更高的性能)。

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

創建自定義區域

CloudKit 自動為私有數據庫創建一個默認區域。 但是,如果您使用自定義區域,您可以獲得更多功能,最值得注意的是,支持獲取增量記錄更改。

由於這是使用操作的第一個示例,因此這裡有一些一般性觀察:

首先,所有 CloudKit 操作都有自定義的完成閉包(許多有中間閉包,具體取決於操作)。 CloudKit 有自己的CKError類,派生自Error ,但您需要注意其他錯誤也可能出現。 最後,任何操作最重要的方面之一是qualityOfService值。 由於網絡延遲、飛行模式等, qualityOfService將在內部處理重試等操作,以“實用程序”或更低的服務質量。 根據上下文,您可能希望分配更高qualityOfService並自己處理這些情況。

設置完成後,操作將傳遞給CKDatabase對象,在後台線程上執行。

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

創建訂閱

訂閱是最有價值的 CloudKit 功能之一。 它們建立在 Apple 的通知基礎架構之上,允許各種客戶端在發生某些 CloudKit 更改時獲得推送通知。 這些可以是 iOS 用戶熟悉的普通推送通知(例如聲音、橫幅或徽章),或者在 CloudKit 中,它們可以是稱為靜默推送的特殊通知類別。 這些靜默推送完全沒有用戶可見性或交互性,因此,不需要用戶為您的應用啟用推送通知,從而為您作為應用開發人員節省了許多潛在的用戶體驗問題。

啟用這些靜默通知的方法是在CKNotificationInfo實例上設置shouldSendContentAvailable屬性,同時保留所有傳統通知設置( shouldBadgesoundName等)未設置。

另請注意,我正在使用帶有非常簡單的“始終為真”謂詞的CKQuerySubscription來監視一個(也是唯一一個)Note 記錄上的更改。 在更複雜的應用程序中,您可能希望利用謂詞來縮小特定CKQuerySubscription的範圍,並且您可能希望查看 CloudKit 下可用的其他訂閱類型,例如CKDatabaseSuscription

最後,請注意您可以使用UserDefaults緩存值來避免多次不必要地保存訂閱。 設置它並沒有太大的危害,但 Apple 建議努力避免這種情況,因為它會浪費網絡和服務器資源。

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

加載記錄

按名稱獲取記錄非常簡單。 您可以將名稱視為簡單數據庫意義上的記錄的主鍵(例如,名稱必須是唯一的)。 實際的CKRecordID有點複雜,因為它包含zoneID

CKFetchRecordsOperation對一條或多條記錄進行操作。 在此示例中,只有一條記錄,但對於未來的可擴展性,這是一個巨大的潛在性能優勢。

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

保存記錄

保存記錄也許是最複雜的操作。 將記錄寫入數據庫的簡單操作非常簡單,但在我的示例中,對於多個客戶端,當多個客戶端嘗試同時寫入服務器時,您將面臨處理衝突的潛在問題。 值得慶幸的是,CloudKit 明確設計用於處理這種情況。 它拒絕響應中具有足夠錯誤上下文的特定請求,以允許每個客戶端就如何解決衝突做出本地、開明的決定。

儘管這增加了客戶端的複雜性,但它最終是一個比讓 Apple 提出解決衝突的少數服務器端機制之一更好的解決方案。

應用程序設計人員始終處於為這些情況定義規則的最佳位置,其中可以包括從上下文感知自動合併到用戶導向的解決指令的所有內容。 我不會在我的例子中變得很花哨。 我正在使用modified的字段來聲明最近的更新獲勝。 對於專業應用程序來說,這可能並不總是最好的結果,但對於第一條規則來說還不錯,為此目的,它用於說明 CloudKit 將衝突信息傳遞回客戶端的機制。

請注意,在我的示例應用程序中,此衝突解決步驟發生在CloudKitNote類中,稍後將進行介紹。

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

處理更新記錄的通知

CloudKit 通知提供了找出記錄何時被另一個客戶端更新的方法。 但是,網絡條件和性能限制可能會導致單個通知被丟棄,或者多個通知故意合併為單個客戶端通知。 由於 CloudKit 的通知是建立在 iOS 通知系統之上的,因此您必須留意這些情況。

但是,CloudKit 為您提供了所需的工具。

與其依靠單個通知來詳細了解單個通知所代表的更改,不如使用通知來簡單地指示某些內容髮生了變化,然後您可以詢問 CloudKit 自上次詢問以來發生了什麼變化。 在我的示例中,我通過使用CKFetchRecordZoneChangesOperationCKServerChangeTokens來做到這一點。 更改標記可以被認為是一個書籤,告訴您在最近的更改序列發生之前您在哪裡。

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

您現在擁有讀取和寫入記錄以及處理記錄更改通知的低級構建塊。

現在讓我們看一下在此之上構建的層,用於在特定 Note 的上下文中管理這些操作。

CloudKitNote

對於初學者,可以定義一些自定義錯誤來保護客戶端免受 CloudKit 內部的影響,並且一個簡單的委託協議可以通知客戶端對底層 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... } // … }

CKRecord映射到 Note

在 Swift 中,可以通過下標運算符訪問CKRecord上的各個字段。 這些值都符合CKRecordValue ,但反過來,這些值始終是熟悉的數據類型的特定子集之一: NSStringNSNumberNSDate等。

此外,CloudKit 為“大”二進制對象提供了特定的記錄類型。 沒有指定特定的截止點(建議每個CKRecord的最大總大小為 1MB),但根據經驗,幾乎任何感覺像獨立項目(圖像、聲音、文本塊)的東西,而不是數據庫字段可能應該存儲為CKAsset 。 這種做法允許 CloudKit 更好地管理這些類型項目的網絡傳輸和服務器端存儲。

對於此示例,您將使用CKAsset來存儲註釋文本。 CKAsset數據通過包含相應數據的本地臨時文件進行處理。

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

載入筆記

加載筆記非常簡單。 您進行一些必要的錯誤檢查,然後簡單地從CKRecord獲取實際數據並將值存儲在您的成員字段中。

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

保存筆記並解決潛在衝突

保存筆記時需要注意幾種特殊情況。

首先,您需要確保從有效的CKRecord開始。 您詢問 CloudKit 那裡是否已經有記錄,如果沒有,則創建一個新的本地CKRecord以用於後續保存。

當您要求 CloudKit 保存記錄時,您可能必須在此處處理衝突,因為自上次獲取記錄以來另一個客戶端更新了記錄。 考慮到這一點,將保存功能分為兩個步驟。 第一步進行一次性設置以準備寫入記錄,第二步將組裝好的記錄傳遞給單例CloudKitNoteDatabase類。 在發生衝突的情況下,可以重複第二步。

如果發生衝突,CloudKit 在返回的CKError中為您提供三個完整的CKRecord可以使用:

  1. 您嘗試保存的記錄的先前版本,
  2. 您嘗試保存的記錄的確切版本,
  3. 您提交請求時服務器持有的版本。

通過查看這些記錄的modified字段,您可以決定首先出現哪條記錄,從而決定保留哪些數據。 如有必要,然後將更新的服務器記錄傳遞給 CloudKit 以寫入新記錄。 當然,這可能會導致另一個衝突(如果在兩者之間進行另一個更新),但是您只需重複該過程直到獲得成功的結果。

在這個簡單的 Note 應用程序中,單個用戶在設備之間切換,您不太可能在“實時並發”意義上看到太多衝突。 但是,這種衝突可能源於其他情況。 例如,用戶可能在飛行模式下在一個設備上進行了編輯,然後在關閉第一台設備上的飛行模式之前心不在焉地在另一台設備上進行了不同的編輯。

在基於雲的數據共享應用程序中,留意每一種可能的情況非常重要。

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

處理遠程更改筆記的通知

當收到一條記錄已更改的通知時, CloudKitNoteDatabase將完成從 CloudKit 獲取更改的繁重工作。 在此示例中,它只是一個筆記記錄,但不難看出如何將其擴展到一系列不同的記錄類型和實例。

例如,我包括了一個基本的健全性檢查,以確保我正在更新正確的記錄,然後更新字段並通知代理我們有新數據。

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

給你! 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