CoreData 学习笔记六-CoreData+TableView+CloudKit技术细节

这篇笔记主要是介绍 TableView+CoreData+iCloud 同步的技术细节。之前的笔记 (opens new window)已经介绍过如何在工程和代码中配置 CloudKit 同步了,这里不再赘述。

# CoreData+iCloud 同步机制说明

CoreData 数据同步的具体流程其实官方文档 (opens new window)说的比较清楚,登录了同样 iCloud 账号的 A、B 设备,系统会自动发送和接收内容改变,我们并不需要在工程上添加设备代码。具体流程

  1. 用户在 A 设备上进行数据修改,CoreData 将修改提交到 CloudKit

    Untitled

  2. CloudKit 收到修改然后将存储修改,并准备通知用户的其他设备,CoreData 会将数据保存到本地存储中。

    Untitled

数据层的同步基本上不用我们操心,但是涉及到视图相关的内容,我们还是需要做一些工作,想象一下 A、B 设备在同步数据,我在 A 设备上删除了一条数据,经过 CloudKitCoreData 在后台的辛勤同步工作后,B 设备上对应的数据也删除了,但视图还没有更新,这时候我点击 B 设备上的这条被删除的数据视图想要查看数据详情,发现数据没了,这种情况并不是我们预期要见到的。所以怎么处理这种情况?官方给出的方案是隔离视图和数据存储层的改变,即数据存储层的改变不影响视图的展示。具体是使用 query generations,其实就是本地存储快照。

就是我们初始化 NSPersistentCloudKitContainer 的时候,设置好快照为当前存储状态,当有新的数据同步下来的时候,比如删除行为同步到当前设备之后,你点开被删除的数据视图想要查看数据详情,这时候还是能看到的,本质上看到的是快照,而并非是最新的本地数据存储(官方的解释在这个文档 Accessing Data When the Store Changes (opens new window) 里)这样我们就不会无缘无故出现底层数据被删除,视图层依然存在,导致交互的时候出现异常的情况了。

当调用下面这些方法的时候会自动更新快照:

  • setQueryGenerationFrom(_:)
  • save()
  • mergeChanges(fromContextDidSave:)
  • mergeChanges(fromRemoteContextSave:into:)
  • reset()

在具体实操的时候遇到一点小问题,在初始化 NSPersistentCloudKitContainer 实例的时候,按照官方文档设置 query generation 发现异常崩溃了 Unsupported feature in this configuration ,为啥呢?CoreData 创建 Sqlite 存储的模式默认是 wal,我打印出来日志是 memory,是因为我初始化 NSPersistentStoreDescription 的时候没有传本地文件路径,所以用的是内存中的数据库,设置好本地文件路径就 OK 了。

# 对 automaticallyMergesChangesFromParent 属性的理解

我最开始对这个属性理解是,当设置此属性为 true 的时候,主上下文变动会同步到子上下文,反之为 false 的时候主上下文不会同步到子上下文。于是写了简单的demo验证

//1.现有包含主上下文 mainContext
//2.我创建一个私有主上下文 privateContext
//3.将 privateContext 的 parent 设置为 mainContext
//4.将私有主上下文的 automaticallyMergesChangesFromParent 设置为 false
//5.我预期的结果是:当 mainContext 发生变动的时候,比如插入一条数据,privateContext 并不会获取到这条数据
var mainContext = AppDelegate.shareInstance().persistentContainer.viewContext
var privateContext = NSManagedObjectContext.init(concurrencyType: .privateQueueConcurrencyType)
privateContext.parent = mainContext
privateContext.automaticallyMergesChangesFromParent = false
//
let book = Book.init(context: mainContext)
book.id = id
book.name = name
try? mainContext.save()  //主上下文存储
do {
    privateContext.perform { [weak self] in
        guard let wself = self else { return }
        let bookFetchRequest:NSFetchRequest<Book> = Book.fetchRequest()
        let books = try? wself.privateContext.fetch(bookFetchRequest)
        NSLog("private books \(books)")
    }
} catch {
    NSLog("private context fetch data insert error: \(error)")
}

最后执行代码发现上面的预期的结果没有成立automaticallyMergesChangesFromParent 这个属性对结果似乎没有任何影响。这是为啥?没想明白,于是去肘子老师的 Discord (opens new window) 群里问了问,得到的解释是因为 privateContext 是从持久化存储中获取的数据,所以可以获取到最新的数据。

而且我对这个属性的理解是根本错误的,automaticallyMergesChangesFromParent 这个属性和 context 的 parent 属性根本没什么关系。只要两个 context 共享一个持久化存储协调器(persistent store coordinator)或者 NSPersistentContainer,尽管这俩 context 没有 parent 关系,也会自动合并改变。所以感觉 automaticallyMergesChangesFromParent 这个命名有点让人歧义。

在 iCloud 同步中,当系统维护的同步上下文处理同步数据的时候,主上下文如果开启 automaticallyMergesChangesFromParent 则会自动更新其中有变化的内容。而并不是我之前理解的主上下文的 parent 是系统维护的同步上下文。StackOverflow 里的这个问题 (opens new window)automaticallyMergesChangesFromParent 解释的也很好。

If the parent is nil, it's nil, there's no implicit parent relationship. When the parent context is nil, automaticallyMergesChangesFromParent  automatically merges changes saved to its persistent store coordinator. It's not a parent context but it does some parent context-like things here. As long as the two contexts use the same persistent store coordinator (or the same NSPersistentContainer ) then this will automatically merge changes without a parent context relationship.

# iCoud 同步开关

iCloud 同步开关的设置,现在很多使用 CoreData+iCloud 方案开发者都会在应用内部提供关闭数据开关的入口。要区分系统 iCloud 同步开关和应用内同步开关的作用。

如果是关闭了系统的 iCoud 同步则会从系统层面禁止本地数据和 iCloud 同步,系统内所有应用都会停止数据同步,并且 NSPersistentCloudContainer 也会自动删除应用数据库中的所有数据。如果有关闭 iCloud 照片同步经验的朋友应该知道,当你关闭 iCoud 照片同步的时候会给你提供两个选项,可以从 iPhone 中移除,也可以可以下载。对于普通应用来说并没有给你选择的权利,而是直接给你删除,相当于你选择了「从 iPhone 移除」的选项。

Untitled

所以很多类似的 App 提供了 App 内数据同步的选项,这样用户可以在不关闭系统 iCloud 同步的情况下关闭当前 App 的数据同步。如何实现呢?两种实现方式

  • 非实时切换
  • 实时切换

区别在于开启或者关闭同步开关之后,是否需要重新启动应用。接下来只是说明非实时切换的做法和原理,实时切换的原理和具体做法可以参考肘子老师的这篇文章实时切换 Core Data 的云同步状态 (opens new window),写的非常清楚。

非实时切换的做法其实很简单就是本地存储开关变量,启动时候判断开关,如果是开的话就设置 NSPersistentStoreDescriptioncloudKitContainerOptions 属性为 NSPersistentCloudKitContainerOptions 实例,这样可以把本地存储和云端 container 关联起来进行 iCloud 同步,否则就不设置这个属性就 OK。

lazy var container:NSPersistentCloudKitContainer = {
    let container = NSPersistentCloudKitContainer(name: "Model")
    let enableMirror = UserDefaults.standard.bool(forKey: "enableMirror")
    if enableMirror {
        container.persistentStoreDescriptions.first?.cloudKitContainerOptions = .init(containerIdentifier: "YourCloudKitContainerID")
    }
    container.loadPersistentStores{ desc,error in
			//
    }
    return container
}()

# 部署相关

参考地址:

  1. Guide-Syncing a Core Data Store with CloudKit (opens new window) #数据同步的大致介绍
  2. Guide-Consuming Relevant Store Changes (opens new window) #
  3. Apple Sample Code Demo-Synchronizing a local store to the cloud (opens new window)
  4. Core Data with CloudKit(二)——同步本地数据库到iCloud私有数据库 (opens new window)
  5. 实时切换 Core Data 的云同步状态 (opens new window)
  6. What parent is viewContext referring to, when viewContext.automaticallyMergesChangesFromParent set to true? (opens new window)
  7. What Are Core Data Query Generations (opens new window) #第三方关于 QueryGeneration 的说明,带图文,很直观