SwiftUI Composable Architecture 實戰拆解:從真實 App 理解模組化架構設計

Published on: | Last updated:

嗯...今天要來聊聊 TCA。Composable Architecture

我知道,很多人應該都跑過去 Point-Free 的教學了,做了那個計數器啊、BMI 計算機什麼的... 對,那些都很棒,用來理解 Reducer、Store、Action 這些核心概念。但老實說,你一離開那個教學環境,想在一個...嗯...真正的專案上用 TCA,就會發現事情完全不是那麼回事。

真實的 App 是混亂的。它有一堆導航、多個步驟的流程、要存資料、還要處理一堆非同步的東西。它不是一個簡單的 BMI 計算機。所以...今天我們不從零開始蓋東西。我們反過來,來做個「解剖」。

我們來拆解一個已經上架的 App,叫做 Bako,是一個情緒追蹤器。看看它到底是怎麼用 TCA 來處理這些...嗯,這些真實世界會遇到的麻煩事。這會比再做一個計數器有用多了,我自己是這麼覺得。

重點一句話

先說結論:要在真實世界用 TCA,關鍵不在於 Reducer 寫得多漂亮,而在於你怎麼處理「導航」、「切分功能」和「依賴注入」這三件髒活,而這些都不是計數器教學會深入談的。

案例分析:Bako 這個 App 到底複雜在哪?

在我們深入程式碼之前,你得先知道我們在看的是什麼。Bako 這個 App 不是單純一個畫面。它的使用者流程...嗯,大概是長這樣:

一個新用戶進來,會先看到 Onboarding 引導畫面。看完之後,進到主畫面 Home,上面有最近的情緒紀錄。點了「新增」按鈕,就跳到 Tracker 畫面,問你今天過得如何。然後,你要先選是正向還是負向情緒(Category Selection),接著再從一堆情緒選項裡挑一個(Emotion Selection),再來,寫點東西、記錄地點活動什麼的(Form),最後看到一個成功畫面,然後... 咚,跳回主畫面。

你看,`Home → Tracker → Category → Emotion → Form → Success → Home`。這條路徑很長,每個箭頭都是一次畫面跳轉,每個畫面都是一個獨立的功能。這才是真實 App 的樣貌。這也是為什麼簡單的教學範例...說真的,參考價值有限。

Bako App 的核心使用者流程示意圖
Bako App 的核心使用者流程示意圖

拆解架構:TCA 專案的真實長相

好,我們來看它的專案結構。這我覺得是第一個重點。如果你看 Bako 的原始碼,你會發現它不是把所有檔案都丟在一起。它的資料夾分的很...嗯,很刻意。

Bako/
 ├── App/                     # 核心應用層,像大腦
 │   ├── AppReducer.swift     # 總司令 Reducer
 │   ├── AppView.swift        # 根 View
 │   └── AppDependencies.swift# 管理所有依賴
 ├── Features/                # 各個獨立功能,像樂高積木
 │   ├── Onboarding/
 │   ├── Home/
 │   ├── Tracker/
 │   └── ... (其他功能)
 └── Core/                    # 共用的東西
     ├── Common/
     └── Design/

看到了嗎?`Features` 這個資料夾。每個功能,比如 `Home`、`Tracker`,都有自己的一個小天地,裡面有自己的 `Reducer` 和 `View`。這就是所謂的 **Feature-based architecture**,基於功能的架構。

然後你看 `App` 資料夾,它就像是...嗯...協調者。它不處理具體「心情怎麼選」的邏輯,但它管著「使用者選完心情後,該跳去哪個畫面」。`Features` 之間是不直接溝通的,都由 `App` 這層來傳話。這種結構在 App 變大的時候,真的,真的能救你一命。

AppReducer:那個管超寬的總司令

我們來看看 `AppReducer.swift` 裡面的 `State` 長什麼樣子。這很重要。

@Reducer
 struct AppReducer {
     @ObservableState
     struct State: Equatable {
         var path = StackState<Route>()      // 導航的路徑都記在這裡
         var home: HomeReducer.State?        // Home 功能的狀態,是可選的
         var tracker: TrackerReducer.State?  // Tracker 功能的狀態,也是可選的
         var onboarding: OnboardingReducer.State // Onboarding,這個不是可選的
         // ... 其他功能的 state,幾乎都是 optional
     }
     // ... actions
 }

你有沒有注意到,裡面幾乎所有的 state 都是可選的 (`Optional`)... 也就是後面有個 `?`。只有 `onboarding` 不是。這是一個超級關鍵的模式。

為什麼要這樣?

因為使用者在 `Home` 畫面的時候,根本不需要載入 `Tracker` 或 `FormFeeling` 的狀態啊。這些功能都還沒出現,把它們的 state 建立起來完全是浪費記憶體。所以,TCA 的實踐方式是:只有當需要跳轉到那個功能時,才去建立它的 `State`。這就是所謂的 **On-demand features**,按需載入。這讓你的 App 狀態管理...乾淨很多。

AppReducer 作為狀態管理中心的概念圖
AppReducer 作為狀態管理中心的概念圖

怎麼做:那些教學沒細講的關鍵模式

好了,理論講完了。我們來看一些...嗯,比較硬核的操作。這些是我覺得 TCA 精華的地方。

導航:把它當成一個 State 來管理

SwiftUI 裡,你可能習慣用 `NavigationLink`。但在複雜的流程裡,`NavigationLink` 會變得很痛苦。你很難從程式碼去控制「跳回首頁」或「連續跳轉兩層」。

TCA 的解法是 `NavigationStackStore`。你看 `AppView.swift` 裡面:

struct AppView: View {
     let store: StoreOf<AppReducer>
 
     var body: some View {
         NavigationStackStore(
             store.scope(state: \.path, action: \.path)
         ) {
             // Root View: 可能是 HomeView 或 OnboardingView
         } destination: { route in
             // 根據 route 決定要顯示哪個 View
             switch route {
             case .tracker:
                 TrackerView(...)
             case .selectCategoryFeeling:
                 SelectCategoryFeelingView(...)
             // ... 其他 destination
             }
         }
     }
 }

它的核心思想是:**導航本身就是一種狀態 (`state`)**。前面 `AppReducer.State` 裡的那個 `path`,它是一個陣列。當你想跳到 Tracker 畫面時,你不是在 View 裡寫 `NavigationLink`,而是在 Reducer 裡做這件事:

// 在 AppReducer 裡
 case .home(.delegate(.routeToTrackerView)):
     state.tracker = TrackerReducer.State()    // 1. 建立 Tracker 的 state
     state.path.append(.tracker)               // 2. 把 .tracker 加到 path 陣列裡
     return .none

就這樣。`NavigationStackStore` 會偵測到 `path` 陣列多了一個東西,然後自動把 `TrackerView` 推出來。想回到上一頁?就把最後一個元素從 `path` 移除。想一次跳回首頁?就把 `path` 清空 (`state.path.removeAll()`)。一切都變成在操作一個陣列,非常...嗯...直觀且可預測。而且,超級好測試。

資料傳遞:明確的「餵食」而不是混亂的共享

以前在 SwiftUI,功能之間傳遞資料,可能會用 `@EnvironmentObject` 或是一長串的 `@Binding`。這在 App 小的時候還好,一大起來,你根本不知道資料是從哪來的,被誰改掉了。像一盤義大利麵,全纏在一起。

TCA 處理這個問題的方式是,子功能需要的資料,由父層在建立它 state 的時候,「餵」給它。我們看從「選分類」跳到「選情緒」的例子:

// 在 AppReducer 裡
 case .selectCategoryFeeling(.delegate(.routeToSelectFeeling)):
     // 1. 從 selectCategoryFeeling 的 state 裡拿出使用者選了哪個分類
     let category = state.selectCategoryFeeling?.selectedEmotionCategory
     let emotions = (category == .positive) ? positiveEmotions : negativeEmotions
     
     // 2. 建立 selectFeeling 的 state,把過濾好的情緒陣列传進去
     state.selectFeeling = SelectFeelingReducer.State(emotions: emotions)
     
     // 3. 執行跳轉
     state.path.append(.selectFeeling)
     return .none

你看,`SelectFeeling`這個功能,它不需要知道世界上所有的情緒。它被建立的時候,`AppReducer` 就已經幫它篩選好了,只把「正向」或「負向」的情緒陣列 (`emotions`) 傳給它。這種方式,資料流動非常清楚:單向、由上往下。除錯的時候,一目了然。

Side Effects:與 SwiftData 共舞的正確姿勢

App 總是要存東西的吧。Bako 用了 SwiftData。但你不會在 Bako 的 Reducer 裡看到 `context.insert(...)` 這種程式碼。這又是為什麼?

因為 Reducer 必須是「純函數」,它只負責根據 state 和 action 算出新的 state。存檔這種操作,是有「副作用」(Side Effect)的,它會改變 App 外部的世界(資料庫)。而且,它可能是非同步的。

TCA 的標準做法是透過 **Dependency Injection(依賴注入)**。它把 SwiftData 的功能包裝成一個叫 `SwiftDataClient` 的東西,然後「注入」到 Reducer 裡。

@Reducer
 struct FormFeelingReducer {
     @Dependency(\.swiftDataClient) var swiftDataClient // 1. 宣告依賴
 
     // ...
     case .saveEmotion:
         // ... 省略準備資料的程式碼
 
         // 2. 回傳一個 .run 的 Effect 來處理非同步和副作用
         return .run { [emotion] send in
             do {
                 try await MainActor.run {
                     let context = swiftDataClient.context()
                     context.insert(emotion)
                     try swiftDataClient.save()
                 }
                 await send(.emotionSaved) // 3. 成功後,發一個 action 通知 Reducer
             } catch {
                 // 4. 處理錯誤
             }
         }
 }

這個模式的好處是...嗯...為了測試。在寫單元測試的時候,你可以把這個 `swiftDataClient` 換成一個假的、存在記憶體裡的 `TestClient`。這樣你就能測試「按下儲存按鈕,邏輯是否正確」,而不需要真的去讀寫一個資料庫。這讓你的業務邏輯和外部依賴完全解耦。

這點跟我們在台灣...或說亞洲開發者社群看到的很不一樣。我看過很多人,即使學了 TCA,還是習慣把網路請求或資料庫操作直接寫在 Reducer 裡。 Point-Free Co. 的官方文件其實講得很清楚,副作用應該要透過 `.run` Effect 來處理,並且將外部依賴抽象化。但很多人可能覺得麻煩,就跳過了這一步...嗯,但這一步,說真的,才是 TCA 強大的地方。

常見模式比較:TCA 真的比較好嗎?

說了這麼多,TCA 的方式跟傳統 SwiftUI 開發到底差在哪?我整理了一下,你看完應該會更有感觉。

任務 傳統 SwiftUI 作法 TCA 作法
畫面跳轉 (Navigation) NavigationLink,狀態用 @State var isActive 控制。很難做複雜跳轉,程式碼不好控制。 操作 StackState 陣列。用 path.append(...) 推入畫面,path.removeAll() 回到首頁。完全由程式碼控制,可測試。
跨功能資料傳遞 @EnvironmentObject 或一長串的 @Binding。資料流混亂,像蜘蛛網,很難追蹤。 父 Reducer 在建立子 Reducer 的 State 時,透過 init 傳入。資料流是單向、明確的。
處理 Side Effects (e.g., API/DB) 直接在 View 的 .onTapGesture.onAppear 裡呼叫。UI 和邏輯混在一起,幾乎無法測試。 在 Reducer 中回傳 .run Effect。邏輯和副作用分離,透過 @Dependency 注入依賴,可輕易替換成 Mock 物件來測試。
複雜 UI 狀態 一堆 @State 變數散落在 View 裡。很難管理,也很難從外部重現某個特定 UI 狀態來除錯。 所有 UI 相關的狀態(e.g., 拖曳的座標、哪個項目被選中)都放在 Feature 的 State struct 裡。狀態集中管理,可預測。

老實說,沒有哪個是絕對的好或壞。TCA 的學習曲線...嗯,確實比較陡。但它換來的是長期的可維護性和可測試性。對於一個要活很久、功能會一直疊加的 App 來說,我自己是覺得,這筆投資是值得的。

情緒選擇器的互動狀態與 TCA State 的對應
情緒選擇器的互動狀態與 TCA State 的對應

常見錯誤與修正

最後,講幾個我剛開始學 TCA 時,還有看別人程式碼時,最常看到的幾個...嗯...誤區吧。

  • 把所有 State 都塞進 AppReducer:這是最常見的錯誤。把某個畫面裡,一個 checkbox 是否勾選的狀態也放到最上層的 `AppReducer`。千萬不要。State 應該盡可能地放在需要它的那個最小 `Feature` 裡。
  • Reducer 裡做非同步操作:就像前面說的,Reducer 必須是同步的、純粹的。任何需要時間、可能失敗的操作,都應該丟到 `.run` Effect 裡面去做。
  • Feature 之間直接引用:比如在 `HomeReducer` 裡,直接去存取 `TrackerReducer.State`。不行。Feature 之間應該是獨立的。如果 `Home` 需要 `Tracker` 的某個結果,應該透過 delegate action 的模式,讓 `Tracker` 通知父層 `AppReducer`,再由 `AppReducer` 去更新 `Home` 的 state。這有點繞,但這是為了保持模組化。
  • 過度使用 WithViewStore:在旧版的 TCA,`WithViewStore`很常用。但在 `@ObservableState` 出現後,很多地方其實可以直接用 `@Bindable var store` 就好,程式碼會乾淨很多。只有當你真的需要觀察 state 的一小部分,或是需要避免 view body 重複計算時,才需要用到 `WithViewStore(store, observe: ...)`。

大致上是這樣吧。拆解一個真實的 App...嗯,我覺得比看文件更能理解這些模式為什麼存在。它不是為了炫技,而是為了解決真實世界中那些...很煩人的問題。


那你呢?看完這個拆解,你覺得 TCA 裡哪個模式對你來說還是最...嗯...最難以下嚥的?是導航 state、依賴注入,還是 delegate action 的概念?在下面留言聊聊吧,也許你的問題也是很多人的問題。

Related to this topic:

Comments

  1. Guest 2025-11-12 Reply
    之前在歐洲的新創試過用TCA搞SwiftUI,其實一開始模組要怎麼切真的卡超久。腦袋裡一直轉,就是那種,欸這個東西到底要不要獨立拆出來還是綁一起?拆了又覺得好像會被自己打臉那種。 後面滿意外的是,團隊協作反而直接變快。大家分工分得很清楚,就算有些小地方兜不太上,至少溝通起來沒那麼痛苦。 你們有人也碰過什麼完全不知道要怎麼拆的module嗎?感覺大project超容易卡這關耶。
撥打專線 LINE免費通話