以 SwiftData 之道,重塑 Core Data 开发

发表于

为您每周带来有关 Swift 和 SwiftUI 的精选资讯!

在现代应用开发中,高效的持久化框架至关重要。SwiftData 的出现,曾让众多 Core Data 开发者眼前一亮,大家期待着一个融合现代编程理念的新时代到来。本篇文章将探讨如何在 Core Data 中应用 SwiftData 的思维方式,重点关注数据建模和并发编程。

本文将不深入讨论技术细节,而是侧重于探讨设计思路、策略和注意事项。

2024 年了,SwiftData 准备好了吗?

SwiftData 在 WWDC 2023 上的突然亮相,为开发者带来了极大的惊喜。尽管首个版本(iOS 17)功能尚未完善、性能略显逊色,但它已经展现出了令人兴奋的潜力。随着小版本的迭代更新,SwiftData 的稳定性逐步提升,赢得了部分开发者的青睐。

一些喜欢尝鲜的开发者已将 SwiftData 应用于新项目中,他们惊喜地发现,这个框架不仅体现了现代编程思想,还显著提升了开发效率,简化了复杂操作。更令人欣喜的是,SwiftData 与 SwiftUI 的无缝协作,以及对 Swift 严格并发检查的完美契合,让开发者们看到了一个充满希望的未来。许多人坚信,SwiftData 终将成为苹果生态系统中最重要的数据管理框架。

然而,iOS 18 的发布给这美好的愿景蒙上了一层阴影。在首次亮相一年后,SwiftData 经历了一次重大的底层重构。这次调整虽然是为了从与 Core Data 的强耦合转向更加灵活的多持久化方案支持,方向无疑是正确的,但似乎由于转变过大,导致新版本的稳定性受到了相当大的冲击。

令人沮丧的是,大量在 iOS 17 上运行良好的 SwiftData 代码在新版本中出现了各种问题,这些问题对于一个肩负重任的数据持久化框架来说,无疑是致命的。更让人担忧的是,这些问题的复杂性意味着它们可能无法在短期内得到彻底解决。可以预见,在整个 iOS 18 周期中,选择使用 SwiftData 的开发者将不得不与这些挑战持续搏斗。

作为一个对 SwiftData 充满热情的开发者,我一直密切关注着它的发展。通过深入学习和实践,我不仅厘清了一些在 Core Data 中未能理解的概念,还大大提升了对现代编程范式、API 设计,尤其是 Swift 语言新特性的认知。去年,我满怀信心地开始用 SwiftData 重构一个应用。尽管过程中遇到了不少挑战,主要是因为某些功能的缺失,但整体开发体验依然令人愉悦。

然而,SwiftData 在 iOS 18 上的表现让我陷入了两难。对于一个以数据管理为核心的应用来说,稳定性和可靠性是不可妥协的。经过反复权衡,我不得不做出一个艰难的决定:放弃已经完成的数千行 SwiftData 代码,重新拥抱 Core Data。

这个决定并非轻易做出。我深知 SwiftData 的潜力和优势,但在当前阶段,项目的稳定性和可靠性必须放在首位。尽管如此,SwiftData 带给我的启发并未白费。在重新构建 Core Data 项目时,我决定将从 SwiftData 中学到的现代化思维融入其中,用更富有创新性的方式来驾驭这个历经考验的框架。

相较于 Core Data,SwiftData 在多个方面都展现出了革新性的突破。在接下来的内容中,我们将重点探讨两个关键领域:数据建模和并发编程。这两个方面是 SwiftData 相对于 Core Data 最具优势的部分。通过深入分析,我们将探索如何在 Core Data 中注入 SwiftData 的先进理念,从而打造出既稳定可靠,又富有现代感的数据管理方案。

声明数据模型

建模思路

在使用 Core Data 构建数据模型时,开发者通常会首先使用到 Xcode 提供的模型编辑器。尽管苹果试图在编辑器中淡化数据模型与底层数据库之间的关系(例如实体对应表、属性对应字段),但开发者难免会在编辑器模式下受到影响,往往会采用注重性能和空间效率的建模策略。另外随着模型的复杂性增加,逐个调整每个实体对应的声明代码变得困难,许多开发者会直接使用 Xcode 自动生成的模型代码,或仅进行少量调整。

SwiftData 从根本上反转了建模思路。开发者首先按照 Swift 标准类来声明实体类型,这使得开发者更关注模型在数据操作和视图代码中的易用性和语义表达。至于如何在持久化存储中保存,只要符合建模规范,SwiftData 会自动进行转换。

考虑到在目前的苹果生态下,开发者更多地将持久化框架用于移动端、桌面端的应用,因此,构建一个适合表现层面的模型声明代码更符合大多数需求,最终的实体对应的代码应当能够方便且无需调整地用于视图和其他代码中。

在实践中,我采用了以下步骤:

  • 初步声明模型:首先不考虑与底层的对应关系,按照 Swift 标准类声明实体类型,确保语义清晰,类型明确,便于使用。
  • 优化底层模型:在模型编辑器中构建实体时,优先考虑性能和存储容量,不必刻意与用于表现层的声明代码对应,并且关闭模型代码的自动生成。
  • 调整声明代码:将初步声明的 Swift 类调整为符合托管对象的格式,对属性进行逐一检查,对于无法直接对应的属性和类型,在声明代码层面进行转换。
  • 构建安全的构造方法:确保模型实例在创建时就具备必要的数据,提高代码的安全性和可读性。

在 SwiftData 中,我们只需完成第一步,剩余的部分由它自动完成。尽管在 Core Data 中这样做会增加一些工作量,但由于我们可以严格控制转换方式和底层类型,对模型的可控性相比 SwiftData 反到有所提高。

可选值与默认值

在 Core Data 中,模型编辑器中的可选值选项常常让开发者感到困惑,因为它并不等同于 Swift 语言中的可选类型,两者之间并非严格对应。例如,在模型编辑器中,一个设置了默认值且关闭了可选值选项的字符串属性,在自动生成的代码中对应的是 String?;而一个开启了可选值选项的浮点数属性,则对应非可选的 Double

提示:模型编辑器中的可选值选项 早于 Swift 语言的出现,表示属性在持久化存储中的字段是否可以为空(NULL)。

SwiftData 通过纯代码声明的方式解决了这种不一致性。比如,我们要声明一个符合云同步规则的 Item 模型,可以直接使用如下代码:

Swift
@Model
public final class Item {
    public var name: String = ""
    public var height: Double?
    public init(name: String, height: Double? = nil) {
        self.name = name
        self.height = height
    }
}

在这段代码中,name 是非可选的且具有默认值,便于在视图中使用。而 height 被声明为 Swift 的可选类型,明确表示该属性允许没有值。

按照上文中的建模思路,我们首先在 Core Data 中声明模型:

Swift
// 初步声明
public final class Item {
    public var name: String = ""
    public var height: Double?
}

但当尝试将上述代码转换为 Core Data 的托管对象时,会发现无法直接对应:

Swift
@objc(Item)
public final class Item: NSManagedObject {}

extension Item {
    @NSManaged
    public var name: String
    @NSManaged
    public var height: Double? // Property cannot be marked @NSManaged because its type cannot be represented in Objective-C
}

image-20241012110005794

这是因为 Core Data 本质上仍基于 Objective-C,要求属性类型必须能在 Objective-C 中表示。Double? 无法直接转换,因此我们需要在代码层面手动进行转换:

Swift
@objc(Note)
public final class Item: NSManagedObject {}

extension Item {
    @NSManaged
    public var name: String

    public var height: Double? {
        get {
            heightValueNumber?.doubleValue
        }
        set {
            heightValueNumber = newValue as NSNumber?
        }
    }

    @NSManaged
    var heightValueNumber: NSNumber? // 没有添加 public 权限
}

为了保持公开的属性名称的一致性,我们在模型编辑器中将对应的存储属性命名为 heightValueNumber,并保留其 NSNumber 的原始类型,手动实现类型转换和映射。

枚举与 Codable

SwiftData 对枚举和符合 Codable 协议的属性类型也提供了自动转换能力。然而,正如我在 其他文章 中提到的,转换后的底层格式可能与预期不符(通常基于 Composite attributes 实现)。考虑到在 SwiftData 中当前的枚举类型仍无法直接用于谓词筛选,无论在 SwiftData 还是 Core Data 中,我都建议对枚举类型使用保存 rawValue 的方式,并在声明代码层面进行转换:

Swift
extension Item {
    ...
    public var valueDisplayMode: DataDisplayMode {
        get {
            DataDisplayMode(rawValue: valueDisplayModeRaw) ?? .average
        }
        set {
            valueDisplayModeRaw = newValue.rawValue
        }
    }
}

public enum DataDisplayMode: Int16, Sendable, Equatable {
    case sum = 1
    case average = 2
}

对于复杂的属性类型,传统的 Core Data 解决方案通常基于 NSSecureUnarchiveFromDataTransformer 实现转换,但这种方式不符合 Swift 语言的特点。在新项目中,我们可以直接采用基于 Codable 的编码和解码方式进行保存:

Swift
extension Item {
		...
    public var options: [ItemOption]? {
        get {
            guard let data = optionsData else { return nil }
            return try? JSONDecoder().decode([ItemOption].self, from: data)
        }
        set {
            optionsData = try? JSONEncoder().encode(newValue)
        }
    }
    
    @NSManaged var optionsData: Data?
}

尽管 WWDC 2023 为 Core Data 引入了 Composite attributes 功能,但鉴于 SwiftData 对符合 Codable 协议类型的转换规则尚未完全明确,我仍会采用基于 Codable 的编解码方式来持久化数据。即使日后项目升级至 SwiftData,我们也可以通过计算属性来确保底层数据的一致性,从而降低潜在的迁移风险。

虽然这种处理方式与 SwiftData 的默认形式不同,但在现阶段,我也建议 SwiftData 的开发者采用这种方式,待 SwiftData 的稳定性和转换规则完全明确后,再考虑使用其默认模式。

关系

在声明 Core Data 实体关系的代码,尤其是多对多关系时,我并没有做过多调整:

Swift
extension Item {
    @NSManaged public var note: Note?

    @NSManaged public var datas: Set<ItemData>? // 用 Set 替换 NSSet
  
    @objc(addDatasObject:)
    @NSManaged public func addToDatas(_ value: ItemData)

    @objc(removeDatasObject:)
    @NSManaged public func removeFromDatas(_ value: ItemData)

    @objc(addDatas:)
    @NSManaged public func addToDatas(_ values: NSSet)

    @objc(removeDatas:)
    @NSManaged public func removeFromDatas(_ values: NSSet)
}

主要原因有:

  • 将多对多关系声明为数组并不会带来实质性好处,反而可能造成语义上的混淆:将一个无序集合表示为有序集合。
  • 尝试转换为 SwiftData 基于数组的 append 方式操作对多关系,会导致性能下降,这一点我在 SwiftData 中的关系:变化与注意事项 一文中有详细阐述。

值得注意的是,由于对多关系的添加和删除代码是手动编写的,因此要特别注意拼写问题,编译器并不会检查对应的 Objective-C 方法名称(例如 @objc(removeDatas))。

构造方法

SwiftData 的纯代码建模中,必须提供构造方法,这是一个关键特性。通过构造方法,开发者可以明确需要赋值的内容,确保模型实例在创建时就具备必要的数据。

然而,在 Core Data 中,开发者通常习惯于如下方式创建实例,这种方式在安全性和模型设计的意图表达上都不如 SwiftData:

Swift
let item = Item(context:context)
item.name = "hello"
...
// 给其他属性赋值

为了改进,我们需要为托管对象类型声明自定义的构造方法:

Swift
extension Item {
    public convenience init(
        name: String,
        height: Double? = nil,
        valueDisplayMode: DataDisplayMode = .sum,
        options: [ItemOption]? = nil
    ) {
        self.init(entity: Self.entity(), insertInto: nil)
        self.name = name
        self.height = height
        self.valueDisplayMode = valueDisplayMode
        self.options = options
    }
}

在构造方法中,我们通过 self.init(entity: Self.entity(), insertInto: nil) 避免在创建实例时提供上下文。这样一来,就需要像 SwiftData 一样,在创建实例后显式地插入上下文:

Swift
let item = Item(name: "fat")
context.insert(item) // 显式插入上下文

对于部分 Core Data 开发者来说,显式插入上下文可能需要一些时间来适应,但这种方式有助于与 SwiftData 的开发模式统一。

想了解托管对象的具体构造细节,请阅读 CoreData 探秘 - 从数据模型构建到托管对象实例

需要注意的是,我没有在构造方法中为关系数据提供参数。这是因为关系的创建是在上下文中进行的,尚未注册到上下文的托管对象实例无法正确处理构造方法中提供的关系数据。因此,所有的关系数据都需要在插入上下文后再设置:

Swift
let note = Note(name: "note1")
context.insert(note)
let item = Item(name: "fat")
context.insert(item)
item.note = note

这个规则同样适用于 SwiftData。虽然有些开发者喜欢在构造方法中直接提供关系数据,但实践证明,这样的代码并不总是稳定的。为了确保代码的可靠性,最好在插入上下文后再设置关系数据。

模型验证

传统上,无论在使用 Core Data(依赖模型编辑器自动生成代码)还是 SwiftData 时,开发者几乎不需要为模型声明代码编写单独的测试,因为模型代码与底层模型的对应关系会由框架自动处理。

但由于我们现在手动编写了模型声明代码,在声明模型后,立即为其编写验证模型有效性的测试是非常必要的,主要关注以下几点:

  • 构造方法:确保涵盖了所有必要的属性。
  • 自定义转换:验证转换部分是否符合预期。
  • 多对多关系:检查关系设置部分是否能正确运行(是否存在拼写错误)。

随着模型的变化和关系的调整,验证方法也需要不断更新。

模块化

模块化是现代编程的重要特征。在创建模型之初,就应将其封装到独立的库中,不仅减少了无关 API 的暴露(例如 heightValueNumber 并非 public 权限),也便于针对每个库进行单元测试。

在我的项目中,由于会为多个不同的数据操作逻辑分别创建库,因此就需要数据模型代码单独成库方便其他的库使用。这个数据模型库功能非常单一,主要提供类型声明。考虑到 Core Data 在构建 container 时的特殊性(一个托管对象模型文件只能在应用中被一个实例持有),除了公开必要的托管对象类型外,还要为整个项目提供唯一的 NSManagedObjectModel 实例。(由于 SwiftData 的模型完全基于代码,因此没有此类限制)

Swift
@preconcurrency import CoreData

public enum DBModelConfiguration {
    public nonisolated static let objectModel: NSManagedObjectModel = {
        guard let modelURL = Bundle.module.url(forResource: "CareLog", withExtension: "momd") else {
            fatalError("Couldn't find the CareLog.momd file in the module bundle.")
        }
        guard let model = NSManagedObjectModel(contentsOf: modelURL) else {
            fatalError("Couldn't load the CareLog.momd file in the module bundle.")
        }
        return model
    }()
}

由于 NSManagedObjectModel 并非 Sendable,因此我们需要使用 @preconcurrency 来消除警告。在我当前的项目中(启用了 Swift 6 模式),所有的 Core Data 代码中,这是唯一需要手动干预才能避免并发安全警告的地方。

另外,如果将 SwiftData 的模型代码单独封装成库,可能需要在该库中包含相关的谓词设置。原因在于 SwiftData 中构建谓词时依赖 KeyPath,因此,若模型中的某些属性是非公开的(如枚举的 rawValue 等底层存储属性),这些属性只能在与模型处在同一库时才能作为筛选条件。相比之下,Core Data 的谓词基于字符串构建,无需在意属性的公开性,因此不受这一限制。

Helge Heß 开发了一款名为 ManagedModels 的第三方库,旨在提供与 SwiftData 中 @Model 类似的体验。

并发

如果说在 SwiftData 中,为了享受纯代码建模带来的优势,开发者需要放弃一些可控性,那么在并发方面,SwiftData 的改变对开发者来说则是纯粹的利好。

用 Actor 替换 perform

SwiftData 的 @ModelActor 为数据操作代码提供了一个运行线程可控的隔离环境(基于自定义 Actor 执行者,使用上下文线程)。在这个隔离环境中,开发者可以毫无顾虑地编写代码,不再受 perform 方法的限制。

幸运的是,自定义 Actor 执行者的功能包含在 Swift 5.9 版本中(需要 iOS 17+)。通过我编写的适用于 Core Data 的 @NSModelActor 宏,Core Data 开发者也可以享受到与 SwiftData 完全一致的并发编程体验。

想了解更多细节,请阅读 Core Data 改革:实现 SwiftData 般的优雅并发操作

Swift
@NSModelActor
public actor DataHandler {}

extension DataHandler {
    // 公开方法
    public func deleteItemData(
        for itemDataID: PersistentObjectID,
        saveImmediately: Bool
    ) throws(DataHandlerError) { ... }

    public func deleteItemDatas(
        for itemDataIDs: [PersistentObjectID],
        saveImmediately: Bool
    ) throws(DataHandlerError) { ... }

    public func createItemData(
        with viewModel: ItemDataVM,
        saveImmediately: Bool = true
    ) throws(DataHandlerError) -> PersistentObjectID { ... }
  
    public func updateItemData(
        with viewModel: ItemDataVM,
        saveImmediately: Bool = true
    ) throws(DataHandlerError) { ... }

    // 内部方法
    func createItemDataObject(
        with itemDataVM: ItemDataVM,
        in item: Item,
        saveImmediately: Bool = true
    ) throws(DataHandlerError) -> ItemData { ... }

    func updateItemDataObject(
        for itemData: ItemData,
        with itemDataVM: ItemDataVM,
        saveImmediately: Bool = true
    ) throws(DataHandlerError) { ... }
}

可以注意到,Actor 中所有公开的 API 都只接受和返回 SendableNSManagedObjectID。而对于仅在 Actor 内部使用的方法,则可以安全地使用托管对象进行参数传递。这是进行安全并发编程的基本原则,无论对 Core Data 还是 SwiftData 都适用。

从 Xcode 16 开始,官方已经为 Core Data 中的 NSManagedObjectIDNSPersistentContainer 标注了 @unchecked Sendable。这使得它们与 SwiftData 中对应类型的并发特性一致,开发者可以安全地在不同线程中传递这两个类型的实例。

关于如何围绕 @NSModelActor 进行并发编程,本文不再赘述,因为其基本方式和思路与 SwiftData 实战:用现代方法构建 SwiftUI 应用 一文中介绍的 SwiftData 使用方式一致。

测试

将数据逻辑代码封装到 Actor 后,尤其是在启用了 Swift 6 模式后,会给单元测试带来一些新的变化。例如,以下是测试代码示例:

Swift
@Test
func createRootNoteTest() async throws {
    let container = PersistentController.createTestContainer(#function)
    let handler = DataHandler(container: container)
    // 创建数据
    try await handler.createRootNote(with: NoteVM.sample2)
    // 验证结果
    ...
}

为了在不重新创建上下文的情况下(仍使用 Actor 中的上下文)验证数据是否创建成功,我们需要为 Actor 构建一个辅助方法:

Swift
extension DataHandler {
    public func perform<T>(_ action: (NSManagedObjectContext) throws -> T) throws -> T where T: Sendable {
        try action(modelContext)
    }
}

这样,我们就可以在测试代码中方便地为 Actor 插入需要验证的逻辑并检查结果:

Swift
@Test
func createRootNoteTest() async throws {
    let container = PersistentController.createTestContainer(#function)
    let handler = DataHandler(container: container)
    // 创建数据
    try await handler.createRootNote(with: NoteVM.sample2)
    // 验证结果
    let query = Note.query(for: .allRootNotes(sortBy: nil))
    // 在 Actor 中运行判断代码
    try await handler.perform { context in
        #expect(try context.count(for: query) == 1)
    }
}

此外,为每个单元测试构建独立的数据库文件非常重要,这使我们可以充分利用 Swift Testing 提供的默认并行测试能力,大大提高测试效率:

Swift
extension PersistentController {
    /// 创建一个用来测试的 NSPersistentContainer( 只包含特定的 Configuration 中的实体 )
    static func createTestContainer(
        _ name: String,
        enablePHT: Bool = false,
        configurationName: String = DBModelConfiguration.privateConfigurationName,
        subDirectory: String = "DBCoreDataTestTemp"
    ) -> NSPersistentContainer {
        let tempURL = URL.temporaryDirectory
        // 如果 tempURL 中没有 subDirectory 就创建
        if !FileManager.default.fileExists(atPath: tempURL.appendingPathComponent(subDirectory).path) {
            try? FileManager.default
                .createDirectory(at: tempURL.appendingPathComponent(subDirectory), withIntermediateDirectories: true)
        }
        let url = tempURL.appendingPathComponent(subDirectory).appendingPathComponent(
            name + ".sqlite"
        )
        // 删除原本的数据库文件,保证每次测试数据环境都是全新的
        if FileManager.default.fileExists(atPath: url.path) {
            try? FileManager.default.removeItem(at: url)
        }
        let container = NSPersistentContainer(name: name, managedObjectModel: model)
        let description = NSPersistentStoreDescription(url: url)
        description.configuration = configurationName
        description.shouldAddStoreAsynchronously = false
        // 开启 Persistent History Tracking
        if enablePHT {
            description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
            description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
        }
        container.persistentStoreDescriptions = [description]
        container.loadPersistentStores { _, error in
            if let error {
                fatalError("加载 Persistent Store 失败: \(error)")
            }
        }
        return container
    }
}

相比使用内存数据库(/dev/null)的 viewContext,在单元测试中基于独立数据库和 Actor 的数据操作效率更高,而且完全避免了串行模式下,viewContext 因数据准备不足导致的测试不稳定情况。

关于 SwiftData 和 Core Data 单元测试的技巧,本身也是一个值得深入探讨的话题,后续会专门撰文进行探讨。

最后

本文重点探讨了如何将 SwiftData 的新思维融入 Core Data 项目。可以看出,开发者为了实现类似 SwiftData 的体验,往往需要额外投入大量精力。这也反映了 SwiftData 在背后为实现自动转换所做的巨大工作量,毕竟它需要兼顾更多的场景并提高通用性。同时,由于引入了与 Core Data 的解耦层,SwiftData 在性能和稳定性方面面临着巨大的挑战。

那么,在付出更多工作量后,应用 SwiftData 思维的 Core Data 代码效果如何呢?至少对我而言,结果是十分令人满意的。除了在建模过程中需要投入更多精力外,在各个方面都获得了与 SwiftData 相似的体验,并且在现阶段具备了更好的性能和稳定度。

新架构未能带来预期的稳定性固然令人遗憾,但如果我们能从中学习并领悟新的知识,并将其应用于现有的框架和项目中,无疑会有所收获。

每周一晚,与全球开发者同步,掌握 Swift & SwiftUI 最新动向
可随时退订,干净无 spam