CloudKit 指南:如何在 iOS 设备之间同步用户数据
已发表: 2022-03-11如今,现代移动应用程序开发需要一个深思熟虑的计划,以使用户数据在各种设备之间保持同步。 这是一个棘手的问题,存在许多陷阱和陷阱,但用户期望该功能,并期望它运行良好。
对于 iOS 和 macOS,Apple 提供了一个强大的工具包,称为 CloudKit API,它允许针对 Apple 平台的开发人员解决这个同步问题。
在本文中,我将演示如何使用 CloudKit 在多个客户端之间保持用户数据同步。 它适用于已经熟悉 Apple 框架和 Swift 的有经验的 iOS 开发人员。 我将对 CloudKit API 进行相当深入的技术研究,以探索利用这项技术制作出色的多设备应用程序的方法。 我将专注于 iOS 应用程序,但同样的方法也可以用于 macOS 客户端。
出于说明目的,我们的示例用例是一个简单的笔记应用程序,只有一个笔记。 在此过程中,我将了解基于云的数据同步的一些棘手方面,包括冲突处理和不一致的网络层行为。
什么是 CloudKit?
CloudKit 建立在 Apple 的 iCloud 服务之上。 公平地说,iCloud 的起步有点坎坷。 从 MobileMe 的笨拙过渡、糟糕的性能,甚至是一些隐私问题,都使该系统在早年停滞不前。
对于应用程序开发人员来说,情况更糟。 在 CloudKit 之前,不一致的行为和弱的调试工具使得使用第一代 iCloud API 交付高质量的产品几乎是不可能的。
然而,随着时间的推移,Apple 已经解决了这些问题。 特别是在 2014 年 CloudKit SDK 发布之后,第三方开发者拥有了一个功能齐全、强大的技术解决方案来实现设备(包括 macOS 应用程序甚至基于 Web 的客户端)之间基于云的数据共享。
由于 CloudKit 与 Apple 的操作系统和设备密切相关,因此它不适合需要更广泛设备支持的应用程序,例如 Android 或 Windows 客户端。 但是,对于针对 Apple 用户群的应用程序,它为用户身份验证和数据同步提供了非常强大的机制。
基本 CloudKit 设置
CloudKit 通过类的层次结构组织数据: CKContainer
、 CKDatabase
、 CKRecordZone
和CKRecord
。
顶层是CKContainer
,它封装了一组相关的 CloudKit 数据。 每个应用都会自动获得一个默认的CKContainer
,如果权限设置允许,一组应用可以共享一个自定义的CKContainer
。 这可以启用一些有趣的跨应用程序工作流。
每个CKContainer
中有多个CKDatabase
实例。 CloudKit 自动配置每个支持 CloudKit 的应用程序开箱即用,以拥有一个公共CKDatabase
(应用程序的所有用户都可以看到所有内容)和一个私有CKDatabase
(每个用户只能看到自己的数据)。 并且,从 iOS 10 开始,一个共享的CKDatabase
,用户控制的组可以在该组的成员之间共享项目。
在CKDatabase
中有CKRecordZone
,在区域中有CKRecord
。 您可以读取和写入记录,查询符合一组条件的记录,并且(最重要的是)接收上述任何更改的通知。
对于您的 Note 应用,您可以使用默认容器。 在这个容器中,您将使用私有数据库(因为您希望用户的注释只被该用户看到),在私有数据库中,您将使用自定义记录区域,它可以通知特定的记录变化。
Note 将存储为带有text
、 modified
(DateTime) 和version
字段的单个CKRecord
。 CloudKit 自动跟踪内部modified
值,但您希望能够知道实际修改时间,包括离线情况,以解决冲突。 version
字段只是对升级验证的良好实践的说明,请记住,拥有多个设备的用户可能不会同时在所有设备上更新您的应用程序,因此需要采取防御措施。
构建笔记应用程序
我假设您对在 Xcode 中创建 iOS 应用程序的基础知识有很好的掌握。 如果您愿意,可以下载并检查为本教程创建的示例 Note App Xcode 项目。
对于我们的目的,一个包含UITextView
和ViewController
作为其委托的单一视图应用程序就足够了。 在概念级别,您希望在文本更改时触发 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
属性,同时保留所有传统通知设置( shouldBadge
、 soundName
等)未设置。
另请注意,我正在使用带有非常简单的“始终为真”谓词的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 自上次询问以来发生了什么变化。 在我的示例中,我通过使用CKFetchRecordZoneChangesOperation
和CKServerChangeTokens
来做到这一点。 更改标记可以被认为是一个书签,告诉您在最近的更改序列发生之前您在哪里。
// 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
,但反过来,这些值始终是熟悉的数据类型的特定子集之一: NSString
、 NSNumber
、 NSDate
等。
此外,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
可以使用:
- 您尝试保存的记录的先前版本,
- 您尝试保存的记录的确切版本,
- 您提交请求时服务器持有的版本。
通过查看这些记录的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 通知通过标准的 iOS 通知机制到达。 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.