Core Data with CloudKit(五)—— 同步公共数据库

本文将介绍如何通过Core Data with CloudKit将公共数据库同步到本地,在本地创建Core Data数据库镜像。

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 用户共享数据的应用

健康笔记 - 全家人的健康助手

健康笔记适用于任何有健康管理需求的人士。提供了强大的自定义数据类型功能,可以记录生活中绝大多数的健康项目数据。你可以为每个家庭成员创建各自的记录笔记,或者针对某个特定项目、特定时期创建对应的笔记。

推荐

三种 CloudKit 数据库

介绍一下CloudKit中的三种数据库:

公共数据库

公共数据库存放的是开发者希望任何人都能够访问的数据。不可以在公共数据库中添加自定义Zone,所有的数据都保存在默认的区域中。无论用户是否有iCloud账户,都可以通过应用程序或CloudKit Web服务访问其中的数据。公共数据库的内容在CloudKit仪表台是可见的。

公共数据库的数据容量计入应用程序的CloudKit存储配额。

私有数据库

这是iCloud用户存储个人数据的地方,用户将不希望公众看到的内容通过应用程序保存在这里。只有在登录了iCloud账户后,用户才可以访问其中的数据。默认情况下,只有用户本人才能访问自己的私有数据库中的内容(可将部分内容分享给其他的iCloud用户)。用户对数据拥有全部的操作权限(创建、查看、更改、删除)。私有数据库中的数据在CloudKit仪表台中是不可见的,对开发者是完全保密的。

开发者可以在私有数据库中创建自定义区域,便于组织管理数据。

私有数据库的数据容量计入用户的iCloud存储配额。

共享数据库

iCloud用户在共享数据库中看到的数据,是其他的iCloud用户共享给你的数据投影,这些数据仍然保存在其他人各自的私有数据库中。你不拥有这些数据,并且只有在拥有必要权限的情况下才能查看和修改内容。只有已经登录了iCloud账户,此数据库才可用。

例如你将某条数据共享给某个用户,该数据仍保存在你的私有数据库中,但被共享者由于你的授权可以在他的共享数据库中看到该记录,且只能依据你设定的权限进行操作。

共享数据库中不可以自定义区域。其中的数据在CloudKit仪表台中不可见。

共享数据库的容量计入应用程序的CloudKit存储配额。

一样的名词、不一样的含义

Core Data with CloudKit(二) 中,我们介绍了如何同步本地数据库到iCloud私有数据库,本篇我们谈的是如果将共享数据库同步到本地。尽管两篇文章都在聊关于同步的话题,但这两个同步的内在含义和逻辑是不一样的

同步本地数据到私有数据库,本质上讲仍是一个标准的Core Data项目,开发者从模型设计到代码开发,同开发【仅支持本地持久化数据库的项目】没有不同。CloudtKit仅起到一个将数据同步到用户其他设备的桥梁作用。在绝大多数的情况下,开发者在使用托管对象时可以完全不考虑私有数据库以及CKRecord的存在。

公共数据库同步到本地,则完全不同。公共数据库是网络数据库概念。标准逻辑为开发者在CloudKit仪表台上创建Record Type,通过仪表台或客户端向公共数据库添加CKRecord记录,客户端通过访问服务器获取网络数据记录。Core Data with CloudKit方便我们利用已有的Core Data知识来完成这一过程。同步到本地的数据,是服务器端公共数据库的镜像,在本地通过对托管对象数据的操作间接完成对服务器端CKRecord记录的操作。

后面讨论的鉴权,尽管操作对象为托管对象或本地持久化存储,但检查的却是网络端的记录或数据库。

公共数据库 vs 私有数据库

我们从几个维度来比较一下公共数据库和私有数据库。

鉴权

在不考虑数据共享的情况下,私有数据库中的数据只有用户自己(已登录iCloud账户)可以访问。用户作为数据的创建者拥有所有的操作权限。私有数据库的鉴权规则非常简单:

image-20210812153836921

iCloud 仪表台 一文中,我们介绍了安全角色的概念。系统为公共数据库创建了 3 个预置角色:WorldAuthenticated以及Creator。在公共数据库中,鉴权时需要考虑用户是否已登录iCloud账户、是否为数据记录的创建者等多种因素。

image-20210812154950463
  • 每个用户都可以读取记录(无论是否登录账户)
  • 每个已登录账户的用户都可以创建记录
  • 已登录用户只能修改或删除自己创建的记录

通过标准CloudKit API来判断权限除了代码量较多外,鉴权时间也较长(每次都需要访问服务器才能获得结果)。Core Data with CloudKit通过在本地备份CKRecord的元数据的方式,完美解决了鉴权效率问题,并提供了便捷API供开发者调用。

我们可以通过类似的代码来判断,用户是否对当前的托管对象(ManagedObject)有修改删除的权限:

let container = PersistenceController.shared.container

if container.canUpdateRecord(forManagedObjectWith:item.objectID) {
    // 修改或删除 itme
}

最近两年,苹果不断提升NSPersistentCloudKitContainer的存在感,为它添加了不少重要的方法。这些方法不仅可以用于公共数据库或其中的托管对象,还可以用于其他类型的数据库或数据(私有数据库、本地数据库、共享数据等)。

  • canUpdateRecordcanDeleteRecord

获取是否具有修改数据的权限。在以下情况都将返回 true:

  1. objectID是临时对象标识符(意味着还没有被持久化)。
    1. 包含托管对象的持久化存储不适用CloudKit(不用于同步的本地数据库)。
    2. 持久化存储管理私有数据库(用户对私有数据库拥有全部权限)
    3. 持久化存储管理公共数据库,并且用户是该记录的创建者,或者Core Data尚未将托管对象更新到iCloud中。
    4. 持久化存储管理共享数据库,并且用户拥有更改数据的权限。

实际使用中canDeleteRecord返回的结果不准,目前推荐大家只使用canUpdateRecord

canUpdateRecord返回false,并非意味着你无法从本地存储删除数据,只意味你并不拥有该托管对象对应的网络记录的修改权限

  • canModifyMangedObject(in:NSPersistentStore)

指示是否可以可以更改特定的持久化存储。

使用此方法确定用户能否将记录写入CloudKit数据库。比如当用户没有登录iCloud账户时,无法写入管理公共数据库的持久化存储。
同样的canModifyManagedObjects返回false,也并非意味着你不可以在本地的sqlite文件中写入数据,仅意味着你不拥有对该持久化存储对应的网络存储的修改权限

由于本地数据和持久化存储是没有权限概念的,开发者很可能编写出尽管没有网络端的权限但仍在本地进行了错误操作的代码。这在同步公共数据库和同步共享数据库的项目中是十分危险的。如果你对一个没有网络端权限的数据记录进行了修改或删除,网络端会拒绝你的请求,Core Data with CloudKit在收到拒绝后会停止之后所有同步工作。因此在编写同步公共数据库或共享数据库的项目时,必须在确保拥有对应的权限后再对数据进行操作

同步机制

export(将本地数据更改同步至服务器)这一侧讲,无论是同步私有数据库还是公共数据库,表现都是一样的。Core Data with CloudKit会在本地数据发生变化后,立即将变化同步给服务器。是一种即时的单向行为。

import(将网络数据的更改同步至本地)角度来将,私有数据库和公共数据库的机制则完全不同。

基础CloudKit 仪表台 两篇文章,我们已经介绍了私有数据库的同步机制:

  • 客户端在服务器订阅CKDatabaseSubscription
  • 服务器端在私有数据库自定义Zone的内容发生变化后,向客户端推送静默远程提醒
  • 客户端收到提醒后,通过CKFetchRecordZoneChangesOperation向服务器端请求变更数据
  • 服务器端在比对令牌后,将令牌更新的变动数据同步给客户端

整个过程有来有往,两方配合共同完成。

由于公共数据库的一些技术限制,上述的机制无法适用于公共数据库的同步。

  • 公共数据库不能自定义Zone
  • 没有自定义Zone则不能订阅CKDatabaseSubscription
  • CKFetchrecordZoneChangesOperation利用了私有数据库的专有技术,公共数据库只能采用CKQureyOperation
  • 公共数据库没有墓碑机制,无法记录全部的用户操作(删除)

由于上述原因,Core Data with CloudKit只能采用轮询方式(poll for changes)来获取公共数据库的变化数据。

当应用程序启动时或每运行 30 分钟,NSPersistentCloudKitContainer都会通过CKQurey操作来查询公共数据库的变化并进行获取数据。import过程是由客户端发起,服务器端响应。

此种同步机制将限制适用场景,只有即时性不高的数据才适合保存在公共数据库中

数据模型

由于同步机制不同,在为公共数据库设计数据模型时须考虑以下几点:

  • 复杂度

公共数据库使用CKQureyOperation查询自上次以来的服务器端变化,它的效率远低于CKFetchRecordZoneChangesOperation。如果能控制ManagedObjectModel的实体、属性数量则查询所需的Request越少,执行效率越高。如无特殊需要,应尽可能减少公共数据库的模型复杂度。

  • 墓碑

私有数据库在收到客户端发送的记录删除操作后,会立即将服务器端的记录删除,并保存删除操作的墓碑标志。其他的客户端设备通过CKFetchRecordZoneChangesOperation获取变更时,私有数据库将变更记录(包括墓碑)一并发送给客户端。客户端根据墓碑指示删除掉本地对应的数据记录,从而保证数据的一致性。

公共数据库也会在收记录删除操作后,立即删除掉服务器端的记录。不过由于公共数据库没有墓碑机制,因此当其他的客户端向它查询是否有数据变化时,公共数据库只会将新增或更改的记录变化告诉客户端设备,无法将删除操作通知给客户端。这意味着,我们无法将删除操作从一个设备传递给另一个设备,两个设备的公共数据库本地镜像将出现差异。

我们在设计公共数据库数据模型时,通过添加一个类似墓碑(比如isDeleted)的属性,尽可能地避免这种差异。

// "删除"时,将 isDelete 设置为 true
if container.canUpdateRecord(forManagedObjectWith:item.objectID){
    item.isDeleted = true
    try! viewContext.save()
}

调用数据时,只获取isDeletedfalse的记录。

@FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
        predicate: NSPredicate(format: "%K = false", #keyPath(Item.isDelete)),
        animation: .default
)
private var items: FetchedResults<Item>

记录并没有被真正删除,只是被屏蔽了。公共数据库可以将记录修改操作在设备间传递,在保证了设备之间数据一致同时,也实现了对数据的"删除"。被"删除"的数据在本地和服务器端仍然占据空间,需谨慎地选择清空其占据空间的时机。

存储配额

私有数据库的数据是保存在用户个人的iCloud空间中的,占用的是其个人空间的容量配额。如果该用户的iCloud空间满了,数据将不能够继续通过网络在各个设备间进行同步。用户可以通过清理个人空间或选择更大的空间方案来解决这个问题。

公共数据库的数据容量占用的是你的应用程序的空间配额。苹果给每一款支持CloudKit的应用都提供了基础的空间容量,限制如下:10GB 的Asset存储,100MB 的数据库,每月 2GB 数据传输量以及每秒 40 次的查询请求。空间、流量、请求数都会根据你应用程序的活跃用户数(16 月内使用过应用)的提高而提高,至多会增加到 10PB、10TB、每天 200TB 的级别。

尽管绝大多数的应用程序都不会超过这些限额,但是作为开发者还是应该尽可能的减少空间的使用量,提高数据响应效率。

Core Data with CloudKit对公共数据库的同步是将整个公共库在本地保存一个镜像,因此,如果不能很好的控制数据量,应用程序对用户设备的占用将十分恐怖。上文采取的"删除"方法还将进一步侵占网络和设备空间。

开发者在项目设计之初就应该考虑好清空伪"删除"数据的时机。

我们无法保证清空一定会发生在所有的客户端都已经同步了"删除"状态,在不影响应用程序业务逻辑的情况下,适当允许设备间的数据不一致是可以接受的。

开发者可以根据应用程序的平均使用频率,在客户端对一定时间前"删除"的数据进行清除操作。尽管Core Data with CloudKit在本地保存了托管对象对应的CKRecord元数据,但没有给开发者提供 API。为了删除方便,我们可以在模型中添加"删除"时间属性,配合清除时的查询工作。

公共数据库的适用场合

通过CloudKit调用公共数据库和通过Core Data with CloudKit同步公共数据库两者的技术特点不同,考虑的侧重点也不一样。

我个人推荐以下几种场合适于使用Core Data with CloudKit同步公共数据库:

  • 只读不写

比如提供模版、初始数据、新闻提醒等。

公共数据库数据的创建、修改、删除均由开发者通过仪表台或特定的应用操作,用户的应用程序仅读取公共数据库的内容,不创建也不更改。

  • 仅处理一条记录

应用程序仅创建一条和用户或设备关联的数据,并仅对该条数据进行内容更新。

通常应用在记录和设备关联的状态或用户(可关联)的状态或 数据。例如游戏高分排行榜(仅保存用户的最高分数)。

  • 只创建不修改

日志类的场景。用户负责创建数据,并不特别依赖数据本身。应用程序定期清除掉本地的过期数据。通过CloudKit Web服务或其他的特定应用对公共数据库记录进行查询或备份并定期清除。

开发者在考虑使用Core data with CloudKit同步公共数据库数据时,一定要仔细考虑各方利弊,选择合适的应用场景。

同步公共数据库

本节大量涉及了 Core Data with CloudKit(二)——同步本地数据库到 iCloud 私有数据库Core Data with CloudKit(三)——CloudKit 仪表台 中的知识,请阅读上述两篇文章后再继续。

项目配置

在项目中配置公共数据库同配置私有数据库几乎完全一致。

  • 在项目TargetSigning&Capabilities中添加iCloud
  • 选择CloudKit并添加Container

如果在项目中仅使用公共数据库,可以不添加Background ModeRemote notifications功能

使用 NSPersistentCloudKitContainer 创建本地镜像

  • Xcode Data Model Editor中创建新的Configuration,并将你想公开的实体(Entity)添加到这个新配置中。
  • 在你的Core Data Stack中(比如模版项目的Persistenc.swift)添加如下代码:
let publicURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("public.sqlite")
let publicDesc = NSPersistentStoreDescription(url: publicURL)
publicDesc.configuration = "public" //Configuration 名称
publicDesc.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: "your.public.containerID")
publicDesc.cloudKitContainerOptions?.databaseScope = .public

代码非常熟悉?那就对了。事实上,同步公共数据库只比同步私有数据库多了一行代码:

publicDesc.cloudKitContainerOptions?.databaseScope = .public

databaseScope是苹果 2020 年为cloudKitContainerOptions新添加的属性。默认值为.private,因此同步私有库时无需设置。

就这?

是的,就这。其他配置都和同步私有数据库一样。将Descriptioin添加到persistentStoreDescriptions,配置上下文,有需要的话配置 Persistent History Tracking

配置仪表台

由于NSPersistentCloudKitContainer对公共数据的获取方式(CKQurey)和对私有数据的获取方式(CKFetchRecordZoneChangesOperation)不同,我们还需要在CloudKit仪表台上对Schema进行一定的修改,才能保证程序的正常运行。

CloudKit仪表台中,选择Indexes,为每个用于公共数据库的Record Type添加两个索引:

image-20210813153127111

在写本文的时候,当我使用Xcode 13 beta5构建演示项目时发现,还需要再增加一个索引才能正常同步公共数据库。如果你使用Xcode 13请在仪表台多添加一个索引Sortable

image-20210813153521321

其他

初始化 Schema

按照上文操作,进行至在CloudKit仪表台上添加索引时,你会发现没有Record Type供你添加索引。这是因为我们并没有在网络数据库端初始化Schema

在网络端初始化 Schema 有两种方法:

  • 创建一个托管对象数据并将其同步到服务器端

服务器在收到数据后,如发现没有对应的Record Type会自动为其创建

  • 使用initializeCloudKitSchema

initializeCloudKitSchema让我们可以在不创建数据的情况下就可以在服务器端初始化Schema。在Core Data Stack中添加下面代码:

try! container.initializeCloudKitSchema(options: .printSchema)

运行项目后,我们就可以在仪表台上看到项目中对应的Record Type了。

该代码只需执行一次,在初始化后将其删除或注释掉。

另外我们也可以在单元测试中使用initializeCloudKitSchema验证Model是否符合同步模型的兼容需求。

let result = try! container.initializeCloudKitSchema(options: .dryRun)

符合兼容需求result为真。.dryRun意味着仅在本地检查,并不在服务器端实际初始化。

多容器、多配置

在之前的文章我们已经提及,可以在一个项目中关联多个CloudKit容器,一个容器也可以对应多个应用程序。

如果你的项目同时使用私有数据库和公共数据库,并且两个容器不一致,除了在项目中对两个容器都进行关联外,在代码中,也需要为Description设置正确的ContainerID

let publicDesc = NSPersistentStoreDescription(url: publicURL)
publicDesc.configuration = "public"
publicDesc.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: "public.container")
publicDesc.cloudKitContainerOptions?.databaseScope = .public

let privateDesc = NSPersistentStoreDescription(url: privateURL)
privateDesc.configuration = "private"
privateDesc.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: "private.container")

公共数据库的NSPersistentStoreDescriptionURL同私有数据库的URL必须是不同的(也就是要创建两个sqlite文件),协调器无法多次加载同一个URL

let publicURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("public.sqlite")

let privateURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("private.sqlite")

Xcode13 beta

Xcode 13 beta好像对CloudKit模块做了未公开的调整。在Xcode 13 beta5下使用Core Data with CloudKit会出现很多奇怪的警告。现阶段,最好使用Xcode 12来进行本文测试。

总结

本地数据同步至私有数据库同步公共数据库在代码中的实现是极为相似的,开发者不要被这种假象所迷惑,一定要认清同步机制的本质,这样才能更好的设计数据模型,规划业务逻辑。

我将在Xcode 13稳定后继续完成本系列的下一篇——同步共享数据库。

希望本文能够对你有所帮助。同时也欢迎你通过 TwitterDiscord 频道或下方的留言板与我进行交流。

本博客文章采用CC 4.0 协议,转载需注明出处和作者。

鼓励作者