所以,TCA 到底好在哪裡?
最近在看 The Composable Architecture,就是大家說的 TCA。很多人說它好,但到底好在哪?看理論有點... 抽象。State、Action、Reducer... 聽起來很厲害,但沒個對照組,感覺不太踏實。
我自己是覺得,學這種東西最好的方法,就是找個爛例子,然後一步步把它變好。這樣最能看出差別。
所以今天來試試看。我們從一個超級單純的 SwiftUI App 開始,一個 BMI 計算機。然後,把它慢慢重構成 TCA 的架構。這樣就能很清楚看到「改造前」跟「改造後」的樣子。
先說結論
簡單講,TCA 把原本混亂的「狀態管理」跟「業務邏輯」,全部收進一個叫 Reducer 的地方統一管理。你的 View 就真的只剩下畫面的事,變得很乾淨、很好維護,也好測試。
第一步:純 SwiftUI 的起點
先來做個最原始的版本。沒有什麼特別的架構,就是用 Xcode 內建的 SwiftUI 樣板來寫。這樣我們才知道我們要解決什麼問題。
這是一個 BMI 計算機,功能很簡單:
- 輸入身高、體重
- 即時算出 BMI 跟對應的等級
- 可以把紀錄存起來
- 可以查看歷史紀錄
首先是 Model,這部分很單純,就是定義 BMI 這個資料長什麼樣子。
import Foundation
struct BMI: Equatable, Identifiable {
let id = UUID()
let height: Double // cm
let weight: Double // kg
let date: Date
var value: Double {
let heightInMeters = height / 100
return weight / (heightInMeters * heightInMeters)
}
// 這邊的分類是參考 WHO 標準
var category: BMICategory {
switch value {
case ..<18.5:
return .underweight
case 18.5..<25:
return .normal
case 25..<30:
return .overweight
default:
return .obese
}
}
var formattedValue: String {
String(format: "%.1f", value)
}
}
enum BMICategory: String, CaseIterable {
case underweight = "過輕"
case normal = "正常"
case overweight = "過重"
case obese = "肥胖"
}
對了,這邊的 BMI 分級是用世界衛生組織 (WHO) 的標準。不過在台灣,我們國健署的建議有點不一樣喔,比如「正常」範圍是 18.5 到 24 之間,然後「過重」是 24 到 27。所以在做本地 App 的時候,這種小細節就要記得調整,這是使用者體驗的一環。
然後是 View,所有東西都塞在 `ContentView.swift` 裡。
import SwiftUI
struct ContentView: View {
@State private var height: String = ""
@State private var weight: String = ""
@State private var bmiHistory: [BMI] = []
@State private var showingHistory = false
// 業務邏輯:計算 BMI
var calculatedBMI: BMI? {
guard let heightValue = Double(height),
let weightValue = Double(weight),
heightValue > 0,
weightValue > 0 else {
return nil
}
return BMI(height: heightValue, weight: weightValue, date: Date())
}
var body: some View {
NavigationView {
Form {
// ... 省略一堆 UI layout code ...
// 顯示結果,這也算 UI 邏輯
Section {
if let bmi = calculatedBMI {
// ...
} else {
Text("輸入身高體重來計算")
}
}
// 按鈕跟互動邏輯
Section {
Button("儲存紀錄") {
if let bmi = calculatedBMI {
bmiHistory.append(bmi) // 直接修改狀態
showingHistory = true // 直接修改狀態
}
}
.disabled(calculatedBMI == nil) // 狀態決定 UI
// ...
}
}
.navigationTitle("BMI 計算機")
.sheet(isPresented: $showingHistory) {
BMIHistoryView(bmiHistory: $bmiHistory) // 把狀態傳下去
}
}
}
}
// 另一個 View,接收來自上層的 Binding
struct BMIHistoryView: View {
@Binding var bmiHistory: [BMI]
// ...
}
好了,這段 code 能跑,功能也正常。但問題很明顯...
你看,`ContentView` 裡面有 `@State` 變數、有計算 BMI 的 `calculatedBMI`、有 UI layout、還有按鈕按下去之後要幹嘛的邏輯... 全部混在一起。
這就是所謂的「關注點沒有分離」。現在 App 還小,感覺還好。但功能一多,這個檔案會變得超級大,然後你改一個小東西,很可能就動到其他不相干的邏輯,超可怕。
而且,你怎麼測 `calculatedBMI` 這段邏輯?幾乎沒辦法... 總不能為了測這個,就跑一個 UI 測試吧?太重了。
第二步:用 TCA 來重構
好,現在開始手術。目標是把上面那個混亂的 `ContentView` 整理乾淨。
首先,當然是要把 TCA 的 package 加到專案裡。這部分就去 Xcode 裡 `File > Add Package Dependencies`,然後貼上 TCA 的 GitHub 網址 `https://github.com/pointfreeco/swift-composable-architecture` 就行了。
拆解 Feature
TCA 的核心思想是把 App 拆成一個個獨立的「Feature」。我們這個 App 可以拆成兩個:
- 輸入 Feature:就是計算 BMI 的主畫面。
- 歷史 Feature:顯示歷史紀錄的列表。
我們先專心處理「輸入 Feature」。
1. 定義 State、Action、Reducer
TCA 的標準起手式,就是定義這三樣東西。我會把它們放在同一個檔案 `BMIInputReducer.swift` 裡,這樣比較好管理。
import ComposableArchitecture
import Foundation
@Reducer
struct BMIInputReducer {
// MARK: - State
@ObservableState
struct State: Equatable {
var height: String = ""
var weight: String = ""
// 衍生狀態 (Computed Properties)
var calculatedBMI: BMI? {
guard let heightValue = Double(height),
let weightValue = Double(weight),
heightValue > 0,
weightValue > 0 else {
return nil
}
return BMI(height: heightValue, weight: weightValue, date: Date())
}
}
// MARK: - Action
enum Action {
case heightChanged(String)
case weightChanged(String)
case saveButtonTapped
}
// MARK: - Reducer
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .heightChanged(height):
state.height = height
return .none
case let .weightChanged(weight):
state.weight = weight
return .none
case .saveButtonTapped:
// 這邊先留空,之後處理儲存的副作用
guard let bmi = state.calculatedBMI else { return .none }
print("準備儲存 BMI: \(bmi.formattedValue)")
return .none
}
}
}
}
你看,發生了什麼事?
- State:原本 View 裡那些 `@State` 變數,還有那個 `calculatedBMI`,全部都搬到 `State` 這個 struct 裡面了。這就是這個 Feature 所有需要的資料。
- Action:所有可能發生的事情,都定義成 `Action`。使用者輸入身高,就是一個 `heightChanged` action;按下儲存,就是 `saveButtonTapped` action。非常語義化。
- Reducer:這就是大腦。它只做一件事:接收目前的 `state` 和一個 `action`,然後告訴系統 `state` 應該怎麼改變。它是一個純函數,沒有副作用,這讓它變得超好測試。
2. 改造 View
邏輯都搬走了,那 View 就變得很單純。它只需要做兩件事:顯示 `State` 提供的資料,以及在使用者操作時發送 `Action`。
import SwiftUI
import ComposableArchitecture
struct BMIInputView: View {
@Bindable var store: StoreOf<BMIInputReducer>
var body: some View {
Form {
Section("輸入你的資料") {
HStack {
Text("身高")
Spacer()
TextField("170", text: $store.height.sending(\.heightChanged))
.keyboardType(.decimalPad)
// ...
Text("cm")
}
HStack {
Text("體重")
Spacer()
TextField("70", text: $store.weight.sending(\.weightChanged))
.keyboardType(.decimalPad)
// ...
Text("kg")
}
}
Section("計算結果") {
if let bmi = store.calculatedBMI {
// ... 顯示 BMI 結果 ...
} else {
Text("請輸入身高與體重")
}
}
Section {
Button("儲存紀錄") {
store.send(.saveButtonTapped)
}
.disabled(store.calculatedBMI == nil)
}
}
.navigationTitle("BMI 計算機")
}
}
乾淨很多吧。`TextField` 的 `text` 綁定 `$store.height.sending(\.heightChanged)`,這行程式碼的意思是:把輸入框的文字跟 `store` 裡的 `height` 雙向綁定,而且只要文字一變,就自動發送一個 `.heightChanged` action 給 Reducer。完全不用自己寫 `.onChange`。
按鈕也是,`action` 裡面就只有一行 `store.send(.saveButtonTapped)`,就是「通知大腦,使用者按了儲存鈕」,至於後續要做什麼,View 完全不管。
第三步:處理副作用 (Dependencies)
剛剛 `saveButtonTapped` 的地方我們留空了。因為「儲存」這件事,它不是單純改變 `state`,它要去跟外部系統溝通(可能是 `UserDefaults`、Core Data 或網路 API)。這種事在 TCA 裡叫做「副作用」(Side Effect),要用 `Dependency` 來處理。
簡單說,就是定義一個「Client」,把所有存取歷史紀錄的動作都包在裡面。
import Foundation
import Dependencies
// 定義一個抽象的 client,包含我們需要的功能
struct BMIHistoryClient {
var loadHistory: @Sendable () -> [BMI]
var saveBMI: @Sendable (BMI) -> Void
}
// 實作一個 "Live" 版本,也就是 App 正式執行時要用的版本
// 這裡為了簡單,先用一個記憶體內的陣列來存,實際上可以換成任何資料庫
extension BMIHistoryClient: DependencyKey {
static let liveValue = Self(
loadHistory: { InMemoryBMIStorage.shared.loadHistory() },
saveBMI: { bmi in InMemoryBMIStorage.shared.saveBMI(bmi) }
)
}
// 把它註冊到 TCA 的 DependencyValues 系統裡
extension DependencyValues {
var bmiHistoryClient: BMIHistoryClient {
get { self[BMIHistoryClient.self] }
set { self[BMIHistoryClient.self] = newValue }
}
}
// 一個簡單的單例來模擬資料庫
private class InMemoryBMIStorage {
static let shared = InMemoryBMIStorage()
private var history: [BMI] = []
func loadHistory() -> [BMI] { history }
func saveBMI(_ bmi: BMI) { history.append(bmi) }
}
這樣做的好處是,Reducer 不用知道資料到底存在哪。在正式 App,它用 `liveValue` 存到記憶體;在跑測試的時候,我們可以注入一個 `testValue`,讓它存到一個我們指定的陣列裡,方便驗證結果。這就是「依賴注入」。
然後在 Reducer 裡把它叫出來用:
@Reducer
struct BMIInputReducer {
// ... State 跟 Action ...
@Dependency(\.bmiHistoryClient) var bmiHistoryClient // 宣告依賴
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
// ... 其他 case ...
case .saveButtonTapped:
guard let bmi = state.calculatedBMI else { return .none }
// 使用 client 來執行副作用
bmiHistoryClient.saveBMI(bmi)
return .none
}
}
}
}
這樣一來,Reducer 還是保持純粹,它只是「呼叫」一個外部服務,本身沒有做 I/O 操作。
第四步:組合 Features
我們現在有了一個獨立的 `BMIInput` feature。同理,我們可以再做一個 `BMIHistory` feature,它有自己的 State(歷史紀錄陣列)、Action(`onAppear` 讀取資料、`delete` 刪除資料)和 Reducer。
最後,需要一個「老大哥」把這兩個 feature 組起來。這通常叫做 `AppReducer`。
@Reducer
struct AppReducer {
// 老大哥的 State 包含所有小弟的 State
@ObservableState
struct State: Equatable {
var selectedTab: Tab = .input
var bmiInput = BMIInputReducer.State()
var bmiHistory = BMIHistoryReducer.State()
}
// 老大哥的 Action 也包含所有小弟的 Action
enum Action {
case tabSelected(Tab)
case bmiInput(BMIInputReducer.Action)
case bmiHistory(BMIHistoryReducer.Action)
}
var body: some ReducerOf<Self> {
// 用 Scope 把小弟的 State 和 Action 對應起來
Scope(state: \.bmiInput, action: \.bmiInput) {
BMIInputReducer()
}
Scope(state: \.bmiHistory, action: \.bmiHistory) {
BMIHistoryReducer()
}
Reduce { state, action in
// 處理只有老大哥關心的事:跨 feature 的溝通
switch action {
case .bmiInput(.saveButtonTapped):
// 當「輸入」feature 說它存檔了...
// 我們就命令 App 切換到「歷史」分頁
state.selectedTab = .history
// 並且順便叫「歷史」feature 重新整理一下資料
return .send(.bmiHistory(.onAppear))
case let .tabSelected(tab):
state.selectedTab = tab
return .none
// 其他小弟自己的事,就不用管了
case .bmiInput, .bmiHistory:
return .none
}
}
}
}
這就是 TCA 最強大的地方之一:`Scope`。它像一個轉接頭,把來自 `AppView` 的 action (`.bmiInput(...)`),正確地轉發給 `BMIInputReducer` 處理。`BMIInputReducer` 完全不知道自己是活在一個 TabView 裡面,它可以被重複用在任何地方。
然後 `AppReducer` 自己還可以處理一些跨 feature 的溝通。像是上面那段,當 `BMIInputReducer` 發出 `.saveButtonTapped` 這個 action,`AppReducer` 攔截到之後,就去改變 `selectedTab`,實現了「存檔後自動跳轉到歷史頁面」的功能。而且這個跳轉邏輯寫得非常清楚。
所以,到底差在哪?
說了這麼多,我們來直接比較一下,這樣最有感。
| 比較項目 | 純 SwiftUI (@State) | Composable Architecture (TCA) |
|---|---|---|
| 狀態管理 | 用 @State,變數散落在 View 裡面,東一個西一個,很亂。 |
全部集中在 State struct 裡,像個專屬的資料庫,一目瞭然。 |
| 業務邏輯 | 直接寫在 View 的 computed property 或 function 裡,跟畫面程式碼混在一起。 | 全部關在 Reducer 裡面,是一個獨立的純函數,跟畫面完全分開。 |
| 可測試性 | 很難測。邏輯跟 UI 綁死,總不能為了測一個計算,就去跑 UI 測試吧... | Reducer 超級好測。就給它一個 state 跟一個 action,看它回傳的新 state 對不對就好。 |
| 資料流 | 有點像蜘蛛網。狀態從哪來、被誰改掉,常常要追半天。 | 非常清楚的單向資料流。Action → Reducer → State → View,一個圈圈,很好追蹤。 |
| 功能擴充 | 有點怕。加一個新功能,常常要動到舊的 View,深怕改壞什麼。 | 比較放心。新功能可以做成獨立的 Feature,再用 Scope 組起來,影響範圍很小。 |
| 副作用 | 直接在 View 的 action closure 裡呼叫 API 或存資料庫,測試時很麻煩。 | 用 @Dependency 抽離出來,正式跑用 live 版,測試時可以換成 mock 版。 |
一些常見的疑惑
我知道,剛看到 TCA 的 code 會覺得...「蛤?寫一個簡單功能要多這麼多檔案跟程式碼?」。
這很正常。我自己一開始也這樣想。
TCA 的確增加了不少「樣板程式碼」(Boilerplate)。對於一個只有一兩個畫面的超小型 App,用 TCA 可能真的有點殺雞用牛刀。你可能會覺得純 SwiftUI 還比較快。
但只要你的 App 開始變複雜,比如有五個以上的分頁、需要登入、有複雜的表單、需要跟後端 API 大量溝通... TCA 的優勢就會完全展現出來。它前期的「投資」會在後期維護和擴充功能時,加倍奉還給你。
它強迫你用一種有紀律、可預測的方式去寫 code。長期來看,這會讓你的專案健康很多。
換你思考看看:
你覺得這種架構,對於小型、可能不會再擴充的專案來說,是加分還是有點殺雞用牛刀?在下面留言分享你的看法吧!
