SwiftData 迁移:轻量级与自定义,以及为何你不需要 V2
SwiftData 的 schema 迁移机制相比 Core Data 是结构性的改进,但有一个陷阱团队反复掉入:为那些 SwiftData 本可通过内联默认值自动处理的变更,声明一个新的 VersionedSchema。结果就是设备上抛出”Duplicate version checksums across stages detected”崩溃,尽管代码看起来没问题,编译也干净通过。该框架真正的迁移模型由三个组件(VersionedSchema、MigrationStage、SchemaMigrationPlan)和三种迁移类型(自动轻量级、声明式轻量级、自定义)构成1。大多数 schema 变更属于自动迁移。部分需要声明式轻量级阶段。少数需要带有 willMigrate 和 didMigrate 闭包的自定义阶段。
本文将对照 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,因为 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 迁移之前运行。该闭包接收的 model context 是旧 schema 的 context。可用于在 schema 变更前捕获数据、反范式化处理或准备辅助状态。
didMigrate 在 schema 迁移之后运行。其 model context 是新 schema 的。可用于回填新字段、计算派生数据或完成迁移收尾。
两个闭包均可为 nil。大多数自定义迁移仅使用 didMigrate;willMigrate 适用于需要读取那些 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 父类配上 Car、Truck、Motorcycle 子类;Account 父类配上 CheckingAccount、SavingsAccount 子类。共有属性放在父类,特定属性放在子类。
测试迁移
能编译通过的迁移并不等于能上线的迁移。发布前值得运行的三类测试模式:
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+ 应用意味着什么
三个要点。
-
默认情况下不要构建
VersionedSchema阶梯。 添加带内联默认值的属性、删除未使用的字段、用@Attribute(originalName:)重命名,全部是轻量级且自动的。VersionedSchema阶梯只为 SwiftData 真正无法自动处理的变更而存在(数据转换、自定义逻辑、继承重构)。 -
MigrationStage.custom用于数据转换,而非 schema 形状变更。willMigrate与didMigrate闭包是为操作数据的代码而设,不是用来声明 schema 已变化。schema 形状的变化应通过轻量级阶段完成。 -
用真实的 V1 数据测试迁移,而不仅仅是合成测试数据。 在合成往返中通过的迁移,仍可能在生产形态数据上因边界情况而失败(schema 未覆盖的可空字段、达到超时阈值的大数据集等等)。测试的成本很小;首次启动迁移崩溃的代价是真实的。
完整的 Apple 生态系列:类型化的 App Intents;MCP servers;路由问题;Foundation Models;运行时与工具链 LLM 的区分;三个表面;Single Source of Truth 模式;Two MCP Servers;Apple 开发的 hooks;Live Activities;watchOS 运行时;SwiftUI 内部机制;RealityKit 的空间心智模型;SwiftData schema 纪律;Liquid Glass 模式;多平台发布;平台矩阵;Vision framework;Symbol Effects;Core ML 推理;Writing Tools API;Swift Testing;Privacy Manifest;作为平台的无障碍能力;SF Pro 排版;visionOS 空间模式;Speech framework;我拒绝写的内容。系列汇总位于 Apple Ecosystem Series。关于更宏观的 iOS 与 AI agent 上下文,请参阅 iOS Agent Development guide。
FAQ
我是否总是需要 SchemaMigrationPlan?
不需要。仅有单个 schema 版本的应用(首次发布、或仅做过轻量级变更的应用)无需 SchemaMigrationPlan。ModelContainer 初始化方法可直接接收 schema 的模型。migrationPlan: 参数仅在首次声明自定义迁移阶段(或开发者首次想要显式声明版本阶梯)时才变得必要。
我怎么知道我的变更是否属于轻量级?
Apple 列出的轻量级合规清单1:新增/删除实体、属性、关系,使用 @Attribute(originalName:) 重命名,变更关系基数,指定删除规则。如果变更属于其中之一且模型类的结构在其他方面未改变,迁移即为自动,无需 VersionedSchema 阶梯。如果变更涉及数据转换(计算、拆分、迁移数据),则属自定义。
willMigrate 和 didMigrate 能同时设置吗?
可以。两个闭包都是单独可选的,但也可同时提供。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
-
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. ↩