← 所有文章

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

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

本文将对照 Apple 的文档梳理这套迁移模型,点明每种迁移类型各自处理哪些场景,并涵盖 iOS 26 新增的类继承支持。贯穿全文的思路是”哪些需要我来声明,哪些 SwiftData 会替我处理”,因为正是这个判断决定了迁移是干净地发布,还是在首次启动时崩溃。

TL;DR

  • SwiftData 迁移由三个协议组合而成:VersionedSchema(某个版本下模型类型的快照)、MigrationStage(一次单独的 fromVersion 到 toVersion 的转换,分 .lightweight.custom 两种)以及 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 迁移之前运行。该闭包收到的模型 context 是 schema 的 context。可用它来捕获数据、做反规范化,或在 schema 于底层发生变化之前准备好辅助状态。

didMigrate 在 schema 迁移之后运行。其模型 context 是 schema 的。可用它来回填新字段、计算派生数据,或收尾整个迁移。

如果不需要,任一闭包都可以为 nil。大多数自定义迁移只用 didMigrate;当迁移需要读取那些在 schema 变化后将无法访问的旧数据时,willMigrate 才派上用场。

闭包会收到一个 ModelContext,可在其中执行抓取、修改和保存。该闭包是可抛出(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. 旧版本仍能启动。 构建上一个应用版本,运行一次以产生 V1 数据,然后构建新版本并验证它能不崩溃地启动。这一测试能抓出 “Duplicate version checksums” 陷阱以及类似的声明错误。

3. 迁移失败后的恢复。 如果迁移抛出错误会怎样?SwiftData 的行为取决于容器的配置;对生产应用而言,一个未处理的迁移错误绝不应当悄无声息地删除用户数据。请显式测试失败路径,并决定应用要怎么做(回滚、提示用户、从备份恢复)。

集群中的单一可信源(Single Source of Truth)一文讨论了相关问题:当一个 SwiftData 存储经由跨进程同步被替换时会发生什么。迁移正是该模式在本地演进维度上的对应物。

跨进程发布迁移并呈现进度

有两个操作层面的细节,文档并未着重强调,但 SwiftData 团队在 WWDC 2026 上专门提到过5:当应用带有 widget 或 extension 时迁移在何处运行,以及在迁移运行时如何驱动一个进度 UI。

一个进程负责迁移。 widget 和 extension 拿不到主应用所拥有的同等运行时资源,因此无法安全地执行迁移。建议的做法是:把 SchemaMigrationPlan1 完全排除在 widget 和 extension 目标之外,绝不从它们发起迁移。选定一个进程(通常是主应用)作为数据库的所有者。如果某个 widget 打开容器,而磁盘上的存储处于未版本化(更旧)的 schema,那么打开操作会出错。把这个错误当作”需要迁移”的信号:呈现 UI 请用户打开主应用,让应用去执行迁移,并由应用把迁移后的 schema 版本写入一个共享的 UserDefault。widget 下次读取该值,并以应用已迁移到的版本打开容器。这一模式让单一写者保持掌控,避免两个进程争相去演进同一份文件。

进度由阶段计数算出,而非挂钟时间。 SwiftData 没有暴露专门的迁移进度 API5。要驱动一个进度指示器,可统计计划中自定义迁移阶段的总数,并重写每个阶段的 didMigrate 处理逻辑4,让每个阶段汇报自己的位置——“第 N 个,共 M 个阶段”。这个数字反映的是已完成的阶段数,而非已耗费的时间,因此进度条是离散地一格一格前进,而不是平滑推进。与之配套的设计取舍是:迁移期间应用要展示什么。一个光秃秃的转圈会被读作卡死,用户会因此流失。在数据允许的前提下让应用保持部分可用,或至少描述每个阶段正在添加什么(迁移所解锁的新功能),让这段等待被读作朝着某个目标前进,而不是一段死时间。

常见失败模式

来自 SwiftData 失败日志的三种模式:

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

自定义迁移代码没有保存。 一个修改了实体却没有调用 context.save()didMigrate 闭包,会产出一个运行一次、丢弃其成果、然后每次启动都重跑的迁移(因为迁移看上去尚未完成)。修复:每个修改数据的闭包在返回之前都必须 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 服务器路由问题Foundation Models运行时 LLM 与工具链 LLM 的区分三个界面单一可信源模式两个 MCP 服务器面向 Apple 开发的 hooksLive ActivitieswatchOS 运行时SwiftUI 内部机制RealityKit 的空间心智模型SwiftData schema 纪律Liquid Glass 模式多平台发布平台矩阵Vision 框架Symbol EffectsCore ML 推理Writing Tools APISwift Testing隐私清单(Privacy Manifest)作为平台能力的无障碍SF Pro 排版visionOS 空间模式Speech 框架我拒绝写什么。枢纽位于 Apple 生态系统系列。要了解更宽泛的”iOS 搭配 AI 智能体”语境,参见 iOS 智能体开发指南

FAQ

我是否总是需要一个 SchemaMigrationPlan

不。只有单一 schema 版本的应用(初始发布版,或只做过轻量级变更的应用)不需要 SchemaMigrationPlanModelContainer 初始化器可以直接接收 schema 的各个模型。migrationPlan: 参数会在第一次声明自定义迁移阶段时(或开发者第一次想声明显式版本阶梯时)变得必要。

我怎么知道我的变更是不是轻量级的?

Apple 的轻量级适用清单1:添加实体、属性、关系,移除它们,用 @Attribute(originalName:) 重命名,变更关系基数,指定删除规则。如果变更符合其中之一、且模型类结构在其他方面没有变化,那么迁移就是自动的,无需 VersionedSchema 阶梯。如果变更需要数据转换(计算、拆分、搬移数据),那它就是自定义的。

willMigratedidMigrate 能同时设置吗?

能。两个闭包各自都是可选的,但也可以同时提供。willMigrate 在 SwiftData 迁移之前针对旧 schema 的 context 运行;didMigrate 在迁移之后针对新 schema 的 context 运行。两者分别负责准备与收尾。

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

错误会从 ModelContainer 的初始化中向外传播。容器无法打开。应用的行为取决于开发者如何处理这个错误:有些应用展示恢复 UI,有些尝试从备份还原,有些删除损坏的存储并从头开始。SwiftData 不会在迁移失败时悄无声息地删除用户数据;这次失败要由应用自己来处理。

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

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

iOS 26 的类继承能用于已有 schema 吗?

能,借助一次轻量级迁移。采用继承的应用会升到一个新 schema 版本(例如 V4),并声明 MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self)。扁平的父类属性得以保留,子类特有属性则带内联默认值添加进来。SwiftData 的轻量级迁移会处理这一结构性变更。

参考资料


  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. 

  5. WWDC 2026 SwiftData Group Lab (session 8017). Paraphrased from a locally transcribed recording of the WWDC 2026 SwiftData Group Lab; Apple publishes no official captions for the labs. The widget-and-extension migration gating (one process owns the migration, the error path is the migration signal, the migrated version is stored in a UserDefault) and the stage-count progress technique (override the per-stage didMigrate handler to report stage N of M, since no dedicated progress API exists) were described by the SwiftData engineering panel. The SchemaMigrationPlan and MigrationStage.custom didMigrate symbols are confirmed against the Apple Developer documentation cited in 1 and 4; the absence of a dedicated progress API reflects the panel’s own framing during the lab. 

相关文章

SwiftData 的真正成本是 schema 纪律

SwiftData 的 API 只有两个宏。成本出现在你发布之后。可选字段是廉价的迁移;新增非可选字段则需要一个 VersionedSchema。

5 分钟阅读

iOS 27 中的 SwiftData:观察与历史记录

iOS 27 为 SwiftData 带来一等的变更观察能力:用 ResultsObserver 跟踪变更、用 HistoryObserver 观察持久化历史,并支持 codable 属性存储。

4 分钟阅读

现在,助手才是读者

第一方边缘数据:AI助手请求我页面的频率大约是真人访问的66倍,而且其中大部分是实时的、由用户驱动的抓取,而非训练爬取。

1 分钟阅读