SwiftData 遷移:輕量級與自訂遷移,以及何時不需要 V2
SwiftData 的結構描述遷移機制相較於 Core Data 是結構性的改進,但有一個團隊不斷掉入的陷阱:為 SwiftData 本可透過內聯預設值自動處理的變更宣告新的 VersionedSchema。結果就是裝置上出現「Duplicate version checksums across stages detected」當機,即使程式碼看起來正確且建置乾淨。框架實際的遷移模型由三個元件組成(VersionedSchema、MigrationStage、SchemaMigrationPlan)以及三種遷移類型(自動輕量級、宣告式輕量級、自訂)1。多數結構描述變更屬於自動類型。部分需要宣告式輕量級階段。少數需要使用 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:)來追蹤重新命名;指定刪除規則。多數結構描述變更都符合此類別。 - 自訂遷移(
MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:))處理資料轉換:將一個欄位拆分為兩個、計算衍生欄位、在模型之間移動資料。willMigrate取得舊的 context;didMigrate取得新的 context。 - iOS 26 為
@Model類型新增類別繼承2。採用繼承的結構描述會升至新版本,並從先前的扁平模型版本進行輕量級階段轉換。
三元件模型
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 在建立容器時讀取持久化儲存的目前結構描述版本,從該版本依序走過計畫中的階段直到目前版本,並按順序套用每個階段。
輕量級遷移會自動處理的內容
多數結構描述變更不需要自訂階段1:
- 新增帶預設值的屬性。 在現有
@Model上加入var foo: Bool = false是自動處理的。 - 新增實體(模型類別)。 當新類型所屬的
VersionedSchema成為目前版本時就會出現;既有資料會被保留。 - 移除屬性或實體。 SwiftData 會卸除該欄位或資料表。
- 重新命名屬性或實體。 在屬性上加入
@Attribute(originalName: "oldName")來保留資料;SwiftData 會將舊值對應至新值。 - 變更關聯類型。 一對多、多對多等。
- 指定刪除規則。
@Relationship(deleteRule: .cascade)等類似新增屬於輕量級。
對於屬於此清單的變更,正確模式是 完全不宣告新的 VersionedSchema,前提是模型類型在其他方面未變更。SwiftData 會針對既有結構描述自動執行輕量級遷移。
陷阱:新增欄位不需要 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 結構描述。在那些情況下,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 閉包針對新結構描述的 context 執行,所以新欄位是可存取的。舊的 fullName 可能需要延後到新欄位填入後再移除;清理工作則作為後續的 V2-to-V3 階段。
2. 計算衍生欄位。 一個依賴既有資料的新 @Attribute 需要在遷移時回填。
3. 在模型之間移動資料。 將原本在 Item 中的資料拆分至 Item 與一個新的 Tag 模型,這類重組需要自訂邏輯來從舊資料指派標籤。
通則:當結構描述形狀改變時用輕量級;當資料形狀改變時用自訂。
willMigrate 與 didMigrate
自訂階段有兩個閉包,在不同時機被呼叫4:
willMigrate 在 SwiftData 套用結構描述遷移之前執行。閉包接收的模型 context 是舊結構描述的 context。可用來擷取資料、將資料反正規化,或在結構描述底層改變前準備輔助狀態。
didMigrate 在結構描述遷移之後執行。模型 context 屬於新結構描述。可用於回填新欄位、計算衍生資料,或敲定遷移。
任一閉包若不需要可設為 nil。多數自訂遷移只使用 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)
}
}
採用繼承的結構描述會升至新版本,並從先前的扁平模型版本進行輕量級遷移階段。如果繼承保留了既有屬性,轉換是自動的;子類別上的新欄位則遵循標準的內聯預設值模式。
這個模式適用於多個 @Model 類型共享特性的情況:Vehicle 父類別搭配 Car、Truck、Motorcycle 子類別;Account 父類別搭配 CheckingAccount、SavingsAccount 子類別。共用屬性放在父類別;特定屬性放在子類別。
測試遷移
可以編譯通過的遷移不等於可以上線的遷移。以下三種測試模式值得在發佈前執行:
1. 在生產環境資料庫副本上做來回測試。 取得近期生產環境形狀的資料庫(或透過測試產生 V1 合成資料),以支援 V2 的容器開啟它,並驗證資料正確遷移。此測試可捕捉型別檢查器無法發現的自訂遷移錯誤。
2. 舊版本仍能啟動。 建置前一個應用程式版本,執行一次以產生 V1 資料,接著建置新版本並驗證它能啟動而不當機。此測試可捕捉「Duplicate version checksums」陷阱與類似的宣告錯誤。
3. 失敗遷移的恢復。 如果遷移擲出錯誤會發生什麼事?SwiftData 的行為取決於容器的設定;對於正式環境的應用程式,未處理的遷移錯誤不應靜默地刪除使用者資料。明確測試失敗路徑,並決定應用程式的反應(回復、提示、從備份還原)。
本系列的單一真實來源文章涵蓋了相關問題:當 SwiftData 儲存透過跨程序同步被替換時會發生什麼事。遷移是該模式在本地演化方面的對應物。
常見失敗模式
以下三種模式來自 SwiftData 失敗紀錄:
為 SwiftData 本可自動處理的變更宣告 V2。 也就是「Duplicate version checksums」當機。修正:別為內聯預設值的屬性新增宣告新結構描述;讓 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用於資料轉換,不是用於結構描述形狀的變更。willMigrate和didMigrate閉包是給操作資料的程式碼用的,不是用來宣告結構描述已變更。結構描述形狀的變更要透過輕量級階段流動。 -
以真實的 V1 資料測試遷移,不要只用合成測試資料。 在合成來回測試中通過的遷移,在生產環境形狀的資料上仍可能因為邊界情況失敗(結構描述未涵蓋的可空欄位、達到逾時的大型資料集等等)。測試的成本很小;首次啟動遷移當機的代價是真實的。
完整的 Apple 生態系列:類型化的 App Intents;MCP 伺服器;路由問題;Foundation Models;執行時 vs 工具 LLM 區別;三種介面;單一真實來源模式;兩個 MCP 伺服器;Apple 開發的 hooks;Live Activities;watchOS 執行時合約;SwiftUI 內部;RealityKit 的空間心智模型;SwiftData 結構描述紀律;Liquid Glass 模式;多平台出貨;平台矩陣;Vision 框架;Symbol Effects;Core ML 推論;Writing Tools API;Swift Testing;Privacy Manifest;Accessibility 作為平台;SF Pro 字體;visionOS 空間模式;Speech 框架;我拒絕書寫的主題。中心點在 Apple 生態系列。如需更廣的「iOS 搭配 AI 代理」相關內容,請參閱 iOS Agent Development 指南。
FAQ
我一定需要 SchemaMigrationPlan 嗎?
不一定。只有單一結構描述版本的應用程式(初始版本,或只做過輕量級變更的應用程式)不需要 SchemaMigrationPlan。ModelContainer 初始化器可直接接受結構描述的模型。當第一次宣告自訂遷移階段時(或開發者第一次想宣告明確的版本階梯時),migrationPlan: 參數才會變得必要。
我怎麼知道我的變更屬於輕量級?
Apple 的輕量級可行清單1:新增實體、屬性、關聯,移除它們,使用 @Attribute(originalName:) 重新命名,變更關聯基數,指定刪除規則。如果變更符合上述其中一項,且模型類別結構在其他方面未變更,遷移會自動處理且不需要 VersionedSchema 階梯。如果變更需要資料轉換(計算、拆分、移動資料),則屬於自訂類型。
willMigrate 與 didMigrate 可以同時設定嗎?
可以。兩個閉包個別都是選用的,但也可以同時提供。willMigrate 在 SwiftData 遷移之前針對舊結構描述的 context 執行;didMigrate 在之後針對新結構描述的 context 執行。兩者分別涵蓋準備與敲定。
如果遷移擲出錯誤會怎麼樣?
錯誤會從 ModelContainer 初始化中傳播出來。容器會無法開啟。應用程式的行為取決於開發者如何處理錯誤:有些應用程式會顯示恢復 UI、有些會嘗試從備份還原、有些會刪除損毀的儲存並重新開始。SwiftData 不會在遷移失敗時靜默刪除使用者資料;失敗由應用程式自行處理。
我要如何在不影響生產環境資料的情況下測試遷移?
建立一個測試 target,建立一個指向暫存檔案 URL 的 ModelContainer,以 V1 資料填充,接著用包含遷移計畫的新容器開啟它。驗證遷移後的資料符合預期。此模式在單元測試與整合測試中都適用;若要獲得最貼近現實的結果,使用實際生產環境形狀資料庫的副本。
iOS 26 的類別繼承能與既有結構描述搭配運作嗎?
可以,透過輕量級遷移即可。採用繼承的應用程式會升至新的結構描述版本(例如 V4)並宣告 MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self)。扁平的父類別屬性保留,子類別專屬屬性以內聯預設值新增。SwiftData 的輕量級遷移會處理結構性變更。
References
-
Apple Developer Documentation:
VersionedSchema與SchemaMigrationPlan協定參考。遷移模型。亦請參閱相關指南 Adopting SwiftData for a Core Data app,取得完整的結構描述演化敘述。 ↩↩↩↩↩↩ -
Apple Developer:SwiftData: Dive into inheritance and schema migration(WWDC 2025 session 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 針對舊 context 執行、didMigrate 針對新 context 執行的語意,記載於 WWDC 2025 session 291 SwiftData: Dive into inheritance and schema migration,即 iOS 26 繼承新增功能所引用的同一場議程。 ↩