TCA-SwiftUI 的救星之二
前言
在上一篇關于 TCA 的文章中,我們通過總覽的方式看到了 TCA 中一個 Feature 的運作方式,并嘗試實現(xiàn)了一個最小的 Feature 和它的測試。在這篇文章中,我們會繼續(xù)深入,看看 TCA 中對 Binding 的處理,以及使用 Environment 來把依賴從 reducer 中解耦的方法。
如果你想要跟做,可以直接使用上一篇文章完成練習后最后的狀態(tài),或者從這里[1]獲取到起始代碼。
關于綁定
綁定和普通狀態(tài)的區(qū)別
在上一篇文章中,我們實現(xiàn)了“點擊按鈕” -> “發(fā)送 Action” -> “更新 State” -> “觸發(fā) UI 更新” 的流程,這解決了“狀態(tài)驅動 UI”這一課題。不過,除了單純的“通過狀態(tài)來更新 UI” 以外,SwiftUI 同時也支持在反方向使用 @Binding 的方式把某個 State 綁定給控件,讓 UI 能夠不經由我們的代碼,來更改某個狀態(tài)。在 SwiftUI 中,我們幾乎可以在所有既表示狀態(tài),又能接受輸入的控件上找到這種模式,比如 TextField 接受 String 的綁定 Binding
當我們把某個狀態(tài)通過 Binding 交給其他 view 時,這個 view 就有能力改變去直接改變狀態(tài)了,實際上這是違反了 TCA 中關于只能在 reducer 中更改狀態(tài)的規(guī)定的。對于綁定,TCA 中為 View Store 添加了將狀態(tài)轉換為一種“特殊綁定關系”的方法。我們來試試看把 Counter 例子中的顯示數(shù)字的 Text 改成可以接受直接輸入的 TextField。
在 TCA 中實現(xiàn)單個綁定
首先,為 CounterAction 和 counterReducer 添加對應的接受一個字符串值來設定 count 的能力:
- enum CounterAction {
- case increment
- case decrement
- + case setCount(String)
- case reset
- }
- let counterReducer = Reducer<Counter, CounterAction, CounterEnvironment> {
- state, action, _ in
- switch action {
- // ...
- + case .setCount(let text):
- + if let value = Int(text) {
- + state.count = value
- + }
- + return .none
- // ...
- }.debug()
接下來,把 body 中原來的 Text 替換為下面的 TextField:
- var body: some View {
- WithViewStore(store) { viewStore in
- // ...
- - Text("\(viewStore.count)")
- + TextField(
- + String(viewStore.count),
- + text: viewStore.binding(
- + get: { String($0.count) },
- + send: { CounterAction.setCount($0) }
- + )
- + )
- + .frame(width: 40)
- + .multilineTextAlignment(.center)
- .foregroundColor(colorOfCount(viewStore.count))
- }
- }
viewStore.binding 方法接受 get 和 send 兩個參數(shù),它們都是和當前 View Store 及綁定 view 類型相關的泛型函數(shù)。在特化 (將泛型在這個上下文中轉換為具體類型) 后:
- get: (Counter) -> String 負責為對象 View (這里的 TextField) 提供數(shù)據(jù)。
- send: (String) -> CounterAction 負責將 View 新發(fā)送的值轉換為 View Store 可以理解的 action,并發(fā)送它來觸發(fā) counterReducer。 在 counterReducer 接到 binding 給出的 setCount 事件后,我們就回到使用 reducer 進行狀態(tài)更新,并驅動 UI 的標準 TCA 循環(huán)中了。
傳統(tǒng)的 SwiftUI 中,我們在通過 $ 符號獲取一個狀態(tài)的 Binding 時,實際上是調用了它的 projectedValue。而 viewStore.binding 在內部通過將 View Store 自己包裝到一個 ObservedObject 里,然后通過自定義的 projectedValue 來把輸入的 get 和 send 設置給 Binding 使用中。對內,它通過內部存儲維持了狀態(tài),并把這個細節(jié)隱藏起來;對外,它通過 action 來把狀態(tài)的改變發(fā)送出去。捕獲這個改變,并對應地更新它,最后再把新的狀態(tài)再次通過 get 設置給 binding,是開發(fā)者需要保證的事情。
簡化代碼
做一點重構:現(xiàn)在 binding 的 get 是從 $0.count 生成的 String,reducer 中對 state.count 的設定也需要先從 String 轉換為 Int。我們把這部分 Mode 和 View 表現(xiàn)形式相關的部分抽取出來,放到 Counter 的一個 extension 中,作為 View Model 使用:
- extension Counter {
- var countString: String {
- get { String(count) }
- set { count = Int(newValue) ?? count }
- }
- }
把 reducer 中轉換 String 的部分替換成 countString:
- let counterReducer = Reducer<Counter, CounterAction, CounterEnvironment> {
- state, action, _ in
- switch action {
- // ...
- case .setCount(let text):
- - if let value = Int(text) {
- - state.count = value
- - }
- + state.countString = text
- return .none
- // ...
- }.debug()
在 Swift 5.2 中,KeyPath 已經可以被當作函數(shù)使用了,因此我們可以把 \Counter.countString 的類型看作 (Counter) -> String。同時,Swift 5.3 中 enum case 也可以當作函數(shù)[2],可以認為 CounterAction.setCount 具有類型 (String) -> CounterAction。兩者恰好滿足 binding 的兩個參數(shù)的要求,所以可以進一步將創(chuàng)建綁定的部分簡化:
- // ...
- TextField(
- String(viewStore.count),
- text: viewStore.binding(
- - get: { String($0.count) },
- + get: \.countString,
- - send: { CounterAction.setCount($0) }
- + send: CounterAction.setCount
- )
- )
- // ...
最后,別忘了為 .setCount 添加測試!
多個綁定值 如果在一個 Feature 中,有多個綁定值的話,使用例子中這樣的方式,每次我們都會需要添加一個 action,然后在 binding 中 send 它。這是千篇一律的模板代碼,TCA 中設計了 @BindableState 和 BindableAction,讓多個綁定的寫法簡單一些。具體來說,分三步:
為 State 中的需要和 UI 綁定的變量添加 @BindableState。
將 Action 聲明為 BindableAction,然后添加一個“特殊”的 case binding(BindingAction
在 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
- public protocol BindableAction {
- static func binding(_ action: BindingAction<State>) -> Self
- }
再一次,利用了將 enum case 作為函數(shù)使用的 Swift 新特性,代碼可以變得非常簡單優(yōu)雅。
環(huán)境值
猜數(shù)字游戲
回到 Counter 的例子來。既然已經有輸入數(shù)字的方式了,那不如來做一個猜數(shù)字的小游戲吧!
猜數(shù)字:程序隨機選擇 -100 到 100 之間的數(shù)字,用戶輸入一個數(shù)字,程序判斷這個數(shù)字是否就是隨機選擇的數(shù)字。如果不是,返回“太大”或者“太小”作為反饋,并要求用戶繼續(xù)嘗試輸入下一個數(shù)字進行猜測。
最簡單的方法,是在 Counter 中添加一個屬性,用來持有這個隨機數(shù):
- struct Counter: 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
- }
- }
有了這個模型,我們就可以通過使用 checkResult 來在 view 中顯示一個代表結果的 Label 了:
- struct CounterView: View {
- let store: Store<Counter, CounterAction>
- var body: some View {
- WithViewStore(store) { viewStore in
- VStack {
- + checkLabel(with: viewStore.checkResult)
- HStack {
- Button("-") { viewStore.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)
- }
- }
- }
最終,我們可以得到這樣的 UI:
外部依賴
當我們用這個 UI “蒙對”答案后,Reset 按鈕雖然可以把猜測歸零,但它并不能為我們重開一局,這當然有點無聊。我們來試試看把 Reset 按鈕改成 New Game 按鈕。
在 UI 和 CounterAction 里我們已經定義了 .reset 行為了,進行一些重命名的工作:
- enum CounterAction {
- // ...
- - case reset
- + case playNext
- }
- struct CounterView: View {
- // ...
- var body: some View {
- // ...
- - Button("Reset") { viewStore.send(.reset) }
- + Button("Next") { viewStore.send(.playNext) }
- }
- }
然后在 counterReducer 里處理這個情況,
- struct Counter: Equatable {
- var count: Int = 0
- - let secret = Int.random(in: -100 ... 100)
- + var secret = Int.random(in: -100 ... 100)
- }
- let counterReducer = Reducer<Counter, CounterAction, CounterEnvironment> {
- // ...
- - case .reset:
- + case .playNext:
- state.count = 0
- + state.secret = Int.random(in: -100 ... 100)
- return .none
- // ...
- }.debug()
運行 app,觀察 reducer debug() 的輸出,可以看到一切正常!太好了。
隨時 Cmd + U 運行測試是大家都應該養(yǎng)成的習慣,這時候我們可以發(fā)現(xiàn)測試編譯失敗了。最后的任務就是修正原來的 .reset 測試,這也很簡單:
- func testReset() throws {
- - store.send(.reset) { state in
- + store.send(.playNext) { state in
- state.count = 0
- }
- }
但是,測試的運行結果大概率會失敗!
這是因為 .playNext 現(xiàn)在不僅重置 count,也會隨機生成新的 secret。而 TestStore 會把 send 閉包結束時的 state 和真正的由 reducer 操作的 state 進行比較并斷言:前者沒有設置合適的 secret,導致它們并不相等,所以測試失敗了。
我們需要一種穩(wěn)定的方式,來保證測試成功。
使用環(huán)境值解決依賴
在 TCA 中,為了保證可測試性,reducer 必須是純函數(shù):也就是說,相同的輸入 (state, action 和 environment) 的組合,必須能給出相同的輸入 (在這里輸出是 state 和 effect,我們會在后面的文章再接觸 effect 角色)。
- let counterReducer = // ... {
- state, action, _ in
- // ...
- case .playNext:
- state.count = 0
- state.secret = Int.random(in: -100 ... 100)
- return .none
- //...
- }.debug()
在處理 .playNext 時,Int.random 顯然無法保證每次調用都給出同樣結果,它也是導致 reducer 變得無法測試的原因。TCA 中環(huán)境 (Environment) 的概念,就是為了對應這類外部依賴的情況。如果在 reducer 內部出現(xiàn)了依賴外部狀態(tài)的情況 (比如說這里的 Int.random,使用的是自動選擇隨機種子的 SystemRandomNumberGenerator),我們可以把這個狀態(tài)通過 Environment 進行注入,讓實際 app 和單元測試能使用不同的環(huán)境。
首先,更新 CounterEnvironment,加入一個屬性,用它來持有隨機生成 Int 的方法。
- struct CounterEnvironment {
- + var generateRandom: (ClosedRange<Int>) -> Int
- }
現(xiàn)在編譯器需要我們?yōu)樵瓉?CounterEnvironment() 的地方加上 generateRandom 的設定。我們可以直接在生成時用 Int.random 來創(chuàng)建一個 CounterEnvironment:
- CounterView(
- store: Store(
- initialState: Counter(),
- reducer: counterReducer,
- - environment: CounterEnvironment()
- + environment: CounterEnvironment(
- + generateRandom: { Int.random(in: $0) }
- + )
- )
- )
一種更加常見和簡潔的做法,是為 CounterEnvironment 定義一組環(huán)境,然后把它們傳到相應的地方:
- struct CounterEnvironment {
- var generateRandom: (ClosedRange<Int>) -> Int
- + static let live = CounterEnvironment(
- + generateRandom: Int.random
- + )
- }
- CounterView(
- store: Store(
- initialState: Counter(),
- reducer: counterReducer,
- - environment: CounterEnvironment()
- + environment: .live
- )
- )
現(xiàn)在,在 reducer 中,就可以使用注入的環(huán)境值來達到和原來等效的結果了:
- 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
- // ...
- }.debug()
萬事俱備,回到最開始的目的 - 保證測試能順利通過。在 test target 中,用類似的方法創(chuàng)建一個 .test 環(huán)境:
- extension CounterEnvironment {
- static let test = CounterEnvironment(generateRandom: { _ in 5 })
- }
現(xiàn)在,在生成 TestStore 的時候,使用 .test,然后在斷言時生成合適的 Counter 作為新的 state,測試就能順利通過了:
- store = TestStore(
- initialState: Counter(count: Int.random(in: -100...100)),
- reducer: counterReducer,
- - environment: CounterEnvironment()
- + environment: .test
- )
- store.send(.playNext) { state in
- - state.count = 0
- + state = Counter(count: 0, secret: 5)
- }
在 store.send 的閉包里,我們現(xiàn)在直接為 state 設置了一個新的 Counter,并明確了所有期望的屬性。這里也可以分開兩行,寫成 state.count = 0 以及 state.secret = 5,測試也可以通過。選擇哪種方式都可以,但在涉及到復雜的情況下,會傾向于選擇完整的賦值:在測試中,我們希望的是通過斷言來比較期望 state 和實際 state 的差別,而不是重新去實現(xiàn)一次 reducer 中的邏輯。這可能引入混亂,因為在測試失敗時你需要去排查到底是 reducer 本身的問題,還是測試代碼中操作狀態(tài)造成的問題。
其他常見依賴
除了像是 random 系列以外,凡是會隨著調用環(huán)境的變化 (包括時間,地點,各種外部狀態(tài)等等) 而打破 reducer 純函數(shù)特性的外部依賴,都應該被納入 Environment 的范疇。常見的像是 UUID 的生成,當前 Date 的獲取,獲取某個運行隊列 (比如 main queue),使用 Core Location 獲取現(xiàn)在的位置信息,負責發(fā)送網(wǎng)絡請求的網(wǎng)絡框架等等。
它們之中有一些是可以同步完成的,比如例子中的 Int.random;有一些則是需要一定時間才能得到結果,比如獲取位置信息和發(fā)送網(wǎng)絡請求。對于后者,我們往往會把它轉換為一個 Effect。我們會在下一篇文章中再討論 Effect。
練習
如果你沒有跟隨本文更新代碼,你可以在這里[3]找到下面練習的起始代碼。參考實現(xiàn)可以在這里[4]找到。
添加一個 Slider
用鍵盤和加減號來控制 Counter 已經不錯了,但是添加一個 Slider 會更有趣。請為 CounterView 添加一個 Slider,用來來和 TextField 以及 “+” “-“ Button 一起,控制我們的猜數(shù)字游戲。
期望的 UI 大概是這樣:
別忘了寫測試!
完善 Counter,記錄更多信息
為了后面功能的開發(fā),我們需要更新一下 Counter 模型。首先,每個謎題添加一些元信息,比如謎題 ID:
在 Counter 中加上下面的屬性,然后讓它滿足 Identifiable:
- - struct Counter: Equatable {
- + struct Counter: Equatable, Identifiable {
- var count: Int = 0
- var secret = Int.random(in: -100 ... 100)
- + var id: UUID = UUID()
- }
在開始新一輪游戲的時候,記得更新 id。還有,別忘了寫測試!