← 所有文章

SwiftData 真正的成本是結構描述紀律

類型: shipped-code。本文記錄了 Get Bananas、Return 和 Reps 三個應用程式中的 SwiftData 結構描述決策:這三個應用程式的結構描述要麼順利完成乾淨遷移,要麼因未規劃遷移而付出代價。Get Bananas 的 ShoppingItem 是經典案例。最初的結構描述並不包含 lastModified 時間戳;之後新增時需要特定的遷移形式,因為現有資料已經存在於磁碟上,而該欄位之所以被設為選用性,正是為了修正最初將其設為非選用性時所引發的遷移當機問題。1

SwiftData 的API就是兩個巨集。@Model 加在類別上,使其成為持久化型別。@Attribute(.unique) 加在屬性上,賦予它唯一性約束。這個框架隱藏了 Core Data 的堆疊管理、值轉換器的繁瑣操作,以及 NSManagedObjectContext 的樣板程式碼。框架沒有隱藏的是結構描述遷移;它只是讓遷移從命令式變成宣告式。不留意遷移的代價,就是在一次例行更新中把使用者資料抹除的臭蟲。

核心論點:SwiftData 上手便宜,但隨便遷移就會付出昂貴代價。紀律在於命名、選用性,以及從第一天起就使用 VersionedSchema,而不是等到你意識到應該這麼做的那一天。

TL;DR

  • @Model 巨集將類別轉換為持久化的 SwiftData 型別。框架在編譯時根據屬性宣告產生結構描述。
  • 新增選用性屬性是無操作遷移:SwiftData 的輕量級遷移會處理。在現有結構描述中新增非選用性屬性需要 VersionedSchema 加上 MigrationPlan,告訴框架如何為現有資料列填入新欄位。
  • 從第一天就跳過 VersionedSchema 的代價是,任何非瑣碎的 v2 結構描述變更都有可能丟失使用者的資料庫,因為輕量級路徑很保守,在無法推斷遷移時會放棄。
  • @Attribute(.unique) 是處理自然鍵的合適工具(您產生的 UUID、您匯入的外部 ID)。@Relationship 是處理父子關聯的合適工具。兩者都是巨集,在底層產生正確的 Core Data 配管。2

@Model 實際上做了什麼

SwiftData 型別就是套用了 @Model 巨集的 Swift 類別。Get Bananas 的 ShoppingItem 是經典結構:

import Foundation
import SwiftData

@Model
final class ShoppingItem {
    @Attribute(.unique) var id: UUID
    var name: String
    var amount: String
    var section: String
    var isChecked: Bool
    var isOptional: Bool
    var sortOrder: Int
    var lastModified: Date?

    init(id: UUID = UUID(), name: String, amount: String, section: String,
         isOptional: Bool = false, sortOrder: Int = 0) {
        self.id = id
        self.name = name
        self.amount = amount
        self.section = section
        self.isChecked = false
        self.isOptional = isOptional
        self.sortOrder = sortOrder
        self.lastModified = Date()
    }
}

這個結構有三個細節是API所隱藏的。

@Model 不需要單獨的持久化儲存結構描述宣告。 SwiftData 在編譯時讀取類別定義並合成結構描述。類別的屬性成為模型的屬性;它們的 Swift 型別成為欄位型別。沒有需要維護的 .xcdatamodeld 檔案(雖然 Core Data 底層的 NSManagedObjectModel 仍然存在,並在執行時支援結構描述)。2

@Attribute(.unique) 是對單一欄位的約束,而不是 PRIMARY KEY 宣告。 SwiftData 的持久化身分是 PersistentIdentifier,每筆資料列自動產生。@Attribute(.unique) 宣告告訴框架「這個欄位每個值最多儲存一筆資料列」。當您插入一個帶有已存在的 .unique 值的模型時,SwiftData 會執行 upsert:現有資料列被更新而非被拒絕。這個語義對產品程式碼很重要:.unique 不是用來阻止重複提交的 UI 層級驗證;它是悄悄合併的「至多一筆」儲存保證。上面的 id: UUID 模式是建議的跨程序同步模式(您希望有一個穩定的識別碼,在程序內 PersistentIdentifier 消失後仍能存活),而 upsert 行為正是您在同一個 UUID 從兩條同步路徑抵達時所需要的。

@Model 類別是參考型別,而不是值型別。 修改 ShoppingItem 實例的屬性會觸發 SwiftData 的變更追蹤;框架登記變更,並在下次情境儲存時持久化。透過 @Query 的 SwiftUI 整合會重新繪製任何觀察相符斷言的視圖。這個模式與 @Observable 類似(在 SwiftUI 是由什麼構成的 中介紹),只是疊加了持久化層。

選用性欄位是便宜的遷移

ShoppingItem 上的 lastModified: Date? 欄位是選用性的,而這個選用性是承重的。該欄位是在 v1 上線後新增的,以支援跨裝置同步和衝突解決;使用者裝置上的現有資料列沒有 lastModified 值。沒有預設值的選用性欄位讓 SwiftData 的輕量級遷移能在不撰寫任何遷移程式碼的情況下處理新增:現有資料列得到 nil;新資料列得到 init 設定的值。3

輕量級遷移路徑是框架的禮貌路徑。SwiftData 檢視新結構描述和持久化儲存,推斷最小的相容變更,並套用它。遷移是自動的;使用者看不到任何東西;應用程式在現有資料上正常啟動。輕量級路徑能夠乾淨處理的情況:

  • 新增選用性屬性
  • 移除屬性(資料被丟棄;現有讀取不再看到該欄位)
  • 重新命名框架可透過提示比對的屬性(使用 @Attribute(originalName: ...))
  • 重新命名框架可比對的 @Model 類別(使用 @Model.originalName 或提示)

輕量級路徑會放棄的情況:

  • 在現有結構描述中新增無預設值的非選用性屬性(現有資料列沒有可填入的值)
  • 變更屬性的型別(例如 IntString)
  • 將一個模型拆成兩個,或將兩個合併為一個
  • 任何需要自訂邏輯來遷移的事情

當輕量級路徑放棄時,安全的行為是讓遷移失敗。不安全的行為會是丟棄資料庫並從頭開始;框架很保守,拒絕悄悄這麼做。使用者看到應用程式在啟動時當機,並出現遷移錯誤;開發者看到指向結構描述不符的堆疊追蹤;沒有人遺失資料,但所有人都失去了信心。

從第一天起跳過 VersionedSchema 的代價會在 v2 → v3 邊界顯現,當您新增第三個結構描述變更超出輕量級路徑能處理的功能時。

VersionedSchema 與 MigrationPlan:第一天的紀律

VersionedSchema 宣告模型結構描述的特定版本。MigrationPlan 宣告如何從一個版本遷移到下一個版本。4 結構如下:

import SwiftData

enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] = [ShoppingItemV1.self]
}

enum SchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] = [ShoppingItemV2.self]
}

enum AppMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] = [
        SchemaV1.self,
        SchemaV2.self,
    ]

    static var stages: [MigrationStage] = [
        MigrationStage.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)
    ]
}

模型類別本身移入帶版本的結構描述命名空間中:

extension SchemaV1 {
    @Model
    final class ShoppingItemV1 { /* v1 fields */ }
}

extension SchemaV2 {
    @Model
    final class ShoppingItemV2 { /* v2 fields, including lastModified */ }
}

ModelContainer 以遷移計畫建構:

let container = try ModelContainer(
    for: ShoppingItemV2.self,
    migrationPlan: AppMigrationPlan.self,
    configurations: ModelConfiguration("ShoppingList")
)

遷移計畫提供框架一個有型別的圖,描述結構描述如何演化。當 v2 上線的應用程式針對 v1 資料庫啟動時,框架會走遷移計畫,套用命名的階段,並將資料庫帶到 v2。當您上線 v3 時,您將 SchemaV3.self 新增到 schemas,並在 v2 和 v3 之間新增一個 MigrationStage

紀律在於即使只有一個版本,也要在 v1 上線 VersionedSchema。這麼做的成本是多一個檔案和多一個 enum 宣告。這麼做的成本是 v2 的第一個非瑣碎結構描述變更需要回溯地將 v1 包裝在 VersionedSchema 中,這是可行的,但需要小心比對精確的 v1 結構,讓框架能識別現有資料為 SchemaV1。未來在 v2 工作的您將付出這個稅;現在的您可以付一次然後忘掉它。

處理棘手情況的自訂 MigrationStage

輕量級遷移涵蓋了大多數新增式變更。型別變更、拆分、合併以及條件性填入需要 MigrationStage.custom:

static var stages: [MigrationStage] = [
    MigrationStage.custom(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self,
        willMigrate: { context in
            // Read v1 rows; stage any derived state to a transient store
            // (UserDefaults / temp file) since the v1 and v2 contexts do
            // not share state, and didMigrate cannot read v1.
            let v1Items = try context.fetch(FetchDescriptor<ShoppingItemV1>())
            stageDerivedState(from: v1Items)
        },
        didMigrate: { context in
            // Populate v2-only fields on existing rows
            let v2Items = try context.fetch(FetchDescriptor<ShoppingItemV2>())
            for item in v2Items where item.lastModified == nil {
                item.lastModified = Date()
            }
            try context.save()
        }
    )
]

兩個閉包在框架套用結構性遷移之前和之後分別觸發。willMigrate 針對 v1 結構描述執行;didMigrate 針對 v2 結構描述執行。閉包主體是普通的 SwiftData 程式碼(取得描述符、模型情境儲存、與執行中的應用程式相同的API),針對暫態的遷移中情境執行。

在生產環境中倖存的模式是讓 willMigrate 保持空白,並將所有填入邏輯放在 didMigrate 中。在 willMigrate 中讀取 v1 資料是允許的,但從框架的角度看,v2 結構描述還不存在,所以任何運算都必須暫存到 didMigrate 閉包能讀取的暫態儲存中。更簡單的規則:結構性遷移是框架的工作;在現有資料列上填入 v2 才有的欄位是 didMigrate 的工作。

何時 @Attribute@Relationship 名副其實

兩個巨集在 @Model 類別中完成大部分結構描述裝飾的工作。

@Attribute 以約束或提示裝飾單一屬性:

  • @Attribute(.unique) 強制唯一性,如 ShoppingItem.id
  • @Attribute(.externalStorage) 將大型 Data blob 儲存在資料庫之外(影像資料、音訊緩衝區)
  • @Attribute(originalName: "old_field_name") 在遷移期間將屬性比對到重新命名的欄位
  • @Attribute(.transformable(by: ...)) 為非 Codable 型別套用 ValueTransformer

正確的紀律:對真正應該唯一的欄位使用 .unique(您產生的 UUID、外部 ID);對任何超過幾 KB 的 blob 使用 .externalStorage;當 v2 重新命名屬性會導致 v1 資料遺失時,使用 originalName

@Relationship 裝飾指向另一個 @Model 類別或它們的集合的屬性:

@Model
final class List {
    var name: String

    @Relationship(deleteRule: .cascade, inverse: \ShoppingItem.list)
    var items: [ShoppingItem] = []
}

@Model
final class ShoppingItem {
    var name: String
    var list: List?
}

deleteRule: .cascade 表示刪除父 List 會刪除所有子 ShoppingItem 資料列。inverse: 參數告訴框架子物件上哪個屬性指回父物件;框架使用它進行可預測的雙向維護。SwiftData 有時可以自動推斷反向關聯,而 inverse: nil 適用於明確的單向關聯,但安全的預設是只要推斷可能含糊,就宣告 inverse:5

正確的紀律:用明確的 deleteRule 宣告關聯(預設是 .nullify,這通常不是您想要的),只要關聯是雙向的就宣告 inverse:(而不是依賴框架的推斷)。隱含的預設值通常是錯的;明確的形式只多一個參數,卻能省下一個永久存在的臭蟲。

我會以何種方式重新建構

集群中的應用程式或者已上線、或者希望已上線的三個模式。

從 v1 開始就上線 VersionedSchema 每個上線的 @Model 類別應該從第一天起就活在 VersionedSchema 內。成本是每個結構描述版本多一個包裝的 enum。好處是 v2 的第一個非瑣碎變更只是在 MigrationPlan.schemas 中新增一行,而不是兩天的回溯重構。

讓每個時間戳都是選用性的。 為了跨裝置同步或衝突解決而存在的 lastModifiedcreatedAtupdatedAt 等欄位,如果 v1 產品不需要它們,就應該在 v1 中設為選用性。選用性讓遷移到 v2(當您確實需要它們時)變得便宜。在 didMigrate 期間填入現有資料列是一個迴圈;從 v1 起就將它們設為非選用性是一個約束,可能會破壞使用者資料的回填。

將 UUID 用作自然鍵,而不是 PersistentIdentifier SwiftData 的 PersistentIdentifier 是程序內的。跨裝置同步、MCP整合(在 兩個代理生態系,一份購物清單 中介紹),以及任何跨程序的參考都需要穩定的識別碼。帶有 @Attribute(.unique)UUID 是正確的形式;程序內的 PersistentIdentifier 對任何跨越程序邊界的東西都是錯誤的形式。

何時 @Model 是錯誤的答案

SwiftData 不是正確工具的三種情況:

單筆紀錄的鍵/值狀態。 應用程式設定、使用者選擇的語言、上次同步的時間戳。使用 UserDefaultsNSUbiquitousKeyValueStore(在 五個 Apple 平台,三個共享檔案 中介紹)。SwiftData 對單筆資料列的開銷是浪費的儀式;鍵值儲存才是正確的基底。

沒有離線寫入的伺服器權威資料。 從 REST API 取得並僅供讀取顯示的列表。如果真實來源是伺服器,本地快取只是快取,SwiftData 就是過度設計。Documents/ 中一個簡單的 Codable 快照加上記憶體快取的陣列就足夠了;如果資料無法在硬重置後存活,就不值得付 SwiftData 的遷移稅。

多程序協調。 SwiftData 在程序內運作。在 iOS 應用程式之外執行的MCP伺服器無法讀取或寫入該應用程式的 SwiftData 容器。跨程序狀態需要不同的形式:iCloud Drive JSON檔案、共享的 App Group 容器,或橋接程序的明確同步層。(Get Bananas 將 SwiftData 與 iCloud Drive JSON配對正是出於這個原因。)6

資料是少有變動的大型 blob。 10MB 的音訊檔、50MB 的影像資料集。如果 blob 在 SwiftData 資料列內,使用 @Attribute(.externalStorage);否則直接使用檔案系統,並在 SwiftData 中用元資料指向檔案 URL。

這個模式對在 iOS 26+ 上線的應用程式意味著什麼

三個要點。

  1. 巨集是簡單的部分。遷移才是成本。 @Model@Attribute 是兩行宣告,隱藏了大量 Core Data 配管。遷移紀律才是您在應用程式生命週期中實際付出的代價;設計 v1 時要考慮 v2。

  2. 從第一天起使用 VersionedSchema 對於上線的應用程式是不可妥協的。 包裝的 enum 多一個檔案。後來才新增的回溯成本要高得多。

  3. 選用性欄位和明確的關聯是便宜的保險。 同步元資料的選用性時間戳、關聯上明確的 deleteRuleinverse:。兩者都是微小的宣告,卻能買到大量的 v2 彈性。

完整的 Apple 生態系集群:用於 Apple Intelligence 的有型別 App Intents;用於跨LLM代理的 MCP伺服器;兩者之間的 路由問題;用於裝置端LLM和工具協定的 Foundation Models;用於 iOS 鎖定畫面狀態機的 Live Activities;Apple Watch 上的 watchOS 執行階段 契約;框架基底的 SwiftUI 內部;visionOS 場景的 RealityKit 空間心智模型;視覺層的 Liquid Glass 模式;跨裝置觸及的 多平台上線。中樞在 Apple 生態系系列。更廣泛的 iOS 與 AI 代理上下文,請參閱 iOS 代理開發指南

常見問題

@Model 與 Core Data 的 NSManagedObject 有什麼不同?

@Model 是一個 Swift 巨集,在底層產生 NSManagedObject 配管。SwiftData 使用 Core Data 作為其支援儲存,所以執行時模型是相同的;不同之處在於介面。@Model 移除了 .xcdatamodeld 檔案、值轉換器儀式,以及 NSManagedObjectContext 生命週期管理。您獲得相同的持久化儲存,但有一個 Swift 風格的API。

如果我從不打算變更結構描述,我需要 VersionedSchema 嗎?

如果您的應用程式可能上線 v2,需要。如果它是一次性的示範,則不需要。從 v1 起使用 VersionedSchema 的成本是多一個 enum 宣告。在 v2 時回溯新增它的成本是比對精確的 v1 結構描述,讓框架能識別現有資料,這是可行的但容易出錯。大多數上線的應用程式最終都需要結構描述變更;在 v1 中為它編列預算。

什麼時候應該使用 @Attribute(.unique)?

當欄位是該資料列的自然鍵時:您產生的 UUID、您匯入的外部 ID、您指派的 slug。SwiftData 將 .unique 視為 upsert:如果您插入一個 .unique 值已存在的模型,現有資料列會被更新,而不是附加新資料列。這個語義使得 upsert 風格的同步路徑(同一個 UUID 從兩個裝置抵達)安全;這也是為什麼 .unique 在像 title 這樣的顯示名稱欄位上是錯誤的工具,因為兩個使用者輸入相同的標題會悄悄合併他們的資料列,而不是產生兩筆不同的紀錄。

我如何處理新增到現有結構描述的非選用性欄位?

使用帶有 didMigrate 閉包的 MigrationStage.custom,在現有資料列上填入該欄位。或者更簡單:在新的結構描述版本中將欄位宣告為選用性,並在存取時延遲填入。選用性是更便宜的遷移;非選用性新增需要明確的填入邏輯。

PersistentIdentifier 與我自己的 UUID 有何差別?

PersistentIdentifier 是 SwiftData 的程序內資料列 ID;它是自動產生的,在執行中程序的生命週期內存活。您自己的 UUID 加上 @Attribute(.unique) 是穩定的跨程序、跨裝置識別碼。在應用程式內的程序內參考使用 PersistentIdentifier。對任何跨越程序邊界的東西(跨裝置同步、外部整合、MCP工具、網路呼叫)使用 UUID。

參考資料


  1. 作者的 Get Bananas,一個 SwiftUI 購物清單應用程式,將 SwiftData 與 iCloud Drive JSON同步以及MCP伺服器配對。ShoppingItem 模型在早期開發週期中演化;lastModified: Date? 欄位是在初始結構描述之後新增的(2025-12-01 的 commit 268a00d,「Make lastModified optional to fix migration crash」),因為將其設為非選用性會在現有資料列沒有可填入的值時破壞遷移。 

  2. Apple Developer,「SwiftData」「Adding and editing persistent data in your app」@Model 巨集、@Attribute 約束介面,以及與 Core Data 的 NSManagedObjectModel 的關係。 

  3. Apple Developer,「Preserving your app’s model data across launches」「Adopting SwiftData for a Core Data app」。輕量級遷移語義,以及什麼會觸發框架放棄。 

  4. Apple Developer,「VersionedSchema」「SchemaMigrationPlan」。帶版本的結構描述宣告、遷移階段定義,以及接受遷移計畫的 ModelContainer 建構式。 

  5. Apple Developer,「Defining data relationships with enumerations and model classes」「Schema.Relationship」@Relationship 巨集、deleteRule 選項(.cascade.nullify.deny.noAction),以及 inverse: 參數在雙向關聯維護中的角色。 

  6. 作者在 兩個代理生態系,一份購物清單 中的分析,2026 年 4 月 29 日,以及 五個 Apple 平台,三個共享檔案。Get Bananas 與 Return 的跨程序與跨裝置同步模式,在多程序工作流程中補充(有時取代)SwiftData。 

相關文章

Two Agent Ecosystems, One Shopping List: An MCP Server Living Alongside an iOS App

Get Bananas runs on iOS, macOS, watchOS, visionOS. It also lives inside Claude Desktop as an MCP server. The bridge is i…

19 分鐘閱讀

Foundation Models On-Device LLM: The Tool Protocol

iOS 26's Foundation Models framework puts a 3B-parameter LLM on every Apple Intelligence device. The Tool protocol is th…

15 分鐘閱讀

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 分鐘閱讀