
书接上回。 办公室的空气仿佛凝固了。Jason 盯着屏幕,手里那行准备提交的代码停在半空,仿佛那是引爆核弹的按钮。他咽了口唾沫,转头看向 Chloe :"也就是说,如果我现在乱改模型,明天用户升级上来,App 就会当场闪退?" Chloe 推了推眼镜,眼神里闪烁着"智慧的光芒":"没错。这就是为什么下集至关重要。系好安全带,我们要进入深水区了。"
✅ 认清"轻量级迁移"的舒适区
"首先,别把事情想得太复杂,"Chloe 安慰道,"SwiftData 还是很聪明的。只要你的改动在'舒适区'内,它完全可以自己搞定。"

以下变更属于轻量级变更,不需要你写任何自定义逻辑:
- 增加一个可选 (Optional)属性(比如
notes: String?)。 - 删除一个属性(数据会被直接丢弃,再见不送)。
- 把属性从非可选 改为可选(Non-optional → Optional)。
- 重命名属性(前提是你告诉了它原来的名字)。
这些变动不需要 SwiftData 去"凭空创造"新值。它要么保留旧值,要么移动它,要么在新坑里填个 nil。
🏷️ 安全地重命名:别让数据"失忆"
"提到重命名,"Chloe 指着屏幕,"假设 Kevin 那个'事儿妈'突然说:'我觉得 name 这个字段不够高大上,必须改成 title'。"
这时候如果你直接改代码,SwiftData 会认为你删了 name 并加了个全新的 title,结果就是------用户的旧数据全没了。
"这得写迁移脚本了吧?"Jason 问道。
"杀鸡焉用牛刀,"Chloe 敲下一行代码,"用 @Attribute(originalName:) 就行。"
swift
@Model
final class Exercise {
// 👇 告诉 SwiftData:这货以前叫 "name",别把数据丢了!
@Attribute(originalName: "name")
var title: String
init(title: String) {
self.title = title
}
}

这样,数据库里的列名可能还是旧的(或者底层做了映射),但你的代码里已经可以用高大上的 title 了。
🚫 警告:轻量级迁移的"禁区"
"但是,"Chloe 话锋一转,语气变得严肃,"一旦你跨过那条线,轻量级迁移就得歇菜。"
当新 Schema 引入了旧数据无法满足的新要求时,或者说,当 SwiftData 无法自动推断如何从旧模型变到新模型时,你就麻烦了。
典型的"翻车"场景包括:
- 新增非可选属性且没有默认值(旧数据里这个字段是空的,你又不让它空,SwiftData 会崩)。
- 任何需要转换步骤 的变更:
- 解析/组合值(比如把
fullName拆成firstName和lastName)。 - 合并或拆分实体。
- 改变值的类型(比如
String变Int)。 - 数据清理(去重、格式化字符串、修复无效状态)。
- 解析/组合值(比如把

"这时候,"Chloe 拍了拍 Jason 的肩膀,"你就得进入**手动迁移(Manual Migration)**的领域了。"
⚠️ 关于"默认值"的一个陷阱
Jason 插嘴道:"如果我加个非可选属性,但在 init 里给个默认值,不就完事了吗?"
"大错特错! "Chloe 的声音提高了一个八度,"这是新手最容易踩的坑。Swift 初始化器里的默认值,并不意味着磁盘上的旧数据在迁移时会自动获得这个值。"
如果你引入一个必须存在的字段(Required Field),请默认你必须显式地回填它,除非你在真机上用真实的旧数据测试过。

🛠️ 手动迁移:SchemaMigrationPlan 的实战
"来,我们实战一下。"Chloe 打开了一个新文件,"假设 Kevin 要求必须记录运动的创建时间 createdAt: Date,而且这个字段不能是空的。"
我们有两个选择:
- 选项 A(认怂版): 把它设为
Date?,旧数据就是nil。这很安全,但数据模型就不纯粹了。 - 选项 B(硬核版): 手写迁移,保持属性非可选,给旧数据填上当前时间。
"作为有追求的工程师,我们当然选 B。"Chloe 开始敲代码。

首先,定义 V1 和 V2:
swift
import SwiftData
// V1 版本:没有 createdAt
enum ExerciseCreatedAtSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] = [Exercise.self]
@Model
final class Exercise {
var name: String
init(name: String) { self.name = name }
}
}
// V2 版本:新增了非可选的 createdAt
enum ExerciseCreatedAtSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] = [Exercise.self]
@Model
final class Exercise {
var name: String
var createdAt: Date // 👈 必须有值!
// 注意:这里的默认值只对新创建的对象有效,对迁移无效!
init(name: String, createdAt: Date = .now) {
self.name = name
self.createdAt = createdAt
}
}
}

接下来,我们编写自定义迁移阶段来给旧数据"补课":
swift
enum AppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] = [
ExerciseCreatedAtSchemaV1.self,
ExerciseCreatedAtSchemaV2.self
]
static var stages: [MigrationStage] = [v1ToV2]
static let v1ToV2 = MigrationStage.custom(
fromVersion: ExerciseCreatedAtSchemaV1.self,
toVersion: ExerciseCreatedAtSchemaV2.self,
willMigrate: { _ in
// 迁移前清理,暂时用不到
},
didMigrate: { context in
// 👇 重点来了!在这里我们获取所有 V2 模型(此时 createdAt 是无效状态)
let exercises = try context.fetch(FetchDescriptor<ExerciseCreatedAtSchemaV2.Exercise>())
// 遍历所有数据,手动赋予当前时间
for exercise in exercises {
exercise.createdAt = Date()
}
// 💾 保存更改,这一步至关重要
try context.save()
}
)
}

🔍 深入理解:willMigrate vs didMigrate
Jason 看着代码有点晕:"这俩闭包有啥区别?"
willMigrate:在 Schema 变更之前 运行。这时候你只能看到旧数据。通常用来做数据清理,比如去重。你没法在这里给新属性赋值,因为新属性还不存在。didMigrate:在 Schema 变更之后 运行。这时候你看到的是新模型。这是你回填数据、赋值的主战场。
"记住,"Chloe 总结道,"我 90% 的工作都是在 didMigrate 里完成的。"

🌉 终极挑战:复杂的"桥接版本"迁移
"好了,现在给你上点强度。"Chloe 露出了大魔王般的笑容,"假设你的 V1 模型里,WeightData(举铁数据)把重量、组数、次数都存在自己身上。"
现在 Kevin 想把结构改成:WeightData 包含一个 PerformedSet(组)的列表。
这是一个结构性的巨变。你不能直接把原来的属性删了,因为你需要用原来的数据去创建新的 PerformedSet 对象。

"这就需要用到**桥接版本(Bridge Version)**策略:"
- V2 (桥接版):保留旧字段(标记为 legacy),同时添加新关系。
- V3 (清理版):等数据迁移完,再把旧字段删掉。
来看看 V2 长什么样:
swift
enum WeightSchemaV2: VersionedSchema {
// ... 版本号定义 ...
@Model
final class WeightData {
// 👇 用 originalName 留住旧数据,改名叫 legacy
@Attribute(originalName: "weight")
var legacyWeight: Float
@Attribute(originalName: "reps")
var legacyReps: Int
// 👇 新增的关系
@Relationship(inverse: \WeightSchemaV2.PerformedSet.weightData)
var performedSets: [PerformedSet] = []
// ... init ...
}
// 👇 新增的模型
@Model
final class PerformedSet {
var weight: Float
var reps: Int
var weightData: WeightData?
// ... init ...
}
}

然后,在 didMigrate 里,把 legacy 的数据搬运到新的 PerformedSet 里:
swift
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: WeightSchemaV1.self,
toVersion: WeightSchemaV2.self,
willMigrate: nil,
didMigrate: { context in
// 获取桥接版数据
let allWeightData = try context.fetch(FetchDescriptor<WeightSchemaV2.WeightData>())
for weightData in allWeightData {
// 🏗️ 利用旧数据创建新对象
let performedSet = WeightSchemaV2.PerformedSet(
weight: weightData.legacyWeight,
reps: weightData.legacyReps,
sets: weightData.legacySets,
weightData: weightData
)
// 🔗 建立关联
weightData.performedSets.append(performedSet)
}
try context.save()
}
)
等你发布了这个版本,用户升级上来,数据成功搬运。下个版本 V3,你就可以放心地把 legacyWeight 这些字段删掉了。

📝 总结:别当"赌狗"
窗外天色已亮,Jason 看着满屏严谨的迁移代码,长出了一口气。

Chloe 合上笔记本,做了最后的总结:
- 版本化是基本功 :把
VersionedSchema当作发布产物,不要在开发过程中乱升版本,发版时再一次性升。 - 能自动就自动:增加可选字段、改名(带映射)都很安全。
- 不能自动就手写 :一旦需要"凭空创造"数据(如非可选字段),立马用
SchemaMigrationPlan。 - 复杂情况用桥接:不要试图在一个版本里完成"大换血",分两步走,稳得一笔。
- 一定要测试! 别只在模拟器上删了 App 重装。要模拟真实场景:装旧版 -> 塞满脏数据 -> 覆盖安装新版。

"好了,"Chloe 站起身,"去给 Kevin 演示吧。"
就在这时,办公室的门被推开了。Kevin 顶着黑眼圈走了进来,手里拿着新的需求文档:"嘿,伙计们,关于那个运动模型,我昨晚想了一宿,觉得我们应该把所有数据都改成区块链存储......"
Jason 和 Chloe 对视一眼,异口同声:"滚!"

(全剧终)