Joe's Blog

iOS 開發筆記

為什麼我在 SwiftUI 專案裡自創了一套 MVVMC 架構

發佈於 2026-04-24

在公司與外包共同開發,並在外包離開後接手留下來的 SwiftUI 專案。外包用的是 TCA(The Composable Architecture),target iOS 15+,架構本身有它的邏輯,但接手之後我做了第一個決定,不繼續用 TCA。

接著把 minimum deployment target 直升 iOS 17+。@Observable 可以用了,WithPerceptionTracking 全部可以掃掉,光這件事就讓整個 codebase 清爽不少,bug 也少了不少。

但還有一個問題一直卡著我:導航

SwiftUI 導航,用過就知道

NavigationStackNavigationLink.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 呢?你需要在對的時機、對的層級,同時操控 navigationPathselectedTab 這些分散在不同 View 的 state,還要確保時序正確,不然畫面會閃、動畫會跳、甚至 state 不同步。更麻煩的是,深層的 View 根本拿不到外層的 selectedTab,除非你用 @Environment@Binding 一層一層往下傳,然後每個中間層都變成了導航邏輯的搬運工。

這就是為什麼我決定把導航還給 UIKit 管。


直接用 UIHostingController 把導航還給 UIKit

想了一陣子,決定乾脆不跟 SwiftUI 導航硬碰硬。

UIKit 的導航我用了好幾年,UINavigationControllerpushpresent 這套熟悉又穩定。所以選擇用 UIHostingController 把 SwiftUI View 包起來,導航這件事完全交給 UIKit 管,SwiftUI 只負責畫面,不碰導航。

這個決定最後長出了一套我自己叫做 MVVMC 的架構。


MVVMC 是什麼?

名字拆開來看:

多的那個 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?因為用 enumswitch,新增 case 沒處理編譯器會直接報錯,比散落各處的 function 好追蹤多了。

View 層

View 分三層,用 private extension 嵌套:

這裡有一個細節值得注意,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 的工程品質沒話說。但我還是選擇不用,原因有幾個:

學習曲線很陡。 ReducerStoreEffectDependencyTestStore… 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 共同完成