Core Data 数据迁移拾遗

2,793 阅读7分钟

首先感谢掘金 猫D 的推荐,很多朋友关注了我,由于刚开始写技术文章,并没有写出什么真正的干货,深感愧疚。

今天讨论的是 Core Data 数据迁移中的一些细节问题,参阅了不少资料,进行了反复的验证,可以说填上了不少坑。

本文讨论的范围仅限于自动触发的自定义迁移情况,其他情况后续再补充。

数据备份与还原

在迁移测试前,先将原数据进行备份:

  • 打开 Xcode -> Windows -> Devices ...
  • 选择要备份的 App,点击⚙️小图标,选择 Download container
  • SQLite 文件在此目录下:xxx.xcappdata/AppData/Library/Application Support/xxx.sqlite
  • 如果需要还原数据,在 Xcode 相同的菜单下,选择 Replace Container

新建数据模型版本

  • 选择数据模型文件 xxx.xcdatamodeld,打开菜单 Editor -> Add Model Version,根据提示添加新版本。
  • 在 Xcode 右侧文件属性中,选择 Model Version 为新建的版本。
  • 在新的模型文件中编辑需要的改动。如果如前面所属,两个版本在形式上一致(记录、属性、类型都相同),但是实质又不同,为了能自动触发迁移,需要在有差异的实体或属性上,在 Xcode 面板中找到 Versioning -> Hash Modifier,填写任意的名称,只有这样运行时才会认为两个版本不同。如果有形式上的差异,这个字段可以不用填写。
  • 如果新版本的模型文件不符合要求,想要删除重建时,在 Xcode 中无法直接删除新版本,此时可以在 Finder 中直接操作,通过右键显示包内容进入内部,删除对应的版本文件。但此时文件还是会存在 Xcode 中且以红色显示,打开工程目录下的 project.pbxproj,按模型文件名搜索,删除对应的行即可。

建立映射模型

  • 要进行自定义迁移,必须要有映射模型,它的作用就是告诉 App 老版数据怎么转移到新版模型里去。这一步一定要在新的数据模型版本最终确定后,再来操作,否则如果不一致运行时就会报错提示找不到映射模型。如果发现模型版本要回去改,那么最好是删除并重新创建映射模型。

  • 模型中有任何变动,包括修改 Hash Modifier 等,也可以通过刷新的方式更新 Mapping Model,选中映射模型文件,打开菜单 Editor -> Refresh Data Models,这个时候会发现 Xcode 中值都变空了,右键点击映射模型文件 Open As -> Quick Look ,再点击一次 Open As -> Mapping Model,这时候就刷新显示了。

  • 有一种情况也会造成报错找不到映射模型,就是已安装的 App 中的 SQlite 文件会记录模型版本的哈希值,这个值与当前运行的模型版本计算出的值是不一致的,造成这个的原因就是一定修改过了模型版本。凡是在 Xcode 编辑器中对模型文件做了任何修改,包括上述的 Hash Modifier 等等,都会造成哈希值不同,App 就会认为发现了一个新的模型文件版本,但现有的映射模型是不匹配的。此时只能恢复模型文件与 App 中安装的版本保持一致。如果确实要做这些修改,只能老实地再新建一个模型版本,以及新的映射模型。

  • 映射模型中的 Value Expression,实际上是 NSExpression 类型值,因此要按照 NSExpression 的规则来写。它可以进行简单的数学运算(数字类型属性),如 $source.xxx + 10;也可以使用类似于 KVC 中的 KeyPath,如 $source.xxx.yyy,但使用 KeyPath 的方式要注意,xxx 必须为 NSObject 的子类,在 Swift 中使用需注意,另外如果 xxx 为集合类型,还可以使用集合操作符,使用方式参考 KVC 的集合操作。

    对于 yyy 的类型有一个要注意,如果要映射的 xxx 属性类型是 DataTransformable 类型,并且实际存储的是自定义类的情况,那么 yyy 只能引用 xxx 中的存储属性或实例方法(不带参数,带参数的见下一条 FUNCTION 使用),如果直接引用计算属性则会出错(因为实际存储中并没有这个值),但引用计算属性的 get 方法是可以的,如有一个计算属性是 property,那么应该再提供一个 getProperty() 的方法,再使用 $source.xxx.getProperty 引用。

  • 属性映射还有一个方法就是使用 FUNCTION(object, selector, parameter...),类似 objc_msgSend 语法,其中 object 代表迁移过程中可以使用到的对象,例如以下几个都是 Core Data 预设的 key,selector 代表 object 拥有的方法指针,parameter 为具体参数:

// Core Data 预设的 key
NSMigrationManagerKey: $manager
NSMigrationSourceObjectKey: $source
NSMigrationDestinationObjectKey: $destination
NSMigrationEntityMappingKey: $entityMapping
NSMigrationPropertyMappingKey: $propertyMapping
NSMigrationEntityPolicyKey: $entityPolicy

其中 selector 的写法需要非常注意,在 swift 中如果你的方法是 combine(firstName:String, lastName: String),那么在 FUNCTION 中就要写成 combineWithFirstName:lastName:,中间要加 "With",Objc 中应该也是类似。如果第一个参数名是 withfrom(第一个字母均为小写),或者方法名以 WithFrom 结尾(注意第一个字母要大写),那就不用再加 With,如果不是这种命名方式,编译时程序就会在第一个参数名前自动加上 With 进行匹配。如果不确定怎么写方法名,可以在 playground 中打印出来,如:

class Test {
    @objc func combine(firstName: String, lastName: String) -> String {
        return firstName + lastName
    }
}

print(#selector(Test.combine(firstName:lastName:))) // combineWithFirstName:lastName:
  • 如果以上写法都无法完成映射转换,那就要自定义迁移策略了。

自定义迁移策略

  • 这个策略其实只针对映射过程,在 Xcode 编辑器中无法满足映射需求时,需要自定义迁移策略类。
  • 创建 NSEntityMigrationPolicy 的子类,通过重写类中的方法,实现自定义映射,如以下代码完成目标对象的映射过程,所有未在代码中自定义的映射都会在映射模型中去找,所以只写非直接复制的部分:
final class V1To2Policy: NSEntityMigrationPolicy {
    override func createDestinationInstances(forSource sInstance: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager) throws
    {
        try super.createDestinationInstances(
            forSource: sInstance, in: mapping, manager: manager)
        guard let xxx = sInstance.value(forKey: "xxx") else { return } // 获取原始属性值
        let newValue = .... // 计算映射后的属性值
        guard let newItem = manager.destinationInstances( // 获取映射后的新对象
            forEntityMappingName: "XXXToXXX", // 注意这里要与映射模型中的实体映射名称一致
            sourceInstances: [sInstance]).first else { return }
        newItem.setValue(newValue, forKey: "xxx") // 设置新对象的属性值
    }
}
  • 最后将定义完的迁移策略,填写在映射模型中实体映射的 Custom Policy 字段中,策略名称一定要按照这个规范填写:ModuleName.CustomPolicyClassName,例如你要运行的 Target 名称为 ExampleApp,自定义策略类名为 CoreDataModelV1ToV2,那么最终就填写 ExampleApp.CoreDataModelV1ToV2。另外还有一个特例,如果 Target 名称以数字开头,如 1ExampleApp,实际应该填写(1改为下划线): _ExampleApp.CoreDataModelV1ToV2 这个可能是 Xcode 自动做的处理。不确定 ModuleName 的,随便找个 storyboard 或 xib 文件查看源码,找到 customModule 字段里是什么值,这个就代表你的 Target ModuleName

托管对象子类

  • 数据模型变化之后,托管对象子类也需要进行相应改动,这里只需要按照最新的模型版本改动即可。但在原始模型的实体属性中,或者迁移策略中,如果使用到了原有的类型或方法,注意保留,否则读取原始数据或迁移时将报类型错误(这也是产生多余兼容代码的一个方面,应尽量避免这样的设计,例如对于实体属性中 Data、Transformable 类型,尽量不要直接存储自定义类型,而应存储基本类型及其组合,再通过方法转换到自定义类型上。)。

迁移选项设置

  • 迁移标志:NSPersistentContainer 有一个属性 persistentStoreDescriptions,或者 NSPersistentStoreCoordinatoraddPersistentStore 方法中有一个 options 选项,在此选项中添加 Migration Options
// iOS 10 及以上写法
container.persistentStoreDescriptions[0].shouldMigrateStoreAutomatically = true
container.persistentStoreDescriptions[0].shouldInferMappingModelAutomatically = false

// iOS 10 以下写法
let options = [NSMigratePersistentStoresAutomaticallyOption: true,
               NSInferMappingModelAutomaticallyOption: false]
do {
    try coordinator.addPersistentStore(ofType: NSSQLiteStoreType, configurationName: nil, at: storeURL, options: options)
} catch {
    fatalError("Failed to add persistent store: \(error)")
}

Core Data 还有很多说不完的话题,慢慢来吧。


欢迎访问 我的个人网站 ,阅读更多文章。

题图:Zigzag - la_paupiette_masquee @unsplash