前言
在这之前,我发布了一篇文章,在其中解释了如何使用映射模型和自定义迁移策略执行复杂的 Core Data 迁移。虽然这种方法性能良好且运行良好,但很难维护,不适用于应用程序扩展,并且存在高度的错误风险。
例如,对于每个需要自定义迁移的新模型,你需要定义一个映射模型,以定义如何将每个模型的现有版本迁移到新版本。与你可能认为的相反(以及我所认为的),Core Data 在跨多个版本进行迁移时并不会按顺序迭代映射模型,相反,它需要从当前版本到新版本的精确模型。
除此之外,你需要使用 Xcode 的 UI 和映射模型来定义所有这些内容,这使得 PR 难以审查,错误难以发现。出于这些原因,我最近重新设计了我们的迁移流程,改用分阶段迁移,对开发者体验产生了巨大的影响!
什么是分阶段迁移?
正如在 WWDC23 中宣布的那样,与在 Swift 数据模型之间执行迁移的方式非常相似,你现在可以使用 NSStagedMigrationManager
实例以编程方式定义 Core Data 迁移。
该方法通过定义一系列迁移步骤(称为阶段),描述了如何在模型的不同版本之间进行迁移。
例如,假设你的应用程序当前正在使用数据模型的第 1 版,你想要迁移到第 3 版。迁移管理器将顺序应用所有必要的阶段,以从第 1 版迁移到第 2 版,然后从第 2 版迁移到第 3 版。
提供一些背景信息
为了演示 Core Data 分阶段迁移的工作原理,我将使用我之前在有关使用映射模型进行自定义 Core Data 迁移的文章中使用的相同示例。
与之前的文章一样,我们想要将 Track
模型中的 json
属性转换为一个单独的实体,该实体将为每个曲目保存所有相关的艺术家信息。将此属性转换也将使模型更灵活、更易于维护,因为我们将能够删除 json
属性本身和 artistName
,而使用新的关系。
让我们比较一下我们的 Track
模型之前和之后的情况,CoreData.swift 文件代码如下:
swift
Copy code
CoreData.swift
// Before
import Foundation
import CoreData
@objc(Track)
public class Track: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Track> {
return NSFetchRequest<Track>(entityName: "Track")
}
@NSManaged public var imageURL: String?
@NSManaged public var json: String?
@NSManaged public var lastPlayedAt: Date?
@NSManaged public var title: String?
@NSManaged public var artistName: String?
}
// After
@objc(Track)
public class Track: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Track> {
return NSFetchRequest<Track>(entityName: "Track")
}
@NSManaged public var imageURL: String?
@NSManaged public var lastPlayedAt: Date?
@NSManaged public var title: String?
@NSManaged public var artists: NSSet?
@objc(addArtistsObject:)
@NSManaged public func addToArtists(_ value: Artist)
@objc(removeArtistsObject:)
@NSManaged public func removeFromArtists(_ value: Artist)
@objc(addArtists:)
@NSManaged public func addToArtists(_ values: NSSet)
@objc(removeArtists:)
@NSManaged public func removeFromArtists(_ values: NSSet)
}
@objc(Artist)
public class Artist: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Artist> {
return NSFetchRequest<Artist>(entityName: "Artist")
}
@NSManaged public var name: String?
@NSManaged public var id: String?
@NSManaged public var imageURL: String?
@NSManaged public var tracks: NSSet?
@objc(addTracksObject:)
@NSManaged public func addToTracks(_ value: Track)
@objc(removeTracksObject:)
@NSManaged public func removeFromTracks(_ value: Track)
@objc(addTracks:)
@NSManaged public func addToTracks(_ values: NSSet)
@objc(removeTracks:)
@NSManaged public func removeFromTracks(_ values: NSSet)
}
从上面的代码中可以看出,迁移并不是微不足道的,而且,对我们来说,Core Data 不能自动推断它。让我们看看如何使用分阶段迁移以代码形式定义迁移步骤。
创建迁移管理器
要定义我们的阶段,我们需要将我们的模型拆分为三个不同的模型版本和迁移:
- 保持原始模型版本不变。
- 第二个模型版本包含所有属性,并添加
Artist
实体和关系。这将是一个自定义阶段。 - 第三个模型版本删除了
json
和artistName
属性。这将是一个轻量级的阶段。
我们需要将迁移分解为三个阶段的原因是,就目前而言,我们不能在同一个阶段中使用并删除属性。
让我们从创建一个负责创建 NSStagedMigrationManager
实例并定义所有阶段的工厂类开始。StagedMigrationFactory.swift 文件代码如下:
swift
import Foundation
import CoreData
import OSLog
// 1
extension Logger {
private static var subsystem = "dev.polpiella.CustomMigration"
static let storage = Logger(subsystem: subsystem, category: "Storage")
}
// 2
extension NSManagedObjectModelReference {
convenience init(in database: URL, modelName: String) {
let modelURL = database.appending(component: "\(modelName).mom")
guard let model = NSManagedObjectModel(contentsOf: modelURL) else { fatalError() }
self.init(model: model, versionChecksum: model.versionChecksum)
}
}
// 3
final class StagedMigrationFactory {
private let databaseURL: URL
private let jsonDecoder: JSONDecoder
private let logger: Logger
init?(
bundle: Bundle = .main,
jsonDecoder: JSONDecoder = JSONDecoder(),
logger: Logger = .storage
) {
// 4
guard let databaseURL = bundle.url(forResource: "CustomMigration", withExtension: "momd") else { return nil }
self.databaseURL = databaseURL
self.jsonDecoder = jsonDecoder
self.logger = logger
}
// 5
func create() -> NSStagedMigrationManager {
let allStages = [
v1toV2(),
v2toV3()
]
return NSStagedMigrationManager(allStages)
}
// 6
private func v1toV2() -> NSCustomMigrationStage {
struct Song: Decodable {
let artists: [Artist]
struct Artist: Decodable {
let id: String
let name: String
let imageURL: String
}
}
// 7
let customMigrationStage = NSCustomMigrationStage(
migratingFrom: NSManagedObjectModelReference(in: databaseURL, modelName: "CustomMigration"),
to: NSManagedObjectModelReference(in: databaseURL, modelName: "CustomMigration 2")
)
// 8
customMigrationStage.didMigrateHandler = { migrationManager, currentStage in
guard let container = migrationManager.container else {
return
}
// 9
let context = container.newBackgroundContext()
context.performAndWait {
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: "Track")
fetchRequest.predicate = NSPredicate(format: "json != nil")
do {
let allTracks = try context.fetch(fetchRequest)
let addedArtists = [String: NSManagedObject]()
for track in allTracks {
if let jsonString = track.value(forKey: "json") as? String {
let jsonData = Data(jsonString.utf8)
let object = try? self.jsonDecoder.decode(Song.self, from: jsonData)
let artists: [NSManagedObject] = object?.artists.map { jsonArtist in
if let matchedArtist = addedArtists[jsonArtist.id] {
return matchedArtist
}
let artist = NSEntityDescription
.insertNewObject(
forEntityName: "Artist",
into: context
)
artist.setValue(jsonArtist.name, forKey: "name")
artist.setValue(jsonArtist.imageURL, forKey: "imageURL")
artist.setValue(jsonArtist.id, forKey: "id")
return artist
} ?? []
track.setValue(Set<NSManagedObject>(artists), forKey: "artists")
}
}
try context.save()
} catch {
logger.error("\(error.localizedDescription)")
}
}
}
return customMigrationStage
}
// 10
private func v2toV3() -> NSCustomMigrationStage {
NSCustomMigrationStage(
migratingFrom: NSManagedObjectModelReference(in: databaseURL, modelName: "CustomMigration 2"),
to: NSManagedObjectModelReference(in: databaseURL, modelName: "CustomMigration 3")
)
}
}
回到上面的代码,让我们逐步分解:
- 我们定义了一个自定义记录器,以将迁移过程中发生的任何错误报告到控制台。
- 我们扩展了
NSManagedObjectModelReference
,创建了一个方便的初始化方法,它接受数据库 URL 和模型名称,并返回一个新的NSManagedObjectModelReference
实例。 - 我们定义了一个工厂类,负责创建
NSStagedMigrationManager
实例并定义所有阶段。 - 我们使用 bundle 初始化工厂,并检索数据库的 URL、JSON 解码器和记录器。
- 我们创建了
NSStagedMigrationManager
实例,并定义了所有阶段。 - 我们定义了一个方法,该方法将返回从我们模型的第 1 版迁移到第 2 版的迁移阶段。
- 我们创建了一个
NSCustomMigrationStage
实例,并传递我们要从何处迁移和迁移到的对象模型引用。文件名需要与包中的.mom
文件的名称匹配。 - 我们定义了
didMigrateHandler
闭包,在模型迁移后调用。此时,新的模型版本可在上下文中使用,你可以填充其属性。你必须知道,还有一个在先前模型版本上执行的单独处理程序,称为willMigrateHandler
,但我们在这种情况下不会使用它。 - 我们创建了一个新的后台上下文,并获取所有具有
json
属性的曲目。然后,我们将 JSON 字符串解码为Song
对象,并为 JSON 中的每个艺术家创建一个新的Artist
实体。然后,我们将Track
实体的artists
关系设置为新的Artist
实体。 - 我们定义了一个方法,该方法将返回从我们模型的第 2 版迁移到第 3 版的迁移阶段。这个迁移非常简单,事实上,它应该是一个轻量级的迁移。然而,我找不到一个能够在所有情况下使用的
NSLightweightMigrationStage
实例的方法。如果你知道如何做,请告诉我!
设置使用 Core Data 栈。
设置使用分阶段迁移的 Core Data 栈。
现在我们有了创建 NSStagedMigrationManager
实例的方法,我们需要设置我们的 Core Data 栈以使用它。PersistenceController.swift 文件代码如下:
swift
PersistenceController.swift
import CoreData
struct PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "CustomMigration")
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
container.viewContext.automaticallyMergesChangesFromParent = true
if let description = container.persistentStoreDescriptions.first {
if let migrationFactory = StagedMigrationFactory() {
description.setOption(migrationFactory.create(), forKey: NSPersistentStoreStagedMigrationManagerOptionKey)
}
}
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
}
}
这部分非常简单,你只需要将 NSStagedMigrationManager
实例设置为持久化存储描述的选项。
总结
这篇文章介绍了使用分阶段迁移来改进 Core Data 迁移流程的重要性和方法。传统的迁移方法使用映射模型,但这种方法不易维护,扩展性差且容易出错。分阶段迁移通过定义一系列迁移步骤,使得在不同模型版本之间进行迁移变得更加简单和可控。文章以一个示例来说明分阶段迁移的工作原理,以及如何以代码形式定义迁移步骤。最后,文章展示了如何设置使用分阶段迁移的 Core Data 栈。通过使用分阶段迁移,可以显著提高开发者体验,简化迁移流程,并降低错误风险。