个性化阅读
专注于IT技术分析

CloudKit指南:如何在iOS设备之间同步用户数据

本文概述

如今, 现代移动应用程序开发需要经过深思熟虑的计划, 以使用户数据在各种设备之间保持同步。这是一个棘手的问题, 存在许多陷阱和陷阱, 但是用户期望该功能并希望它能很好地工作。

对于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通过以下类的层次结构组织数据:CKContainer, CKDatabase, CKRecordZone和CKRecord。

顶层是CKContainer, 它封装了一组相关的CloudKit数据。每个应用程序都会自动获取默认的CKContainer, 并且如果权限设置允许, 则一组应用程序可以共享自定义CKContainer。这可以启用一些有趣的跨应用程序工作流。

每个CKContainer中都有CKDatabase的多个实例。 CloudKit开箱即用地自动配置每个启用了CloudKit的应用程序, 以拥有一个公共CKDatabase(该应用程序的所有用户都可以看到所有内容)和一个私有CKDatabase(每个用户只能看到自己的数据)。而且, 从iOS 10开始, 共享的CKDatabase在其中用户控制的组可以在组的成员之间共享项目。

在CKDatabase中是CKRecordZones, 在区域CKRecords中。你可以读写记录, 查询与一组条件匹配的记录, 并且(最重要的是)可以收到上述任何更改的通知。

对于你的Note应用程序, 你可以使用默认容器。在此容器中, 你将使用私有数据库(因为你希望仅该用户看到用户的注释), 而在私有数据库中, 你将使用自定义记录区域, 该区域可通知特定记录更改。

该注释将与文本, 已修改的(DateTime)和版本字段一起存储为单个CKRecord。 CloudKit会自动跟踪内部修改的值, 但是你希望能够知道实际的修改时间, 包括离线案例, 以解决冲突。版本字段仅是用于证明升级的良好实践的例证, 请记住, 拥有多个设备的用户可能不会同时在所有设备上更新你的应用程序, 因此需要采取防御措施。

构建笔记应用

我假设你能很好地掌握在Xcode中创建iOS应用的基本知识。如果愿意, 可以下载并检查为本教程创建的示例Note App Xcode项目。

就我们的目的而言, 一个包含UITextView且具有ViewController作为其委托的单一视图应用程序就足够了。从概念上讲, 你希望每当文本更改时触发CloudKit记录更新。但是, 实际上, 使用某种更改合并机制是有意义的, 例如定期触发的后台计时器, 以避免因太多微小更改而向iCloud服务器发送垃圾邮件。

CloudKit应用程序需要在Xcode目标的功能窗格上启用以下几项:iCloud(自然), 包括CloudKit复选框, 推送通知和后台模式(特别是远程通知)。

对于CloudKit功能, 我将其分为两类:较低级别的CloudKitNoteDatabase单例和较高级别的CloudKitNote类。

但是首先, 快速讨论CloudKit错误。

CloudKit错误

谨慎的错误处理对于CloudKit客户端绝对是必不可少的。

由于它是基于网络的API, 因此很容易出现一系列性能和可用性问题。而且, 服务本身必须防止出现一系列潜在问题, 例如未授权的请求, 冲突的更改等。

CloudKit提供了完整的错误代码以及相关信息, 以允许开发人员处理各种极端情况, 并在必要时向用户提供有关可能问题的详细说明。

此外, 多个CloudKit操作可以将错误作为单个错误值返回, 也可以将在顶层表示为partialFailure的复合错误返回。它带有包含CKErrors的字典, 值得更仔细地检查以弄清楚复合操作期间到底发生了什么。

为了帮助解决这种复杂性, 你可以使用一些辅助方法来扩展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 Singleton

Apple在CloudKit SDK中提供了两个级别的功能:高级的”便利”功能, 例如fetch(), save()和delete(), 以及具有繁琐名称的较低级别的操作构造, 例如CKModifyRecordsOperation。

便捷的API更加易于访问, 而操作方法可能有些令人生畏。但是, 苹果强烈敦促开发人员使用操作而不是便利方法。

CloudKit操作可提供对CloudKit如何工作细节的高级控制, 而且也许更重要的是, 确实迫使开发人员仔细考虑了CloudKit所做的一切的网络行为。由于这些原因, 我在这些代码示例中使用这些操作。

你的单例课程将负责你将要使用的每个CloudKit操作。实际上, 从某种意义上说, 你是在重新创建便捷API。但是, 通过基于Operation API自己实现它们, 你将自己放在一个合适的位置来自定义行为并调整错误处理响应。例如, 如果你想扩展此应用程序以处理多个Notes而不是仅处理一个Notes, 那么与仅使用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值。由于网络延迟, 飞行模式等, CloudKit将在内部处理重试等, 以便以”效用”或更低的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等)不变。

还要注意, 我正在使用带有非常简单的” always true”谓词的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 subscriptionID = "cloudkit-note-changes"
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提出解决冲突的几种服务器端机制之一相比, 它最终是一个更好的解决方案。

应用程序设计人员始终处于最佳位置, 以针对这些情况定义规则, 其中可能包括从上下文感知的自动合并到用户控制的分辨率指令等所有内容。在我的例子中, 我不会变得很花哨。我正在使用Modifyed字段声明最近的更新成功。对于专业应用程序来说, 这不一定总是最好的结果, 但对于第一条规则来说并不坏, 因此, 它旨在说明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内部的攻击, 并且简单的委托协议可以通知客户端有关基础Notes数据的远程更新。

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保存记录时, 由于自上次获取记录以来另一个客户端更新了记录, 因此你可能必须在此处理冲突。为此, 请将保存功能分为两个步骤。第一步进行一次性设置以准备写入记录, 第二步将组装好的记录向下传递给Singleton CloudKitNoteDatabase类。在发生冲突的情况下, 可以重复执行此第二步。

在发生冲突的情况下, CloudKit在返回的CKError中为你提供三个完整的CKRecords可以使用:

  1. 你尝试保存的记录的先前版本,
  2. 你尝试保存的记录的确切版本,
  3. 提交请求时服务器保留的版本。

通过查看这些记录的修改字段, 你可以确定哪个记录最先出现, 从而决定要保留哪些数据。如有必要, 然后将更新的服务器记录传递给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通知机制到达。因此, 你的AppDelegate应该在didFinishLaunchingWithOptions中调用application.registerForRemoteNotifications并实现didReceiveRemoteNotification。当应用收到通知时, 请检查它是否与你创建的订阅相对应, 如果是, 则将其传递给CloudKitNoteDatabase单例。

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

提示:由于iOS模拟器不完全支持推送通知, 因此你将需要在CloudKit通知功能的开发和测试过程中使用物理iOS设备。你可以在模拟器中测试所有其他CloudKit功能, 但必须在模拟设备上登录到iCloud帐户。

你去!现在, 你可以使用CloudKit API编写, 读取和处理iCloud存储的应用程序数据更新的远程通知。更重要的是, 你具有添加更多高级CloudKit功能的基础。

还值得指出的是你不必担心的事情:用户身份验证。由于CloudKit基于iCloud, 因此该应用程序完全依赖于通过Apple ID / iCloud登录过程进行的用户身份验证。对于应用程序开发人员来说, 这应该可以节省大量后端开发和运营成本。

处理离线案例

也许你会以为上述内容是一种完全健壮的数据共享解决方案, 但事实并非如此简单。

所有这些隐含的是, CloudKit可能并不总是可用。用户可能未登录, 他们可能已禁用该应用程序的CloudKit, 他们可能处于飞行模式-例外列表不胜枚举。从用户的角度来看, 在使用该应用程序时要求活动的CloudKit连接的暴力手段根本无法令人满意, 实际上, 这可能是被Apple App Store拒绝的理由。因此, 必须仔细考虑离线模式。

在这里, 我不会详细介绍这种实现方式, 但是概述就足够了。

可以通过NSKeyedArchiver等将用于文本和修改日期时间的相同注释字段本地存储在文件中, 并且UI可以基于此本地副本提供几乎全部功能。也可以直接在本地存储之间来回串行CKRecords。更高级的情况可以使用SQLite或等效的方法作为影子数据库, 以实现脱机冗余。然后, 该应用程序可以利用各种操作系统提供的通知(尤其是CKAccountChangedNotification)来了解用户何时登录或注销, 并启动与CloudKit的同步步骤(当然包括适当的冲突解决方法)以将本地脱机更改服务器, 反之亦然。

另外, 可能需要提供一些UI指示, 说明CloudKit的可用性, 同步状态, 当然还有内部分辨率不令人满意的错误情况。

CloudKit解决了同步问题

在本文中, 我探讨了CloudKit API的核心机制, 该机制可确保多个iOS客户端之间的数据保持同步。

请注意, 相同的代码也将适用于macOS客户端, 并对通知在该平台上的工作方式的差异进行了一些小的调整。

CloudKit在此基础上提供了更多功能, 特别是对于复杂的数据模型, 公共共享, 高级用户通知功能等。

尽管仅向Apple客户提供iCloud, 但CloudKit提供了一个功能强大的平台, 在此平台上, 可以以最少的服务器端投资来构建真正有趣且用户友好的多客户端应用程序。

为了更深入地了解CloudKit, 我强烈建议你花时间查看来自最近几个WWDC的每个中的各种CloudKit演示, 并按照它们提供的示例进行操作。

相关:Swift教程:MVVM设计模式简介

赞(0)
未经允许不得转载:srcmini » CloudKit指南:如何在iOS设备之间同步用户数据

评论 抢沙发

评论前必须登录!