SwiftData 迁移:轻量级与自定义,以及何时你并不需要 V2
相比 Core Data,SwiftData 的 schema 迁移机制是一次结构性的改进,但有一个陷阱团队总是反复掉进去:为那些 SwiftData 本可以通过内联默认值自动处理的变更,去声明一个新的 VersionedSchema。其结果是在设备上崩溃,报出 “Duplicate version checksums across stages detected”,哪怕代码看上去没问题、编译也干干净净。该框架实际的迁移模型由三个部件(VersionedSchema、MigrationStage、SchemaMigrationPlan)和三种迁移类型(自动轻量级、声明式轻量级、自定义)构成1。大多数 schema 变更都是自动的。有些需要一个声明式的轻量级阶段。极少数才需要带 willMigrate 和 didMigrate 闭包的自定义阶段。
本文将对照 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,因为 SchemaV1 和 SchemaV2 解析出了相同的校验和(模型类型并未以 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 字段要拆成 firstName 和 lastName 两个字段。迁移需要读取旧值、解析它,再写入新字段。
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 形态变化时用轻量级;数据形态变化时用自定义。
willMigrate 与 didMigrate
自定义阶段有两个闭包,在不同时机被调用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 父类带 Car、Truck、Motorcycle 子类;一个 Account 父类带 CheckingAccount、SavingsAccount 子类。共享属性放在父类上,各自的特有内容放在子类上。
测试迁移
能编译通过的迁移并不等于能发布的迁移。发布前值得运行三种测试模式:
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+ 应用意味着什么
三点要义。
-
默认不搭建
VersionedSchema阶梯。 用内联默认值添加属性、删除未用字段、用@Attribute(originalName:)重命名——全都是轻量级且自动的。VersionedSchema阶梯只用于 SwiftData 确实无法自动处理的变更(数据转换、自定义逻辑、继承重构)。 -
用
MigrationStage.custom来做数据转换,而非 schema 形态变更。willMigrate和didMigrate闭包是给操作数据的代码用的,而不是用来声明 schema 已经变化的。schema 形态的变化要走轻量级阶段。 -
用真实的 V1 数据来测试迁移,而不只是合成测试数据。 在合成往返中通过的迁移,仍可能在带有边界情况的生产形态数据上失败(schema 未覆盖到的可空字段、大到触发超时的数据集等)。测试的成本很小;首次启动时迁移崩溃的代价却是实打实的。
完整的 Apple 生态系统集群:带类型的 App Intents;MCP 服务器;路由问题;Foundation Models;运行时 LLM 与工具链 LLM 的区分;三个界面;单一可信源模式;两个 MCP 服务器;面向 Apple 开发的 hooks;Live Activities;watchOS 运行时;SwiftUI 内部机制;RealityKit 的空间心智模型;SwiftData schema 纪律;Liquid Glass 模式;多平台发布;平台矩阵;Vision 框架;Symbol Effects;Core ML 推理;Writing Tools API;Swift Testing;隐私清单(Privacy Manifest);作为平台能力的无障碍;SF Pro 排版;visionOS 空间模式;Speech 框架;我拒绝写什么。枢纽位于 Apple 生态系统系列。要了解更宽泛的”iOS 搭配 AI 智能体”语境,参见 iOS 智能体开发指南。
FAQ
我是否总是需要一个 SchemaMigrationPlan?
不。只有单一 schema 版本的应用(初始发布版,或只做过轻量级变更的应用)不需要 SchemaMigrationPlan。ModelContainer 初始化器可以直接接收 schema 的各个模型。migrationPlan: 参数会在第一次声明自定义迁移阶段时(或开发者第一次想声明显式版本阶梯时)变得必要。
我怎么知道我的变更是不是轻量级的?
Apple 的轻量级适用清单1:添加实体、属性、关系,移除它们,用 @Attribute(originalName:) 重命名,变更关系基数,指定删除规则。如果变更符合其中之一、且模型类结构在其他方面没有变化,那么迁移就是自动的,无需 VersionedSchema 阶梯。如果变更需要数据转换(计算、拆分、搬移数据),那它就是自定义的。
willMigrate 和 didMigrate 能同时设置吗?
能。两个闭包各自都是可选的,但也可以同时提供。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 的轻量级迁移会处理这一结构性变更。
参考资料
-
Apple Developer Documentation:
VersionedSchemaandSchemaMigrationPlanprotocol references. The migration model. See also the related guide Adopting SwiftData for a Core Data app for the full schema-evolution narrative. ↩↩↩↩↩↩↩↩ -
Apple Developer: SwiftData: Dive into inheritance and schema migration (WWDC 2025 session 291). The introduction of SwiftData class inheritance in iOS 26. ↩↩
-
Apple Developer Documentation:
MigrationStagewith the.lightweight(fromVersion:toVersion:)and.custom(fromVersion:toVersion:willMigrate:didMigrate:)cases. ↩ -
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. ↩↩↩ -
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-stagedidMigratehandler to report stage N of M, since no dedicated progress API exists) were described by the SwiftData engineering panel. TheSchemaMigrationPlanandMigrationStage.customdidMigratesymbols 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. ↩↩