Core Data with CloudKit(二)——同步本地数据库到iCloud私有数据库
本系列文章一共六篇。如果想获得更好的阅读体验可以访问我的个人博客 www.fatbobman.com
本篇文章中,我们将探讨Core Data with CloudKit
应用中最常见的场景——将本地数据库同步到iCloud
私有数据库。我们将从几个层面逐步展开:
•在新项目中直接支持Core Data with CloudKit
•创建可同步Model
的注意事项•在现有项目Core Date
中添加Host in CloudKit
支持•有选择的同步数据
本文使用的开发环境为
Xcode 12.5
。关于私有数据库的概念,请参阅Core Data with CloudKit (一) —— 基础[1]。如想实际操作本文内容,需要拥有Apple Developer Program[2]账号。
快速指南
在应用程序中启用Core Data with CloudKi
t功能,只需要以下几步:
1.使用NSPersistentCloudKitContainer
2.在项目Target
的Signing&Capablities
中添加CloudKit
支持3.为项目创建或指定CloudKit container
4.在项目Target
的Signing&Capablities
中添加background
支持5.配置NSPersistentStoreDescription
以及viewContext
6.检查Data Model
是否满足同步的要求
在新项目中直接支持Core Data with CloudKit
在最近几年苹果不断完善Xcode
的Core Data模版
,直接使用自带模版来新建一个支持Core Data with CloudKit
的项目是最便捷的入手方式。
创建新的Xcode项目
创建新项目,在项目设置界面勾选Use Core Data
及Host in CloudKit
(早期版本为Use CloudKit
),并设置开发团队(Team
)
image-20210806180200853
设定保存地址后,Xcode将使用预置模版为你生成包含Core Data with CloudKit
支持的项目文档。
Xcode可能会提醒新项目代码有错误,如果觉得烦只需要Build一下项目即可取消错误提示(生成NSManagoedObject Subclass)
接下来,我们根据快速指南逐步操作。
设置PersistentCloudKitContainer
Persistence.swift
是官方模版创建的Core Data Stack
。由于在创建项目的时候已经选择了Host in CloudKit
,因此模版代码已直接使用NSPersistentCloudKitContianer
替代NSPersistentContianer
,无需进行修改。
let container: NSPersistentCloudKitContainer
启用CloudKit
点击项目中对应的Target
,选择Signing&Capabilities
。点击 Capability
查找icloud
添加CloudKit
支持。
image-20210806185136390
image-20210806185247739
勾选CloudKit
。点击
,输入CloudKit container
名称。Xcode会在你CloutKit container
名称的前面自动添加iCloud.
。container
的名称通常采用反向域名的方式,无需和项目或BundleID
一致。如果没有配置开发者团队,将无法创建container
。
image-20210808091434886
在添加了CloudKit
支持后,Xcode会自动为你添加Push Notifications
功能,原因我们在上一篇聊过。
启用后台通知
继续点击 Capability
,搜索backgroud
并添加,勾选Remote notifications
image-20210806190813361
此功能让你的应用程序能够响应云端数据内容变化时推送的静默通知。
配置NSPersistentStoreDescription和viewContext
查看当前项目中的.xcdatamodeld
文件,CONFIGURATIONS
中只有一个默认配置Default
,点击可以看到,右侧的Used with CloudKit
已经被勾选上了。
image-20210806193028530
如果开发者没有在Data Model Editor
中自定义Configuration
,如果勾选了Used with CloudKit
,Core Data
会使用选定的Cloudkit container
设置``cloudKitContainerOptions。因此在当前的
Persistence.swift代码中,我们无需对
NSPersistentStoreDescription做任何额外设置(我们会在后面的章节介绍如何设置
NSPersistentStoreDescription`)。
在Persistence.swift
对上下文做如下配置:
container.loadPersistentStores(completionHandler: { (storeDescription, error) in if let error = error as NSError? { ... fatalError("Unresolved error (error), (error.userInfo)") }})//添加如下代码 container.viewContext.automaticallyMergesChangesFromParent = truecontainer.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicydo { try container.viewContext.setQueryGenerationFrom(.current)} catch { fatalError("Failed to pin viewContext to the current generation:(error)")}
container.viewContext.automaticallyMergesChangesFromParent = true
让视图上下文自动合并服务器端同步(import
)来的数据。使用@FetchRequest
或NSFetchedResultsController
的视图可以将数据变化及时反应在UI上。
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
设定合并冲突策略。如果不设置该属性,Core Data
会默认使用NSErrorMergePolicy
作为冲突解决策略(所有冲突都不处理,直接报错),这会导致iCloud
的数据无法正确合并到本地数据库。
Core Data
预设了四种合并冲突策略,分别为:
•NSMergeByPropertyStoreTrumpMergePolicy逐属性比较,如果持久化数据和内存数据都改变且冲突,持久化数据胜出•NSMergeByPropertyObjectTrumpMergePolicy逐属性比较,如果持久化数据和内存数据都改变且冲突,内存数据胜出•NSOverwriteMergePolicy内存数据永远胜出•NSRollbackMergePolicy持久化数据永远胜出
对于Core Data with CloudKit
这样的使用场景,通常会选择NSMergeByPropertyObjectTrumpMergePolicy
。
setQueryGenerationFrom(.current)
这个是在最近才出现在苹果的文档和例程中的。目的是避免在数据导入期间应用程序产生的数据变化和导入数据不一致而可能出现的不稳定情况。尽管在我两年多的使用中,基本没有遇到过这种情况,但我还是推荐大家在代码中增加上下文快照的锁定以提高稳定性。
直到
Xcode 13 beta4
苹果仍然没有在预置的Core Data with CloudKit
模版中添加上下文的设置,这导致使用原版模版导入数据的行为会和预期有出入,对初学者不很友好。
检查Data Model是否满足同步的要求
模版项目的Data Model非常简单,只有一个Entity
且只有一个Attribute
,当下无需做调整。Data Model
的同步适用规则会在下个章节详细介绍。
image-20210806204211377
修改ContentView.swift
代码语言:javascript复制提醒:模版生成的ContentView.swift是不完整的,需修改后方能正确显示。
var body: some View { NavigationView { // 添加NavigationView List { ForEach(items) { item in Text("Item at (item.timestamp!, formatter: itemFormatter)") } .onDelete(perform: deleteItems) } .toolbar { HStack { // 添加HStack EditButton() Button(action: addItem) { Label("Add Item", systemImage: "plus") } } } } }
修改后,可以正常显示Toolbar按钮了。
至此,我们已经完成了一个支持Core Data with CloudKit
的项目了。
运行
在模拟器上或实机上设置并登录相同的iCloud
账户,只有同一个账户才能访问同一个iCloud
私有数据库。
下面的动图,是在一台实机(Airplay
投屏)和一个模拟器上的运行效果。
syncToPrivateDB
视频经过剪辑,数据的同步时间通常为15-20秒左右。
从模拟器上进行的操作(添加、删除)通常会在15-20秒中左右会反应到实机上;但从实机上进行的操作,则需要将模拟器切换到后台再返回前台才能在模拟器中体现出来(因为模拟器不支持静默通知响应)。如果是在两个模拟器间进行测试,两端都需要做类似操作。
苹果文档对同步 分发的时间描述为不超过1分钟,在实际使用中通常都会在10-30秒左右。支持批量数据更新,无需担心大量数据更新的效率问题。
当数据发生变化时,控制台会有大量的调试信息产生,之后会有专文涉及更多关于调试方面的内容。
创建可同步Model的注意事项
要在Core Data
和CloudKit
数据库之间完美地传递记录,最好对双方的数据结构类型有一定的了解,具体请参阅Core Data with CloudKit (一) —— 基础[3]。
CloudKit Schema
并不支持Core Data Model
的所有功能、配置,因此在设计可同步的Core Data
项目时,请注意以下限制,并确保你创建了一个兼容的数据模型。
Enitites
•CloudKit Sechma
不支持Core Data
的唯一限制(Unique constraints
)
Core Data
的Unique constraints
需要SQLite
提供支持,CloudKit
本身并非关系型数据库,因此不支持并不意外。
CREATE UNIQUE INDEX Z_Movie_UNIQUE_color_colors ON ZMOVIE (ZCOLOR COLLATE BINARY ASC, ZCOLORS COLLATE BINARY ASC)
image-20210807090639166
Attributes
•不可以有即为非可选值
又没有默认值
的属性。允许:可选 、有默认值、可选 有默认值
image-20210807091044353
上图中的属性 非Optional
且 没有Default Value
是不兼容的形式,Xcode
会报错。
•不支持Undefined
类型
image-20210808073123665
Relationships
•所有的relationship必须设置为可选(Optional
)•所有的relationship必须有逆向(Invers
)关系•不支持Deny
的删除规则
CloudKit
本来也有一种类似于Core Data
关系类型的对象——CKReference
。不过该对象最多只能支持对应750条记录,无法满足大多数Core Data
应用场景的需要,CloudKit
采用将Core Data
的关系转换成Record Name
(UUID
字符串形式)逐条对应,这导致CloudKit
可能不会原子化(atomically
)地保存关系变化,因此对关系的定义做出了较严格的限制。
在Core Data
日常始终中,多数的关系定义还是能满足上述的要求。
Configurations
•实体(Entity
)不得与其他配置(Configuration
)中的实体建立relationship
官方文档中这个限制我比较困惑,因为即使不采用网络同步,开发者也通常不会为两个Configuration
中的实体建立relationship
。如果需要建立联系,通常会采用创建Fetched Properties
。
image-20210807094550677
在启用
CloudKit
同步后,如果Model
不满足同步兼容条件时Xcode
会报错提醒开发者。在将已有项目更改为支持Core Data with CloudKit
时,可能需要对代码做出一定的修改。
在现有Core Data项目中添加Host in CloudKit支持
有了模版项目的基础,将Core Data
项目升级为支持Core Data with CloudKit
也就非常容易了:
•使用NSPersistentCloudKitContainer
替换NSPersistentContainer
•添加CloudKit
、background
功能并添加CloudKit container
•配置上下文
以下两点仍需提醒:
CloudKit container
无法认证
添加CloudKit container
时,有时候会出现无法认证的情况。尤其是添加一个已经创建的container
,该情况几乎必然发生。
CoreData: error: CoreData CloudKit: -[NSCloudKitMirroringDelegate recoverFromPartialError:forStore:inMonitor:]block_invoke(1943): <NSCloudKitMirroringDelegate: 0x282430000>: Found unknown error as part of a partial failure: <CKError 0x28112d500: "Permission Failure" (10/2007); server message = "Invalid bundle ID for container"; uuid = ; container ID = "iCloud.Appname">
解决的方法为:登录开发者账户->Certificates,Identifiers&Profiles
->Identifiers App IDs
,选择对应的BundleID
,配置iCloud
,点击Edit
,重新配置container
。
image-20210807100856319
使用自定义的NSPersistentStoreDescription
有些开发者喜欢自定义NSPersistentDescription
(即使只有一个Configuration
),这种情况下,需要显式为NSPersistentDescription
设置cloudKitContainerOptions
,例如:
let cloudStoreDescription = NSPersistentStoreDescription(url: cloudStoreLocation)cloudStoreDescription.configuration = "Cloud" cloudStoreDescription.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: "your.containerID")
即使不将Model Editor
中的Configuration
设置为Used with CloudKit
,网络同步功能同样生效。勾选Used with CloudKit
的最大好处是:Xcode
会帮你检查Model
是否兼容CloudKit
。
有选择的同步数据
在实际应用中,有某些场景我们想有选择性地对数据进行同步。通过在Data Model Editor
中定义多个Configuration
,可以帮助我们实现对数据同步的控制。
配置Configuration
非常简单,只需将Entity
拖入其中即可。
在不同的Configuration中放置不同的Enitity
假设以下场景,我们有一个Entity
——Catch
,用于作为本地数据缓存,其中的数据不需要同步到iCloud上。
苹果的官方文档以及其他探讨Configuration的资料基本上都是针对类似上述这种情况
我们创建两个Configuration
:
•local——Catch
•cloud——其他需要同步的Entities
采用类似如下的代码:
代码语言:javascript复制let cloudURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! .appendingPathComponent("cloud.sqlite")let localURL = FileManager.default.urls(for:.documentDirectory, in:.userDomainMask).first! .appendingPathComponent("local.sqlite")let cloudDesc = NSPersistentStoreDescription(url: cloudURL)cloudDesc.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: "your.cloudKit.container")cloudDesc.configuration = "cloud"let localDesc = NSPersistentStoreDescription(url: localURL)localDesc.configuration = "local"container.persistentStoreDescriptions = [cloudDesc,localDesc]
只有Configuration cloud
中的Entities
数据会被同步到iCloud
上。
我们不可以在跨Configuration
的Entity
之间创建relationship
,如确有需要可以使用Fetched Preoperties
达到受限的近似效果
在不同的Configuration中放置同一个Entity
如果想对**同一个Entity
**的数据进行同步控制(部分同步),可以使用下面的方案。
场景如下:假设有一个Entity
——Movie
,无论出于什么理由,你只想对其中的部分数据进行同步。
•为Movie
增加一个Attribute
——local:Bool
(本地数据为true
,同步数据为false
)•创建两个Configuration
——cloud
、local
,在两个Configuration
中都添加上Moive
•采用和上面一样的代码,在NSPersistentCloudKitContainer
中添加两个Description
当fetch Movie
的时候,NSPersistentCoordinator
会自动合并处理两个Store
里面的Moive
记录。不过当写入Movie
实例时,协调器只会将实例写到最先包含Movie
的Description
,因此需要特别注意添加的顺序。比如container.persistentStoreDescriptions = [cloudDesc,localDesc]
,在container.viewContext
中新建的Movie
会写入到cloud.sqlite
中•创建一个NSPersistentContainer
命名为localContainer
,只包含localDesc
(多container
方案)•在localDesc
上开启Persistent History Tracking
•使用localContainer
创建上下文写入Movie
实例(实例将只保存到本地,而不进行网络同步)•处理NSPersistentStoreRemoteChange
通知,将从localContainer
中写入的数据合并到container
的viewContext
中
我目前没有找到任何资料解释为什么协调器可以合并查询多个Store
中的*同一个Entity
,但在实际使用中确实可以实现预期中的结果。*
以上方案需要使用Persistent History Tracking
,更多资料可以查看我的另一篇文章【在CoreData中使用持久化历史跟踪】[4]。
总结
在本文中,我们探讨了如何实现将本地数据库同步到iCloud
私有数据库。
下一篇文章让我们一起探讨如何使用CloudKit
仪表台。从另一个角度认识Core Data with CloudKit
。
引用链接
[1]
Core Data with CloudKit (一) —— 基础: https://fatbobman.com/posts/coreDataWithCloudKit-1/
[2]
Apple Developer Program: https://developer.apple.com/programs/
[3]
Core Data with CloudKit (一) —— 基础: https://fatbobman.com/posts/coreDataWithCloudKit-1/
[4]
【在CoreData中使用持久化历史跟踪】: https://www.fatbobman.com/posts/persistentHistoryTracking/