关于 axiom-swiftdata-migration
面向 SwiftData 开发者的迁移指南,涵盖轻量迁移与自定义迁移、关系保留和升级验证。内容着重说明迁移阶段的访问限制及实用测试方法。
主要功能
- 01轻量迁移与自定义迁移判断
- 02属性类型变更的两阶段方案
- 03关系迁移模式
- 04VersionedSchema 设置
- 05迁移测试清单
适用场景
- 01更改持久化属性类型
- 02重组模型关系
- 03为重复数据添加唯一约束
- 04在真机上测试升级
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-swiftdata-migration安装后在 Claude Code 中输入/axiom-swiftdata-migration确认已加载。
详细介绍
SwiftData 自定义架构迁移
概述
SwiftData 架构迁移可在模型发生变化时安全地迁移数据。核心原则:SwiftData 的 willMigrate 只能访问旧模型,didMigrate 只能访问新模型——你永远无法同时访问两者。这一限制决定了所有迁移策略。
要求:iOS 17+、Swift 5.9+
目标:iOS 26+(支持 propertiesToFetch 等功能)
何时需要自定义迁移
轻量级迁移(自动)
SwiftData 可自动迁移以下变更:
- ✅ 添加新的可选属性
- ✅ 添加带默认值的新必需属性
- ✅ 移除属性
- ✅ 重命名属性(使用
@Attribute(originalName:)) - ✅ 更改关系删除规则
- ✅ 添加新模型
自定义迁移(本技能)
以下情况需要自定义迁移:
- ❌ 更改属性类型(
String→AttributedString、Int→String) - ❌ 将可选属性改为必需属性(必须为现有的空值填充数据)
- ❌ 复杂的关系重构
- ❌ 数据转换(拆分/合并字段)
- ❌ 添加唯一性约束时进行去重
示例提示词
以下是开发者实际会提出的问题,本技能旨在解答这些问题:
1. “我需要将属性从 String 改为 AttributedString。如何在迁移现有数据时保留关系?”
→ 本技能介绍了可绕过 willMigrate/didMigrate 限制的两阶段迁移模式
2. “我的模型具有级联删除的一对多关系。如何在类型变更迁移期间保留这种关系?”
→ 本技能讲解如何预取关系,以及如何在不同架构版本间维护反向关系
3. “Tags 和 Notes 之间有多对多关系。迁移失败并提示 ‘Expected only Arrays for Relationships’。这是怎么回事?”
→ 本技能介绍了显式反向关系的要求,以及 iOS 17.0 的字母顺序命名错误
4. “我需要重命名一个模型,但保留其所有关系。”
→ 本技能展示了用于轻量级迁移的 @Attribute(originalName:) 模式
5. “我的迁移在模拟器上运行正常,但在有现存数据的真机上崩溃。”
→ 本技能强调了真机测试,并解释了为什么模拟器测试成功并不能保证生产环境安全
6. “为什么我必须将所有模型都复制到每个 VersionedSchema 中,包括那些没有变化的模型?”
→ 本技能解释了 SwiftData 的设计:每个 VersionedSchema 都是完整快照,而非差异集
7. “我遇到了 ‘The model used to open the store is incompatible with the one used to create the store’ 错误。”
→ 本技能提供了排查架构版本不匹配问题的步骤
8. “发布到生产环境之前,如何测试 SwiftData 迁移?”
→ 本技能介绍了迁移测试工作流、真机测试要求和验证策略
willMigrate/didMigrate 的限制
重要:这是决定所有 SwiftData 迁移模式的架构约束。
可访问的内容
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// ✅ CAN access: SchemaV1 models (old)
let v1Notes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
// ❌ CANNOT access: SchemaV2 models
// SchemaV2.Note doesn't exist yet
},
didMigrate: { context in
// ✅ CAN access: SchemaV2 models (new)
let v2Notes = try context.fetch(FetchDescriptor<SchemaV2.Note>())
// ❌ CANNOT access: SchemaV1 models
// SchemaV1.Note is gone
}
)
这为何重要
你无法在单个迁移阶段中直接将数据从旧类型转换为新类型。例如:
// ❌ IMPOSSIBLE - you can't do this in one stage
willMigrate: { context in
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
for oldNote in oldNotes {
let newNote = SchemaV2.Note() // ❌ Doesn't exist yet!
newNote.content = oldNote.contentAsAttributedString()
}
}
解决方案:使用两阶段迁移模式(下文将介绍)。
核心模式
模式 1:基本的 VersionedSchema 设置
每个不同的架构版本都必须定义为 VersionedSchema。
import SwiftData
enum NotesSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self, Folder.self, Tag.self] // ALL models, even if unchanged
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
var content: String // Original type
var createdAt: Date
@Relationship(deleteRule: .nullify, inverse: \Folder.notes)
var folder: Folder?
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = []
init(id: String, title: String, content: String, createdAt: Date) {
self.id = id
self.title = title
self.content = content
self.createdAt = createdAt
}
}
@Model
final class Folder {
@Attribute(.unique) var id: String
var name: String
@Relationship(deleteRule: .cascade)
var notes: [Note] = []
init(id: String, name: String) {
self.id = id
self.name = name
}
}
@Model
final class Tag {
@Attribute(.unique) var id: String
var name: String
@Relationship(deleteRule: .nullify)
var notes: [Note] = []
init(id: String, name: String) {
self.id = id
self.name = name
}
}
}
关键模式
- 完整快照:包含所有模型,即使有些模型没有变化
- 语义化版本控制:使用 Schema.Version(major, minor, patch)
- 显式初始化器:SwiftData 不会自动合成初始化器
- 反向关系:在关系的两端都进行指定,以实现双向关系
模式 2:类型变更的两阶段迁移
适用场景:更改属性类型(String → AttributedString、Int → String 等)
问题
我们要将 Note.content 从 String 改为 AttributedString,但无法同时访问新旧两种类型。
解决方案
使用同时包含两个属性的中间架构版本(V1.1)。
// Stage 1: V1 → V1.1 (Add new property alongside old)
enum NotesSchemaV1_1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 1, 0)
static var models: [any PersistentModel.Type] {
[Note.self, Folder.self, Tag.self]
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
// OLD property (to be deprecated)
@Attribute(originalName: "content")
var contentOld: String = ""
// NEW property (target type)
var contentNew: AttributedString?
var createdAt: Date
@Relationship(deleteRule: .nullify, inverse: \Folder.notes)
var folder: Folder?
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = []
init(id: String, title: String, contentOld: String, createdAt: Date) {
self.id = id
self.title = title
self.contentOld = contentOld
self.createdAt = createdAt
}
}
// Folder and Tag unchanged (copy from V1)
@Model final class Folder { /* same as V1 */ }
@Model final class Tag { /* same as V1 */ }
}
// Stage 2: V1.1 → V2 (Transform data, remove old property)
enum NotesSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self, Folder.self, Tag.self]
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
// Renamed from contentNew
@Attribute(originalName: "contentNew")
var content: AttributedString?
var createdAt: Date
@Relationship(deleteRule: .nullify, inverse: \Folder.notes)
var folder: Folder?
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = []
init(id: String, title: String, content: AttributedString?, createdAt: Date) {
self.id = id
self.title = title
self.content = content
self.createdAt = createdAt
}
}
@Model final class Folder { /* same as V1 */ }
@Model final class Tag { /* same as V1 */ }
}
迁移计划
enum NotesMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[NotesSchemaV1.self, NotesSchemaV1_1.self, NotesSchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV1_1, migrateV1_1toV2]
}
// Stage 1: Lightweight migration (adds contentNew)
static let migrateV1toV1_1 = MigrationStage.lightweight(
fromVersion: NotesSchemaV1.self,
toVersion: NotesSchemaV1_1.self
)
// Stage 2: Custom migration (transform String → AttributedString)
static let migrateV1_1toV2 = MigrationStage.custom(
fromVersion: NotesSchemaV1_1.self,
toVersion: NotesSchemaV2.self,
willMigrate: { context in
// Transform data while we still have access to V1.1 models
var fetchDesc = FetchDescriptor<NotesSchemaV1_1.Note>()
// Prefetch relationships to preserve them
fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags]
let notes = try context.fetch(fetchDesc)
for note in notes {
// Convert String → AttributedString
note.contentNew = try? AttributedString(markdown: note.contentOld)
}
try context.save()
},
didMigrate: nil
)
}
应用迁移计划
@main
struct NotesApp: App {
let container: ModelContainer = {
do {
let schema = Schema(versionedSchema: NotesSchemaV2.self)
return try ModelContainer(
for: schema,
migrationPlan: NotesMigrationPlan.self
)
} catch {
fatalError("Failed to create container: \(error)")
}
}()
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
模式 3:迁移多对多关系
适用场景 存在多对多关系(标签 ↔ 笔记)
关键要求
- 显式反向关系 SwiftData 不会推断多对多关系
- 两侧都使用数组 不能为可选类型,必须是数组
- iOS 17.0 错误的解决方法 字母顺序命名问题
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self, Tag.self]
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
// Many-to-many: MUST specify inverse
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = [] // ✅ Array with default value
init(id: String, title: String) {
self.id = id
self.title = title
}
}
@Model
final class Tag {
@Attribute(.unique) var id: String
var name: String
// Many-to-many: MUST specify inverse
@Relationship(deleteRule: .nullify, inverse: \Note.tags)
var notes: [Note] = [] // ✅ Array with default value
init(id: String, name: String) {
self.id = id
self.name = name
}
}
}
iOS 17.0 字母顺序错误的解决方法
在 iOS 17.0 中,如果模型名称按字母顺序排列,多对多关系可能会失败(例如,Actor ↔ Movie 可行,但 Movie ↔ Person 不可行)。
解决方法 为关系数组提供默认值:
@Relationship(deleteRule: .nullify, inverse: \Movie.actors)
var actors: [Actor] = [] // ✅ Default value prevents bug
已在 iOS 17.1+ 中修复
添加联结表元数据
如果需要为关系添加其他字段(例如,“添加此标签的时间是什么时候?”),请使用显式联结模型:
@Model
final class NoteTag {
@Attribute(.unique) var id: String
var addedAt: Date // Metadata on relationship
@Relationship(deleteRule: .cascade)
var note: Note?
@Relationship(deleteRule: .cascade)
var tag: Tag?
init(id: String, note: Note, tag: Tag, addedAt: Date) {
self.id = id
self.note = note
self.tag = tag
self.addedAt = addedAt
}
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
@Relationship(deleteRule: .cascade)
var noteTags: [NoteTag] = [] // One-to-many to junction
var tags: [Tag] {
noteTags.compactMap { $0.tag }
}
}
@Model
final class Tag {
@Attribute(.unique) var id: String
var name: String
@Relationship(deleteRule: .cascade)
var noteTags: [NoteTag] = [] // One-to-many to junction
var notes: [Note] {
noteTags.compactMap { $0.note }
}
}
模式 4:迁移期间预取关系
适用场景 迁移带有关系的模型,以避免 N+1 查询
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
var fetchDesc = FetchDescriptor<SchemaV1.Note>()
// Prefetch relationships (iOS 26+)
fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags]
// Only fetch properties you need (iOS 26+)
fetchDesc.propertiesToFetch = [\.title, \.content]
let notes = try context.fetch(fetchDesc)
// Relationships are already loaded - no N+1
for note in notes {
let folderName = note.folder?.name // ✅ Already in memory
let tagCount = note.tags.count // ✅ Already in memory
}
try context.save()
},
didMigrate: nil
)
性能影响
Without prefetching:
- 1 query to fetch notes
- N queries to fetch each note's folder
- N queries to fetch each note's tags
= 1 + N + N queries
With prefetching:
- 1 query to fetch notes
- 1 query to fetch all folders
- 1 query to fetch all tags
= 3 queries total
模式 5:重命名属性
适用场景 想要重命名属性且不丢失数据
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self]
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String // Original name
}
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self]
}
@Model
final class Note {
@Attribute(.unique) var id: String
// Renamed from "title" to "heading"
@Attribute(originalName: "title")
var heading: String
}
}
// Migration plan (lightweight migration)
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
)
}
原理 SwiftData 会识别 originalName,并在轻量级迁移期间保留数据。
模式 6:对唯一约束进行去重
适用场景 为包含重复值的字段添加 @Attribute(.unique)
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Trip.self]
}
@Model
final class Trip {
@Attribute(.unique) var id: String
var name: String // ❌ Not unique, has duplicates
init(id: String, name: String) {
self.id = id
self.name = name
}
}
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Trip.self]
}
@Model
final class Trip {
@Attribute(.unique) var id: String
@Attribute(.unique) var name: String // ✅ Now unique
init(id: String, name: String) {
self.id = id
self.name = name
}
}
}
enum TripMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// Deduplicate before adding unique constraint
let trips = try context.fetch(FetchDescriptor<SchemaV1.Trip>())
var seenNames = Set<String>()
for trip in trips {
if seenNames.contains(trip.name) {
// Duplicate - delete or rename
context.delete(trip)
} else {
seenNames.insert(trip.name)
}
}
try context.save()
},
didMigrate: nil
)
}
测试迁移
必须执行的测试清单
- 测试全新安装(从 V1 运行所有迁移,直至最新版本)
- 测试从每个先前版本升级
- 在真实设备上测试(不能只用模拟器)
- 验证迁移后的关系完整性
- 检查是否丢失数据(比较迁移前后的记录数)
- 使用接近生产规模的数据集进行测试
为什么仅在模拟器中测试还不够
模拟器行为 重建时会删除数据库,因此始终使用全新架构
真实设备行为 更新期间会保留持久化数据库,因此架构必须匹配
// ❌ WRONG - only testing in simulator
// You rebuild → simulator deletes database → fresh install
// Migration code never runs!
// ✅ CORRECT - test on real device
// 1. Install v1 build on device
// 2. Create sample data
// 3. Install v2 build (with migration)
// 4. Verify data preserved
测试工作流
将任何迁移部署到生产环境之前:
1. 创建测试数据集
准备代表迁移前状态的测试数据:
- 最小数据集 - 10-20 条记录,涵盖所有关系类型
- 逼真数据集 - 1,000+ 条记录,符合生产环境规模
- 边界情况 - 空关系、关系数量达到上限、可选字段
2. 在模拟器中测试
使用测试数据运行迁移:
// Create test data in V1 schema
let v1Container = try ModelContainer(for: Schema(versionedSchema: SchemaV1.self))
// ... populate test data ...
// Run migration
let v2Container = try ModelContainer(
for: Schema(versionedSchema: SchemaV2.self),
migrationPlan: MigrationPlan.self
)
验证:
- 所有关系均得到保留
- 没有数据丢失(比较迁移前后的记录数)
- 新字段已正确填充
- 使用逼真规模的数据集时,性能可以接受
3. 在真实设备上测试
重要 - 在模拟器中成功并不代表在生产环境中安全。
# Workflow:
1. Install v1 build on real device
2. Create 100+ records with relationships
3. Verify data exists
4. Install v2 build (over existing app, don't delete)
5. Launch app
6. Verify:
- App launches without crash
- All 100+ records still exist
- Relationships intact
- New fields populated
4. 使用生产数据验证(如可行)
如果可以访问生产数据:
- 将生产数据库复制到开发环境
- 对副本运行迁移
- 验证没有数据损坏
- 检查使用生产规模数据集时的性能
如果迁移失败,请参阅 axiom-swiftdata-migration-diag 获取调试工具。
迁移测试模式
import Testing
import SwiftData
@Test func testMigrationFromV1ToV2() throws {
// 1. Create V1 data
let v1Schema = Schema(versionedSchema: SchemaV1.self)
let v1Config = ModelConfiguration(isStoredInMemoryOnly: true)
let v1Container = try ModelContainer(for: v1Schema, configurations: v1Config)
let context = v1Container.mainContext
let note = SchemaV1.Note(id: "1", title: "Test", content: "Original")
context.insert(note)
try context.save()
// 2. Run migration to V2
let v2Schema = Schema(versionedSchema: SchemaV2.self)
let v2Container = try ModelContainer(
for: v2Schema,
migrationPlan: MigrationPlan.self,
configurations: v1Config
)
// 3. Verify data migrated
let v2Context = v2Container.mainContext
let notes = try v2Context.fetch(FetchDescriptor<SchemaV2.Note>())
#expect(notes.count == 1)
#expect(notes.first?.content != nil) // String → AttributedString
}
决策树:轻量级迁移还是自定义迁移
What change are you making?
├─ Adding optional property → Lightweight ✓
├─ Adding required property with default → Lightweight ✓
├─ Renaming property (with originalName) → Lightweight ✓
├─ Removing property → Lightweight ✓
├─ Changing relationship delete rule → Lightweight ✓
├─ Adding new model → Lightweight ✓
├─ Changing property type → Custom (two-stage) ✗
├─ Making optional → required → Custom (populate nulls first) ✗
├─ Adding unique constraint (duplicates exist) → Custom (deduplicate first) ✗
└─ Complex relationship restructure → Custom ✗
常见错误
❌ 忘记在 VersionedSchema 中包含所有模型
enum SchemaV1: VersionedSchema {
static var models: [any PersistentModel.Type] {
[Note.self] // ❌ WRONG: Missing Folder and Tag
}
}
// ✅ CORRECT: Include ALL models
enum SchemaV1: VersionedSchema {
static var models: [any PersistentModel.Type] {
[Note.self, Folder.self, Tag.self] // ✅ Even if unchanged
}
}
原因 每个 VersionedSchema 都是数据模型的完整快照,而不是差异。
❌ 尝试在 didMigrate 中访问旧模型
static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: nil,
didMigrate: { context in
// ❌ CRASH: SchemaV1.Note doesn't exist here
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
}
)
// ✅ CORRECT: Use willMigrate for old models
static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// ✅ SchemaV1.Note exists here
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
},
didMigrate: nil
)
❌ 未在真实设备上使用真实数据进行测试
// ❌ WRONG: Simulator success ≠ production safety
// Rebuild simulator → database deleted → fresh install
// Migration never actually runs!
// ✅ CORRECT: Test migration path
// 1. Install v1 on real device
// 2. Create data (100+ records)
// 3. Install v2 with migration
// 4. Verify data preserved
❌ 多对多关系没有显式反向关系
// ❌ WRONG: SwiftData can't infer many-to-many
@Model
final class Note {
var tags: [Tag] = [] // ❌ Missing inverse
}
// ✅ CORRECT: Explicit inverse
@Model
final class Note {
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = [] // ✅ Inverse specified
}
❌ 认为模拟器测试成功就等于生产环境成功
模拟器会在重建时删除数据库。真实设备会在更新期间保留持久化数据库。
影响 在模拟器中被隐藏的迁移错误,会导致 100% 的生产用户遇到崩溃。
修复方法 发布前务必在真实设备上测试。
调试失败的迁移
启用 Core Data SQL 调试
# In Xcode scheme, add argument:
-com.apple.coredata.swiftdata.debug 1
输出 在迁移期间显示实际的 SQL 查询
CoreData: sql: SELECT Z_PK, Z_ENT, Z_OPT, ZID, ZTITLE FROM ZNOTE
CoreData: sql: ALTER TABLE ZNOTE ADD COLUMN ZCONTENT TEXT
常见错误消息
| 错误 | 可能原因 | 修复方法 |
|---|---|---|
| "Expected only Arrays for Relationships" | 缺少多对多关系的反向关系 | 添加 @Relationship(inverse:) |
| "The model used to open the store is incompatible" | 架构版本不匹配 | 检查迁移计划中的 schemas 数组 |
| "Failed to fulfill faulting for..." | 关系完整性遭到破坏 | 在迁移期间预取关系 |
| 架构更改后应用在启动时崩溃 | VersionedSchema 中缺少模型 | 包含所有模型 |
快速参考
基本迁移设置
// 1. Define versioned schemas
enum SchemaV1: VersionedSchema { /* models */ }
enum SchemaV2: VersionedSchema { /* models */ }
// 2. Create migration plan
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
)
}
// 3. Apply to container
let schema = Schema(versionedSchema: SchemaV2.self)
let container = try ModelContainer(
for: schema,
migrationPlan: MigrationPlan.self
)
资源
WWDC:2025-291、2023-10195
文档:/swiftdata
技能:axiom-swiftdata、axiom-swiftdata-migration-diag、axiom-database-migration
创建时间 2025-12-09 目标平台 iOS 17+(重点介绍 iOS 26+ 功能) 框架 SwiftData(Apple) Swift 5.9+