SwiftData 遷移:輕量級 vs 自訂,以及何時其實不需要 V2
SwiftData 的 schema 遷移機制相較於 Core Data 是一項結構性的改進,但有個陷阱讓不少團隊一再踩中:為了那些 SwiftData 原本就會透過 inline default 自動處理的變更,卻去宣告一個新的 VersionedSchema。結果就是在裝置上發生「Duplicate version checksums across stages detected」的崩潰,即使程式碼看起來沒問題、也能乾淨地編譯通過。這套框架真正的遷移模型由三個部分(VersionedSchema、MigrationStage、SchemaMigrationPlan)與三種遷移類型(自動輕量級、宣告式輕量級、自訂)組成1。多數 schema 變更屬於自動類型。有些需要一個宣告式的輕量級 stage。只有少數需要帶有 willMigrate 與 didMigrate 閉包的自訂 stage。
本文會對照 Apple 的官方文件逐一檢視這套遷移模型,點名每種遷移類型各自負責的情境,並涵蓋 iOS 26 全新的類別繼承支援。整體的思考框架是「我要宣告什麼,相對於 SwiftData 會替我處理什麼」,因為這個判斷會決定遷移究竟能乾淨上線,還是在首次啟動時崩潰。
TL;DR
- SwiftData 遷移由三個協定組合而成:
VersionedSchema(某個版本下 model 型別的快照)、MigrationStage(單一 fromVersion 到 toVersion 的轉換,含.lightweight或.custom兩種 case),以及SchemaMigrationPlan(依序排列的 stage 清單)1。 - 為既有
@Model新增一個帶 inline default 的屬性(var foo: Bool = false)並不需要新的VersionedSchema。SwiftData 會把這次新增當作輕量級遷移自動處理。為此宣告一個 V2 反而會造成「Duplicate version checksums across stages detected」崩潰。 - 輕量級遷移可處理:新增/重新命名/刪除 entity、attribute、relationship;變更 relationship 型別;以
@Attribute(originalName:)追蹤重新命名;指定 delete rule。多數 schema 變更都落在這個範圍內。 - 自訂遷移(
MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:))負責資料轉換:把一個欄位拆成兩個、計算衍生欄位、在不同 model 之間搬移資料。willMigrate拿到的是舊的 context;didMigrate拿到的是新的 context。 - iOS 26 為
@Model型別新增了類別繼承2。採用繼承的 schema 會升到新版本,並以一個從前一個扁平 model 版本出發的輕量級 stage 完成轉換。
三件式模型
一次 SwiftData 遷移由三個部分組合而成。
VersionedSchema
某個特定 schema 版本下 model 型別的快照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 都會為自己的 model 類別建立命名空間,如此一來,在遷移期間,多個使用相同 model 名稱的 schema 就能在同一份程式碼庫中並存。
MigrationStage
兩個 VersionedSchema 型別之間的單一轉換3。有兩種 case:
.lightweight(fromVersion: any VersionedSchema.Type, toVersion: any VersionedSchema.Type)。宣告一個 SwiftData 不需 App 程式碼即可處理的轉換。參數本身是VersionedSchema型別(例如SchemaV1.self),而非原始的Schema.Version值。.custom(fromVersion:toVersion:willMigrate:didMigrate:)。宣告一個帶有程式碼的轉換,這些程式碼會在資料遷移之前和/或之後執行。版本引數的參數型別與.lightweight相同。
SchemaMigrationPlan
依序排列的 stage 清單,負責把 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(...)
)
在建立 container 時,SwiftData 會讀取持久化儲存中目前的 schema 版本,沿著計畫中的 stage 從該版本一路向前推進到目前版本,並依序套用每個 stage。
輕量級遷移會自動處理哪些變更
多數 schema 變更並不需要自訂 stage1:
- 新增一個帶預設值的 attribute。 在既有
@Model上加var foo: Bool = false屬於自動處理。 - 新增一個 entity(model 類別)。 當某型別所屬的
VersionedSchema成為目前版本時,新型別便會出現;既有資料則完整保留。 - 移除一個 attribute 或 entity。 SwiftData 會卸除該欄位或資料表。
- 重新命名一個 attribute 或 entity。 為該屬性加上
@Attribute(originalName: "oldName")即可保留資料;SwiftData 會把舊名對應到新名。 - 變更 relationship 型別。 一對多、多對多等變更。
- 指定 delete rule。
@Relationship(deleteRule: .cascade)及類似的新增都屬於輕量級。
對於清單中的這些變更,正確的做法是:只要 model 型別本身沒有其他改動,就完全不要宣告新的 VersionedSchema。SwiftData 會針對既有 schema 自動執行輕量級遷移。
陷阱:新增一個欄位並不需要 V2
最常見的 SwiftData 遷移錯誤是:開發者為 model 加上一個帶 inline default 的新屬性(var foo: Bool = false),接著宣告一個 SchemaV2,卻參照與 SchemaV1 相同的 model 型別。編譯乾淨無誤。但在已有 V1 資料的裝置上首次啟動時就會崩潰,並丟出 Duplicate version checksums across stages detected,因為 SchemaV1 與 SchemaV2 都解析到相同的 checksum(model 型別的變動方式並未被 SwiftData 認定為有所不同)。
正確的做法是:別動既有的 VersionedSchema,直接在 model 上加入帶 inline default 的新屬性,讓 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(...)
)
只有當某項變更無法以輕量級方式完成時(資料轉換、model 拆分、需要自訂邏輯的繼承重構)才需要 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 的 stage 來處理。
2. 計算衍生欄位。 一個依賴既有資料的新 @Attribute,需要在遷移時被回填。
3. 在不同 model 之間搬移資料。 一次重組,把原本屬於 Item 的資料拆分到 Item 與一個新的 Tag model 之間,就需要自訂邏輯,從舊資料指派 tag。
通則是:schema 的形狀改變時用輕量級;資料的形狀改變時用自訂。
willMigrate vs didMigrate
自訂 stage 有兩個閉包,會在不同時間點被呼叫4:
willMigrate 在 SwiftData 套用 schema 遷移之前執行。該閉包收到的 model context 是舊 schema 的 context。可用它在 schema 在底層改變之前,先擷取資料、做反正規化,或準備輔助狀態。
didMigrate 在 schema 遷移之後執行。其 model context 屬於新 schema。可用它回填新欄位、計算衍生資料,或為遷移收尾。
任一閉包若不需要都可以是 nil。多數自訂遷移只用 didMigrate;當遷移需要讀取那些在 schema 改變後就再也存取不到的舊資料時,willMigrate 才派得上用場。
閉包收到一個 ModelContext,可進行 fetch、修改與 save。閉包會 throw;錯誤會往外傳遞出遷移流程並使其中止。
iOS 26:@Model 的類別繼承
iOS 26 為 SwiftData model 導入了類別繼承2。現在 model 之間可以有父子關係:
@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 版本出發的輕量級遷移 stage 完成轉換。只要繼承保留了既有屬性,這次轉換就是自動的;子類別上的新欄位則遵循標準的 inline default 模式。
當多個 @Model 型別共享某些特性時,這個模式正好適用:一個 Vehicle 父類別搭配 Car、Truck、Motorcycle 子類別;一個 Account 父類別搭配 CheckingAccount、SavingsAccount 子類別。共享的屬性放在父類別,特有的屬性放在子類別。
測試遷移
能編譯的遷移不等於能上線的遷移。發布前值得執行三種測試模式:
1. 在生產資料庫副本上做來回測試。 取一份近期、形狀貼近生產的資料庫(或透過測試產生合成的 V1 資料),用支援 V2 的 container 開啟它,並驗證資料正確完成遷移。這個測試能抓出型別檢查器無法察覺的自訂遷移錯誤。
2. 確認舊版本仍能啟動。 建置前一版 App,跑一次讓它產生 V1 資料,接著建置新版 App,驗證它能啟動而不崩潰。這個測試能抓出「Duplicate version checksums」陷阱及類似的宣告錯誤。
3. 遷移失敗的復原。 如果遷移 throw 了會怎樣?SwiftData 的行為取決於 container 的設定;對生產級 App 而言,未經處理的遷移錯誤不應在無聲無息中刪掉使用者資料。明確測試這條失敗路徑,並決定 App 該怎麼做(回復、提示使用者、從備份復原)。
本系列中的 Single Source of Truth 一文探討了一個相關問題:當 SwiftData 儲存因跨行程同步而被替換時會發生什麼。遷移正是該模式在本機演進層面上的對應情境。
跨行程上線遷移並呈現進度
有兩個操作細節,文件並未特別凸顯,但 SwiftData 團隊在 WWDC 2026 上有特別點出5:當 App 帶有 widget 或 extension 時遷移在哪裡執行,以及在遷移執行時如何驅動進度 UI。
由單一行程掌管遷移。 widget 與 extension 拿不到主 App 所擁有的同等執行期資源,因此無法安全地執行遷移。建議的做法是:把 SchemaMigrationPlan1 完全排除在 widget 與 extension target 之外,並且絕不從這些行程進行遷移。挑選一個行程(通常是主 App)作為資料庫的擁有者。如果某個 widget 開啟 container,而磁碟上的儲存仍處於未版本化(較舊)的 schema,開啟就會出錯。把這個錯誤當成「需要遷移」的訊號:呈現 UI 請使用者開啟主 App,讓 App 執行遷移,再由 App 把遷移後的 schema 版本寫入一個共享的 UserDefault。widget 下次便會讀取該值,並以 App 已遷移到的版本來開啟 container。這個模式讓單一寫入者全權負責,避免兩個行程競相演進同一份檔案。
進度由 stage 數量計算,而非掛鐘時間。 SwiftData 並未提供專門的遷移進度 API5。要驅動一個進度指示器,可統計計畫中自訂遷移 stage 的總數,並覆寫各 stage 的 didMigrate 處理器4,讓每個 stage 回報自己的位置,「第 N 個 stage,共 M 個」。這個數字反映的是已完成的 stage 數,而非經過的時間,因此進度條會以離散步進前進,而非平滑移動。隨之而來的設計決策是:遷移期間 App 要顯示什麼。一個光禿禿的轉圈圈看起來就像卡死了,使用者會掉頭離開。在資料允許的範圍內讓 App 保持部分可用,或至少說明每個 stage 正在新增什麼(遷移所解鎖的新功能),讓等待讀起來像是朝著某個目標前進,而不是一段空白的死時間。
常見失敗模式
來自 SwiftData 失敗紀錄的三種模式:
為 SwiftData 本可自動處理的變更宣告 V2。 也就是「Duplicate version checksums」崩潰。修正方式:別為 inline default 的屬性新增去宣告新 schema;讓 SwiftData 自動處理它們。
自訂遷移程式碼沒有 save。 一個修改了 entity 卻沒呼叫 context.save() 的 didMigrate 閉包,會產生一種「執行一次、丟掉成果、每次啟動都重跑」的遷移(因為該遷移看起來像沒完成)。修正方式:每個會修改資料的閉包在 return 之前都必須 try context.save()。
重新命名屬性卻沒用 @Attribute(originalName:)。 SwiftData 會把新屬性當成全新的、把舊屬性當成已刪除的;舊屬性上的既有資料就會被丟掉。修正方式:宣告 @Attribute(originalName: "oldName") var newName: ...,讓 SwiftData 在重新命名的過程中把資料對應過去。
這個模式對 iOS 26+ App 的意義
三個重點。
-
預設不要搭
VersionedSchema階梯。 用 inline default 新增屬性、刪除未使用的欄位、用@Attribute(originalName:)重新命名,全都是輕量級且自動的。VersionedSchema階梯是給那些 SwiftData 確實無法自動處理的變更用的(資料轉換、自訂邏輯、繼承重構)。 -
MigrationStage.custom用於資料轉換,而非 schema 形狀的變更。willMigrate與didMigrate閉包是給操作資料的程式碼用的,不是用來宣告 schema 已經改變。schema 形狀的變更應透過輕量級 stage 流動。 -
用真實的 V1 資料測試遷移,而不只是合成測試資料。 在合成來回測試中過關的遷移,仍可能在形狀貼近生產、帶有各種邊界情況的資料上失敗(schema 未涵蓋的可空欄位、會碰到逾時的大型資料集等等)。測試的成本很小;遷移在首次啟動就崩潰的代價卻是真實的。
完整的 Apple Ecosystem 系列:型別化的 App Intents;MCP server;路由抉擇;Foundation Models;執行期與工具鏈 LLM 的分野;三個介面;single source of truth 模式;兩個 MCP Server;Apple 開發的 hooks;Live Activities;watchOS 執行期;SwiftUI 內部機制;RealityKit 的空間心智模型;SwiftData schema 紀律;Liquid Glass 模式;多平台上線;平台矩陣;Vision 框架;Symbol Effects;Core ML 推論;Writing Tools API;Swift Testing;Privacy Manifest;把無障礙視為平台;SF Pro 字體排印;visionOS 空間模式;Speech 框架;我拒絕寫的主題。系列匯整頁在 Apple Ecosystem 系列。若想了解更廣的「iOS 結合 AI agent」脈絡,請參閱 iOS Agent 開發指南。
FAQ
我是不是一定都需要 SchemaMigrationPlan?
不需要。只有單一 schema 版本的 App(首次發布,或一路上只做過輕量級變更的 App)並不需要 SchemaMigrationPlan。ModelContainer 初始化器可直接接收 schema 的 models。migrationPlan: 參數會在第一次宣告自訂遷移 stage 時(或開發者第一次想宣告明確的版本階梯時)才變得必要。
我要怎麼知道我的變更是不是輕量級?
Apple 列出的可採輕量級的清單1:新增 entity/attribute/relationship、移除它們、以 @Attribute(originalName:) 重新命名、變更 relationship 的基數、指定 delete rule。如果某項變更符合其中之一、且 model 類別結構本身沒有其他改動,遷移就是自動的,不需要 VersionedSchema 階梯。如果某項變更需要資料轉換(計算、拆分、搬移資料),它就屬於自訂。
willMigrate 與 didMigrate 可以同時設定嗎?
可以。兩個閉包各自都是選用的,但也可以兩個都提供。willMigrate 在 SwiftData 遷移之前針對舊 schema 的 context 執行;didMigrate 在遷移之後針對新 schema 的 context 執行。兩者分別涵蓋準備與收尾。
如果遷移 throw 了錯誤會怎樣?
錯誤會往外傳遞出 ModelContainer 的初始化過程。container 開啟失敗。App 的後續行為取決於開發者如何處理該錯誤:有些 App 顯示復原 UI,有些嘗試從備份還原,有些則刪掉損毀的儲存並重新開始。SwiftData 不會在遷移失敗時無聲地刪掉使用者資料;這個失敗交由 App 自行處理。
我要怎麼在不影響生產資料的前提下測試遷移?
建立一個測試 target,建立一個指向暫存檔案 URL 的 ModelContainer,以 V1 資料填入它,再用包含遷移計畫的新 container 開啟它。驗證遷移後的資料是否符合預期。這個模式在單元測試與整合測試中都適用;若想得到最貼近真實的結果,請使用一份真實、形狀貼近生產的資料庫副本。
iOS 26 的類別繼承能搭配既有 schema 運作嗎?
可以,搭配一次輕量級遷移即可。採用繼承的 App 會升到新的 schema 版本(例如 V4),並宣告一個 MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self)。扁平的父類別屬性保持不變,子類別特有的屬性則以 inline default 加入。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. ↩↩↩ -
WWDC 2026 SwiftData Group Lab (session 8017). Paraphrased from a locally transcribed recording of the WWDC 2026 SwiftData Group Lab; Apple publishes no official captions for the labs. The widget-and-extension migration gating (one process owns the migration, the error path is the migration signal, the migrated version is stored in a
UserDefault) and the stage-count progress technique (override the per-stagedidMigratehandler to report stage N of M, since no dedicated progress API exists) were described by the SwiftData engineering panel. TheSchemaMigrationPlanandMigrationStage.customdidMigratesymbols are confirmed against the Apple Developer documentation cited in 1 and 4; the absence of a dedicated progress API reflects the panel’s own framing during the lab. ↩↩