SwiftData 真正的成本在於 Schema 紀律
Get Bananas 的 ShoppingItem,正是「SwiftData 的 Schema 紀律為何重要」最典型的例子。最初的 Schema 裡並沒有 lastModified 時間戳記;後來要補上它時,由於磁碟上早已存在資料,必須採用特定形態的遷移。而這個欄位之所以宣告為可選,正是為了修正它最初以非可選形式加入時所引發的遷移當機。1
SwiftData 的 API 就是兩個巨集。在類別上加 @Model,它便成為可持久化的型別;在屬性上加 @Attribute(.unique),它便帶有唯一性限制。框架把 Core Data 的堆疊管理、值轉換器的繁瑣步驟,以及 NSManagedObjectContext 的樣板程式碼都藏了起來。框架沒有藏起來的,是 Schema 遷移——它只是把遷移從命令式改成宣告式。不把遷移當一回事的代價,就是一次例行更新便抹掉使用者資料的那種 bug。
本文的主張是:SwiftData 起步便宜,草率遷移卻昂貴。所謂紀律,是從第一天就管好命名、可選性與 VersionedSchema,而不是等到驚覺早該如此的那一天才動手。
TL;DR
@Model巨集把一個類別變成可持久化的 SwiftData 型別。框架會在編譯期依據屬性宣告產生 Schema。- 新增一個可選屬性等於零成本遷移:SwiftData 的輕量遷移會自動處理。而要在既有 Schema 上新增非可選屬性,就需要
VersionedSchema,再加上一份MigrationPlan,告訴框架該如何替既有的資料列填入這個新欄位。 - 第一天就略過
VersionedSchema的代價是:v2 裡任何一次不單純的 Schema 變更,都可能危及使用者的資料庫——輕量路徑相當保守,一旦推斷不出遷移方式就會直接放棄。 @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 不需要另外宣告持久化儲存的 Schema。 SwiftData 會在編譯期讀取類別定義並合成 Schema:類別的各個屬性成為模型的欄位,屬性的 Swift 型別成為對應欄位的型別。沒有 .xcdatamodeld 檔案需要維護(雖然 Core Data 底層的 NSManagedObjectModel 依然存在,執行階段正是由它撐起這份 Schema)。2
@Attribute(.unique) 是針對單一欄位的限制,而不是 PRIMARY KEY 宣告。 SwiftData 的持久化識別是 PersistentIdentifier,由框架替每一筆資料列自動產生。@Attribute(.unique) 是在告訴框架:「這個欄位的每個值最多只對應一筆資料列。」當您插入的模型帶有已經存在的 .unique 值時,SwiftData 會執行 upsert——更新既有的那一筆,而不是拒絕寫入。這個語意對產品程式碼很關鍵:.unique 並不是攔下重複輸入的 UI 層驗證,而是一種「最多一筆」的儲存保證,它會安靜地合併資料。上面的 id: UUID 寫法,正是跨行程同步時建議採用的模式(此時您需要一個穩定的識別碼,即使行程內的 PersistentIdentifier 消失也依然有效);而當同一組 UUID 從兩條同步路徑抵達時,upsert 行為恰好就是您要的。
@Model 類別是參考型別,不是實值型別。 修改 ShoppingItem 實例上的某個屬性,會觸發 SwiftData 的變更追蹤:框架記下這次變更,並在下一次上下文儲存時寫入磁碟。透過 @Query 完成的 SwiftUI 整合,會重新繪製所有觀察對應述詞的視圖。這套機制與 @Observable 相仿(詳見SwiftUI 是由什麼構成的),只是在其上再疊了一層持久化。
可選欄位是廉價的遷移
ShoppingItem 上的 lastModified: Date? 是可選的,而這份可選性是承重的。這個欄位是在 v1 上線之後才加入,用來支援跨裝置同步與衝突解決;使用者裝置上既有的資料列並沒有 lastModified 值。一個沒有預設值的可選欄位,讓 SwiftData 的輕量遷移不必寫任何遷移程式碼就能完成這次新增:既有資料列拿到 nil,新的資料列則拿到初始化式所設定的值。3
輕量遷移是框架「客氣」的那條路。SwiftData 會檢視新的 Schema 與持久化儲存,推斷出最小的相容變更並加以套用。整個遷移是自動的,使用者毫無所覺,App 照常在既有資料上啟動。輕量路徑能乾淨處理的情況包括:
- 新增一個可選屬性
- 移除一個屬性(該欄位的資料會被丟棄,之後的讀取不再看到它)
- 重新命名框架能靠提示對應上的欄位(使用
@Attribute(originalName: ...)) - 重新命名框架能對應上的
@Model類別(使用@Model.originalName或提示)
輕量路徑會放棄的情況包括:
- 在既有 Schema 上新增沒有預設值的非可選屬性(既有資料列沒有值可以填)
- 變更屬性的型別(例如
Int→String) - 把一個模型拆成兩個,或把兩個併成一個
- 任何需要自訂邏輯才能完成的遷移
輕量路徑放棄時,安全的行為是讓遷移失敗。不安全的行為則是砍掉資料庫從頭來過;框架夠保守,拒絕默默做這種事。於是使用者看到的是 App 一啟動就因為遷移錯誤而當機,開發者看到的是指向 Schema 不符的呼叫堆疊:沒有人掉資料,但所有人都失去信心。
第一天略過 VersionedSchema 的代價,會在 v2 → v3 的交界上現形——當您加入第三個功能,而它的 Schema 變更已經超出輕量路徑能處理的範圍時。
VersionedSchema 與 MigrationPlan:第一天就該有的紀律
VersionedSchema 用來宣告模型 Schema 的某個特定版本,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)
]
}
模型類別本身則搬進版本化 Schema 的命名空間:
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")
)
遷移計畫等於給了框架一張帶型別的圖,說明 Schema 如何演進。當搭載 v2 的 App 面對 v1 資料庫啟動時,框架會沿著遷移計畫逐階段走過去,把資料庫帶到 v2。等到要推出 v3,只需在 schemas 裡補上 SchemaV3.self,再於 v2 與 v3 之間加一個新的 MigrationStage。完整的遷移模型——哪些變更是自動的、哪些必須明確宣告階段,以及宣告了一個根本不需要的 V2 會撞上的總和檢查碼當機——是姊妹作《SwiftData 遷移:輕量與自訂》的主題。
紀律就是:即使只有一個版本,也要在 v1 就帶上 VersionedSchema。這麼做的成本是多一個檔案、多一段 enum 宣告。不這麼做的成本,則是 v2 第一次不單純的 Schema 變更會逼您回頭把 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 Schema,didMigrate 面對的是 v2 Schema。閉包裡就是一般的 SwiftData 程式碼(fetch descriptor、模型上下文儲存,與 App 執行時用的是同一套 API),只是操作的對象是一個遷移期間的暫時上下文。
能在正式環境活下來的模式是:讓 willMigrate 保持空白,把所有填值邏輯放進 didMigrate。在 willMigrate 裡讀取 v1 資料是允許的,但從框架的角度來看 v2 Schema 還不存在,因此任何運算結果都必須先暫存到 didMigrate 閉包讀得到的暫時儲存區。更簡單的規則是:結構性遷移是框架的工作;替既有資料列填入 v2 才有的欄位,是 didMigrate 的工作。
@Attribute 與 @Relationship 何時當之無愧
在 @Model 類別裡,Schema 的修飾工作大多由兩個巨集完成。
@Attribute 替單一屬性加上限制或提示:
@Attribute(.unique)強制唯一性,例如ShoppingItem.id@Attribute(.externalStorage)把大塊的Data二進位內容存放在資料庫之外(影像資料、音訊緩衝)@Attribute(originalName: "old_field_name")在遷移時把屬性對應到改過名字的欄位@Attribute(.transformable(by: ...))替非 Codable 的型別套上ValueTransformer
正確的紀律是:只對真正該唯一的欄位用 .unique(您產生的 UUID、外部匯入的 ID);只要二進位內容超過幾 KB 就用 .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:(而不是仰賴框架推斷)。隱含的預設值通常是錯的;明確寫法只多一個參數,卻能一勞永逸省掉一個 bug。
跨越 actor 邊界:傳識別碼,不要傳物件圖
@Model 類別不是 Sendable,而正確的做法是別再想把它變成 Sendable。實例是指向某個 ModelContext 所持有的活躍物件圖的參考;框架無法保證那張圖可以安全地從另一個 actor 讀取,因此這個型別是刻意被留成非 Sendable 的。硬是讓它符合協定並不會讓資料競爭消失,只是把它藏起來而已。7
行得通的模式是:把識別與純值傳過去,再在另一端重新取回。PersistentIdentifier 是 Sendable,能乾淨地跨過邊界。把目的端需要的純量值取出來(一個名稱、一個旗標、裝在小結構裡的一份差異值),連同識別碼一起傳遞,讓接收端的 actor 用這個識別碼從自己的上下文重新取回模型:
// On the source actor: extract identity + plain values, never the model.
let id: PersistentIdentifier = item.persistentModelID
let snapshot = ItemSnapshot(name: item.name, isChecked: item.isChecked)
// On the destination actor: re-fetch from this context, then mutate.
let fetched = destinationContext.model(for: id) as? ShoppingItem
要避開的失敗模式,是把模型物件圖本身傳過去。當圖的一部分跨過邊界,接收端拿到的會是一個在對面只填了一半的模型:那些在來源上下文中從未被觸發載入的關係與延遲載入屬性,會對著錯誤的上下文解析(或者根本解析不出來),隨之而來的都是不出聲的那種 bug。安全的契約是「識別碼加上取出的值」,而不是整張物件圖。ModelActor 正是把這條紀律封裝起來——它自己持有一個上下文,對外交出的是值而不是實例。7
CloudKit 同步與 App Group 授權陷阱
把 SwiftData 儲存搬進 App Group 容器,好讓小工具或擴充功能讀得到它——這件事與 CloudKit 同步之間,存在一種會在上線之後反咬一口的交互作用。兩個事實就足以推出其餘的一切。
第一,儲存的位置。使用預設的 ModelConfiguration 時,當 App 從「沒有 App Group」演進成「使用 App Group」,SwiftData 會替您把既有的儲存複製進 App Group 容器;Apple 的原文是 SwiftData「copies the existing store to the app group container」(把既有儲存複製到 App Group 容器)。8 若改用自訂的儲存 URL,位置就歸您管:檔案要由您複製到新容器,並讓組態指向它。預設路徑之所以省事,正是因為複製由框架代勞;自訂路徑則是拿這份省事換取控制權。
第二,授權(entitlement)。凡是會讀取 CloudKit 同步儲存的 App Group 成員,都必須帶著同一份 CloudKit entitlement,因為這些行程每一個都會以自己的名義去同步那個容器。這項要求就是陷阱:小工具或擴充功能既沒有足夠的執行階段預算,也沒有前景視窗去撐起一場囉嗦的同步,而把 CloudKit entitlement 交到它手上,等於逼它去試。解法是拆成兩個 ModelConfiguration:一個是同步儲存(帶 CloudKit entitlement,由主 App 持有),另一個是放在 App Group 容器裡的本機儲存,供小工具與擴充功能讀取、從不參與同步。把同步放在前景 App 能做好的地方,把「共享供讀取」的資料擋在同步路徑之外。8
如果重來一次,我會怎麼做
這一組 App 要麼已經採用、要麼後悔沒採用的三種模式。
從 v1 就帶上 VersionedSchema。 每一個要上線的 @Model 類別,第一天就該住在某個 VersionedSchema 裡。成本是每個 Schema 版本多一層包起來的 enum。好處是 v2 的第一次不單純變更,只要在 MigrationPlan.schemas 加一行,而不是花兩天做回頭補救的重構。
所有時間戳記都設成可選。 lastModified、createdAt、updatedAt 這類為了跨裝置同步或衝突解決而存在的欄位,如果 v1 的產品本身用不到,就該在 v1 保持可選。可選性能讓(真正需要它們時的)v2 遷移維持廉價。在 didMigrate 裡替既有資料列填值,不過是一個迴圈;從 v1 就設成非可選,則是一條可能讓使用者資料回填失敗的限制。
用 UUID 當自然鍵,而不是 PersistentIdentifier。 SwiftData 的 PersistentIdentifier 只在行程內有效。跨裝置同步、MCP 整合(詳見兩個代理生態,一份購物清單),以及任何行程外的參照,都需要一個穩定的識別碼。帶著 @Attribute(.unique) 的 UUID 才是對的形態;行程內的 PersistentIdentifier 對任何跨越行程邊界的用途來說都是錯的形態。
@Model 什麼時候是錯的答案
以下三種情況,SwiftData 都不是對的工具:
單筆記錄的鍵值狀態。 App 設定、使用者選擇的語言、上一次同步的時間戳記。請改用 UserDefaults 或 NSUbiquitousKeyValueStore(詳見五個 Apple 平台,三個共享檔案)。為了一筆資料列付出 SwiftData 的開銷,只是白費工夫的排場;鍵值儲存才是對的底層。
以伺服器為準、沒有離線寫入的資料。 從 REST API 取回、只做唯讀顯示的清單。如果真實來源在伺服器、本機快取就只是快取,那 SwiftData 就是殺雞用牛刀。放在 Documents/ 裡的一份簡單 Codable 快照,加上一個放在記憶體裡的陣列快取就夠了;既然這些資料本來就撐不過一次徹底重置,那份 SwiftData 遷移稅就不值得繳。
多行程協調。 SwiftData 只在單一行程內運作。跑在 iOS App 之外的 MCP 伺服器,既讀不到也寫不了 App 的 SwiftData 容器。跨行程狀態需要另一種形態:放在 iCloud 雲碟的 JSON 檔案、共享的 App Group 容器,或是一層在行程之間搭橋的明確同步層。(Get Bananas 把 SwiftData 與 iCloud 雲碟 JSON 搭配使用,正是出於這個原因。)6
資料是很少變動的大塊二進位內容。 一個 10MB 的音訊檔、一份 50MB 的影像資料集。如果這些二進位內容放在 SwiftData 的資料列裡,就用 @Attribute(.externalStorage);否則直接使用檔案系統,SwiftData 裡只保留指向檔案 URL 的中繼資料。
Core Data 仍然勝出的場合
SwiftData 是蓋在 Core Data 之上的一層,而不是它的全面替代品。三年下來,仍有一批特定工作屬於那個較舊的框架。為這些場合選擇 Core Data,不是背負包袱的決定,而是當下正確的決定。
資料庫端彙總。 Core Data 有以 NSExpression 為基礎的取回方式,能把 sum、average、min、max 下推給 SQLite,讓資料庫在不載入資料列的情況下算完,SwiftData 沒有對等物9。在 SwiftData 裡只能先取回再於記憶體中彙整,面對大型資料表時這等於自廢武功。文件裡寫明的逃生出口是共存:Apple 描述了同時跑「two completely separate persistent stacks, one Core Data stack and one SwiftData stack, talking to the same persistent store」(兩套完全獨立的持久化堆疊,一套 Core Data、一套 SwiftData,連向同一個持久化儲存)的做法,讓 Core Data 那一側針對 SwiftData 擁有的那個檔案執行下推到 SQL 的彙總9。其中的機制,包括對 NSPersistentHistoryTrackingKey 的要求,收錄在《SwiftData 的效能問題其實是儲存問題》。
共享與 CloudKit 公開資料庫。 SwiftData 的自動 iCloud 同步底層搭的是 NSPersistentCloudKitContainer,而它所設定的容器,只會把您的儲存鏡像到使用者的私人 CloudKit 資料庫10。透過 CKShare 在不同 iCloud 使用者之間協作,以及發布到公開資料庫,都是有文件記載的 Core Data + CloudKit 能力;截至 iOS 27 beta,SwiftData 層級並沒有對應的 API10。一款核心功能就是共享清單或協作文件的 App,不是在同步儲存上退回 Core Data,就是自己手工搭出 CloudKit 那一層。
儲存層級的批次更新。 Core Data 的 NSBatchUpdateRequest 不必載入物件,就能直接在儲存裡改寫符合條件的資料列11。SwiftData 的 ModelContext 只有刪除那一半(delete(model:where:) 接受一個述詞),沒有批次更新的對等物;因此在 SwiftData 裡做大量欄位改寫,就等於要把每一個受影響的模型都實體化出來。
部署下限低於 iOS 17。 SwiftData 需要 iOS 17;Core Data 則能一路回溯到任何 App 還在支援的版本,它的 CloudKit 容器最早可到 iOS 131012。作業系統尾巴拖得很長的程式碼庫,沒有選擇的餘地。
值得點名的是從這份清單上掉下去的一項:「在視圖之外需要 NSFetchedResultsController」曾經是 Core Data 的優勢,直到 iOS 27 beta 加入了 ResultsObserver——它透過 Swift Observation,在 App 的任何地方觀察一次取回13。這份落差清單正一個版本一個版本地縮短。押注的方式是:新 App 從 SwiftData 起步,針對上述特定工作採用共存方案,只有在共享、公開資料庫或部署下限逼您就範時,才把完整的 Core Data 堆疊當成答案。
這套模式對在 iOS 26+ 上推出的 App 意味著什麼
三點結論。
-
巨集是簡單的部分,遷移才是成本。
@Model與@Attribute是兩行宣告,背後藏著大量 Core Data 的接線工作。在 App 的整個生命週期裡,您真正付出的是遷移紀律;設計 v1 時就要把 v2 放在心上。 -
對要上線的 App 來說,第一天就用
VersionedSchema沒有商量餘地。 那層包起來的enum只是多一個檔案;事後才補上的代價高出許多。 -
可選欄位與明確的關係,是廉價的保險。 同步中繼資料用可選時間戳記,關係上明確寫出
deleteRule與inverse:。兩者都只是極小的宣告,卻能換來 v2 階段大量的迴旋餘地。
完整的 Apple 生態系列:為 Apple Intelligence 而生的型別化 App Intents;服務跨 LLM 代理的 MCP 伺服器;兩者之間的路由抉擇;用於裝置端 LLM 與 Tool 協定的 Foundation Models;撐起 iOS 鎖定畫面狀態機的即時動態;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。
如果我根本不打算更動 Schema,還需要 VersionedSchema 嗎?
如果您的 App 有機會推出 v2,那就需要;如果它只是一次性的示範,那就不必。從 v1 就採用 VersionedSchema 的成本,是多一段 enum 宣告;等到 v2 才回頭補上的成本,則是要精準重現 v1 的 Schema 形態,框架才認得出既有資料——這辦得到,卻很容易出錯。多數正式上線的 App 遲早都會需要一次 Schema 變更;請在 v1 就把這筆預算算進去。
什麼時候該用 @Attribute(.unique)?
當這個欄位是該筆資料列的自然鍵時:您產生的 UUID、匯入的外部 ID、您指定的 slug。SwiftData 把 .unique 當成 upsert 處理:如果插入的模型帶有已經存在的 .unique 值,框架會更新既有那一筆,而不是再追加一筆。正是這個語意,讓 upsert 式的同步路徑(同一組 UUID 從兩台裝置送來)得以安全;同樣也因為如此,.unique 並不適合用在 title 這類顯示名稱的欄位上——兩位使用者打出相同的標題,會讓他們的資料被安靜地合併,而不是產生兩筆各自獨立的記錄。
在既有 Schema 上新增非可選欄位時該怎麼處理?
使用帶有 didMigrate 閉包的 MigrationStage.custom,在閉包裡替既有資料列填入該欄位。或者更省事:在新的 Schema 版本裡把該欄位宣告為可選,等到存取時再延遲填入。可選是比較廉價的遷移;新增非可選欄位則需要明確的填值邏輯。
PersistentIdentifier 和我自己的 UUID 差在哪?
PersistentIdentifier 是 SwiftData 的行程內資料列 ID,由框架自動產生,效期與執行中的行程相同。而帶有 @Attribute(.unique) 的自訂 UUID,則是穩定的跨行程、跨裝置識別碼。App 內部的行程內參照請用 PersistentIdentifier;任何跨越行程邊界的情境(跨裝置同步、外部整合、MCP 工具、網路呼叫)都請用 UUID。
什麼時候仍然該選 Core Data 而不是 SwiftData?
截至 iOS 27 beta 有四種情況:資料庫端彙總(SwiftData 缺少的 NSExpression 取回)、透過 CKShare 或 CloudKit 公開資料庫在 iCloud 使用者之間共享(SwiftData 的同步只涵蓋私人資料庫)、儲存層級的批次更新(NSBatchUpdateRequest),以及部署目標低於 iOS 1791011。彙總這一項並不需要您放棄 SwiftData:讓一套共存的 Core Data 堆疊針對同一個儲存檔案運作即可。
參考資料
-
作者的 Get Bananas,一款 SwiftUI 購物清單 App,把 SwiftData 與 iCloud 雲碟 JSON 同步以及一個 MCP 伺服器搭在一起。
ShoppingItem模型在早期開發週期中不斷演進;lastModified: Date?欄位是在最初的 Schema 之後才加入的(2025年12月1日的提交268a00d,「Make lastModified optional to fix migration crash」),因為把它設成非可選,會在既有資料列沒有值可填時弄壞遷移。 ↩ -
Apple Developer,“SwiftData” 與 “Adding and editing persistent data in your app”。
@Model巨集、@Attribute的限制介面,以及它與 Core DataNSManagedObjectModel之間的關係。 ↩↩ -
Apple Developer,“Preserving your app’s model data across launches” 與 “Adopting SwiftData for a Core Data app”。輕量遷移的語意,以及哪些狀況會讓框架放棄遷移。 ↩
-
Apple Developer,“VersionedSchema” 與 “SchemaMigrationPlan”。版本化 Schema 的宣告、遷移階段的定義,以及接受遷移計畫的
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。 ↩
-
Apple Developer,“PersistentIdentifier”(符合
Sendable)與 “ModelActor”。SwiftData 團隊在 WWDC 2026 SwiftData Group Lab 上確認:@Model物件不是Sendable,也不該硬要它符合該協定,因為它們是活在某個上下文裡的參考圖;建議的邊界契約,是傳遞屬於Sendable的PersistentIdentifier加上取出的純值,並在目的端上下文重新取回,而把模型物件圖傳過去,會讓接收端拿到一個只填了一半的物件。以上內容轉述自本地轉錄的 WWDC 2026 SwiftData Group Lab 錄音;Apple 並未替這些 Lab 發布官方字幕。 ↩↩ -
Apple Developer,“Adopting SwiftData for a Core Data app”,其中寫道:在預設組態下「SwiftData copies the existing store to the app group container」(SwiftData 會把既有儲存複製到 App Group 容器),而改用自訂儲存 URL 時,位置就交由您自行管理。App Group 成員必須帶有 CloudKit entitlement 的要求,以及用兩個
ModelConfiguration拆開(一個同步、一個本機)好讓小工具與擴充功能遠離同步路徑的做法,都在 WWDC 2026 SwiftData Group Lab 上說明過。以上內容轉述自本地轉錄的 WWDC 2026 SwiftData Group Lab 錄音;Apple 並未替這些 Lab 發布官方字幕。 ↩↩ -
Apple,WWDC 2023 session 10189,“Migrate to SwiftData”,共存說法的出處(「two completely separate persistent stacks, one Core Data stack and one SwiftData stack, talking to the same persistent store」);以及 Apple Developer,“NSExpression”,也就是 Core Data 把彙總下推到 SQL 的機制,SwiftData 並未提供對等物。這道落差經 WWDC 2026 SwiftData Group Lab 上的 SwiftData 工程團隊座談確認(轉述自本地轉錄的錄音)。 ↩↩↩
-
Apple Developer,“Syncing model data across a person’s devices”,其中寫道「SwiftData uses the
NSPersistentCloudKitContainerclass from Core Data to handle CloudKit synchronization」(SwiftData 使用 Core Data 的NSPersistentCloudKitContainer類別來處理 CloudKit 同步);“NSPersistentCloudKitContainer”(iOS 13.0+),其摘要描述了把「select persistent stores to a CloudKit private database」(選定的持久化儲存鏡像到 CloudKit 私人資料庫);以及“Sharing Core Data objects between iCloud users”,也就是以CKShare為基礎的協作在 Core Data 中有文件記載的做法。截至 iOS 27 beta,SwiftData 的文件並未公開任何共享或公開資料庫的 API。 ↩↩↩↩ -
Apple Developer,“NSBatchUpdateRequest”,以及 “ModelContext.delete(model:where:includeSubclasses:)”,也就是 SwiftData 以述詞為基礎的批次刪除。SwiftData 的
ModelContext文件裡並未列出批次更新的對等物。 ↩↩ -
平台可用性依據 Apple Developer 文件:SwiftData(iOS 17.0+)與 Core Data(iOS 3.0+)。 ↩
-
Apple Developer,“ResultsObserver”(iOS 27.0 beta),它「observes and tracks changes to a collection of persistent models in a model context」(觀察並追蹤模型上下文中一組持久化模型的變化)且符合
Observable,承擔起過去必須靠 Core DataNSFetchedResultsController才能完成的、在視圖之外進行觀察的角色。 ↩