SwiftData 迁移:lightweight 与 custom 的取舍,以及何时并不需要 V2
相比 Core Data,SwiftData 的架构迁移在结构上是一次改进,但有一个陷阱让不少团队反复踩中:为那些只要给出内联默认值、SwiftData 就会自动处理的变更,专门声明一个新的 VersionedSchema。结果是代码看着没问题、编译也干干净净,却在真机上崩溃并抛出“Duplicate version checksums across stages detected”。这套框架真正的迁移模型由三个部件(VersionedSchema、MigrationStage、SchemaMigrationPlan)和三种迁移类型(自动 lightweight、显式声明的 lightweight、custom)组成1。大多数架构变更都是自动完成的。一部分需要显式声明的 lightweight 阶段。只有极少数需要带 willMigrate 和 didMigrate 闭包的 custom 阶段。
本文对照 Apple 的文档梳理这套迁移模型,逐一点明每种迁移类型所覆盖的场景,并介绍 iOS 26 的类继承支持,以及 iOS 27 beta 把迁移放在了什么位置。全文的视角始终是“哪些需要我自己声明,哪些由 SwiftData 替我处理”,因为正是这个判断决定了迁移是干净上线,还是在首次启动时崩溃。与之配套的问题——如何设计 v1 架构,让后续迁移始终廉价——在SwiftData 真正的成本是架构纪律中讨论。
要点速览
- SwiftData 迁移由三个协议组合而成:
VersionedSchema(某个版本上模型类型的快照)、MigrationStage(从 fromVersion 到 toVersion 的单次转换,分.lightweight和.custom两种情形)以及SchemaMigrationPlan(按顺序排列的阶段列表)1。 - 新增一个带内联默认值的
@Model属性(var foo: Bool = false)并不需要新的VersionedSchema。SwiftData 会作为 lightweight 迁移自动处理。为此声明 V2 反而会带来“Duplicate version checksums across stages detected”崩溃。 - lightweight 迁移能够处理:新增、重命名、删除实体、属性和关系;变更关系类型;用
@Attribute(originalName:)记录重命名;指定删除规则。大多数架构变更都落在这个范围内。 - custom 迁移(
MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:))处理的是数据变换:把一列拆成两列、计算派生字段、在模型之间搬运数据。willMigrate拿到的是旧上下文,didMigrate拿到的是新上下文。 - iOS 26 为
@Model类型加入了类继承2。采用继承的架构需要升到新版本,并从此前扁平模型的版本用一个 lightweight 阶段衔接过来。
三个部件构成的模型
一次 SwiftData 迁移,由三个部件组装而成。
VersionedSchema
特定架构版本上模型类型的快照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
}
}
}
在 enum 中嵌套类型是惯用写法。每个 VersionedSchema 都为自己的模型类提供命名空间,因此同名模型的多个架构可以在迁移期间共存于同一份代码里。
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
把架构从任意早期版本带到当前版本的、按顺序排列的阶段列表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 需要同时拿到当前架构和迁移计划:
let container = try ModelContainer(
for: SchemaV3.Item.self,
migrationPlan: AppMigrationPlan.self,
configurations: ModelConfiguration(...)
)
创建容器时,SwiftData 会读取持久化存储当前的架构版本,从该版本沿着计划中的阶段一路走到当前版本,并按顺序逐个应用。
lightweight 迁移自动处理的范围
大多数架构变更都不需要 custom 阶段1:
- 新增带默认值的属性。 在既有
@Model上写var foo: Bool = false会被自动处理。 - 新增实体(模型类)。 当某个
VersionedSchema成为当前版本时,新类型随之出现,既有数据保持不变。 - 移除属性或实体。 SwiftData 会删除对应的列或表。
- 重命名属性或实体。 给属性加上
@Attribute(originalName: "oldName")即可保留数据,SwiftData 会把旧名映射到新名。 - 变更关系类型。 一对多、多对多等等。
- 指定删除规则。 像
@Relationship(deleteRule: .cascade)这类添加属于 lightweight。
对于列表中的这些变更,只要模型类型没有别的改动,正确的做法就是完全不声明新的 VersionedSchema。SwiftData 会针对既有架构自动执行 lightweight 迁移。
陷阱:加一个字段并不需要 V2
SwiftData 迁移中最常见的错误是这样的:开发者加了一个带内联默认值的新属性(var foo: Bool = false),接着声明了一个 SchemaV2,而它引用的模型类型和 SchemaV1 完全相同。编译一切正常。可是在已有 V1 数据的设备上首次启动时,应用会以 Duplicate version checksums across stages detected 崩溃,因为 SchemaV1 和 SchemaV2 解析出的校验和相同(模型类型并没有发生 SwiftData 认定为差异的变化)。
正确的做法是:不动既有的 VersionedSchema,给模型加上带内联默认值的新属性,交给 SwiftData 的自动 lightweight 迁移。不需要 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(...)
)
只有当某个变更无法用 lightweight 完成时(数据变换、模型拆分、需要自定义逻辑的继承重构),才真正需要 V2 架构。在这些场景里,V2 是实打实的,并由 SchemaMigrationPlan 统筹整个转换。
何时必须使用 custom 迁移
custom 迁移在三类场景中值回它的复杂度。
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 闭包运行在新架构的上下文中,因此可以访问新字段。旧的 fullName 或许需要推迟删除,等新字段填充完毕之后再清理;这次清理属于后续 V2 到 V3 的阶段。
2. 计算派生字段。 依赖既有数据的新 @Attribute,需要在迁移时回填。
3. 在模型之间搬运数据。 把 Item 的数据拆分到 Item 和一个新的 Tag 模型中,这样的重组需要自定义逻辑,从旧数据里派生出标签。
一条通用的判断标准是:架构形状变了,用 lightweight;数据形状变了,用 custom。
willMigrate 与 didMigrate 的区别
custom 阶段有两个闭包,分别在不同时机被调用4:
willMigrate 在 SwiftData 应用架构迁移之前运行。闭包收到的模型上下文是旧架构的上下文。适合在脚下的架构发生变化之前,先抓取数据、做反范式化,或者准备一些辅助状态。
didMigrate 在架构迁移之后运行。模型上下文属于新架构。适合回填新字段、计算派生数据,或者为迁移收尾。
如果不需要,两个闭包都可以是 nil。大多数 custom 迁移只用 didMigrate;当迁移需要读取那些在架构变更后就再也访问不到的旧数据时,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)
}
}
采用继承的架构要升到新版本,并从此前扁平模型的版本用一个 lightweight 迁移阶段衔接过来。只要继承保留了既有属性,这次转换就是自动的;子类上的新字段,仍然遵循标准的内联默认值写法。
这套写法适合多个 @Model 类型共享特征的场景:Vehicle 作为父类,Car、Truck、Motorcycle 作为子类;Account 作为父类,CheckingAccount、SavingsAccount 作为子类。共有属性放在父类,各自特有的放在子类。
iOS 27:迁移模型不变,存储变得可观察
iOS 27 beta 完全没有改动迁移机制本身。VersionedSchema、MigrationStage 和 SchemaMigrationPlan 原样延续,上文的每一种写法都照旧适用。iOS 27 新增的东西位于迁移旁边,而不是迁移内部:一个新的“Data store observation”领域,包含 ResultsObserver 和 HistoryObserver 两个类型,外加一个 @Attribute(.codable) 选项,用属性的 Codable 表示来存储该属性6。
其中有两项,值得在一篇迁移指南里提上一笔。
@Attribute(.codable) 减轻了未来的迁移压力。 能够声明式地存储一个 Codable 值类型,就少了很多先把结构体摊平成若干列、日后再写一个 custom 阶段把它拼回来的情况。在新属性上采用该选项的架构,遵循前文标准的内联默认值规则;它是一个属性选项,而非架构形状的变化6。
HistoryObserver 为 custom 迁移之后的流程收口。 didMigrate 中的回填会写入一批数据行,应用的其余部分(以及任何正在观察该存储的小组件或扩展)都需要知道它们的存在。在 iOS 27 上,通过 HistoryObserver 观察持久化历史的观察者,能看到迁移的事务落地,并可调用 ModelContext.fetchHistory,按模型类型和事务作者过滤,精确读取到底有哪些改动,而不必把所有数据重新取一遍6。关于 observation 的完整脉络,参见 iOS 27 中的 SwiftData:observation 与 history。
对规划而言的结论是:27 beta 中没有任何东西会迫使你提升架构版本,也没有迁移代码需要重写。在那些迁移后对账逻辑原本靠轮询或重新取数的地方,换用新的 observation 类型即可。
测试迁移
能编译通过的迁移,未必是能上线的迁移。发布之前有三种测试值得跑一遍。
1. 用生产数据库副本做往返测试。 取一份近期的生产形态数据库(或者用测试生成合成的 V1 数据),用认识 V2 的容器打开它,验证数据是否正确迁移。类型检查器抓不到的 custom 迁移缺陷,正是靠这类测试暴露。
2. 从旧版本启动的验证。 构建上一个应用版本,运行一次以产生 V1 数据,然后构建新版本,确认它能不崩溃地启动。这个测试能抓住“Duplicate version checksums”这类陷阱,以及类似的声明失误。
3. 迁移失败后的恢复。 如果迁移抛出错误会怎样?SwiftData 的行为取决于容器的配置;对生产应用来说,未被处理的迁移错误绝不该悄悄删掉用户数据。请显式地测试失败路径,并决定应用要做什么(回滚、提示用户、从备份恢复)。
同一系列中的 Single Source of Truth 一文讨论了一个相关问题:当 SwiftData 存储因跨进程同步而被替换时会发生什么。迁移,正是那套模式在本地演进方向上的对应物。
跨进程上线与展示迁移进度
有两个运维层面的细节,文档并未突出,但 SwiftData 团队在 WWDC 2026 上专门点了出来5:当应用带有小组件或扩展时,迁移到底在哪里执行;以及迁移进行时,如何驱动进度界面。
迁移只属于一个进程。 小组件和扩展拿不到主应用那样的运行时资源,因此无法安全地执行迁移。给出的指引是:把 SchemaMigrationPlan1 从小组件和扩展的 target 中彻底移出,永远不要从它们发起迁移。选定一个进程作为数据库的所有者,通常就是主应用。如果小组件打开容器时,磁盘上的存储仍处于未版本化(较旧)的架构,打开操作会报错。请把这个错误当作“需要迁移”的信号:给出界面提示用户打开主应用,由应用完成迁移,并把迁移后的架构版本写入共享的 UserDefault。小组件下次读取该值,就能以应用已经迁移到的版本打开容器。这套做法保证始终只有一个写入方,避免两个进程争着演进同一个文件。
进度按阶段数计算,而不是按挂钟时间。 SwiftData 没有提供专门的迁移进度 API5。要驱动进度指示器,就统计计划中 custom 迁移阶段的总数,并重写每个阶段的 didMigrate 处理逻辑4,让每个阶段报告自己的位置,即“第 N 个,共 M 个”。这个数字反映的是已完成的阶段数,而非已用时间,因此进度条是一格一格跳,而不是平滑推进。与之配套的设计决策,是迁移期间应用给用户看什么:一个光秃秃的转圈会被读成卡死,用户随即流失。在数据允许的前提下让应用保持部分可用,至少也要说明每个阶段在添加什么(这次迁移解锁了哪些新功能),这样等待才会被读成朝着某个目标的进展,而不是白白耗掉的时间。
常见失败模式
从 SwiftData 的失败记录中总结出三种情形。
为 SwiftData 本可自动处理的变更声明 V2。 也就是“Duplicate version checksums”崩溃。解决办法:不要为内联默认值的属性新增声明一份新架构,交给 SwiftData 自动处理。
不保存的 custom 迁移代码。 一个修改了实体却没有调用 context.save() 的 didMigrate 闭包,会产生这样一次迁移:跑了一遍,丢掉成果,然后每次启动都重跑(因为这次迁移看起来尚未完成)。解决办法:凡是修改数据的闭包,返回前都必须执行 try context.save()。
重命名属性时没有加 @Attribute(originalName:)。 SwiftData 会把新属性当成新增、把旧属性当成删除,旧属性上的既有数据随之丢失。解决办法:声明 @Attribute(originalName: "oldName") var newName: ...,让 SwiftData 跨越这次重命名把数据映射过去。
这套模式对 iOS 26+ 应用意味着什么
三点结论。
-
默认不要搭
VersionedSchema阶梯。 新增带内联默认值的属性、删除弃用字段、用@Attribute(originalName:)重命名——全都是 lightweight 且自动的。VersionedSchema阶梯留给 SwiftData 确实无法自动处理的变更(数据变换、自定义逻辑、继承重构)。 -
MigrationStage.custom用于数据变换,而不是架构形状的变化。willMigrate和didMigrate闭包是给操作数据的代码用的,不是用来声明“架构变了”。架构形状的变化,走 lightweight 阶段。 -
用真实的 V1 数据测试迁移,不要只用合成测试数据。 在合成数据往返中通过的迁移,仍可能在生产形态的数据上因边界情况失败(架构没考虑到的可空字段、大到触发超时的数据集等等)。测试的成本很小,而首次启动就迁移崩溃的代价是实打实的。
完整的 Apple Ecosystem 系列:带类型的 App Intents;MCP 服务器;路由之问;Foundation Models;运行时 LLM 与工具链 LLM 的区分;三个界面;single source of truth 模式;两个 MCP 服务器;面向 Apple 开发的 hooks;Live Activities;watchOS 运行时;SwiftUI 的内部构造;RealityKit 的空间心智模型;SwiftData 架构纪律;Liquid Glass 模式;多平台交付;平台矩阵;Vision 框架;Symbol Effects;Core ML 端侧推理;Writing Tools API;Swift Testing;Privacy Manifest;作为平台能力的无障碍;SF Pro 排版体系;visionOS 空间模式;Speech 框架;我拒绝写的那些主题。系列主页在 Apple Ecosystem 系列。若想了解 iOS 与 AI 智能体结合的更广背景,请参阅 iOS Agent Development 指南。
常见问题
我是不是总需要 SchemaMigrationPlan?
不是。只有一个架构版本的应用(首个发布版本,或者一路只做过 lightweight 变更的应用)并不需要 SchemaMigrationPlan。ModelContainer 初始化器可以直接接收架构中的模型。只有在第一次声明 custom 迁移阶段时(或者开发者第一次想显式声明版本阶梯时),migrationPlan: 参数才变得必要。
我怎么判断自己的改动是不是 lightweight?
Apple 给出的 lightweight 适用清单是1:新增实体、属性和关系,移除它们,用 @Attribute(originalName:) 重命名,变更关系的基数,指定删除规则。如果改动落在其中之一,且模型类的结构没有别的变化,那么迁移就是自动的,不需要 VersionedSchema 阶梯。如果改动需要数据变换(计算、拆分、搬运数据),那就是 custom。
willMigrate 和 didMigrate 可以同时设置吗?
可以。两个闭包各自都是可选的,但也可以同时提供。willMigrate 在 SwiftData 迁移之前、针对旧架构的上下文运行;didMigrate 在迁移之后、针对新架构的上下文运行。二者分别负责准备与收尾。
如果迁移抛出错误会怎样?
错误会从 ModelContainer 的初始化中向外传播,容器打开失败。此后应用的行为,取决于开发者如何处理这个错误:有的应用会展示恢复界面,有的会尝试从备份还原,有的则删除损坏的存储重新开始。SwiftData 不会在迁移失败时悄悄删除用户数据;如何应对失败,是应用自己的责任。
如何在不影响生产数据的前提下测试迁移?
建一个测试 target,创建一个指向临时文件 URL 的 ModelContainer,往里填入 V1 数据,然后用包含迁移计划的新容器打开它,验证迁移后的数据是否符合预期。这套做法在单元测试和集成测试中都适用;若想得到最贴近现实的结果,请使用一份真实生产形态数据库的副本。
iOS 26 的类继承能用在既有架构上吗?
可以,配合一次 lightweight 迁移即可。采用继承的应用需要升到新的架构版本(例如 V4),并声明 MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self)。原本扁平的父类属性保持不变,子类特有的属性以内联默认值的方式加入。这种结构性变化由 SwiftData 的 lightweight 迁移负责处理。
参考资料
-
Apple Developer Documentation:
VersionedSchema与SchemaMigrationPlan协议参考,即迁移模型本身。关于架构演进的完整叙述,另见相关指南 Adopting SwiftData for a Core Data app。 ↩↩↩↩↩↩↩↩ -
Apple Developer:SwiftData: Dive into inheritance and schema migration(WWDC 2025 第 291 场)。介绍 iOS 26 中 SwiftData 类继承的引入。 ↩↩
-
Apple Developer Documentation:
MigrationStage,其中包含.lightweight(fromVersion:toVersion:)与.custom(fromVersion:toVersion:willMigrate:didMigrate:)两种情形。 ↩ -
Apple Developer Documentation:情形的签名参见
MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:)。willMigrate 针对旧上下文运行、didMigrate 针对新上下文运行的语义,记录在 WWDC 2025 第 291 场 SwiftData: Dive into inheritance and schema migration 中,与前文引用 iOS 26 继承特性的是同一场。 ↩↩↩ -
WWDC 2026 SwiftData Group Lab(第 8017 场)。内容转述自 WWDC 2026 SwiftData Group Lab 的本地转写录音;Apple 并未为这类 Lab 提供官方字幕。其中,小组件与扩展的迁移限制(迁移只属于一个进程,报错即是需要迁移的信号,迁移后的版本存入
UserDefault)以及按阶段数计算进度的技巧(由于不存在专门的进度 API,重写每个阶段的didMigrate处理逻辑来报告“第 N 个,共 M 个”),均由 SwiftData 工程团队在座谈中说明。SchemaMigrationPlan与MigrationStage.custom的didMigrate等符号,已与 1 和 4 所引的 Apple Developer 文档核对一致;至于不存在专门的进度 API 这一点,反映的是座谈中工程团队自己的表述。 ↩↩ -
Apple Developer Documentation:
ResultsObserver与HistoryObserver(均为 iOS 27.0 beta,归入 SwiftData 的“Data store observation”主题),以及Schema.Attribute.Option.codable(iOS 27.0 beta),“使用该属性的 codable 表示来存储它”。根据 WWDC26 第 274 场 What’s new in SwiftData,HistoryObserver暴露了一个可观察的eventCounter,每当有新事务落地就会递增,代码则通过调用ModelContext.fetchHistory并按模型类型和事务作者过滤来做出响应。 ↩↩↩