Post

SwiftData @Query vs ViewModel:MVVM 在 SwiftUI 時代還活著嗎?

SwiftData @Query vs ViewModel:MVVM 在 SwiftUI 時代還活著嗎?

SwiftData @Query vs ViewModel:MVVM 在 SwiftUI 時代還活著嗎?

SwiftData 的 @Query 只能放在 View 裡面,不能注入 ViewModel。這個限制讓社群吵翻了——MVVM 到底還能不能用?

這篇整理兩大陣營的論點和具體做法,附上社群討論的連結。

問題的核心

@Query 需要 SwiftUI environment 才能運作。你沒辦法在一個 @Observable class 裡面放 @Query,它只能活在 View struct 裡。

這代表如果你用傳統 MVVM,ViewModel 沒辦法直接持有 @Query 來取資料。你只有兩個選擇:

做法View 的職責ViewModel 的職責
A:View 持有 @Query拿 raw data,傳給 ViewModel接收資料、做轉換、管理 UI 狀態
B:ViewModel 持有 ModelContext純 UI 呈現自己用 FetchDescriptor 查詢、監聽變化

做法 A:View 持有 @Query,ViewModel 管轉換

View 還是用 @Query 拿資料,但不在 View 裡面做任何商業邏輯。把 raw data 丟給 ViewModel,由 ViewModel 負責轉換和狀態管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// View:只負責 @Query 和 UI 呈現
struct InsulinCycleChartView: View {
    @Query(sort: \CareEvent.occurredAt)
    private var allEvents: [CareEvent]

    @State private var viewModel = InsulinChartViewModel()

    var body: some View {
        ForEach(viewModel.recentDays) { day in
            DayBandChart(day: day, focus: viewModel.focus)
        }
        .onChange(of: allEvents) { _, newValue in
            viewModel.updateEvents(newValue)
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
// ViewModel:管狀態和轉換,不碰 SwiftUI
@Observable
final class InsulinChartViewModel {
    var dayCount: Int = 3
    var focus: ChartLayer? = nil
    private(set) var recentDays: [DayData] = []

    func updateEvents(_ events: [CareEvent]) {
        recentDays = DayData.build(from: events, lastDays: dayCount)
    }
}

好處:

  • 保留 @Query 的自動監聽和差異更新(Apple 幫你做的)
  • ViewModel 是純 Swift class,可以單元測試
  • 不會跟 @Query 的快取打架

壞處:

  • View 還是得 import Model 型別(CareEvent
  • 需要 .onChange(of:) 來驅動更新,多一個接合點

做法 B:ViewModel 持有 ModelContext,自己查

ViewModel 透過 init 或 environment 接收 ModelContext,用 FetchDescriptor 自己查詢。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Observable
final class InsulinChartViewModel {
    private let modelContext: ModelContext
    var recentDays: [DayData] = []

    init(modelContext: ModelContext) {
        self.modelContext = modelContext
        fetchEvents()
    }

    func fetchEvents() {
        let descriptor = FetchDescriptor<CareEvent>(
            sortBy: [SortDescriptor(\.occurredAt)]
        )
        let events = (try? modelContext.fetch(descriptor)) ?? []
        recentDays = DayData.build(from: events, lastDays: 3)
    }
}

好處:

  • View 完全不碰資料層,乾淨分離
  • 跟 UIKit 時代 Core Data + ViewModel 模式一致
  • 單元測試可以注入 in-memory ModelContainer

壞處:

  • 失去 @Query 的自動監聽。SwiftData 沒有像 Core Data 的 NSFetchedResultsController,你要自己實作監聽機制
  • 資料同步要手動處理(insert 完要記得 re-fetch)
  • 容易跟其他 View 的 @Query 快取產生不一致

社群怎麼看?

「MVVM 已死」派

Apple Developer Forums 上有一篇著名的 Stop using MVVM for SwiftUI,主張 declarative UI 不需要 MVVM,改用 MV(Model-View)或 Store pattern 就好。

Thomas Ricouard(Ice Cubes 的作者)也寫了 SwiftUI in 2025: Forget MVVM,認為 @Observable + @Query 已經夠用。

「MVVM 沒死」派

Matteo Manferdini 在 Is SwiftData incompatible with MVVM? 裡詳細論證 MVVM 跟 SwiftData 完全相容,只要你理解 single source of truth 原則,@Query 可以跟 ViewModel 共存。

Paul Hudson 的 How to use MVVM to separate SwiftData from your views 提供了具體範例。

論壇討論串

社群共識(如果有的話)

綜合 Swift Forums、Hacking with Swift、Reddit r/SwiftUI 的討論,目前比較主流的看法是:

在 SwiftData 的世界觀裡,@Query 就是你的 ViewModel。 如果邏輯簡單,直接在 View 用 @Query + computed property 就好(Apple 推的路線)。 如果轉換邏輯複雜到需要抽出來,用做法 A 比較務實——保留 @Query 的 reactivity,ViewModel 只管轉換。

以下是幾段有代表性的原文:

Matteo Manferdini 在 Is SwiftData incompatible with MVVM? 寫道:

Most online claims about MVVM and SwiftData can be debunked, demonstrating that MVVM is perfectly compatible with SwiftData. With a proper understanding of the single source of truth principle that drives SwiftUI’s architecture, it is possible to combine view models, @Query properties, and the shared model context.

Paul Hudson 在 How to use MVVM to separate SwiftData from your views 則坦言:

SwiftData and SwiftUI perform best when tightly integrated, and when you separate them – when you want to introduce view models into your code – you lose a fair amount of their power. However, with some extra work MVVM can be used just fine with SwiftUI and SwiftData, as long as you’re careful to keep your data synchronized.

Apple Developer Forums 上的 Stop using MVVM for SwiftUI 討論串則是「反 MVVM」陣營最常被引用的文章,主張:

In SwiftUI (declarative) you don’t need that, in SwiftUI you can use the concept of “Store” to do the odd jobs.

Hacking with Swift Forums 的討論串 裡也有開發者指出,放棄 @Query 之後要自己處理監聽:

When moving away from @Query, we lose the powerful functionality of that property wrapper which automatically fetches the latest data if there are changes to a Model that implements SwiftData, so we need to fetch the array of data manually after inserting new data.

那測試呢?View 碰 Model 不是很難測?

這是支持做法 B 最常見的理由:「如果 View 直接 import CareEvent,就沒辦法在不碰 SwiftData 的情況下測試。」

但實際上要看你測的是什麼:

測試目標做法 A 能不能測?怎麼測
資料轉換邏輯可以DayData.build() 是純函式,傳入 mock [CareEvent] 就能測
ViewModel 狀態可以ViewModel 是純 Swift class,不依賴 SwiftUI
單一元件的 UI可以DayBandChart 接收 DayData(不是 CareEvent),用 DayData.sample() 就能 Preview 和 snapshot test
View + 真實資料的整合測試需要 ModelContainer用 in-memory ModelContainer 注入,A 和 B 都一樣

關鍵在於:View 碰 @Query 不等於 View 碰商業邏輯。只要你把轉換抽乾淨(CareEventDayData),下游元件只依賴轉換後的 value type,測試性跟做法 B 幾乎一樣。

真正難測的情況是:View 裡面直接寫 events.filter { ... }.map { ... } 這種 inline 轉換。那才是應該抽出來的訊號——不管你叫它 ViewModel 還是 helper function。

我的選擇

在 MeowCare 專案裡,我目前用的是做法 A。原因:

  1. @Query 的自動監聽太好用了。iCloud 同步回來的新資料會自動反映在 UI,不用手動 re-fetch
  2. 圖表的轉換邏輯(CareEvent → DayData)已經抽成獨立的 DayData.build(),放在 ViewModel 裡只是多一層呼叫
  3. 各個圖表元件(DayBandChart)已經獨立成自己的 View + #Preview,可以用 mock DayData 單獨測試

如果未來資料量大到 @Query 在主執行緒延遲,才會考慮切到做法 B,搭配 @ModelActor 在背景查詢。

結論

沒有標準答案。看你的專案規模和團隊偏好:

  • 小專案、原型 → 直接 @Query 在 View,不需要 ViewModel
  • 中型專案、需要測試 → 做法 A,@Query + ViewModel 各司其職
  • 大型專案、嚴格分層 → 做法 B,ViewModel 持有 ModelContext,完全隔離

重點是不要為了「MVVM」而強行分層。如果你的 View 只有一個 @Query 加幾行 computed property,硬塞一個 ViewModel 只是多一個需要維護的檔案。

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