在公司與外包共同開發,並在外包離開後接手留下來的 SwiftUI 專案。外包用的是 TCA(The Composable Architecture),target iOS 15+,架構本身有它的邏輯,但接手之後我做了第一個決定,不繼續用 TCA。
接著把 minimum deployment target 直升 iOS 17+。@Observable 可以用了,WithPerceptionTracking 全部可以掃掉,光這件事就讓整個 codebase 清爽不少,bug 也少了不少。
但還有一個問題一直卡著我:導航。
SwiftUI 導航,用過就知道
NavigationStack、NavigationLink、.navigationDestination,光看 API 名稱感覺很整齊,用下去才知道有多零碎。
頁面跳轉的邏輯開始出現在各個 View 裡,A 頁知道 B 頁的存在,B 頁知道 C 頁的存在。專案小的時候還好,Feature 一多,導航邏輯就像藤蔓一樣纏進每個 View,要改一個跳轉邏輯得找半天。
更麻煩的是 state 管理,navigationPath、@State var isPresented、.sheet、.fullScreenCover 各自為政,散落在不同層級的 View 裡,沒有一個統一的地方可以看清楚整個 app 的導航狀態。
然後你就會遇到這種需求:
「使用者在購物車結帳完成後,dismiss 掉結帳流程,然後自動切換到『訂單』Tab,同時把購物車的 Navigation stack 清空。」
UIKit 的做法很直覺:
1
2
3
4
| // 清空 stack、切 Tab,三行搞定
navigationController?.popToRootViewController(animated: false)
tabBarController?.selectedIndex = 2
dismiss(animated: true)
|
SwiftUI 呢?你需要在對的時機、對的層級,同時操控 navigationPath、selectedTab 這些分散在不同 View 的 state,還要確保時序正確,不然畫面會閃、動畫會跳、甚至 state 不同步。更麻煩的是,深層的 View 根本拿不到外層的 selectedTab,除非你用 @Environment 或 @Binding 一層一層往下傳,然後每個中間層都變成了導航邏輯的搬運工。
這就是為什麼我決定把導航還給 UIKit 管。
直接用 UIHostingController 把導航還給 UIKit
想了一陣子,決定乾脆不跟 SwiftUI 導航硬碰硬。
UIKit 的導航我用了好幾年,UINavigationController、push、present 這套熟悉又穩定。所以選擇用 UIHostingController 把 SwiftUI View 包起來,導航這件事完全交給 UIKit 管,SwiftUI 只負責畫面,不碰導航。
這個決定最後長出了一套我自己叫做 MVVMC 的架構。
MVVMC 是什麼?
名字拆開來看:
- Model — 資料層,包含 State、Domain Model、DTO
- View — SwiftUI 畫面,只管顯示
- ViewModel — 邏輯層,管狀態與 API
- Controller —
UIHostingController,管導航
多的那個 C 就是這套架構最核心的特色。
四層各自的職責
Model 層
每個 Feature 有一個 FeatureViewModel+Models.swift,裡面放三個區塊:
// MARK: - State ← 頁面狀態,View 唯一的資料來源
// MARK: - Domain Models ← 業務資料結構
// MARK: - DTOs ← API 原始回傳結構
State 只能放 Domain Model,DTO 不能往上流。DTO 保留 API 原始的 snake_case key,方便跟後端溝通時直接對 key 講話,不用翻譯。
ViewModel 層
所有操作走唯一進入點 doAction(_:):
1
2
3
4
5
6
7
8
9
10
11
12
13
| @Observable
@MainActor
final class FeatureViewModel {
var state: State = .init()
func doAction(_ action: Action) async {
switch action {
case .view(let action): await handleViewAction(action)
case .apiRequest(let action): await handleAPIRequest(action)
// ...
}
}
}
|
為什麼強制走 doAction?因為用 enum 配 switch,新增 case 沒處理編譯器會直接報錯,比散落各處的 function 好追蹤多了。
View 層
View 分三層,用 private extension 嵌套:
- L1:頁面根視圖,reference ViewModel
- L2:
XxxSection,負責區塊組合 - L3:
XxxRow、XxxInfo 等,最小單元
這裡有一個細節值得注意,L1 的 ViewModel 是 let,不是 @State:
1
2
3
4
| struct ProductListView: View {
let viewModel: ProductListViewModel // ✅ let,由 HostController 傳入
// @State var viewModel = ProductListViewModel() // ❌ View 不自己建立
}
|
@State 代表 View 自己建立並擁有 ViewModel,但在這套架構裡,ViewModel 的生命週期由 HostController 管,HostController 建立 ViewModel、持有它、並透過 init 注入給 View。View 只是 reference,不是 owner。
View 只讀 State,只往上丟 Action,不做任何邏輯判斷。
HostController 層
這是整套架構最有趣的地方。
1
2
3
4
5
6
7
8
9
| @MainActor
final class FeatureHostController: UIHostingController<FeatureView> {
let viewModel: FeatureViewModel
init(viewModel: FeatureViewModel) {
self.viewModel = viewModel
super.init(rootView: FeatureView(viewModel: viewModel))
}
}
|
ViewModel 由 HostController 持有,導航邏輯集中在 handleSelfRouter(_:),SwiftUI View 完全不知道導航的存在。換頁是 UIKit 的事,SwiftUI 只管畫面。
實際範例:ProductList
光說不練沒說服力,來看一個完整的 ProductList 範例,包含載入列表、點擊跳轉 Detail、以及收藏 / 取消收藏。
Model 層
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| // MARK: - State
extension ProductListViewModel {
struct State: Sendable {
var products: [Product] = []
var isLoading: Bool = false
var errorMessage: String? = nil
}
}
// MARK: - Domain Models
extension ProductListViewModel {
struct Product: Identifiable, Sendable {
let id: String
let name: String
let price: Double
let imageURL: String
var isFavorited: Bool
}
}
// MARK: - DTOs
extension ProductListViewModel {
struct ProductListDTO: Sendable, Codable {
var products: [ProductDTO]
}
struct ProductDTO: Sendable, Codable {
var product_id: String
var product_name: String
var price: Double
var image_url: String
var is_favorited: Bool
}
}
|
State 只放 Domain Model,DTO 保留 API 原始 key,兩者嚴格分開。
ViewModel 層
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
| @Observable
@MainActor
final class ProductListViewModel {
enum Action: Sendable {
case view(ViewAction)
case router(Router)
case apiRequest(APIRequest)
case apiResponse(APIResponse)
}
var state: State = .init()
@ObservationIgnored
var onAction: (@MainActor (Action) -> Void)?
func doAction(_ action: Action) async {
switch action {
case let .view(action): await handleViewAction(action)
case let .router(router): onAction?(.router(router))
case let .apiRequest(request): await handleAPIRequest(request)
case let .apiResponse(response): await handleAPIResponse(response)
}
}
}
extension ProductListViewModel {
enum ViewAction: Sendable {
case onAppear
case productDidTap(Product)
case favoriteDidTap(Product)
}
private func handleViewAction(_ action: ViewAction) async {
switch action {
case .onAppear:
await doAction(.apiRequest(.fetchProducts))
case let .productDidTap(product):
await doAction(.router(.toDetail(product)))
case let .favoriteDidTap(product):
await doAction(.apiRequest(.toggleFavorite(product)))
}
}
}
extension ProductListViewModel {
enum Router: Sendable {
case toDetail(Product)
}
}
extension ProductListViewModel {
enum APIRequest: Sendable {
case fetchProducts
case toggleFavorite(Product)
}
private func handleAPIRequest(_ request: APIRequest) async {
switch request {
case .fetchProducts:
await handleFetchProducts()
case let .toggleFavorite(product):
await handleToggleFavorite(product)
}
}
private func handleFetchProducts() async {
state.isLoading = true
defer { state.isLoading = false }
do {
let dto: ProductListDTO = try await API.shared.request(.getProducts)
await doAction(.apiResponse(.fetchProductsSuccess(dto)))
} catch {
state.errorMessage = error.localizedDescription
}
}
private func handleToggleFavorite(_ product: Product) async {
do {
let isFavorited = !product.isFavorited
try await API.shared.request(.toggleFavorite(product.id, isFavorited))
await doAction(.apiResponse(.toggleFavoriteSuccess(product.id, isFavorited)))
} catch {
state.errorMessage = error.localizedDescription
}
}
}
extension ProductListViewModel {
enum APIResponse: Sendable {
case fetchProductsSuccess(ProductListDTO)
case toggleFavoriteSuccess(String, Bool)
}
private func handleAPIResponse(_ response: APIResponse) async {
switch response {
case let .fetchProductsSuccess(dto):
state.products = dto.products.map {
Product(
id: $0.product_id,
name: $0.product_name,
price: $0.price,
imageURL: $0.image_url,
isFavorited: $0.is_favorited
)
}
case let .toggleFavoriteSuccess(id, isFavorited):
guard let index = state.products.firstIndex(where: { $0.id == id }) else { return }
state.products[index].isFavorited = isFavorited
}
}
}
|
Action 分層之後,光看 enum case 名稱就能知道資料流怎麼走,不用進去看實作。
View 層
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
| // L1
struct ProductListView: View {
let viewModel: ProductListViewModel
var body: some View {
ListSection(
products: viewModel.state.products,
isLoading: viewModel.state.isLoading
) { action in
switch action {
case let .productDidTap(product):
Task { await viewModel.doAction(.view(.productDidTap(product))) }
case let .favoriteDidTap(product):
Task { await viewModel.doAction(.view(.favoriteDidTap(product))) }
}
}
.task { await viewModel.doAction(.view(.onAppear)) }
}
}
// L2 / L3
private extension ProductListView {
enum List {
enum Action {
case productDidTap(ProductListViewModel.Product)
case favoriteDidTap(ProductListViewModel.Product)
}
}
struct ListSection: View {
let products: [ProductListViewModel.Product]
let isLoading: Bool
let send: (List.Action) -> Void
var body: some View {
if isLoading {
ProgressView()
} else {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(products) { product in
ListRow(product: product) { action in
switch action {
case .didTap: send(.productDidTap(product))
case .favoriteTap: send(.favoriteDidTap(product))
}
}
}
}
.padding(.horizontal, 16)
}
}
}
}
enum ListItem {
enum Action {
case didTap
case favoriteTap
}
}
struct ListRow: View {
let product: ProductListViewModel.Product
let send: (ListItem.Action) -> Void
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(product.name)
.font(.headline)
Text("$\(product.price, specifier: "%.0f")")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Button {
send(.favoriteTap)
} label: {
Image(systemName: product.isFavorited ? "heart.fill" : "heart")
.foregroundStyle(product.isFavorited ? .red : .gray)
}
}
.padding(16)
.background(.background)
.clipShape(RoundedRectangle(cornerRadius: 12))
.onTapGesture { send(.didTap) }
}
}
}
|
View 完全不知道有 API、不知道有導航,只管顯示和往上丟 Action。
HostController 層
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| @MainActor
final class ProductListHostController: UIHostingController<ProductListView> {
// MARK: - ViewModel
let viewModel: ProductListViewModel
// MARK: - Init
init() {
let viewModel = ProductListViewModel()
self.viewModel = viewModel
let view = ProductListView(viewModel: viewModel)
super.init(rootView: view)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: - Lifecycle
extension ProductListHostController {
override func viewDidLoad() {
super.viewDidLoad()
listenSelfAction()
}
}
// MARK: - Router
private extension ProductListHostController {
func listenSelfAction() {
viewModel.onAction = { [weak self] action in
switch action {
case let .router(router):
self?.handleSelfRouter(router)
case .view, .apiRequest, .apiResponse:
break
}
}
}
func handleSelfRouter(_ router: ProductListViewModel.Router) {
switch router {
case let .toDetail(product):
let detail = ProductDetailHostController(product: product)
navigationController?.pushViewController(detail, animated: true)
}
}
}
|
導航邏輯全在這裡,ProductListView 完全不知道 ProductDetailHostController 的存在。
那為什麼不繼續用 TCA?
這個問題我在接手的時候就想清楚了。
TCA 是一套設計嚴謹的架構,Point-Free 的工程品質沒話說。但我還是選擇不用,原因有幾個:
學習曲線很陡。 Reducer、Store、Effect、Dependency、TestStore… TCA 有一整套自己的概念體系,新人加入團隊需要花不少時間才能上手。對一個要快速迭代的產品團隊來說,這個成本不小。
過度工程的風險。 TCA 鼓勵把所有 side effect 都放進 Effect,所有狀態變更都走 Reducer。這在大型、需要高度可測試性的專案很合適,但在一般規模的 app 裡,有時候會覺得為了寫一個簡單功能,需要搭很多鷹架。
版本升級有破壞性變更。 TCA 這幾年迭代很快,每次大版本升級幾乎都有 breaking change,跟著升上去需要不少改動成本。
更深的問題:架構主導權
但最核心的一點是,我不想讓公司的產品整個 base on 一個第三方套件的架構上。
套件可以用,但架構的主導權應該在自己手上。如果哪天 TCA 停止維護、或者方向跟我們的需求開始分歧,整個 codebase 要怎麼辦?自己設計的架構,修改、擴充、甚至整個重構,決定權都在自己。這種掌控感對我來說比什麼都重要。
Objective-C 時代也有一些很優秀的第三方架構,ReactiveCocoa 就是一個例子,當年整個 app 的事件流都 base 上去,Reactive 的概念很先進,用起來也很香。後來 Swift 出世,Apple 直接推出 Combine 把這個概念內建進來,ReactiveCocoa 就漸漸沒落。當時整個 codebase 都跟它綁死的團隊,要嗎硬撐著用一個沒落的套件,要嗎大改,沒有好選擇。
TCA 今天很活躍,但沒有人能保證五年後還是如此。原生的東西,Apple 會幫你維護。
還有一個現實問題,我常看到使用第三方套件的團隊,SPM 指定完版本之後就再也沒動過。套件有 breaking change、有安全性更新、甚至已經出了更好的 API,全都不知道。架構 base 在第三方上,技術債其實從第一天就開始累積了,只是你看不到。
所以我選擇從零開始設計,借鑒 TCA 和 MVI 的好概念,但用原生 Swift 實作,這就是 MVVMC 的由來。
本文使用 Claude 共同完成