Core Data 在 iOS 13 上的实践经验

本文围绕 NSPersistentCloudKitContainer 简单介绍最新的 Core Data 实践经验,分享几个比较难直接通过搜索解决的实践问题。

苹果在 iOS13 新推出的 NSPersistentCloudKitContainer API 让应用支持 iCloud 云端同步变得空前容易。

根据 WWDC 视频的介绍,只要应用足够好地遵循 Core Data 最佳实践,那么在你现在代码的基础上,初始化 NSPersistentContainer 的时候换成 NSPersistentCloudKitContainer 就可以了。

首先必须承认 Core Data 是有学习成本的,如果仅仅把 Core Data 当作一个数据库,是不全面的。先看看苹果对它的定义,persistence 只是 Core Data 的其中一部分:

Core Data is a framework that you use to manage the model layer objects in your application. It provides generalized and automated solutions to common tasks associated with object life cycle and object graph management, including persistence.

就像 Auto Layout 一样,建议先通读一下官方的文档以及观看最近几年的 WWDC 视频。因为如果你不事先学习一番它的设计原则和最佳实践,比较难写出干净好用的代码。

总结一下 Core Data 带来的好处,来说明我最近为什么会选择 Core Data 作为应用储存方案:

  1. 可维护性高,正确使用 Core Data ,数据层代码量少
  2. Model.xcdatamodeld 自动数据库迁移
  3. 通过 NSPersistentCloudKitContainer 实现 CloudKit 云同步 4. NSFetchedResultsController 实现界面增量更新方便
  4. NSManagedObjectContext 的 Multi-context 支持,是代码架构变得更简单
  5. Undo / Redo API 实现数据修改的撤回
  6. 跟苹果原生机制一样安全,无需用户另外登陆

归根结底,因为是苹果原生的数据储存方案,苹果给我们解决了数据库设计、维护、升级时候会出现的问题,结果就是我们需要做的事情少了,但是实现的功能更强大。

当然作为 iOS 者,能凭实力顺利撑到现在,不能只靠苹果的 “Just works”。

下面通过几个小案例介绍一下我开发中自己遇到过的几个问题。

如何配置 NSPersistentCloudKitContainer 实现云同步?

如果你的项目已经支持 Core Data,那么恭喜你,只需要将 NSPersistentContainer 初始化时换成 NSPersistentCloudKitContainer 就可以了。

为了支持用户离线使用,需要设置 NSPersistentHistoryTrackingKey 这个值,设置之后,将来如果用户重新打开了 CloudKit,本地数据会被继续同步。

虽然打开同步功能容易,但是要友好地支持 Core Data 云同步并非一件轻而易举的事情。你需要遵循大部分 Core Data 最佳实践,譬如 NSManagedViewContext 的管理、model 对象的线程问题以及保存时机的问题等。关于这些知识,建议通过 WWDC 视频、苹果文档和 Stackoverflow 进行学习。

if #available(iOS 13.0, *) {
    persistentContainer = CloudKitDataPersistentContainer(name: Constants.modelName)
} else {
    persistentContainer = DataPersistentContainer(name: Constants.modelName)
    let description = persistentContainer.persistentStoreDescriptions.first
    // This allows a 'non-iCloud' sycning container to keep track of changes if a user changes their mind
    // and turns it on.
    description?.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
}

善用 NSManagedObjectContext 的 Multi-Context

为 NSManagedObjectContext 创建 child context 是我非常喜欢的一个 Core Data 特性。NSManagedObjectContext 套嵌是 Core Data 设计原则之一。

运用 Multi-Context 特性,可以使用非常少量的代码,实现一些其他数据库需要花很多时间实现的功能。譬如数据的 Redo / Undo ,临时数据的修改和保存时机的把握。另外,Multi-Context 使 ViewController 不用担心对其他场景的数据影响,代码会变得简单以及可拓展,同时也易于测试。

关于多 context 的 Core Data 系统设计,本文说不完,有两篇很不错的文章值得参考:

  1. Multi-Context CoreData
  2. Core Data background context best practice
class ExampleListVC: UIViewController {
    private let scratchContext: NSManagedObjectContext

    init(_ viewContext: NSManagedObjectContext) {
        mainViewContext = viewContext
        // configure with a child view context so that we can make temporary changes freely
        self.scratchContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
        scratchContext.parent = viewContext
        scratchContext.undoManager = UndoManager()
        scratchContext.name = "Example-Scratch"
}

如何允许用户打开或者关闭 iCloud 同步?

通常我们会使用一个开关来允许用户打开或者关闭 iCloud 同步,使用 NSPersistentCloudKitContainer 时如何实现呢?

看到 Stackoverflow 上有人说只有重启 App 才能切换 CloudKit 的配置,事实上这是不对的。在共享同一个数据库文件的情况下,只需要重建 NSPersistentContainerNSPersistentCloudKitContainer,然后使用新的 container 来重载界面就行了。

示例代码如下

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    softBootApp()
    return true
}

// 用户点击开关之后,执行一次这个方法
func softBootApp() {
    let cloudKitEnabled = UserDefaults.standard.bool(forKey: .cloudKitEnabled)
    self?.engine = DataEngine(cloudKitEnabled: cloudKitEnabled, completionClosure: { engine in
        self?.setupMainVC(viewContext: engine.viewContext)
    })
}

func setupMainVC(viewContext: NSManagedObjectContext) {
    let exampleListVC = ExampleListVC(viewContext)
    let navVC = UINavigationController(rootViewController: exampleListVC)

    window = UIWindow(frame: UIScreen.main.bounds)
    window?.rootViewController = navVC
    window?.makeKeyAndVisible()
}

巧用 NSInMemoryStoreType

使用 Core Data 可以方便地创建一个内存版本的数据库,我发现在下面两个场景下非常实用:

  1. 临时数据展示:譬如应用首次下载安装,我们需要展示事例数据,实用一个纯内存版本的 Core Data 数据库,基本上可以实现对原有逻辑零侵入。
  2. 单元测试
// Creating
persistentContainer = NSPersistentContainer(name: Constants.modelName)
let persistentStoreDescription = NSPersistentStoreDescription()
persistentStoreDescription.type = NSInMemoryStoreType
persistentStoreDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
persistentContainer.persistentStoreDescriptions = [persistentStoreDescription]

使用 CKContainer 查找云端数据

这里有几个坑:

  1. 注意 identifier 必须跟 CloudKit Dashboard 的匹配,如果使用CKContainer.default()来初始化,系统会使用一个跟 bundle identifier 相似的ID来寻找。在 CloudKit Dashboard 查看id之后,可以使用 .init(identifier:) 来初始化。我因为这 ID 不匹配的原因耽误了不少时间。
  2. recordType 跟本地数据库表名会不一样,云端表名以及字段都会自动加上 CD_ 前缀,检索的时候必须使用云端表名以及字段。

Debug 时候的一个小技巧,可以把 CloudKit 的 error 打印出来,一般会告诉你错误发生在哪里。同时可以在 CloudKit Dashboard 查看实时 Logs 来调试。

之后,就可以像下面这样使用 CKQuery 来查找 CloudKit 的数据:

let query = CKQuery(recordType: "CD_Habit", predicate: NSPredicate(format: "CD_isExample = %@", NSNumber(true)))
let cloudDatabase = CKContainer.init(identifier: "your.app.Identifier").privateCloudDatabase

cloudDatabase.perform(query, inZoneWith: nil) { (records, error) in
    print("error \(error)")
    records?.forEach({ (record) in
    print("\(record)")
  })
}

将 Core Data 类放在非主工程里面发生报错

解决办法是创建一个类,继承 NSPersistentContainerNSPersistentCloudKitContainer,然后在主工程里使用这个类。

class DataPersistentContainer: NSPersistentContainer { }

@available(iOS 13.0, *)
class CloudKitDataPersistentContainer: NSPersistentCloudKitContainer { }
Posted 2019-12-15

More writing at jakehao.com