上一篇介紹了 Massive View Controller 產生的原因。由於檢視控制器同時兼任 View 和 Controller,無處可放的程式碼最後全都堆到那裡。
最廣泛採用的解法是MVVM(Model-View-ViewModel)。但打開聲稱導入 MVVM 的程式碼,常會看到這種情況:有一個只有名稱是 ViewModel 的類別,而檢視控制器仍逐一取出值並套用到畫面。
今天就來整理 ViewModel 的真正職責,以及為什麼沒有繫結的 MVVM 只完成了一半。
ViewModel 是產生畫面狀態的工廠
MVVM 的三個角色如下劃分。
- Model:資料與商業邏輯(與 MVC 相同)
- View:顯示畫面。在 iOS 中,UIView 和 UIViewController 都屬於這一側
- ViewModel:將 Model 的資料加工成可顯示在畫面上的形式,並持有畫面狀態的物件
有兩個重點。
第一,在 MVVM 中,UIViewController 屬於 View 這一側。上一篇提到的檢視控制器模糊定位,現在直接定義為 View。檢視控制器只負責繪製畫面,所有判斷都交給 ViewModel。
第二,ViewModel 不應知道 UIKit。也就是說,import UIKit不應存在 UIKit 相依性Date。接收 Date 並轉成「3 分鐘前」這類字串,以及用 Bool 持有是否正在載入,都是 ViewModel 的工作。至於要把字串放進哪個 UILabel,則是 View 的工作。
這種分離讓 ViewModel 可以在沒有畫面的情況下測試。你可以不依賴 UIViewController,直接用純邏輯驗證「貼文數為 0 時會顯示提示文字」。
沒有繫結時,為什麼只完成了一半?
如果停在這裡,程式碼會變成這樣。
// 沒有繫結的「只有 MVVM 外表」 MVVM"
final class ProfileViewController: UIViewController {
let viewModel = ProfileViewModel()
func refresh() {
viewModel.load()
nameLabel.text = viewModel.displayName // 直接取出值
statusLabel.text = viewModel.statusText // 逐一套用
emptyView.isHidden = !viewModel.isEmpty
}
}
每當 ViewModel 的狀態改變,檢視控制器都必須記得呼叫refresh()。只要漏掉一次呼叫時機,畫面與狀態就會不一致。你只是移動了狀態,「同步狀態與畫面的責任」仍留在檢視控制器上。
MVVM 從誕生於 Microsoft 的 WPF 開始,就以資料繫結為前提設計,原因就在這裡。必須自動保證「ViewModel 改變時 View 也會跟著改變」,才算真正完成。
在 UIKit 中使用 Combine 進行繫結
UIKit 沒有內建繫結,因此搭配 Combine 幾乎是標準做法。
final class ProfileViewModel {
@Published private(set) var displayName = ""
@Published private(set) var isLoading = false
func load() { ... } // 完成時 @Published 更新值
}
final class ProfileViewController: UIViewController {
private var cancellables = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
viewModel.$displayName
.assign(to: \.text!, on: nameLabel)
.store(in: &cancellables)
viewModel.$isLoading
.sink { [weak self] in self?.spinner.isAnimating = $0 }
.store(in: &cancellables)
}
}
設定一次訂閱後,無論 ViewModel 何時、如何改變,畫面都會跟著更新。「什麼時候該呼叫 refresh?」這個煩惱本身就消失了。
在 SwiftUI 中,這種繫結已經內建於語言層級。View 只要讀取加上@Observable的物件屬性,值改變時該 View 就會自動重新繪製。連訂閱程式碼都不需要。
Massive ViewModel 的陷阱
導入 MVVM 幾個月後,你會遇到新的問題。這次是ViewModel 變得臃腫。如果網路請求、快取和商業規則全都塞進 ViewModel,就只是把怪物換了位置。
ViewModel 終究只負責簡報邏輯(加工畫面狀態)。取得資料應交給 Repository 或 Service,商業規則則應下放到 Model 層。MVVM 是用來分離 View 與其餘部分的模式,不是要把其餘一切都放進 ViewModel。
總結
- ViewModel 是將 Model 資料加工成畫面狀態的物件,而且不應知道 UIKit。如此才能在沒有畫面的情況下測試。
- 只移動狀態而沒有繫結時,同步責任仍留在檢視控制器。MVVM 必須包含繫結才算完整。
- 在 UIKit 中由 Combine 負責繫結,在 SwiftUI 中則由 @Observable 負責。
- 把所有邏輯放進 ViewModel,就會變成 Massive ViewModel。請只讓它負責簡報邏輯。
不過在 SwiftUI 中,由於 View 本身會以宣告式方式反映狀態,「根本不需要 ViewModel」的主張也逐漸受到重視。這場爭論將在下一篇正面討論。

![[iOS 架構 #2] MVVM 與繫結重點整理 封面圖](/assets/images/posts/f493544a-c1fe-4846-88cc-edd9a3879c4c/1.jpg)