SwiftData-CRUD-Patterns-from-Apple-Sample
從 Apple 範例看 SwiftData 的 CRUD 寫法
Apple 的 Adding and editing persistent data in your app 範例專案(SwiftDataAnimals)是學 SwiftData CRUD 最直接的教材。
這篇把範例裡的讀取、新增、修改、刪除拆開看,整理出 Apple 推薦的寫法。
專案結構
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
SwiftDataAnimals/
├── Models/
│ ├── Animal.swift @Model
│ ├── AnimalCategory.swift @Model(有 cascade delete)
│ └── NavigationContext.swift @Observable(只管 navigation 狀態)
├── Views/
│ ├── ContentView.swift
│ ├── AnimalCategoryListView.swift
│ ├── AnimalListView.swift
│ ├── AnimalDetailView.swift
│ └── AnimalEditor.swift
├── PreviewHelper/
│ ├── ModelContainerPreview.swift
│ └── Preview+ModelContainer.swift
└── SampleData/
├── SampleData+Animal.swift
└── SampleData+AnimalCategory.swift
重點:沒有 ViewModel 資料夾。 唯一的 @Observable class 是 NavigationContext,只負責追蹤「使用者選了哪個 category、哪隻動物」,完全不碰資料存取。
Read:@Query 直接在 View
1
2
3
4
5
6
7
8
9
10
11
// AnimalListView.swift
struct AnimalListView: View {
@Query(sort: \Animal.name) private var animals: [Animal]
init(animalCategoryName: String?) {
let predicate = #Predicate<Animal> { animal in
animal.category?.name == animalCategoryName
}
_animals = Query(filter: predicate, sort: \Animal.name)
}
}
幾個值得注意的地方:
動態 predicate
@Query 的 filter 可以在 init 裡用 _animals = Query(filter:sort:) 動態設定。不需要在 body 裡手動 filter,SwiftData 會在資料庫層面就過濾好。
多個 View 各自 @Query
AnimalListView、AnimalCategoryListView、AnimalEditor 各自有自己的 @Query。它們不共享查詢結果,各自獨立監聽。這是 Apple 推薦的做法——讓每個 View 只拿自己需要的資料。
Create:modelContext.insert
1
2
3
4
5
6
7
8
9
10
11
12
13
// AnimalEditor.swift
@Environment(\.modelContext) private var modelContext
private func save() {
if let animal {
// 修改既有的(見下面 Update 段落)
} else {
// 新增
let newAnimal = Animal(name: name, diet: selectedDiet)
newAnimal.category = selectedCategory
modelContext.insert(newAnimal)
}
}
新增的流程:
- 建立
@Model物件 - 設定 properties 和 relationships
modelContext.insert(newAnimal)- 不需要手動
try modelContext.save()— SwiftData 預設 autosave
沒有 ViewModel、沒有 Repository
Apple 的範例就是在 View 裡面直接 modelContext.insert。沒有經過中間層。
這不代表你「不能」加中間層,而是 Apple 在告訴你:對大部分的 app,你不需要。
Update:直接改 property
1
2
3
4
5
6
7
8
9
// AnimalEditor.swift
private func save() {
if let animal {
// 直接改 @Model 物件的 property,SwiftData 自動追蹤
animal.name = name
animal.diet = selectedDiet
animal.category = selectedCategory
}
}
這是 SwiftData 最方便的地方:改 property 就是改資料庫。 @Model macro 會自動幫你追蹤所有 property 的變化,不需要手動呼叫 save 或 update。
跟 Core Data 的 NSManagedObject 類似,但語法更乾淨。
Delete:modelContext.delete
1
2
3
4
5
6
7
// AnimalDetailView.swift
@Environment(\.modelContext) private var modelContext
private func delete(_ animal: Animal) {
navigationContext.selectedAnimal = nil // 先清掉 navigation 狀態
modelContext.delete(animal)
}
1
2
3
4
5
6
7
// AnimalListView.swift
private func deleteAnimals(at indexSet: IndexSet) {
for index in indexSet {
let animalToDelete = animals[index]
modelContext.delete(animalToDelete)
}
}
Delete 有兩個地方要注意:
先清 navigation 狀態
如果使用者正在看某隻動物的詳情,刪掉之後要先把 selectedAnimal 設成 nil,不然 UI 會試圖顯示一個已被刪除的物件。
Cascade delete
AnimalCategory 的 animals relationship 設了 .cascade:
1
2
3
4
5
@Model
final class AnimalCategory {
@Relationship(deleteRule: .cascade, inverse: \Animal.category)
var animals: [Animal] = []
}
刪除 category 時,底下所有 animal 會被一起刪掉。這個行為在 Model 層定義,View 不需要知道。
Sample Data 的設計
範例把假資料放在 SampleData/ 目錄,用 static extension 實作:
1
2
3
4
5
6
// SampleData+Animal.swift
extension Animal {
static let dog = Animal(name: "Dog", diet: .herbivorous)
static let cat = Animal(name: "Cat", diet: .carnivorous)
// ...
}
1
2
3
4
5
6
7
8
9
10
11
// SampleData+AnimalCategory.swift
extension AnimalCategory {
static func insertSampleData(modelContext: ModelContext) {
// 建立 category,設定 relationship,insert 到 context
}
static func reloadSampleData(modelContext: ModelContext) {
// 先刪除所有資料,再重新插入
// 用於 UI 上的「重設」按鈕
}
}
這個設計有兩個用途:
- Preview 假資料 —
Preview+ModelContainer.swift呼叫insertSampleData填充 in-memory container - App 內建範例 —
AnimalCategoryListView裡有「Insert Sample Data」和「Reload Sample Data」按鈕,直接呼叫同一組 method
整體架構圖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
┌─────────────────────────────────────────────┐
│ SwiftUI Views │
│ │
│ @Query → 讀取 │
│ modelContext.insert → 新增 │
│ model.property = value → 修改 │
│ modelContext.delete → 刪除 │
│ │
│ NavigationContext (@Observable) → 管 UI 狀態 │
└──────────────┬──────────────────────────────┘
│
│ .modelContainer(for:)
│
┌──────────────▼──────────────────────────────┐
│ SwiftData │
│ │
│ @Model classes │
│ @Relationship (cascade delete) │
│ ModelContainer / ModelContext │
│ 自動追蹤 property 變化 │
│ 自動 autosave │
└─────────────────────────────────────────────┘
跟「正統 MVVM」的差異
| Apple 範例 | 傳統 MVVM | |
|---|---|---|
| 資料讀取 | @Query 在 View | ViewModel 持有資料 |
| CRUD 操作 | View 直接操作 modelContext | ViewModel 提供 method |
| 中間層 | 無 | ViewModel / Repository |
| 資料追蹤 | SwiftData 自動監聽 | ViewModel publish 變化 |
| 測試 | 需要 ModelContainer(in-memory) | 可以 mock ViewModel |
Apple 的立場很清楚:SwiftData + SwiftUI 已經內建了「資料綁定」和「變化追蹤」,不需要再疊一層 ViewModel 來做同樣的事。
什麼時候應該加入中間層?
範例是教學用的,故意保持簡單。以下情境可能需要額外的抽象:
- 複雜的資料轉換 — 例如把
[CareEvent]轉成[DayData]做圖表,這個轉換邏輯不該塞在 View 的 computed property 裡 - 跨 View 共享狀態 — 例如全域的 filter 設定、搜尋條件
- 需要背景查詢 — 資料量大到
@Query在主執行緒延遲時,用@ModelActor在背景處理 - 單元測試覆蓋率要求高 — 想測的不只是 UI 呈現,還有商業邏輯
但請注意:加中間層是有成本的。 你會失去 @Query 的自動監聽、需要手動管理資料同步、程式碼量增加。
先從 Apple 範例的做法開始,等真的遇到痛點再加層,比「預設 MVVM」來得務實。