← 所有文章

SwiftData 迁移:轻量级与自定义,以及为何你不需要 V2

SwiftData 的 schema 迁移机制相比 Core Data 是结构性的改进,但有一个陷阱团队反复掉入:为那些 SwiftData 本可通过内联默认值自动处理的变更,声明一个新的 VersionedSchema。结果就是设备上抛出”Duplicate version checksums across stages detected”崩溃,尽管代码看起来没问题,编译也干净通过。该框架真正的迁移模型由三个组件(VersionedSchemaMigrationStageSchemaMigrationPlan)和三种迁移类型(自动轻量级、声明式轻量级、自定义)构成1。大多数 schema 变更属于自动迁移。部分需要声明式轻量级阶段。少数需要带有 willMigratedidMigrate 闭包的自定义阶段。

本文将对照 Apple 官方文档梳理迁移模型,列举每种迁移类型所处理的场景,并涵盖 iOS 26 新增的类继承支持。核心框架是”我需要声明什么,SwiftData 又会自动处理什么”,因为这一判断决定了迁移是顺利上线还是首次启动即崩溃。

TL;DR

  • SwiftData 迁移由三个协议组合而成:VersionedSchema(某一版本下模型类型的快照)、MigrationStage(带 .lightweight.custom 用例的单次 fromVersion-to-toVersion 转换)以及 SchemaMigrationPlan(按序排列的阶段列表)1
  • @Model 添加带内联默认值的新属性(var foo: Bool = false)并不需要新的 VersionedSchema。SwiftData 会自动以轻量级迁移处理此类新增。为此声明 V2 反而会导致”Duplicate version checksums across stages detected”崩溃。
  • 轻量级迁移可处理:新增/重命名/删除实体、属性、关系;变更关系类型;通过 @Attribute(originalName:) 跟踪重命名;指定删除规则。大多数 schema 变更都属于此类。
  • 自定义迁移(MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:))用于数据转换:将一列拆分为两列、计算派生字段、在模型间迁移数据。willMigrate 拿到旧的 context;didMigrate 拿到新的 context。
  • iOS 26 为 @Model 类型添加了类继承能力2。采用继承的 schema 会升级到新版本,并使用从前一个扁平模型版本过来的轻量级阶段。

三件套模型

一次 SwiftData 迁移由三个组件组合而成。

VersionedSchema

特定 schema 版本下模型类型的快照1。该协议要求:

  • static var versionIdentifier: Schema.Version。语义化的三段版本号(Schema.Version(1, 0, 0))。
  • static var models: [any PersistentModel.Type]。当前版本中所有 @Model 类型的数组。
enum SchemaV1: VersionedSchema {
    static let versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Item.self]
    }

    @Model
    final class Item {
        var name: String
        var createdAt: Date
        init(name: String, createdAt: Date) {
            self.name = name
            self.createdAt = createdAt
        }
    }
}

枚举嵌套类型这一模式是约定俗成的写法。每个 VersionedSchema 都会为其模型类提供命名空间,使得多个同名模型的 schema 能够在迁移期间共存于代码库中。

MigrationStage

两个 VersionedSchema 类型之间的单次转换3。包含两种用例:

  • .lightweight(fromVersion: any VersionedSchema.Type, toVersion: any VersionedSchema.Type)。声明一个由 SwiftData 在无须应用代码介入的情况下处理的转换。参数本身就是 VersionedSchema 类型(例如 SchemaV1.self),而非原始的 Schema.Version 值。
  • .custom(fromVersion:toVersion:willMigrate:didMigrate:)。声明一个在数据迁移前后运行代码的转换。版本参数的类型与 .lightweight 相同。

SchemaMigrationPlan

将 schema 从任一旧版本推进到当前版本的有序阶段列表1

enum AppMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self, SchemaV3.self]
    }

    static var stages: [MigrationStage] {
        [migrateV1toV2, migrateV2toV3]
    }

    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self
    )

    static let migrateV2toV3 = MigrationStage.custom(
        fromVersion: SchemaV2.self,
        toVersion: SchemaV3.self,
        willMigrate: { context in
            // Pre-migration: read old data, prepare it
            try context.save()
        },
        didMigrate: { context in
            // Post-migration: backfill new fields
            let descriptor = FetchDescriptor<SchemaV3.Item>()
            let items = try context.fetch(descriptor)
            for item in items {
                item.computedField = computeFromExisting(item)
            }
            try context.save()
        }
    )
}

ModelContainer 在初始化时同时传入当前 schema 和迁移计划:

let container = try ModelContainer(
    for: SchemaV3.Item.self,
    migrationPlan: AppMigrationPlan.self,
    configurations: ModelConfiguration(...)
)

容器创建时,SwiftData 会读取持久化存储的当前 schema 版本,沿着计划中的阶段从该版本一路推进到当前版本,并按顺序应用每个阶段。

轻量级迁移会自动处理什么

大多数 schema 变更并不需要自定义阶段1

  • 添加带默认值的属性。 在已有的 @Model 上写 var foo: Bool = false 即为自动处理。
  • 新增实体(模型类)。 当某 VersionedSchema 成为当前版本时,新类型自然出现;现有数据得以保留。
  • 删除属性或实体。 SwiftData 会丢弃对应的列或表。
  • 重命名属性或实体。 在属性上添加 @Attribute(originalName: "oldName") 以保留数据;SwiftData 会建立新旧之间的映射。
  • 变更关系类型。 一对多、多对多等等。
  • 指定删除规则。 @Relationship(deleteRule: .cascade) 等类似新增均属轻量级。

对于上述列表中的变更,正确做法是完全不声明新的 VersionedSchema——只要模型类型本身没有其他变化即可。SwiftData 会针对现有 schema 自动执行轻量级迁移。

陷阱:添加字段并不需要 V2

最常见的 SwiftData 迁移失误:开发者添加了一个带内联默认值的新属性(var foo: Bool = false),随即声明一个 SchemaV2,引用与 SchemaV1 完全相同的模型类型。编译干净。但在已存有 V1 数据的设备上首次启动便崩溃,报错 Duplicate version checksums across stages detected,因为 SchemaV1SchemaV2 解析出的校验和完全相同(模型类型并未发生 SwiftData 视为不同的变化)。

正确的做法是:保持原有的 VersionedSchema 不变,给模型添加带内联默认值的新属性,让 SwiftData 自动的轻量级迁移来处理。无需 MigrationPlan、无需 MigrationStage、无需 V2。

// V1 schema
enum SchemaV1: VersionedSchema {
    @Model
    final class Item {
        var name: String
        // BEFORE: just these two properties
        var createdAt: Date
        // AFTER: add a third with inline default
        var isFavorite: Bool = false   // Lightweight, automatic
    }
}

var isFavorite: Bool = false 这一变更无需任何 MigrationStage 声明即可发布。不传 migrationPlan:ModelContainer 初始化方式即可工作:

let container = try ModelContainer(
    for: SchemaV1.Item.self,
    configurations: ModelConfiguration(...)
)

仅当变更不能以轻量级方式完成时(数据转换、模型拆分、需要自定义逻辑的继承重构),V2 schema 才是必要的。在这些情况下,V2 是真实存在的,并由 SchemaMigrationPlan 编排转换。

何时需要自定义迁移

自定义迁移在以下三种情形下才值得引入其复杂度:

1. 将一个字段拆分为多个。 一个保存 "Last, First"String 字段拆为 firstNamelastName 两个字段。迁移需要读取旧值、解析它,再写入新字段。

static let migrateV1toV2 = MigrationStage.custom(
    fromVersion: SchemaV1.self,
    toVersion: SchemaV2.self,
    willMigrate: nil,
    didMigrate: { context in
        let descriptor = FetchDescriptor<SchemaV2.Person>()
        let people = try context.fetch(descriptor)
        for person in people {
            let parts = person.fullName.split(separator: ", ", maxSplits: 1)
            person.lastName = String(parts.first ?? "")
            person.firstName = String(parts.dropFirst().first ?? "")
        }
        try context.save()
    }
)

didMigrate 闭包运行在新 schema 的 context 中,因此可以访问到新字段。旧的 fullName 删除可能需要推迟到新字段填充完成之后;这一清理可作为后续 V2 到 V3 阶段处理。

2. 计算派生字段。 一个依赖现有数据的新 @Attribute 需要在迁移时回填。

3. 在模型间迁移数据。 例如把原本属于 Item 的数据拆分到 Item 与新增的 Tag 模型之间,这种重组需要自定义逻辑来根据旧数据分配标签。

总体规律:schema 形状变化用轻量级;数据形状变化用自定义。

willMigratedidMigrate

自定义阶段有两个闭包,在不同时点被调用4

willMigrate 在 SwiftData 应用 schema 迁移之前运行。该闭包接收的 model context 是 schema 的 context。可用于在 schema 变更前捕获数据、反范式化处理或准备辅助状态。

didMigrate 在 schema 迁移之后运行。其 model context 是 schema 的。可用于回填新字段、计算派生数据或完成迁移收尾。

两个闭包均可为 nil。大多数自定义迁移仅使用 didMigratewillMigrate 适用于需要读取那些 schema 变更后将无法访问的旧数据的场景。

闭包接收一个 ModelContext,可以执行 fetch、修改和 save 操作。闭包是 throwing 的;错误会向外传播并中止迁移。

iOS 26:@Model 的类继承

iOS 26 为 SwiftData 模型引入了类继承2。模型现在可以拥有父子关系:

@Model
class Vehicle {
    var make: String
    var year: Int
    init(make: String, year: Int) {
        self.make = make
        self.year = year
    }
}

@Model
final class Car: Vehicle {
    var doorCount: Int
    init(make: String, year: Int, doorCount: Int) {
        self.doorCount = doorCount
        super.init(make: make, year: year)
    }
}

采用继承的 schema 会升级到新版本,并使用从前一个扁平模型版本过来的轻量级迁移阶段。如果继承保留了原有属性,转换是自动的;子类上的新字段则遵循标准的内联默认值模式。

这一模式适用于多个 @Model 类型共享特征的场景:Vehicle 父类配上 CarTruckMotorcycle 子类;Account 父类配上 CheckingAccountSavingsAccount 子类。共有属性放在父类,特定属性放在子类。

测试迁移

能编译通过的迁移并不等于能上线的迁移。发布前值得运行的三类测试模式:

1. 在生产数据库副本上做往返测试。 拉取一份近期生产形态的数据库(或通过测试生成合成的 V1 数据),用具备 V2 能力的容器打开它,验证数据正确迁移。该测试能捕获类型检查器无法发现的自定义迁移 bug。

2. 旧版本仍能启动。 构建上一个 app 版本,运行一次以产生 V1 数据,然后构建新版本并验证其能够正常启动而不崩溃。该测试可发现”Duplicate version checksums”陷阱及类似的声明错误。

3. 迁移失败的恢复。 如果迁移抛出异常会发生什么?SwiftData 的行为取决于容器的配置;对生产应用而言,未处理的迁移错误不应静默删除用户数据。务必显式测试失败路径,并决定 app 的应对方式(回滚、提示用户、从备份恢复)。

本系列的Single Source of Truth post涵盖了相关问题:当 SwiftData 存储被跨进程同步替换时会发生什么。迁移是该模式的本地演化对应物。

常见失败模式

来自 SwiftData 故障日志的三种典型模式:

为本应由 SwiftData 自动处理的变更声明 V2。 即”Duplicate version checksums”崩溃。修复:不要为带内联默认值的属性新增声明新 schema;让 SwiftData 自动处理。

自定义迁移代码未保存。 didMigrate 闭包修改了实体却未调用 context.save(),会导致迁移每次都执行、每次都丢弃工作、每次启动都重跑(因为迁移看起来未完成)。修复:每个修改数据的闭包都必须在返回前 try context.save()

重命名属性时未加 @Attribute(originalName:) SwiftData 会把新属性视为新增、把旧属性视为删除;旧属性上的现有数据被丢弃。修复:声明 @Attribute(originalName: "oldName") var newName: ...,让 SwiftData 在重命名间映射数据。

该模式对 iOS 26+ 应用意味着什么

三个要点。

  1. 默认情况下不要构建 VersionedSchema 阶梯。 添加带内联默认值的属性、删除未使用的字段、用 @Attribute(originalName:) 重命名,全部是轻量级且自动的。VersionedSchema 阶梯只为 SwiftData 真正无法自动处理的变更而存在(数据转换、自定义逻辑、继承重构)。

  2. MigrationStage.custom 用于数据转换,而非 schema 形状变更。 willMigratedidMigrate 闭包是为操作数据的代码而设,不是用来声明 schema 已变化。schema 形状的变化应通过轻量级阶段完成。

  3. 用真实的 V1 数据测试迁移,而不仅仅是合成测试数据。 在合成往返中通过的迁移,仍可能在生产形态数据上因边界情况而失败(schema 未覆盖的可空字段、达到超时阈值的大数据集等等)。测试的成本很小;首次启动迁移崩溃的代价是真实的。

完整的 Apple 生态系列:类型化的 App IntentsMCP servers路由问题Foundation Models运行时与工具链 LLM 的区分三个表面Single Source of Truth 模式Two MCP ServersApple 开发的 hooksLive ActivitieswatchOS 运行时SwiftUI 内部机制RealityKit 的空间心智模型SwiftData schema 纪律Liquid Glass 模式多平台发布平台矩阵Vision frameworkSymbol EffectsCore ML 推理Writing Tools APISwift TestingPrivacy Manifest作为平台的无障碍能力SF Pro 排版visionOS 空间模式Speech framework我拒绝写的内容。系列汇总位于 Apple Ecosystem Series。关于更宏观的 iOS 与 AI agent 上下文,请参阅 iOS Agent Development guide

FAQ

我是否总是需要 SchemaMigrationPlan

不需要。仅有单个 schema 版本的应用(首次发布、或仅做过轻量级变更的应用)无需 SchemaMigrationPlanModelContainer 初始化方法可直接接收 schema 的模型。migrationPlan: 参数仅在首次声明自定义迁移阶段(或开发者首次想要显式声明版本阶梯)时才变得必要。

我怎么知道我的变更是否属于轻量级?

Apple 列出的轻量级合规清单1:新增/删除实体、属性、关系,使用 @Attribute(originalName:) 重命名,变更关系基数,指定删除规则。如果变更属于其中之一且模型类的结构在其他方面未改变,迁移即为自动,无需 VersionedSchema 阶梯。如果变更涉及数据转换(计算、拆分、迁移数据),则属自定义。

willMigratedidMigrate 能同时设置吗?

可以。两个闭包都是单独可选的,但也可同时提供。willMigrate 在 SwiftData 迁移前对旧 schema 的 context 运行;didMigrate 在迁移后对新 schema 的 context 运行。两者分别对应准备和收尾。

如果迁移抛出错误会怎样?

错误会从 ModelContainer 初始化中向外传播。容器打开失败。app 的行为取决于开发者如何处理该错误:有些应用展示恢复 UI,有些尝试从备份恢复,有些则删除损坏的存储重新开始。SwiftData 不会在迁移失败时静默删除用户数据;失败由应用自行处理。

如何在不影响生产数据的情况下测试迁移?

构建一个测试 target,让 ModelContainer 指向一个临时文件 URL,向其填充 V1 数据,然后用包含迁移计划的新容器打开它。验证迁移后的数据符合预期。该模式在单元测试和集成测试中都适用;为了得到最贴近真实的结果,可使用一份实际生产形态数据库的副本。

iOS 26 的类继承能与现有 schema 协作吗?

可以,通过一次轻量级迁移即可。采用继承的应用会升级到新的 schema 版本(例如 V4),并声明 MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self)。扁平的父类属性得以保留,子类特有的属性则以内联默认值方式新增。SwiftData 的轻量级迁移会处理这一结构性变化。

References


  1. Apple Developer Documentation: VersionedSchema and SchemaMigrationPlan protocol references. The migration model. See also the related guide Adopting SwiftData for a Core Data app for the full schema-evolution narrative. 

  2. Apple Developer: SwiftData: Dive into inheritance and schema migration (WWDC 2025 session 291). The introduction of SwiftData class inheritance in iOS 26. 

  3. Apple Developer Documentation: MigrationStage with the .lightweight(fromVersion:toVersion:) and .custom(fromVersion:toVersion:willMigrate:didMigrate:) cases. 

  4. Apple Developer Documentation: MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:) for the case signature. The willMigrate-runs-against-old-context and didMigrate-runs-against-new-context semantics are documented in WWDC 2025 session 291 SwiftData: Dive into inheritance and schema migration, the same session referenced for the iOS 26 inheritance addition. 

相关文章

SwiftData's Real Cost Is Schema Discipline

SwiftData's API is two macros. The cost is what happens after you ship. Optional fields are the cheap migration; non-opt…

15 分钟阅读

The Privacy Manifest Deep Dive: What Counts As Data Collection

Apple's privacy manifest is a structured contract, not a checkbox: four sections, five required-reason API categories, S…

14 分钟阅读

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 分钟阅读