嗯...今天要來聊聊 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 的樣貌。這也是為什麼簡單的教學範例...說真的,參考價值有限。
拆解架構: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 狀態管理...乾淨很多。
怎麼做:那些教學沒細講的關鍵模式
好了,理論講完了。我們來看一些...嗯,比較硬核的操作。這些是我覺得 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 都塞進 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 的概念?在下面留言聊聊吧,也許你的問題也是很多人的問題。
