Core Data with CloudKit(四)—— 调试、测试、迁移及其他
本文聊一下在开发Core Data with CloudKit
项目中常见的一些问题,让大家少走弯路、避免踩坑。
Core Data with CloudKit (一) —— 基础
Core Data with CloudKit(二) —— 同步本地数据库到 iCloud 私有数据库
Core Data with CloudKit(三)—— CloudKit 仪表台
Core Data with CloudKit(四)—— 调试、测试、迁移及其他
Core Data with CloudKit(五)—— 同步公共数据库
Core Data with CloudKit (六) —— 创建与多个 iCloud 用户共享数据的应用
健康笔记是一款智能的数据管理和分析工具,让您完全掌控自己和全家人的健康信息。作为慢性病患者,肘子深知健康管理的重要与难度。创建健康笔记的初心,就是要为您提供一款轻松高效的健康信息记录与分析工具
控制台日志信息

一个支持Core Data with CloudKit
的项目,控制台输出将常态化地成为上图状态。
每个项目面对的情况不同且信息中的废话较多,因此我仅就可能的信息种类做一下归纳。
正常情况的信息
- 初始化信息
代码启动后,通常首先出现在控制台的便是NSPersistentCloudKitContainer
展示的初始化信息。包括:成功在指定url
创建了容器,成功启用了NSCloudKitMirroringDelegate
同步响应等。如果是首次运行项目,还会有成功在iCloud
上创建了Schema
之类的提示。
- 数据模型迁移信息
如果本地和服务器端的数据模型不一致,会出现迁移提醒。有时即使本地的Core Data
模型和iCloud
上的模型一致,也会看到类似Skipping migration for 'ANSCKDATABASEMETADATA' because it already has a column named 'ZLASTFETCHDATE'
之类的信息,表示无需迁移。
- 数据同步信息
会详细描述导入、导出的具体的内容,信息比较好理解。应用程序端或服务器端任何数据发生变动都会出现对应的信息。
- 持久化历史跟踪信息
NSPersistentCloudKitContainer
使用持久化历史跟踪来管理导入导出事务,在数据同步信息的左右经常会伴随包含NSPersistentHistoryToken
之类的提示。另外类似Ignoring remote change notification because the exporter has already caught up to this transaction: 11 / 11 - <NSSQLCore: 0x7ff73e4053b0>
的信息也是持久化历史跟踪产生的,容易让人误以为总有事务没有处理。关于Persistent History Tracking
可以阅读我另一篇文章 在 CoreData 中使用持久化历史跟踪。
可能的不正常情况的信息
- 初始化错误
比较常见的有,无法创建或读取sqlite
文件产生的本地url
错误以及CKContainerID
权限问题。如果url
指向appGroupContainer
,一定要确认appGroupID
正确,且app
已获得group
权限。CKContainerID
权限问题通常使用 之前文章 中提到的重置Certificates,Identifiers&Profiles
中的配置来解决。
- 模型迁移错误
正常情况下,Xcode
不会让你生成同CloudKit
的Schema
不兼容的ManagedObjectModel
,所以多数情况下,都是由于在开发环境下,本地的数据模型和服务器端的数据模型不匹配导致的问题(比如更改了某个属性名称、或者使用了较老的开发版本等)。在确认代码版本正确的情况下,可采取删除本地app
,重置CloudKit
端开发环境的方法来解决。但如果你的应用程序已经上线,应尽量避免此类问题的发生可能。请考虑后文中的更新数据模型提供的模型迁移策略。
- 合并冲突
请检查是否设置了正确的合并冲突策略NSMergeByPropertyObjectTrumpMergePolicy
?是否从CloudKit
控制台对数据做出了错误的修改?如仍处于开发阶段,可采用和上面一样的方式解决。
- iCloud 账号或网络错误
iCloud
没登录,iCloud
服务器没响应,iCloud 账号受限等。以上问题多数都是开发人员这端无法解决的。NSPersistentCloudKitContainer
会在iCloud
账户登录后自动恢复同步。在代码中进行账号状态检查,并提醒用户登录账号。
关闭日志输出
在确认同步功能代码已正常工作的情况下,如无法忍受控制台的信息轰炸,可尝试关闭Core Data with CloudKit
的日志输出。调试任何使用Core Data
的项目,我都推荐大家为项目添加如下的默认参数:

- -com.apple.CoreData.ConcurrencyDebug
及时发现由托管对象或上下文线程错误而导致的问题。执行任何可能导致错误的代码时,应用程序会立刻崩溃,帮助在开发阶段清除隐患。启用后,控制台会显示CoreData: annotation: Core Data multi-threading assertions enabled.
- -com.apple.CoreData.CloudKitDebug
CloudKit
调试信息输出级别,从 1 开始,数字越大信息愈详尽
- -com.apple.CoreData.SQLDebug
CoreData
发送到SQLite
的实际SQL
语句,1——4,数值越大越详细。输出提供的信息在调试性能问题时很有用——特别是它可以告诉你什么时候 Core Data
正在执行大量的小提取(例如当单独填充fault
时)。
- -com.apple.CoreData.MigrationDebug
迁移调试启动参数将使您在控制台中了解迁移数据时的异常情况。
- -com.apple.CoreData.Logging.stderr
信息输出开关
设置-com.apple.CoreData.Logging.stderr 0
,所有的同数据库有关日志信息都将不再输出。
关闭网络同步
在程序开发阶段,我们有时候并不想被数据同步所打扰。增加网络同步控制参数方便提高专注力。
当NSPersistentCloudKitContainer
载入没有配置cloudKitContainerOptions
的NSPersistentStoreDescription
时,它的行为同NSPersistentContainer
是一致的。通过使用类似下面的代码,可在调试中控制是否启用数据网络同步功能。
let allowCloudKitSync: Bool = {
let arguments = ProcessInfo.processInfo.arguments
guard let index = arguments.firstIndex(where: {$0 == "-allowCloudKitSync"}),
index + 1 < arguments.count - 1 else {return true}
return arguments[index + 1] == "1"
}()
if allowCloudKitSync {
cloudDesc.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.fatobman.blog.SyncDemo")
} else {
cloudDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
cloudDesc.setOption(true as NSNumber,
forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
}
因为NSPersistentCloudKitContiner
会自动启用持久化历史跟踪的,如没有设置NSPersistentCloudKitContainerOptions
,必须在代码中显式启用Persistent History Tracking
,否则数据库会变成只读。

设置为0
将关闭网络同步。
本地数据库的更改在恢复同步功能后,仍将会同步到服务器端。
同步不正常
当网络同步不正常时,请先尝试做以下检查:
- 网络连接是否正常
- 设备是否已登录
iCloud
账户 - 同步私有数据库的设备是否登录的是同一个
iCloud
账号 - 检查日志,是否有错误提示,尤其是服务器端的
- 模拟器不支持后台静默推送,将模拟器中的
app
切换至后台再切换回来,看看是否有数据
如果还是找不到原因的话,请泡壶茶、听听歌、看看远方,过一会可能就好了。
苹果服务器抽风的频率并不低,推送延迟不必惊讶。
检查用户账户状态
NSPersistentCloudKitContainer
会在iCloud
账号可用时自动恢复网络同步。通过代码检查用户的iCloud
账户登录情况,在应用程序中提醒用户进行账户登录。
调用CKContainer.default().accountStatus
检查用户iCloud
账号状态,订阅CKAccountChanged
,在登录成功后取消提醒。譬如
func checkAccountStatus() {
CKContainer.default().accountStatus { status, error in
DispatchQueue.main.async {
switch status {
case .available:
accountAvailable = true
default:
accountAvailable = false
}
if let error = error {
print(error)
}
}
}
}
检查网络同步状态
CloudKit
没有提供详尽的网络同步状态API
,开发者无法获得例如有多少数据需要同步、同步进度等信息。
NSPersistentCloudKitContainer
提供了一个eventChangedNotification
通知,该通知将在import
、export
、setup
三种状态切换时提醒我们。严格意义上,我们很难仅通过切换通知来判断当前同步的实际状态。
在实际的使用中,对用户感知影响最大的是数据导入状态。当用户在新设备上安装了应用程序,并且已经在网络上保存有较多数据时,面对完全没有数据的应用程序用户会感到很茫然。
数据会在应用程序启动后 20-30 秒开始导入,如果数据量较大,用户很可能会在 1-2 分钟后才会在 UI 上看到数据(批量导入通常会在整批数据都导入后才会merge
到上下文中)。因此为用户提供足够的提示尤为重要。
在实际使用中,当导入状态结束后,会切换到其他的状态。利用类似如下的代码,尝试给用户提供一点提示。
@State private var importing = false
@State private var publisher = NotificationCenter.default.publisher(for: NSPersistentCloudKitContainer.eventChangedNotification)
var body:some View{
VStack{
if importing {
ProgressView()
}
}
.onReceive(publisher){ notification in
if let userInfo = notification.userInfo {
if let event = userInfo["event"] as? NSPersistentCloudKitContainer.Event {
if event.type == .import {
importing = true
}
else {
importing = false
}
}
}
}
}
当应用程序被切到后台时,同步任务仅能继续执行 30 秒左右,在切换回前台后数据会继续进行同步。因此当数据较多时,需做好用户的提示工作(比如保持在前台,或让用户继续等待)。
创建默认数据集
有的应用程序会为用户提供一些默认的数据,比如说起始数据集,或者演示数据集。如果提供的数据集是放置在可同步的数据库中时需要谨慎处理。比如,已经在一台设备上创建了默认数据集并进行了修改,当在新设备上再次安装并运行应用程序时,处理不当可能导致数据被异常覆盖,或者重复。
- 确认数据集是否一定需要被同步
如无需同步可以考虑采用 同步本地数据库到 iCloud 私有数据库 一文中,有选择的同步数据解决方案。
- 如数据集必须要同步
- 最好引导用户手动点击创建默认数据按钮,让用户自行判断是否需要再度创建。
- 也可在应用程序首次运行时,利用
CKQuerySubscription
通过查询特定记录判断网络数据库中是否已有数据(此方法是在前几天和一个网友交流时他采用的方法,不过该网友对返回响应并不满意,用户感知不太好)。- 或许可考虑通过使用
NSUbiquitousKeyValueStore
进行判断。
- 或许可考虑通过使用
2、3 两种方式都需要保证网络及账号状态正常的情况下才能检查,让用户自行判断或许最为简单。
移动本地数据库
已经在AppStore
上架的应用程序,在某些情况下有移动本地数据库到其他URL
的需求。比如,为了让Widget
也可以访问数据库,我将 健康笔记 的数据库移动到了appGroupContainerURL
。
如果使用NSPersistentContainer
,可以直接调用coordinator.migratePersistentStore
即可安全完成数据库文件的位置转移。但如果对NSPersistentCloudKitContainer
加载的store
调用此方法,则必须强制退出应用程序后再次进入方可正常使用(虽然数据库文件被转移,但迁移后会告知加载CloudKit container
错误,无法进行同步。需重启应用程序才能正常同步)。
因此正确的移动方案是,在创建container
之前,采用FileManager
将数据库文件移动到新位置。需同时移动sqlite
、sqlite-wal
、sqlite-shm
三个文件。
类似如下代码:
func migrateStore() {
let fm = FileManager.default
guard !FileManager.default.fileExists(atPath: groupStoreURL.path) else {
return
}
guard FileManager.default.fileExists(atPath: originalStoreURL.path) else {
return
}
let walFileURL = originalStoreURL.deletingPathExtension().appendingPathExtension("sqlite-wal")
let shmFileURL = originalStoreURL.deletingPathExtension().appendingPathExtension("sqlite-shm")
let originalFileURLs = [originalStoreURL, walFileURL, shmFileURL]
let targetFileURLs = originalFileURLs.map {
groupStoreURL
.deletingLastPathComponent()
.appendingPathComponent($0.lastPathComponent)
}
// 将原始文件移动到新的位置。
zip(originalFileURLs, targetFileURLs).forEach { originalURL, targetURL in
do {
try fm.moveItem(at: originalURL, to: targetURL)
} catch error {
print(error)
}
}
}
更新数据模型
在 CloudKit 仪表台 一文,我们已经探讨过CloudKit
的两种环境设置。一旦将Schema
部署到生产环境,开发者便无法对记录类型和字段进行重命名或者删除。必须仔细规划你的应用程序,保证其在对数据模型进行更新时仍做到向前兼容。
不可以随心所欲地修改数据模型,对实体、属性尽量做到:只加、不减、不改。
可以考虑以下的模型更新策略:
增量更新
以增量的方式添加记录类型或向现有记录类型添加新字段。
采用这种方式,旧版本的应用程序仍可以访问用户创建的记录,但不是每个字段。
请确保新增的属性或实体都只服务于新版本的新功能,且即使没有这些数据,新版本程序仍可可正常运行(如此时用户仍使用旧版本更新数据,新添加的实体和属性都不会有内容)。
增加 version 属性
这个策略是上一个策略的加强版。通过一开始在实体上添加version
属性,对实体进行版本控制,通过谓词仅提取与应用程序当前版本兼容的记录。旧版本程序将不会提取新版本创建的数据。
例如,实体Post
具备version
属性
// 当前的数据版本。
let maxCompatibleVersion = 3
context.performAndWait {
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: "Post")
// 提取不大于当前版本的数据
fetchRequest.predicate = NSPredicate(
format: "version <= %d",
argumentArray: [maxCompatibleVersion]
)
let results = context.fetch(fetchRequest)
}
锁定数据,提示升级
利用version
属性,应用程序可以很容易知道当前的版本已经不满足数据模型的需要。它可以禁止用户修改数据,并提示用户更新应用程序版本。
创建新 CKContainer 及新的本地存储
如果你的数据模型发生了巨大的变化,采用上述方式已经很难处理,或者上述方式会造成巨大的数据浪费时,可以为应用程序添加一个新的关联容器,并通过代码将原始数据转移到新容器上。
大概的流程为:
- 在应用程序中添加新的
xcdatamodeld
(此时应该有两个模型,旧模型对应旧容器,新模型对应新容器) - 为应用程序添加新的关联容器(同时使用两个容器)
- 判断是否已经迁移,如果没有迁移则让应用程序通过旧模型和容器正常运行
- 让用户选择迁移数据(提醒用户须确保旧数据都已经同步到本地再执行迁移)
- 通过代码将旧数据转移到新容器和本地存储中,标记迁移完成(使用两个
NSPersistentCloudKitContainer
) - 切换数据源
无论采用上述哪种策略,都应该不计一切代价避免数据丢失、混乱。
总结
本文中的问题,是我在开发过程中碰到并已尝试解决的。其他的开发者还会碰到更多的未知情况,只要能掌握其规律,总是可以找到解决之法。
在下一篇文章中,我们聊一下同步公共数据库
通过 微信、 Patreon、 Buy Me a Coffee 进行捐赠。
欢迎通过 Twitter、 Discord 频道 或下方的留言板与我进行交流。
本博客文章采用 CC 4.0 协议,转载需注明出处和作者。