← 所有文章

SwiftData 遷移:lightweight 與 custom 的取捨,以及何時根本不需要 V2

比起 Core Data,SwiftData 的架構遷移在結構上是一次改良,卻有一個陷阱讓不少團隊一再踩中:為了那些只要給出行內預設值、SwiftData 就會自動處理的變更,特地宣告一個新的 VersionedSchema。結果是程式碼看起來沒問題、建置也乾乾淨淨,卻在實機上以「Duplicate version checksums across stages detected」崩潰。這套框架真正的遷移模型由三個部件(VersionedSchemaMigrationStageSchemaMigrationPlan)與三種遷移類型(自動 lightweight、明確宣告的 lightweight、custom)構成1。多數架構變更都是自動完成的。有一部分需要明確宣告的 lightweight 階段。只有極少數需要帶有 willMigratedidMigrate 閉包的 custom 階段。

本文對照 Apple 的文件走過這套遷移模型,逐一點出每種遷移類型負責哪些情況,並談 iOS 26 的類別繼承支援,以及 iOS 27 beta 把遷移擺在什麼位置。全文的視角始終是「哪些要我自己宣告,哪些由 SwiftData 代勞」,因為正是這個判斷,決定了遷移是乾淨上線,還是在首次啟動時崩潰。與之相配的問題——如何設計 v1 架構,好讓後續遷移一直保持便宜——則在SwiftData 真正的成本是架構紀律中討論。

重點摘要

  • SwiftData 遷移由三個協定組合而成:VersionedSchema(某個版本上模型型別的快照)、MigrationStage(從 fromVersion 到 toVersion 的單次轉換,分成 .lightweight.custom 兩種情況),以及 SchemaMigrationPlan(依序排列的階段清單)1
  • 新增一個帶行內預設值的 @Model 屬性(var foo: Bool = false)並不需要新的 VersionedSchema。SwiftData 會當成 lightweight 遷移自動處理。為此宣告 V2,反而會招來「Duplicate version checksums across stages detected」崩潰。
  • lightweight 遷移能處理的範圍包括:新增、重新命名、刪除實體、屬性與關係;變更關係型別;以 @Attribute(originalName:) 記錄重新命名;指定刪除規則。多數架構變更都落在這個範圍內。
  • custom 遷移(MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:))負責的是資料轉換:把一個欄位拆成兩個、計算衍生欄位、在模型之間搬移資料。willMigrate 拿到的是舊的 context,didMigrate 拿到的是新的 context。
  • iOS 26 為 @Model 型別加入了類別繼承2。採用繼承的架構要升到新版本,並從先前扁平模型的版本以一個 lightweight 階段銜接過來。

三個部件構成的模型

一次 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)。宣告一次不需要 App 程式碼、由 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 會讀取持久化儲存目前的架構版本,從該版本沿著計畫中的階段一路走到目前版本,並依序套用每個階段。

lightweight 遷移自動處理的範圍

多數架構變更都不需要 custom 階段1

  • 新增帶有預設值的屬性。 在既有的 @Model 上寫 var foo: Bool = false,會被自動處理。
  • 新增實體(模型類別)。 當某個 VersionedSchema 成為目前版本時,新型別隨之出現,既有資料保持不變。
  • 移除屬性或實體。 SwiftData 會移除對應的欄位或資料表。
  • 重新命名屬性或實體。 在屬性上加 @Attribute(originalName: "oldName") 即可保留資料,SwiftData 會把舊名稱對應到新名稱。
  • 變更關係型別。 一對多、多對多等等。
  • 指定刪除規則。@Relationship(deleteRule: .cascade) 這類新增,屬於 lightweight。

面對清單中的這些變更,只要模型型別沒有其他改動,正確的做法就是完全不宣告新的 VersionedSchema。SwiftData 會針對既有架構,自動執行 lightweight 遷移。

陷阱:加一個欄位並不需要 V2

SwiftData 遷移最常見的錯誤是這樣的:開發者加了一個帶行內預設值的新屬性(var foo: Bool = false),接著宣告一個 SchemaV2,而它參照的模型型別和 SchemaV1 一模一樣。建置毫無問題。可是在已有 V1 資料的裝置上首次啟動時,App 會以 Duplicate version checksums across stages detected 崩潰,因為 SchemaV1SchemaV2 解析出的檢查碼相同(模型型別並沒有發生 SwiftData 會視為差異的變化)。

正確的做法是:別動既有的 VersionedSchema,替模型加上帶行內預設值的新屬性,交給 SwiftData 的自動 lightweight 遷移。不需要 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(...)
)

只有當某項變更無法以 lightweight 完成時(資料轉換、模型拆分、需要自訂邏輯的繼承重構),才真的需要 V2 架構。在那些情況裡,V2 是實實在在的,並由 SchemaMigrationPlan 統籌整個轉換。

何時必須使用 custom 遷移

custom 遷移在三種情況下對得起它的複雜度。

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 到 V3 的階段。

2. 計算衍生欄位。 依賴既有資料的新 @Attribute,需要在遷移時回填。

3. 在模型之間搬移資料。Item 的資料拆分到 Item 與一個新的 Tag 模型,這種重組需要自訂邏輯,從舊資料中指派標籤。

一條通用的判準是:架構的形狀變了,用 lightweight;資料的形狀變了,用 custom。

willMigratedidMigrate 的差別

custom 階段有兩個閉包,分別在不同時機被呼叫4

willMigrate 在 SwiftData 套用架構遷移之前執行。閉包收到的模型 context,是架構的 context。適合在腳下的架構改變之前,先取出資料、做反正規化,或準備一些輔助狀態。

didMigrate 在架構遷移之後執行。模型 context 屬於架構。適合回填新欄位、計算衍生資料,或替遷移收尾。

若用不到,兩個閉包都可以是 nil。多數 custom 遷移只用 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)
    }
}

採用繼承的架構要升到新版本,並從先前扁平模型的版本以一個 lightweight 遷移階段銜接過來。只要繼承保留了既有屬性,這次轉換就是自動的;子類別上的新欄位,仍然遵循標準的行內預設值寫法。

這種寫法適合多個 @Model 型別共享特徵的情況:Vehicle 當父類別,CarTruckMotorcycle 當子類別;Account 當父類別,CheckingAccountSavingsAccount 當子類別。共有的屬性放在父類別,各自特有的放在子類別。

iOS 27:遷移模型不變,儲存變得可觀察

iOS 27 beta 完全沒有更動遷移機制本身。VersionedSchemaMigrationStageSchemaMigrationPlan 原樣延續,上面每一種寫法都照舊適用。iOS 27 新增的東西位於遷移旁邊,而不是遷移內部:一個新的「Data store observation」區塊,包含 ResultsObserverHistoryObserver 兩個型別,外加一個 @Attribute(.codable) 選項,以屬性的 Codable 表示法來儲存該屬性6

其中有兩項,值得在一篇談遷移的指南裡提上一筆。

@Attribute(.codable) 減輕了未來的遷移壓力。 能夠宣告式地儲存一個 Codable 值型別,就少了許多先把結構攤平成好幾個欄位、日後再寫一個 custom 階段把它拼回來的情況。在新屬性上採用這個選項的架構,遵循前面標準的行內預設值規則;它是一個屬性選項,而不是架構形狀的變化6

HistoryObserver 替 custom 遷移之後的流程收尾。 didMigrate 中的回填會寫入一批資料列,App 的其他部分(以及任何正在觀察該儲存的小工具或擴充功能)都需要知道它們的存在。在 iOS 27 上,透過 HistoryObserver 觀察持久化歷史的觀察者,能看見遷移的交易落地,並可呼叫 ModelContext.fetchHistory,依模型型別與交易作者篩選,精準讀出到底改了哪些東西,而不必把所有資料重新抓一遍6。關於 observation 的完整脈絡,請見 iOS 27 中的 SwiftData:observation 與 history

就規劃而言的結論是:27 beta 中沒有任何東西會逼你提升架構版本,也沒有遷移程式碼需要重寫。在那些遷移後對帳邏輯原本靠輪詢或重新抓取的地方,換上新的 observation 型別即可。

測試遷移

能編譯過的遷移,未必是能上線的遷移。發布之前,有三種測試值得跑一遍。

1. 用正式環境資料庫的副本做來回測試。 取一份近期的正式環境形態資料庫(或用測試產生合成的 V1 資料),以認得 V2 的容器開啟它,驗證資料是否正確遷移。型別檢查器抓不到的 custom 遷移錯誤,正是靠這類測試現形。

2. 舊版本仍能啟動。 建置前一個 App 版本,執行一次以產生 V1 資料,接著建置新版本,確認它能不崩潰地啟動。這個測試能抓出「Duplicate version checksums」這類陷阱,以及類似的宣告失誤。

3. 遷移失敗後的復原。 如果遷移拋出錯誤會怎樣?SwiftData 的行為取決於容器的設定;對正式上線的 App 來說,未被處理的遷移錯誤絕不該悄悄刪掉使用者資料。請明確測試失敗路徑,並決定 App 要怎麼做(回復、提示使用者、從備份還原)。

同一系列中的 Single Source of Truth 一文談的是一個相關問題:當 SwiftData 儲存因跨行程同步而被替換時會發生什麼事。遷移,正是那套模式在本地演進方向上的對應物。

跨行程上線與呈現遷移進度

有兩個運維層面的細節,文件並未特別強調,SwiftData 團隊卻在 WWDC 2026 上點了出來5:當 App 帶有小工具或擴充功能時,遷移究竟在哪裡執行;以及遷移進行時,該如何驅動進度介面。

遷移只屬於一個行程。 小工具與擴充功能拿不到主 App 那樣的執行資源,因此無法安全地執行遷移。給出的指引是:把 SchemaMigrationPlan1 從小工具與擴充功能的 target 中徹底移出,永遠不要從它們發起遷移。挑一個行程作為資料庫的擁有者,通常就是主 App。如果小工具開啟容器時,磁碟上的儲存仍停在未版本化(較舊)的架構,開啟就會失敗。請把這個錯誤當成「需要遷移」的訊號:顯示介面請使用者打開主 App,由 App 完成遷移,再把遷移後的架構版本寫入共用的 UserDefault。小工具下次讀取這個值,就能以 App 已經遷移到的版本開啟容器。這套做法讓寫入者始終只有一個,避免兩個行程搶著演進同一個檔案。

進度是用階段數算出來的,不是用實際經過時間。 SwiftData 並未提供專用的遷移進度 API5。想驅動進度指示器,就統計計畫中 custom 遷移階段的總數,並覆寫每個階段的 didMigrate 處理邏輯4,讓每個階段回報自己的位置,也就是「第 N 個,共 M 個」。這個數字反映的是已完成的階段數,而非已耗費的時間,因此進度列是一格一格跳,而不是平滑前進。與之相配的設計決策,是遷移期間 App 要給使用者看什麼:光禿禿的轉圈圖示會被讀成卡住,使用者隨即離開。在資料允許的前提下,讓 App 保持部分可用;至少也要說明每個階段正在加入什麼(這次遷移解鎖了哪些新功能),這樣等待才會被讀成朝著某個目標前進,而不是白白浪費的時間。

常見的失敗模式

從 SwiftData 的失敗紀錄中,可以整理出三種情況。

替 SwiftData 本來就會自動處理的變更宣告 V2。 也就是「Duplicate version checksums」崩潰。解法:不要為了新增帶行內預設值的屬性而宣告新的架構,交給 SwiftData 自動處理。

沒有儲存的 custom 遷移程式碼。 一個修改了實體卻沒有呼叫 context.save()didMigrate 閉包,會產生這樣一次遷移:跑了一遍、丟掉成果,然後每次啟動都重跑(因為這次遷移看起來還沒完成)。解法:凡是會修改資料的閉包,回傳之前都必須執行 try context.save()

重新命名屬性卻沒有加上 @Attribute(originalName:) SwiftData 會把新屬性當成新增、把舊屬性當成刪除,舊屬性上的既有資料隨之消失。解法:宣告 @Attribute(originalName: "oldName") var newName: ...,讓 SwiftData 跨過這次重新命名把資料對應過去。

這套模式對 iOS 26 以後的 App 意味著什麼

三點結論。

  1. 預設不要搭 VersionedSchema 階梯。 新增帶行內預設值的屬性、刪除不再使用的欄位、以 @Attribute(originalName:) 重新命名——全都是 lightweight 而且自動的。VersionedSchema 階梯留給 SwiftData 真的無法自動處理的變更(資料轉換、自訂邏輯、繼承重構)。

  2. MigrationStage.custom 用於資料轉換,而不是架構形狀的變化。 willMigratedidMigrate 閉包,是給操作資料的程式碼用的,不是用來宣告「架構變了」。架構形狀的變化,一律走 lightweight 階段。

  3. 用真實的 V1 資料測試遷移,不要只用合成的測試資料。 在合成資料來回測試中通過的遷移,仍可能在正式環境形態的資料上因邊界情況而失敗(架構沒考慮到的可為 null 欄位、大到觸發逾時的資料集等等)。測試的代價很小,而首次啟動就遷移崩潰的代價是實打實的。

完整的 Apple Ecosystem 系列:帶型別的 App IntentsMCP 伺服器路由的抉擇Foundation Models執行期 LLM 與工具鏈 LLM 的區分三個介面single source of truth 模式兩個 MCP 伺服器給 Apple 開發用的 hooksLive ActivitieswatchOS 執行環境SwiftUI 的內部構造RealityKit 的空間心智模型SwiftData 架構紀律Liquid Glass 模式多平台交付平台矩陣Vision 框架Symbol EffectsCore ML 裝置端推論Writing Tools APISwift TestingPrivacy Manifest作為平台能力的無障礙SF Pro 字體排印系統visionOS 空間模式Speech 框架我拒絕書寫的那些題目。系列首頁在 Apple Ecosystem 系列。若想了解 iOS 搭配 AI 代理的更廣背景,請參閱 iOS Agent Development 指南

常見問題

我是不是一定需要 SchemaMigrationPlan

不是。只有一個架構版本的 App(首次發布,或一路以來只做過 lightweight 變更的 App)並不需要 SchemaMigrationPlanModelContainer 初始化器可以直接接收架構中的模型。要到第一次宣告 custom 遷移階段時(或開發者第一次想明確宣告版本階梯時),migrationPlan: 參數才變得必要。

我怎麼知道自己的改動算不算 lightweight?

Apple 給出的 lightweight 適用清單是1:新增實體、屬性與關係,移除它們,以 @Attribute(originalName:) 重新命名,變更關係的基數,指定刪除規則。如果改動符合其中之一,而模型類別的結構沒有其他變化,那麼遷移就是自動的,也不需要 VersionedSchema 階梯。如果改動需要資料轉換(計算、拆分、搬移資料),那就屬於 custom。

willMigratedidMigrate 可以同時設定嗎?

可以。兩個閉包各自都是選擇性的,但也可以同時提供。willMigrate 在 SwiftData 遷移之前,針對舊架構的 context 執行;didMigrate 在遷移之後,針對新架構的 context 執行。兩者分別負責準備與收尾。

如果遷移拋出錯誤會發生什麼事?

錯誤會從 ModelContainer 的初始化往外傳播,容器開啟失敗。之後 App 的行為,取決於開發者怎麼處理這個錯誤:有的 App 會顯示復原介面,有的會嘗試從備份還原,有的則刪掉損壞的儲存重新來過。SwiftData 不會在遷移失敗時悄悄刪除使用者資料;如何面對失敗,是 App 自己的責任。

如何在不影響正式環境資料的前提下測試遷移?

建立一個測試 target,做出一個指向暫存檔案 URL 的 ModelContainer,往裡面填入 V1 資料,再用包含遷移計畫的新容器開啟它,驗證遷移後的資料是否符合預期。這套做法在單元測試與整合測試中都行得通;若想得到最貼近現實的結果,請使用一份真實正式環境形態資料庫的副本。

iOS 26 的類別繼承能用在既有架構上嗎?

可以,搭配一次 lightweight 遷移即可。採用繼承的 App 要升到新的架構版本(例如 V4),並宣告 MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self)。原本扁平的父類別屬性保持不變,子類別特有的屬性則以行內預設值的方式加入。這種結構上的變化,由 SwiftData 的 lightweight 遷移負責處理。

參考資料


  1. Apple Developer Documentation:VersionedSchemaSchemaMigrationPlan 協定參考,也就是遷移模型本身。關於架構演進的完整敘述,另見相關指南 Adopting SwiftData for a Core Data app。 

  2. Apple Developer:SwiftData: Dive into inheritance and schema migration(WWDC 2025 第 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 第 291 場 SwiftData: Dive into inheritance and schema migration,與前面引用 iOS 26 繼承特性的是同一場。 

  5. WWDC 2026 SwiftData Group Lab(第 8017 場)。內容轉述自 WWDC 2026 SwiftData Group Lab 的本地逐字稿錄音;Apple 並未為這類 Lab 提供官方字幕。其中,小工具與擴充功能的遷移限制(遷移只屬於一個行程、錯誤即是需要遷移的訊號、遷移後的版本存入 UserDefault),以及依階段數計算進度的技巧(由於沒有專用的進度 API,覆寫每個階段的 didMigrate 處理邏輯來回報「第 N 個,共 M 個」),都是 SwiftData 工程團隊在座談中說明的內容。SchemaMigrationPlanMigrationStage.customdidMigrate 等符號,已與 14 所引的 Apple Developer 文件核對一致;至於沒有專用進度 API 這一點,反映的是座談中工程團隊自己的說法。 

  6. Apple Developer Documentation:ResultsObserverHistoryObserver(兩者皆為 iOS 27.0 beta,歸在 SwiftData 的「Data store observation」主題下),以及 Schema.Attribute.Option.codable(iOS 27.0 beta),「以該屬性的 codable 表示法來儲存它」。依據 WWDC26 第 274 場 What’s new in SwiftDataHistoryObserver 公開了一個可觀察的 eventCounter,每當有新交易落地就會遞增,程式碼則以呼叫 ModelContext.fetchHistory 並依模型型別與交易作者篩選的方式來回應。 

相關文章

SwiftData 真正的成本在於 Schema 紀律

SwiftData 的 API 只有兩個巨集,真正的成本出現在上線之後。可選欄位是廉價的遷移;新增非可選欄位則需要 VersionedSchema。

16 分鐘閱讀

iOS 27 的 SwiftData:Observation 與 History

iOS 27 為 SwiftData 帶來一流的變更觀察能力,透過 ResultsObserver 追蹤變更、透過 HistoryObserver 觀察持久化歷史,並支援 codable 屬性儲存。

9 分鐘閱讀

安裝與更新 Codex CLI:Mac、Linux、Windows

安裝、更新、鎖定版本與移除 OpenAI Codex CLI 的每一種方式:安裝指令碼、npm、Homebrew、winget,涵蓋 macOS、Linux、WSL 與 Windows。

9 分鐘閱讀