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或提示)
輕量級路徑會放棄的情況:
- 在現有結構描述中新增無預設值的非選用性屬性(現有資料列沒有可填入的值)
- 變更屬性的型別(例如
Int→String) - 將一個模型拆成兩個,或將兩個合併為一個
- 任何需要自訂邏輯來遷移的事情
當輕量級路徑放棄時,安全的行為是讓遷移失敗。不安全的行為會是丟棄資料庫並從頭開始;框架很保守,拒絕悄悄這麼做。使用者看到應用程式在啟動時當機,並出現遷移錯誤;開發者看到指向結構描述不符的堆疊追蹤;沒有人遺失資料,但所有人都失去了信心。
從第一天起跳過 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)將大型Datablob 儲存在資料庫之外(影像資料、音訊緩衝區)@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 中新增一行,而不是兩天的回溯重構。
讓每個時間戳都是選用性的。 為了跨裝置同步或衝突解決而存在的 lastModified、createdAt 和 updatedAt 等欄位,如果 v1 產品不需要它們,就應該在 v1 中設為選用性。選用性讓遷移到 v2(當您確實需要它們時)變得便宜。在 didMigrate 期間填入現有資料列是一個迴圈;從 v1 起就將它們設為非選用性是一個約束,可能會破壞使用者資料的回填。
將 UUID 用作自然鍵,而不是 PersistentIdentifier。 SwiftData 的 PersistentIdentifier 是程序內的。跨裝置同步、MCP整合(在 兩個代理生態系,一份購物清單 中介紹),以及任何跨程序的參考都需要穩定的識別碼。帶有 @Attribute(.unique) 的 UUID 是正確的形式;程序內的 PersistentIdentifier 對任何跨越程序邊界的東西都是錯誤的形式。
何時 @Model 是錯誤的答案
SwiftData 不是正確工具的三種情況:
單筆紀錄的鍵/值狀態。 應用程式設定、使用者選擇的語言、上次同步的時間戳。使用 UserDefaults 或 NSUbiquitousKeyValueStore(在 五個 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+ 上線的應用程式意味著什麼
三個要點。
-
巨集是簡單的部分。遷移才是成本。
@Model和@Attribute是兩行宣告,隱藏了大量 Core Data 配管。遷移紀律才是您在應用程式生命週期中實際付出的代價;設計 v1 時要考慮 v2。 -
從第一天起使用
VersionedSchema對於上線的應用程式是不可妥協的。 包裝的enum多一個檔案。後來才新增的回溯成本要高得多。 -
選用性欄位和明確的關聯是便宜的保險。 同步元資料的選用性時間戳、關聯上明確的
deleteRule和inverse:。兩者都是微小的宣告,卻能買到大量的 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。
參考資料
-
作者的 Get Bananas,一個 SwiftUI 購物清單應用程式,將 SwiftData 與 iCloud Drive JSON同步以及MCP伺服器配對。
ShoppingItem模型在早期開發週期中演化;lastModified: Date?欄位是在初始結構描述之後新增的(2025-12-01 的 commit268a00d,「Make lastModified optional to fix migration crash」),因為將其設為非選用性會在現有資料列沒有可填入的值時破壞遷移。 ↩ -
Apple Developer,「SwiftData」 和 「Adding and editing persistent data in your app」。
@Model巨集、@Attribute約束介面,以及與 Core Data 的NSManagedObjectModel的關係。 ↩↩ -
Apple Developer,「Preserving your app’s model data across launches」 和 「Adopting SwiftData for a Core Data app」。輕量級遷移語義,以及什麼會觸發框架放棄。 ↩
-
Apple Developer,「VersionedSchema」 和 「SchemaMigrationPlan」。帶版本的結構描述宣告、遷移階段定義,以及接受遷移計畫的
ModelContainer建構式。 ↩ -
Apple Developer,「Defining data relationships with enumerations and model classes」 和 「Schema.Relationship」。
@Relationship巨集、deleteRule選項(.cascade、.nullify、.deny、.noAction),以及inverse:參數在雙向關聯維護中的角色。 ↩ -
作者在 兩個代理生態系,一份購物清單 中的分析,2026 年 4 月 29 日,以及 五個 Apple 平台,三個共享檔案。Get Bananas 與 Return 的跨程序與跨裝置同步模式,在多程序工作流程中補充(有時取代)SwiftData。 ↩