Joe's Blog

iOS 開發筆記

如果用 Pure SwiftUI 來實作 MVVMC

發佈於 2026-06-28

之前寫過我為什麼自創了一套 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 無關——@ObservabledoAction、SwiftUI View,純 Swift。唯一綁死 UIKit 的只有 C 層UIHostingControllerUINavigationController、自訂轉場、邊緣返回手勢。

而 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、.onAppearonRoute 就能完成——@State 持有 view model 本來就是 SwiftUI 慣用寫法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
struct PostListView: View {
  @Environment(AppRouter.self) private var router
  @State private var viewModel = PostListViewModel()

  var body: some View {
    content
      .onAppear {
        viewModel.onRoute = { route in
          switch route {
          case let .toDetail(post):   router.to(.postDetail(id: post.id, title: post.title, body: post.body))
          case let .toUserDetail(id): router.to(.userDetail(id: id))
          case .toProfile:            router.switchTab(.profile)
          case .toFilter:             showingFilter = true
          }
        }
      }
      .task { await viewModel.doAction(.view(.isFirstAppear)) }
  }
}

但這裡有一筆取捨要誠實講。 MVVMC 的 View 是「純」的(let viewModel、零導航邏輯),C 層才碰導航。現在 View 自己持有 VM、又自己接 onRoute——View 不再純。而且 router.to(.postDetail(...)) 這行把「語意意圖」翻成「具體路由」,這個翻譯本身就是一個導航決策,落進了 View。所以準確的講法是:VM 發語意意圖(不知道路由細節,乾淨可測)、View 做翻譯與執行。真正保住的是「VM 不碰導航實作」這條。少一層的代價,就是 View 變胖了一點——這是取捨,不是「C 一定得死」。

2. 路由是值,但管理仍然集中

重現 MVVMC 集中式導航,最容易誤會的一點:「路由變成值」不等於「狀態要散到各個 View」。這是兩件獨立的事——可以路由是值,但由一個 AppRouter 集中持有:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@MainActor
@Observable
final class AppRouter {              // 不是 singleton:App 用 @State 持有、@Environment 注入
  var tab: AppTab = .posts
  var postsPath: [AppRoute] = []     // 每個 tab 各自的導航堆疊
  var profilePath: [AppRoute] = []
  var deeplinkRoute: AppRoute?       // 全螢幕呈現,獨立的 context

  func to(_ route: AppRoute) {
    switch tab {
    case .posts:   postsPath.append(route)
    case .profile: profilePath.append(route)
    }
  }
  func back() {                      // 有 deeplink 先收,否則退當前 tab 一層
    if deeplinkRoute != nil { deeplinkRoute = nil; return }
    switch tab {
    case .posts:   if !postsPath.isEmpty   { postsPath.removeLast() }
    case .profile: if !profilePath.isEmpty { profilePath.removeLast() }
    }
  }
  func switchTab(_ tab: AppTab) { self.tab = tab }
  func deeplink(_ route: AppRoute) { deeplinkRoute = route }
}

AppRouter 持有狀態,RootView 把狀態渲染成畫面——NavigationStack(path:) / .fullScreenCover(item:) / .onOpenURL 都在那。這些在 MVVMC 是 SceneDelegate + UIWindow + UITabBarController 的活。

3. 把路由當值,多出來的角色

UIKit 導航是命令式:nav.pushViewController(SomeHostController())——「要去哪」跟「畫面長怎樣」綁在同一個動作裡。純 SwiftUI 把它拆成「資料」與「渲染」,於是多了兩個 MVVMC 沒有的檔案:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// AppRoute:把「要去哪」抽成可比較的值
enum AppRoute: Hashable, Sendable {
  case postDetail(id: Int, title: String, body: String)
  case userDetail(id: Int)
  case settings
}

// RouteView:把值映射回畫面
struct RouteView: View {
  let route: AppRoute
  var body: some View {
    switch route {
    case let .postDetail(id, title, body): PostDetailView(id: id, title: title, body: body)
    case let .userDetail(id):              UserDetailView(userId: id)
    case .settings:                        SettingsView()
    }
  }
}

對照一下檔案的增減:

MVVMRMVVMC性質
—(沒有功能各自的 router)*HostController.swift消滅
AppRouter.swiftAppRouter.swift替換(命令式 → 宣告式,一樣集中)
AppRoute / RouteView不存在新生
RootView.swiftSceneDelegate + UITabBarController 組裝新生
MVVMRDemoApp.swiftAppDelegate + SceneDelegate替換

🧱 純 SwiftUI 化不是完全免費

換執行層,M / VM 零修改。但有一處代價跟架構無關,純粹是「拿掉 UIKit」本身要付的,值得記住——它也是一條實用的純 SwiftUI 準則。

狀態驅動的 View,每個分支都必須回傳實體畫面。UserDetail 初始狀態這段:

1
2
3
4
5
6
default:                               // 初始 prepare 狀態,此時 user 還是 nil
  if let user = viewModel.state.user {
    UserInfoView(user: user)
  } else {
    ProgressView()                       // ← 這個 else 不能省
  }

如果這裡漏了 elseuser == 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 執行

不是「新的取代舊的」,是同一套架構、兩種底座,按需求選

兩者共用同一套 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 共同完成