
New Zealand — Deer Park Heights Queenstown
這篇是閱讀了喵神的文章 TCA - SwiftUI 的救星?,這一系列喵神分成了四篇來講解,每篇都有許多重點,而且也是從易到難,很好閱讀,以下是個人的學習筆記,內容幾乎原文搬過來的,但也有是學習過程中額外補充的,因為有語法的更新。 TCA 的核心思想還是大同小異,所以建議可以先閱讀原文,假如遇到語法不支援之類的問題,在參考這邊。
因為篇幅的關係,分成上下兩集,下集在此
以下程式碼語法已更新至 TCA v1.20.2
附上完整 demo,使用 Xcode 16.4
第一章
前言
從2019年發表 SwiftUI 至今,也要6年的歷史了,Apple 發表至今沒有給他特定的架構,例如以前的 UIKit 有 MVC 架構。
官方很多教學,有資料傳遞,也有狀態管理,但似乎也不夠指導我們寫出穩定的 app。
在 SwiftUI 中做到了 single source of truth: 所有的 View 都是由狀態變化出來的,但是也有一些問題,例如:
- 在使用時數據管理是個人覺得學習門檻比較高的地方,因為有各種狀態的屬性包裝器(Property Wrapper)要先搞懂,像是
@State, @Binding, @StateObject, @ObservedObject, @EnvironmentObject
之類的,假如沒有先搞清楚他們的特性,很多時候是不知道如何正確使用。 - 很多修改狀態的程式碼室內檻在
View.body
內,或者只能在body
中和其他 View 的程式碼混雜再一起。而且同一個狀態可能會被多個不相關的 View 直接修改(例如:@Binding
),這些修改難以追蹤定位,在 app 很複雜的情況下簡直是噩夢。 - 測試困難,這點有一點反直覺,因為 SwiftUI 框架的 View 是由狀態決定的,所以理論上我們只需要測試狀態(也就是 Model 層),這應該是很容易的事,但是照著 Apple 官方教學來看,app 中會有許多 private 狀態,這些難以 Mock,而且就算可以,如何測試對這些狀態修改也是問題。
簡單地克服方法當然也有,把各種狀態的屬性包裝器完全搞懂,盡可能減少共享可變狀態來避免被意外修改,或者按照 Apple 的推薦準備一組 preview 的數據,然後打開 View 文件去挨個檢查 preview 的結果,但是還是有些自動化的工具可以協助
但結論就是我們需要一種架構,讓使用 SwiftUI 更容易輕鬆。
從 Elm 啟發
Elm 架構 ( The Elm Architecture, TEA)

- 在 View 上做某個操作(例如點擊某個按鈕),這時會以消息的方式傳送。在 Elm 中的某種機制將會捕獲到這消息。
- 偵測到新消息來時,它會和當前
Model
一起作為輸入傳遞給update
函數。這個函數通常是開發者需要花費時間最多的部分,控制 app 狀態的變化。是 Elm 架構的核心,需要根據輸入的消息和狀態,演算出新的Model
。 - 這個新的
Model
將替換原有的Model
,並準備在下一個msg
到來時,再次重複上面的過程,去捕獲新的狀態。 - Elm 執行時負責在得到新
Model
後呼叫View
函數,渲染出結果。用戶可以透過他再次發送新的消息,重複上面的循環。
目前對 TEA 有基本的了解,我們回頭看一下 SwiftUI 中的實現,就像步驟4一樣:當 @State
或 @ObservedObject
的 @Published
發生變化時,SwiftUI 會自動呼叫 View.body
為我們渲染新畫面。
簡單範例
一個很簡單的範例,功能就是有一個數字,可以分別用加和減按鈕控制,使用 SwiftUI + TCA 做法如下:
// logic
@Reducer
struct Counter {
@Dependency(\.counterEnvironment) var environment
@ObservableState
struct State: Equatable {
var count: Int = 0
}
enum Action {
case increment
case decrement
}
struct Environment {}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .decrement:
state.count -= 1
case .increment:
state.count += 1
}
return .none
}
}
}
extension DependencyValues {
var counterEnvironment: Counter.Environment {
get { self[Counter.Environment.self] }
set { self[Counter.Environment.self] = newValue }
}
}
// view
struct CounterView: View {
@Bindable var store: StoreOf<Counter>
var body: some View {
HStack {
Button("-") { store.send(.decrement) }
Text("\(store.count)")
Button("+") { store.send(.increment) }
}
}
}
#Preview {
CounterView(
store: Store(initialState: Counter.State()) {
Counter()
}
)
}
我們先看最核心的 Reducer:
- 發送訊息,而非直接改變狀態: 任何操作,都向
store
發送Action
,而Action
適合用 enum 定義。 - 只在 Reducer 中改變狀態:
Reducer
是邏輯核心,也是 TCA 中最靈活的地方,所以狀態的改變,應在Reducer
中完成,他的初始化是長這樣:
public func reduce(
into state: inout Body.State, action: Body.Action
) -> Effect<Body.Action>
inout
的 State
讓我們可以「原地」對 state
進行變更,而不需要明確地返回它。這個函數的回傳值是一個 Effect
,它代表不應該在 Reducer 中的副作用,例如 API 請求,取得當前時間等。
但記得在非同步的事件,例如 API 請求,我們通常會寫 .run { send in ... }
,不要在這裡面直接改變狀態,應該要由一個 Action 來改變,所以通常 call API 後的結果處理會有兩個 Action ( Success 和 Failure)。
- 更新狀態並觸發渲染: 在 Reducer 閉包中改變狀態是合法的,新的狀態將被 TCA 用來觸發 view 的渲染,保存下來等待下一次 Action 到來。
Reducer 的核心是純函式特性
- Action 一般來說就像 User 的某個操作,例如點擊按鈕
- Environment 提供依賴解決了 reducer 輸入階段的副作用(比如 reducer 需要獲取某個
Date
等),這很關鍵,Dependency injection (依賴注入)都從這來做。 - Effect 解決的則是 reducer 輸出階段的副作用,如果在 Reducer 接收到某個行為之後,需要做出非狀態變化的反應,比如發送一個網路請求、向硬碟寫一些數據,或是監聽某個通知等,都需要透過 Effect 進行。
- Effect 定義了需要在純函數外執行的程式碼,以及處理結果的方式:一般來說這個執行過程會是一個耗時的行為,行為的結果通過
Action
的方式在未來某個時間再次觸發 reducer 並更新最終狀態。 - TCA 在運行 reducer 的程式碼,並獲取到返回的
Effect
後,負責執行它所定義的程式碼,然後按照需要發送新的Action
。
Debug & Test
在 TCA 中有非常方便的 debug()
方法,打印出接收到 Action 以及其中 State 的變化
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
...
}
}
._printChanges()
}

而且他只有在 #if DEBUG
的編譯條件下打印
import ComposableArchitecture
import Foundation
import Testing
@MainActor
struct CounterTests {
@Test
func increment() async throws {
let store = TestStore(initialState: Counter.State()) {
Counter()
}
await store.send(.increment) {
$0.count = 1
}
}
}
假設出錯的話,故意寫一個錯誤的測試
@Test
func increment() async throws {
await store.send(.increment) {
$0.count = 2
}
}

也可以很好 debug,一目瞭然
另外官方建議測試的 store 都是各自獨立,不要寫成共享的 gloabl 變數
@MainActor
struct FeatureTests {
// 👎 Don't do this:
- let store = TestStore(initialState: Feature.State()) {
- Feature()
- }
@Test
func basics() async {
// 👍 Do this:
+ let store = TestStore(initialState: Feature.State()) {
+ Feature()
+ }
// ...
}
}
原因:
- 這樣可以確保每個測試都有乾淨的初始狀態,避免受其他測試影響。
- 你可以根據不同的測試需求,精確地設定
initialState
和dependencies
。 - 假如真的需要用 global 變數共享,記得每個測試使用完要
await store.finish()
Store 和 ViewStore
切分 Store 避免不必要的 view 更新, Store
是狀態持有者,同時也負責在運行的時候連結 State
和 Action
。Single source of truth 是狀態驅動 UI 的最基本原則之一,由於這個要求,我們希望持有狀態的角色只有一個。因此很常見的選擇是,一整個 app 只有一個 Store
。UI 對這個 Store
進行觀察。UI 對這 Store 進行觀察 (比如透過將它設置為 @ObservedObject
),攫取它们所需要的狀態,並對狀態的變化做出響應。

通常一個 Store
會存在非常多的狀態,但是具體的 View
一般只需要其中一個很小的子集。比如上圖中 View1 只需要 Sate1 不需要知道 State2
如果讓 View
直接觀察整個 Store
,某個狀態發生變化時,SwiftUI 將會要求所有對 Store
進行觀察的 UI 更新,這樣就造成所有的 view 都對 body
進行重新渲染,是非常消耗資源的。 (這讓我聯想到 iOS 17 後 Apple 推出 Observation
,也是一樣的道理,原本使用 ObservableObject
的 Model,假如有更新時,會更新的 UI,效能較差,但新的 Observation
不會)
例如下圖的 State 2 發生了變化,但是不依賴 State 2 的 View 1 和 View 1-1 只是因為觀察了 Store,也會由於 @ObservedObject
的特性,重新對 body
進行求值:

而 TCA 為了避免這問題,把傳統意義的 Store
進行拆分,發明了 ViewStore
的概念:
Store
依然是狀態的實際管理者和持有者,它代表了 app 狀態的 純數據層 的表示。在 TCA 的使用者來看 Store
最重要的功能,是對狀態進行切分
程式碼
// state:
struct State1 {
struct State1_1 {
var foo: Int
}
var childState: State1_1
var bar: Int
}
struct State2 {
var baz: Int
}
struct AppState {
var state1: State1
var state2: State2
}
let store = Store(
initialState: AppState( /* */ ),
reducer: appReducer,
environment: ()
)
在將 Store 傳遞給不同頁面使用時,可以用 .scope 將其「切分」,這在後面章節會再詳細說明
let store: Store<AppState, AppAction>
var body: some View {
TabView {
View1(
store: store.scope(
state: \.state1, action: AppAction.action1
)
)
View2(
store: store.scope(
state: \.state2, action: AppAction.action2
)
)
}
}
這樣就可以限制每個頁面能夠訪問到的狀態,抱持清晰。

struct CounterView: View {
let store: Store<Counter, CounterAction>
var body: some View {
WithViewStore(store) { viewStore in
HStack {
Button("-") { viewStore.send(.decrement) }
Text("\(viewStore.count)")
Button("+") { viewStore.send(.increment) }
}
}
}
}
TCA 透過 WithViewStore
來把一個代表純資料的 Store 轉換成 SwiftUI 可觀測的資料。不出意外,當 WithViewStore
接受的閉包滿足 View 協議時,它本身也將滿足 View,這也是為什麼我們能在 CounterView 的 body 直接用它來構建一個 View 的原因。 WithViewStore
這個 view,在內部持有一個 ViewStore
類型,它進一步保持了對於 store 的引用。作為 View,它透過 @ObservedObject
對這個 ViewStore
進行觀察,並回應它的變更。因此,如果我們的 View 持有的只是切分後的 Store,那麼原始 Store 其他部分的變更,就不會影響到目前這個 Store 的切片,從而保證那些和當前 UI 不相關的狀態改變,不會導致當前 UI 的刷新。
注意!
WithViewStore
的用法已棄用,可參考 Migrated to 1.7 的文件
iOS 17 以下,用 WithPerceptionTracking
,且 store property 要這樣宣告 @Perception.Bindable var store: StoreOf<Feature>
。
但假設是 iOS 17 以上,WithViewStore
和 WithPerceptionTracking
都不用了
而且現在會使用 @ObservableState
在 State 上,而 view 上的 store 加上 @Bindable
。
@Reducer
struct Feature {
@ObservableState
struct State {
// ...
}
}
struct View: View {
@Bindable var store: StoreOf<Feature>
// ...
}
第二章
綁定和普通狀態的區別
在上篇的功能裡,基本上狀態驅動 UI 的流程是:點擊按鈕 → 發送 Action → 更新 State → 觸發 UI 更新
除了單純透過狀態來更新 UI,在 SwiftUI 同時也支持反向使用 @Binding
的方式把某個 State 綁定給 UI 元件,這些元件不用經由我們的程式碼就能改變某個狀態,例如 TextField
, Toogle
等。
但假如 view 有能力直接改變狀態,其實就違反了 TCA 中關於只能在 reducer 中更改狀態的規定。對於這種情況,TCA 中為 View Store 增加了將狀態轉換爲一種「特殊綁定關係」的方法。
實作
enum Action {
case increment
case decrement
+ case setCount(String)
case reset
}
var body: some ReducerOf<Self> {
Reduce { state, action in
// ...
+ case .setCount(let text):
+ if let value = Int(text) {
+ state.count = value
+ }
+ return .none
// ...
}
再把 body
中原來的 Text
替換 TextField
var body: some View {
// ...
- Text("\(viewStore.count)")
+ TextField(
+ String(viewStore.count),
+ text: store.binding(
+ get: { String($0.count) },
+ send: { CounterAction.setCount($0) }
+ )
+ )
+ .frame(width: 40)
+ .multilineTextAlignment(.center)
.foregroundColor(colorOfCount(viewStore.count))
}
viewStore.binding
方法接受 get 和 send 兩個參數,它們都是和目前 View Store 及綁定 view 類型相關的泛型函數。在特化 (將泛型在這個上下文中轉換為具體類型) 後
get: (Counter) -> String
負責為物件 View (這裡的TextField
) 提供資料。send: (String) -> CounterAction
負責將 View 新發送的值轉換為 View Store 可以理解的 action,並發送它來觸發counterReducer
。
在 counterReducer
接到 binding
給予的 setCount
事件後,我們就回到使用 reducer 進行狀態更新,並驅動 UI 的標準 TCA 循環中了。
但在我的 demo code ,是使用 swiftUI native 的 Binding
,在 binding 的時候,當 textField 輸入文字時就是發一個 action setCont(String)
,待會在 View 的部分會看到。
再來我們可以簡化一下程式碼,到 Counter 裡新增:
extension Counter.State {
var countString: String {
get { String(count) }
set { count = Int(newValue) ?? count }
}
var countFloat: Float {
get { Float(count) }
set { count = Int(newValue) }
}
}
Reduce 的部分,就可以變成這樣
case .setCount(let text):
state.countString = text
然後 View 的部分,TextField 改成這樣
TextField(
String(store.count),
text: Binding(
get: { store.countString },
set: { store.send(.setCount($0)) }
)
)
多個綁定值
如果在同一個 Feature 中,有多個綁定值的話,使用例子中這樣的方式,每次都會需要新增一個 action,然後再 binding
中 send
它。這是千篇一律的模板程式碼,TCA 中設計了 BindingState
, BindableAction
, 以及 BindingReducer
來解決,讓多個綁定的寫法簡單一點,具體來說,分成三步:
- 為
State
中的需要和 UI 綁定的變數新增@BindableState
。 - 將
Action
聲明為BindableAction
,然後新增一個「特殊」的 casebinding(BindingAction<Feature>)
。 - 在 Reducer 中處理這個
.binding
,並新增.binding()
調用。
用程式碼來說就是:
// 1
struct MyState: Equatable {
+ @BindableState var foo: Bool = false
+ @BindableState var bar: String = ""
}
// 2
- enum MyAction {
+ enum MyAction: BindableAction {
+ case binding(BindingAction<MyState>)
}
// 3
let myReducer = //...
// ...
+ case .binding:
+ return .none
}
+ .binding()
這樣一番操作後,可以在 View 裡用類似標準 SwiftUI 的做法,使用 $
取 projected value
來進行 Binding 了:
struct MyView: View {
let store: Store<MyState, MyAction>
var body: some View {
WithViewStore(store) { viewStore in
+ Toggle("Toggle!", isOn: viewStore.binding(\.$foo))
+ TextField("Text Field!", text: viewStore.binding(\.$bar))
}
}
}
這樣一來,即使有多個 binding 值,都可以只用一個 .binding
action 處理。這段程式碼能運作是因為 BindableAction
要求一個簽名為 BindingAction<State> -> Self
且名為 binding
的函式:
public protocol BindableAction {
static func binding(_ action: BindingAction<State>) -> Self
}
利用了 enum case 作為函式使用的 Swift 新特性,程式碼變得很優雅。
詳細可以參考官方文件(寫法有稍微不一樣)
Environment
現在可以輸入數字了,我們來做個猜數字的小遊戲,玩法就是從 -100 到 100 之間,隨機一個數字,我們要猜這數字,數字太大太小都要回報給 User。
所以我們先加一個 var secret = Int.random(in: -100...100)
至 State 裡,由他來產出一個隨機數。
@ObservableState
struct State: Equatable {
var count: Int = 0
let secret = Int.random(in: -100...100)
}
再來檢查 count
和 secret
的關係,返回答案
extension Counter {
enum CheckResult {
case lower, equal, higher
}
var checkResult: CheckResult {
if count < secret { return .lower }
if count > secret { return .higher }
return .equal
}
}
再來就可以修改 View
struct CounterView: View {
@Bindable var store: StoreOf<Counter>
var body: some View {
VStack {
+ checkLabel(with: store.checkResult)
HStack {
Button("-") { store.send(.decrement) }
// ...
}
func checkLabel(with checkResult: Counter.CheckResult) -> some View {
switch checkResult {
case .lower:
return Label("Lower", systemImage: "lessthan.circle")
.foregroundColor(.red)
case .higher:
return Label("Higher", systemImage: "greaterthan.circle")
.foregroundColor(.red)
case .equal:
return Label("Correct", systemImage: "checkmark.circle")
.foregroundColor(.green)
}
}
}
最後我們就有

外部依賴
當我們答對後,Reset 按鈕只能清除,不能重新一局,我們讓遊戲好玩一點,變成下一局
所以我們調整
enum Action {
// ...
- case reset
+ case playNext
}
struct CounterView: View {
// ...
var body: some View {
// ...
- Button("Reset") { store.send(.reset) }
+ Button("Next") { store.send(.playNext) }
}
}
在邏輯的部分
@ObservableState
struct State: Equatable {
var count: Int = 0
- let secret = Int.random(in: -100 ... 100)
+ var secret = Int.random(in: -100 ... 100)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
- case .reset:
+ case .playNext:
state.count = 0
+ state.secret = Int.random(in: -100 ... 100)
return .none
// ...
現在來跑一下測試,結果沒意外是失敗的
func testReset() throws {
- store.send(.reset) { state in
+ store.send(.playNext) { state in
state.count = 0
}
}
這是因為 .playNext
現在不僅重設 count
,也會隨機產生新的 secret
。而 TestStore
會把 send 閉包結束時的 state 和真正的由 reducer 操作的 state 進行比較並斷言:前者沒有設定合適的 secret
,導致它們並不相等,所以測試失敗了。
我們需要一種穩定的方式,來確保測試成功。
使用環境值解決依賴
在 TCA 中,為了保證可測試性,reducer 必須是純函數,也就是說相同輸入 (state, action 和 environment) 的組合,必須能給出相同的輸入(在這輸出是 state 和 effect,後面的章節在接觸 effect 角色)
其中使用了 Int.random
所以無法保證每次的結果,TCA 中的 Environment 就是要處理這種情況,對應外部依賴的情況。
首先程式碼是
struct Environment {
+ var generateRandom: (ClosedRange<Int>) -> Int
}
再來原本 CounterEnvironment()
加上 generateRandom
的設定。
另一種更常見和簡潔的做法,是為 Counter.Environment
定義一組環境,然後把他們傳到相對應的地方:
struct Environment {
var generateRandom: (ClosedRange<Int>) -> Int
+ static let liveValue = Self(
+ generateRandom: Int.random
+ )
+ static let testValue = Self(
// ...
}
CounterView(
store: Store(initialState: Counter.State()) {
Counter()
} withDependencies: {
$0.counterEnvironment = .testValue
}
)
現在,在 reducer 中,就可以使用注入的環境值來達到和原來一樣的結果了
let counterReducer = // ... {
- state, action, _ in
+ state, action, environment in
// ...
case .playNext:
state.count = 0
- state.secret = Int.random(in: -100 ... 100)
+ state.secret = environment.generateRandom(-100 ... 100)
return .none
// ...
}
在 test target 中,就可以創建一個 .test
環境
extension Counter.Environment {
static let test = CounterEnvironment(generateRandom: { _ in 5 })
}
現在,在生成 TestStore
的時候,使用 .test
,然後在斷言時生成合適的 Counter
作為新的 state,測試就能順利通過了
store = TestStore(initialState: Counter.State()) {
Counter()
} withDependencies: {
$0.counterEnvironment = .init(
generateRandom: { _ in 10 },
uuid: { .dummy }
)
}
@Test
func playNext() async throws {
await store.send(.playNext) {
$0.count = 0
$0.secret = 5
}
}
其他常見的依賴
除了像是 random 系列以外,凡事隨著調用環境變化(包括 ID、時間、地點、各種外部狀態等等)而打破 reducer 純函數特性的外部依賴,都應該被納入 Environment 的範疇。
有些可以同步完成,像是 Int.random
,但有些則需要一定的時間才得到結果,比如獲取位置和發送網路請求。對於後者,可以轉換為 Effect
。
接下來我可以參考下集