如果用 Pure SwiftUI 來實作 MVVMC
之前寫過我為什麼自創了一套 MVVMC:四層 M / VM / V / C,前三層管資料 / 畫面 / 邏輯,特色在那個 C——UIHostingController 把 SwiftUI View 嵌進 UINavigationController,配一個集中式 AppRouter 統一管導航。核心是 把導航還給 UIKit 管。
那套設計的理由到今天我還是覺得對:SwiftUI 導航當年太零碎,交給穩定的 UIKit 是最務實的選擇。
但這幾年 SwiftUI 導航一直在補。NavigationStack(path:) 出來之後,「集中、可程式化的導航」——當年逼我逃去 UIKit 的那個核心需求——SwiftUI 自己終於給得了。於是一個問題變得值得問:
同一套 MVVMC,改用 Pure SwiftUI 實作會長什麼樣?
這篇就是答案,我把它叫做 MVVMR——MVVMC 的 C(Controller) 換成 R(Router):UIKit 的 UIHostingController 退場,導航改用純 SwiftUI 的 Router pattern。先講定位:它不取代 MVVMC,是同一套架構的另一種底座——M / VM 紀律、集中式導航、決策與執行分離全部一樣,只是把 C 層綁 UIKit 的部分換成純 SwiftUI。
🤔 要換掉的,其實只有一塊
MVVMC 四層裡,M / VM / V 本來就跟 UIKit 無關——@Observable、doAction、SwiftUI View,純 Swift。唯一綁死 UIKit 的只有 C 層:UIHostingController、UINavigationController、自訂轉場、邊緣返回手勢。
而 C 是 Controller——MVVMC 用 UIHostingController 當導航的家。MVVMR 做的就是把這個 Controller 換成純 SwiftUI 的 Router:
MVVMC 的 C 是 Controller,MVVMR 的 R 是 Router。 換的是同一個位置上、負責導航的那一層;M / VM / V 不動。
所以「用 Pure SwiftUI 實作 MVVMC」範圍很集中:M / VM / V 幾乎不動,問題全在「C 層那套 UIKit 導航,純 SwiftUI 怎麼重現」。
有個前提讓這件事可行:MVVMC 的 VM 只發導航意圖(onRoute?(.toDetail(post))),它不知道導航怎麼執行——是 UIKit push 還是 SwiftUI 把值塞進 path,VM 不在乎。所以換掉導航的執行層,M / VM 一行都不用改。這正是「決策與執行分離」開始回收紅利的地方。
🛠️ 純 SwiftUI 怎麼重現
C(Controller)做的兩件事拆開來看:持有 VM 回到 View,導航 交給 R——也就是 Router pattern(AppRouter + AppRoute + RouteView 三者組成)。
1. C 消滅,VM 回歸 View 自己持有
C 做兩件事:持有 VM、把 onRoute 接上導航。這在 SwiftUI 由 View 自己用 @State 持有 VM、.onAppear 接 onRoute 就能完成——@State 持有 view model 本來就是 SwiftUI 慣用寫法:
| |
但這裡有一筆取捨要誠實講。 MVVMC 的 View 是「純」的(let viewModel、零導航邏輯),C 層才碰導航。現在 View 自己持有 VM、又自己接 onRoute——View 不再純。而且 router.to(.postDetail(...)) 這行把「語意意圖」翻成「具體路由」,這個翻譯本身就是一個導航決策,落進了 View。所以準確的講法是:VM 發語意意圖(不知道路由細節,乾淨可測)、View 做翻譯與執行。真正保住的是「VM 不碰導航實作」這條。少一層的代價,就是 View 變胖了一點——這是取捨,不是「C 一定得死」。
2. 路由是值,但管理仍然集中
重現 MVVMC 集中式導航,最容易誤會的一點:「路由變成值」不等於「狀態要散到各個 View」。這是兩件獨立的事——可以路由是值,但由一個 AppRouter 集中持有:
| |
AppRouter 持有狀態,RootView 把狀態渲染成畫面——NavigationStack(path:) / .fullScreenCover(item:) / .onOpenURL 都在那。這些在 MVVMC 是 SceneDelegate + UIWindow + UITabBarController 的活。
3. 把路由當值,多出來的角色
UIKit 導航是命令式:nav.pushViewController(SomeHostController())——「要去哪」跟「畫面長怎樣」綁在同一個動作裡。純 SwiftUI 把它拆成「資料」與「渲染」,於是多了兩個 MVVMC 沒有的檔案:
| |
對照一下檔案的增減:
| MVVMR | MVVMC | 性質 |
|---|---|---|
| —(沒有功能各自的 router) | *HostController.swift | 消滅 |
AppRouter.swift | AppRouter.swift | 替換(命令式 → 宣告式,一樣集中) |
AppRoute / RouteView | 不存在 | 新生 |
RootView.swift | SceneDelegate + UITabBarController 組裝 | 新生 |
MVVMRDemoApp.swift | AppDelegate + SceneDelegate | 替換 |
🧱 純 SwiftUI 化不是完全免費
換執行層,M / VM 零修改。但有一處代價跟架構無關,純粹是「拿掉 UIKit」本身要付的,值得記住——它也是一條實用的純 SwiftUI 準則。
狀態驅動的 View,每個分支都必須回傳實體畫面。 看 UserDetail 初始狀態這段:
| |
如果這裡漏了 else,user == nil 時整段會解析成空內容。實測下來,SwiftUI 不會為純空內容建立畫面節點,掛在上面的 .task 不會觸發 → 不會發 API → 永遠空白。
有意思的是:這個漏 else 的寫法,MVVMC 裡擺著也不會壞。 因為 UIHostingController 給了一個隱性保證——只要被 push 上 UINavigationController,它包的 SwiftUI 根畫面一定被實體化、.task 一定觸發,即使內容是空的。NavigationStack 沒這個保證。
所以這不是 MVVMR 引入的 bug,而是 UIKit 一直在替你兜底一個潛伏寫法,拿掉之後它才現形。修法就是每個分支都回實體畫面——無論底層機制細節如何,這都是對的防禦寫法。(這是 demo 裡唯一一處;其他平台整合的零 UIKit 細節同個主題,留給 repo。)
⚖️ 兩種底座,怎麼選
把 MVVMC(UIKit)跟 MVVMR(Pure SwiftUI)並排:
| ✅ MVVMR 得到 | 零 UIKit 依賴;路由是值(deeplink、狀態還原自然);更貼 SwiftUI 慣用寫法;系統返回手勢免費;平台呼叫更乾淨(openURL / 推播都走非 UIKit 框架) |
| ❌ MVVMR 失去 | 自訂轉場(要等 iOS 18 navigationTransition 才有原生替代);View 的純粹性;統一 back() 範圍變窄——MVVMC 連 sheet 的後退都收編進同一個 back,MVVMR 的 back() 不管 sheet;UIKit 的隱性兜底(上面那段);NavigationStack 較新,有 edge case |
| ⚖️ 兩邊一樣(MVVMC 真正值錢的核心) | M / VM 紀律(doAction 單一入口、onRoute 發意圖、VM 不碰導航實作);全域導航集中(單一 AppRouter);VM 發意圖、View 執行 |
不是「新的取代舊的」,是同一套架構、兩種底座,按需求選:
- 要自訂轉場、要 UIKit 那套成熟的導航控制、團隊熟 UIKit → MVVMC
- 想擺脫 UIKit 依賴、偏好純 SwiftUI、吃得下
NavigationStack的新與 edge case → MVVMR
兩者共用同一套 M / VM,就算之後想換,換的也只是導航那一層。
📍 結語
MVVMR 證明的事很簡單:MVVMC 這套架構,不綁 UIKit 也成立。 把 C 層換成 NavigationStack(path:) + 集中式 AppRouter,M / VM 一行不改,集中式導航、決策與執行分離原封帶走。
而讓這次換底座幾乎免費的,不是 SwiftUI 變好,是 MVVMC 從第一天就把導航決策跟執行分開——VM 只發意圖,從不知道底層是 UIKit 還是 SwiftUI。這才是「擁有自己的架構」最划算的地方:不是它永遠不變,而是當你想換一種底座,M / VM 的投資不用打掉重練。
MVVMR(Pure SwiftUI);對照組 MVVMC(UIKit-based)。完整程式碼與架構說明在 MVVMR repo 的
CLAUDE.md。環境:iOS 17+、Swift 6、純 SwiftUI(零
import UIKit)。
本文使用 Claude 共同完成