Post

SwiftData-CRUD-Patterns-from-Apple-Sample

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

AnimalListViewAnimalCategoryListViewAnimalEditor 各自有自己的 @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)
    }
}

新增的流程:

  1. 建立 @Model 物件
  2. 設定 properties 和 relationships
  3. modelContext.insert(newAnimal)
  4. 不需要手動 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

AnimalCategoryanimals 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 上的「重設」按鈕
    }
}

這個設計有兩個用途:

  1. Preview 假資料Preview+ModelContainer.swift 呼叫 insertSampleData 填充 in-memory container
  2. 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 在 ViewViewModel 持有資料
CRUD 操作View 直接操作 modelContextViewModel 提供 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」來得務實。

This post is licensed under CC BY 4.0 by the author.