← 所有文章

SwiftData 遷移:輕量級與自訂遷移,以及何時不需要 V2

SwiftData 的結構描述遷移機制相較於 Core Data 是結構性的改進,但有一個團隊不斷掉入的陷阱:為 SwiftData 本可透過內聯預設值自動處理的變更宣告新的 VersionedSchema。結果就是裝置上出現「Duplicate version checksums across stages detected」當機,即使程式碼看起來正確且建置乾淨。框架實際的遷移模型由三個元件組成(VersionedSchemaMigrationStageSchemaMigrationPlan)以及三種遷移類型(自動輕量級、宣告式輕量級、自訂)1。多數結構描述變更屬於自動類型。部分需要宣告式輕量級階段。少數需要使用 willMigratedidMigrate 閉包的自訂階段。

本文對照 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 而當機,因為 SchemaV1SchemaV2 解析為同一個總和檢查碼(模型類型並未以 SwiftData 認得的方式有所差異)。

正確的模式:保持既有的 VersionedSchema 不變,在模型上加入帶內聯預設值的新屬性,讓 SwiftData 自動的輕量級遷移去處理。不需要 MigrationPlanMigrationStage,也不需要 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 欄位變成兩個欄位,firstNamelastName。遷移需要讀取舊值、解析它,並寫入新欄位。

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 模型,這類重組需要自訂邏輯來從舊資料指派標籤。

通則:當結構描述形狀改變時用輕量級;當資料形狀改變時用自訂。

willMigratedidMigrate

自訂階段有兩個閉包,在不同時機被呼叫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 父類別搭配 CarTruckMotorcycle 子類別;Account 父類別搭配 CheckingAccountSavingsAccount 子類別。共用屬性放在父類別;特定屬性放在子類別。

測試遷移

可以編譯通過的遷移不等於可以上線的遷移。以下三種測試模式值得在發佈前執行:

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+ 應用程式的意義

三點要領。

  1. 預設不要建立 VersionedSchema 階梯。 新增帶內聯預設值的屬性、刪除未使用的欄位、用 @Attribute(originalName:) 重新命名,全都是輕量級且自動的。VersionedSchema 階梯是為了 SwiftData 真的無法自動處理的變更而存在(資料轉換、自訂邏輯、繼承重構)。

  2. MigrationStage.custom 用於資料轉換,不是用於結構描述形狀的變更。 willMigratedidMigrate 閉包是給操作資料的程式碼用的,不是用來宣告結構描述已變更。結構描述形狀的變更要透過輕量級階段流動。

  3. 以真實的 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 嗎?

不一定。只有單一結構描述版本的應用程式(初始版本,或只做過輕量級變更的應用程式)不需要 SchemaMigrationPlanModelContainer 初始化器可直接接受結構描述的模型。當第一次宣告自訂遷移階段時(或開發者第一次想宣告明確的版本階梯時),migrationPlan: 參數才會變得必要。

我怎麼知道我的變更屬於輕量級?

Apple 的輕量級可行清單1:新增實體、屬性、關聯,移除它們,使用 @Attribute(originalName:) 重新命名,變更關聯基數,指定刪除規則。如果變更符合上述其中一項,且模型類別結構在其他方面未變更,遷移會自動處理且不需要 VersionedSchema 階梯。如果變更需要資料轉換(計算、拆分、移動資料),則屬於自訂類型。

willMigratedidMigrate 可以同時設定嗎?

可以。兩個閉包個別都是選用的,但也可以同時提供。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


  1. Apple Developer Documentation:VersionedSchemaSchemaMigrationPlan 協定參考。遷移模型。亦請參閱相關指南 Adopting SwiftData for a Core Data app,取得完整的結構描述演化敘述。 

  2. Apple Developer:SwiftData: Dive into inheritance and schema migration(WWDC 2025 session 291)。在 iOS 26 中引入 SwiftData 類別繼承。 

  3. Apple Developer Documentation:MigrationStage,含 .lightweight(fromVersion:toVersion:).custom(fromVersion:toVersion:willMigrate:didMigrate:) 案例。 

  4. 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 繼承新增功能所引用的同一場議程。 

相關文章

SwiftData真正的成本是Schema紀律

SwiftData的API只是兩個macros。成本則展現在你發布之後。可選欄位是廉價的遷移;新增非可選欄位則需要VersionedSchema。

3 分鐘閱讀

iOS 27 的 SwiftData:觀察與歷史記錄

iOS 27 為 SwiftData 帶來一流的變更觀察能力,包括 ResultsObserver、可觀察持久化歷史的 HistoryObserver,以及 codable 屬性儲存。

3 分鐘閱讀

迴圈工程:在驗證成本低廉之處,迴圈才能取勝

以 Boris Cherny 的完整逐字稿驗證迴圈工程:他點名的每一個迴圈,驗證成本都很低廉。這項限制決定了什麼適合自動化。

4 分鐘閱讀