TCA - SwiftUI 的救星?(一)

語言: CN / TW / HK

轉自喵神部落格:http://onevcat.com/2021/12/tca-1/

前言

打算用幾篇文章介紹一下 TCA (The Composable Architecture),這是一種看起來非常契合 SwiftUI 的架構方式。

四年多前我寫過一篇關於使用單向資料流來架構 View Controller 的文章,因為 UIKit 中並沒有強制的 view 重新整理流程,所以包括繫結資料在內的很多事情都需要自己動手,這為大規模使用造成了不小的障礙。而自那時過了兩年後, SwiftUI 的釋出才讓這套機制有了更加合適的舞臺。在 SwiftUI 釋出初期,我也寫過一本相關的書籍,裡面使用了一些類似的想法,但是很不完善。現在,我想要回頭再看看這樣的架構方式,來看看最近一段時間在社群幫助下的進化,以及它是否能成為現下更好的選擇。

對於以前很少接觸宣告式或者類似架構的朋友來說,其中有一些概念和選擇可能不太容易理解,比如為什麼 Side Effect 需要額外對應,如何在不同 View 之間共享狀態,頁面遷移的時候如何優雅處理等等。在這一系列文章裡,我會盡量按照自己的理解,嘗試闡明一些常見的問題,希望能幫助讀者有一個更加平滑的入門體驗。

作為開篇,我們先來簡單看一看現在 SwfitUI 在架構上存在的一些不足。然後使用 TCA 實現一個最簡單的 View。

SwiftUI 很贊,但是…

iOS 15 一聲炮響,給開發們送來了全新版本的 SwiftUI。它不僅有更加合理的非同步方法和全新特性,更是修正了諸多頑疾。可以說,從 iOS 14 開始,SwiftUI 才算逐漸進入了可用的狀態。而最近隨著公司的專案徹底拋棄 iOS 13,我也終於可以更多地正式在工作中用上 SwiftUI 了。

Apple 並沒有像在 UIKit 中貫徹 MVC 那樣,為 SwiftUI ”欽定“ 一個架構。雖然 SwiftUI 中提供了諸多狀態管理的關鍵字或屬性包裝 (property wrapper),比如 @State@ObservedObject 等,但是你很難說官方 SwiftUI 教程裡關於資料傳遞狀態管理的部分,足夠指導開發者構建出穩定和可擴充套件的 app。SwiftUI 最基礎的狀態管理模式,做到了 single source of truth:所有的 view 都是由狀態匯出的,但是它同時也存在了很多不足。簡單就可以列舉一些:

  • 複雜的狀態修飾,想要”正常“使用,你至少必須要記住 @State@ObservedObject@StateObject@Binding@EnvironmentObject 各自的特點和區別。

  • 很多修改狀態的程式碼內嵌在 View.body 中,甚至只能在 body 中和其他 view 程式碼混雜在一起。同一個狀態可能被多個不相關的 View 直接修改 (比如通過 Binding),這些修改難以被追蹤和定位,在 app 更復雜的情況下會是噩夢。

  • 測試困難: 這可能和直覺相反,因為 SwiftUI 框架的 view 完全是由狀態決定的,所以理論上來說我們只需要測試狀態 (也就是 model 層) 就行,這本應是很容易的。但是如果嚴格按照 Apple 官方教程的基本做法,app 中會存在大量私有狀態,這些狀態難以 mock,而且就算可以,如何測試對這些狀態的修改也是問題。

當然,這些不足都可以克服,比如死記硬背下五種屬性包裝的寫法、儘可能減少共享可變狀態來避免被意外修改、以及按照 Apple 的推薦準備一組 preview 的資料然後開啟 View 檔案去挨個檢查 Preview 的結果 (雖然有一些自動化工具幫我們解放雙眼,但嚴肅點兒,別笑,Apple 在這個 session 裡原本的意思就是讓我們去查渲染結果!)。

我們真的需要一種架構,來讓 SwiftUI 的使用更加輕鬆一些。

從 Elm 獲得的啟示

我估摸著前端開發的圈子一年能大約能誕生 500 多種架構。如果我們需要一種新架構,那去前端那邊抄一下大抵是不會錯的。結合 SwiftUI 的特點,Elm 就是非常優秀的”抄襲“物件。

說實話,要是你現在正好想要學習一門語言,那我想推薦的就是 Elm。不過雖然 Elm 是一門通用程式語言,但可以說這門語言實際上只為一件事服務,那就是 Elm 架構 ( The Elm Architecture, TEA)。一個最簡單的 counter 在 Elm 中長成這個樣子:

```swift type Msg = Increment | Decrement

update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of Increment -> ( model + 1, Cmd.none )

Decrement ->
  ( model - 1, Cmd.none )

view model = div [] [ button [ onClick Decrement ] [ text "-" ] , div [] [ text (String.fromInt model) ] , button [ onClick Increment ] [ text "+" ] ] ```

如果有機會,我再寫一些 Elm 或者 Haskell 的東西。在這裡,我決定直接把上面這段程式碼”翻譯“成偽 SwiftUI:

```swift enum Msg { case increment case decrement }

typealias Model = Int func update(msg: Msg, model: Model) -> (Model, Cmd) { switch msg { case .increment: return (model + 1, .none) case .decrement: return (model - 1, .none) } }

func view(model: Model) -> some View { HStack { Button("-") { sendMsg(.decrement) } Text("(model)") Button("+") { sendMsg(.increment) } } } ```

TEA 架構組成部件

整個過程如圖所示 (為了簡潔,先省去了 Cmd 的部分,我們會在系列後面的文章再談到這個內容):

  1. 使用者在 view 上的操作 (比如按下某個按鈕),將會以訊息的方式進行傳送。Elm 中的某種機制將捕獲到這個訊息。

  2. 在檢測到新訊息到來時,它會和當前的 Model 一併,作為輸入傳遞給 update 函式。這個函式通常是 app 開發者所需要花費時間最長的部分,它控制了整個 app 狀態的變化。作為 Elm 架構的核心,它需要根據輸入的訊息和狀態,演算出新的 Model

  3. 這個新的 model 將替換掉原有的 model,並準備在下一個 msg 到來時,再次重複上面的過程,去獲取新的狀態。

  4. Elm 執行時負責在得到新 Model 後呼叫 view 函式,渲染出結果 (在 Elm 的語境下,就是一個前端 HTML 頁面)。使用者可以通過它再次傳送新的訊息,重複上面的迴圈。

現在,你已經對 TEA 有了基本的瞭解了。我們類比一下這些步驟在 SwiftUI 中的實現,可以發現步驟 4 其實已經包含在 SwiftUI 中了:當 @State@ObservedObject@Published 發生變化時,SwiftUI 會自動呼叫 View.body 為我們渲染新的介面。因此,想要在 SwiftUI 中實現 TEA,我們需要做的是實現 1 至 3。或者換句話說,我們需要的是一套規則,來把零散的 SwiftUI 狀態管理的方式進行規範。TCA 正是在這方面做出了非常多的努力。

第一個 TCA app

來實際做一點東西吧,比如上面的這個 Counter。新建一個 SwiftUI 專案。因為我們會涉及到大量測試的話題,所以記得把 “Include Tests” 勾選上。然後在專案的 Package Dependencies 裡把 TCA 加入到依賴中:

在本文寫作的 TCA 版本 (0.29.0) 中,使用 Xcode 13.2 的話將無法編譯 TCA 框架。暫時可以使用 Xcode 13.1,或者等待 workaround 修正。

把 ContentView.swift 的內容替換為

```swift struct Counter: Equatable { var count: Int = 0 }

enum CounterAction { case increment case decrement }

struct CounterEnvironment { }

// 2 let counterReducer = Reducer { state, action, _ in switch action { case .increment: // 3 state.count += 1 return .none case .decrement: // 3 state.count -= 1 return .none } }

struct CounterView: View { let store: Store var body: some View { WithViewStore(store) { viewStore in HStack { // 1 Button("-") { viewStore.send(.decrement) } Text("(viewStore.count)") Button("+") { viewStore.send(.increment) } } } } } ```

基本上就是對上面 Elm 翻譯的偽 SwiftUI 程式碼進行了一些替換:Model -> CounterMsg -> CounterActionupdate(msg:model:) -> counterReducerview(model:) -> ContentView.body

ReducerStoreWithViewStore 是 TCA 中的型別:

  • Reducer 是函數語言程式設計中的常見概念,顧名思意,它將多項內容進行合併,最後返回單個結果。

  • ContentView 中,我們不直接操作 Counter,而是將它放在一個 Store 中。這個 Store 負責把 Counter (State) 和 Action 連線起來。

  • CounterEnvironment 讓我們有機會為 reducer 提供自定義的執行環境,用來注入一些依賴。我們會把相關內容放到後面再解釋。

上面的程式碼中 1 至 3,恰好就對應了 TEA 組成部件中對應的部分:

  1. 傳送訊息,而非直接改變狀態

任何使用者操作,我們都通過向 viewStore 傳送一個 Action 來表達。在這裡,當用戶按下 “-“ 或 “+” 按鈕時,我們傳送對應的 CounterAction。選擇將 Action 定義為 enum,可以帶來更清晰地表達意圖。但不僅如此,它還能在合併 reducer 時帶來很多便利的特性,在後續文章中我們會涉及相關話題。雖然並不是強制,但是如果沒有特殊理由,我們最好跟隨這一實踐,用 enum 來表達 Action。

  1. 只在 Reducer 中改變狀態

我們已經說過,Reducer 是邏輯的核心部分。它同時也是 TCA 中最為靈活的部分,我們的大部分工作應該都是圍繞打造合適的 Reducer 來展開的。對於狀態的改變,應且僅應在 Reducer 中完成:它的初始化方法接受一個函式,其型別為:

swift (inout State, Action, Environment) -> Effect<Action, Never>

inoutState 讓我們可以“原地”對 state 進行變更,而不需要明確地返回它。這個函式的返回值是一個 Effect,它代表不應該在 reducer 中進行的副作用,比如 API 請求,獲取當前時間等。我們會在下一篇文章中看到這部分內容。

  1. 更新狀態並觸發渲染 在 Reducer 閉包中改變狀態是合法的,新的狀態將被 TCA 用來觸發 view 的渲染,並儲存下來等待下一次 Action 到來。在 SwiftUI 中,TCA 使用 ViewStore (它本身是一個 ObservableObject) 來通過 @ObservedObject 觸發 UI 重新整理。

有了這些內容,整個模組的執行就閉合了。在 Preview 的部分傳入初始的 model 例項和 reducer 來建立 Store:

swift struct ContentView_Previews: PreviewProvider { static var previews: some View { CounterView( store: Store( initialState: Counter(), reducer: counterReducer, environment: CounterEnvironment() ) } }

最後,在 App 的入口將 @main 的內容也替換成帶有 store 的 CounterView,整個程式就可以運行了:

swift @main struct CounterDemoApp: App { var body: some Scene { WindowGroup { CounterView( store: Store( initialState: Counter(), reducer: counterReducer, environment: CounterEnvironment()) ) } } }

Debug 和 Test

這一套機制能正常執行的一個重要前提,是通過 model 對 view 進行渲染的部分是正確的。也就是說,我們需要相信 SwiftUI 中 State -> View 的過程是正確的 (實際上就算不正確,作為 SwiftUI 這個框架的使用者來說,我們能做的事情其實有限)。在這個前提下,我們只需要檢查 Action 的傳送是否正確,以及 Reducer 中對 State 的變更是否正確就行了。

TCA 中 Reducer 上有一個非常方便的 debug() 方法,它會為這個 Reducer 開啟控制檯的除錯輸出,打印出接收到的 Action 以及其中 State 的變化。為 counterReducer 加上這個呼叫:

swift let counterReducer = Reducer<Counter, CounterAction, CounterEnvironment> { // ... }.debug()

這時,點選按鈕會給我們這樣的輸出,State 的變化被以 diff 的方式打印出來:

.debug() 只會在 #if DEBUG 的編譯條件下列印,也就是說在 Release 時其實並不產生影響。另外,當我們有更多更復雜的 Reducer 時,我們也可以選擇只在某個或某幾個 Reducer 上呼叫 .debug() 來幫助除錯。在 TCA 中,一組關聯的 State/Reducer/Action (以及 Environment) 統合起來稱為一個 Feature。我們總是可以通過把小部件的 Feature 整體一起,組合形成更大的 Feature 或是新增到其他 Feature 上去,形成一組更大的功能。這種依靠組合的開發方式,可以讓我們保持小 Feature 的可測試和可用性。而這種組合,也正是 The Composable Architecture 中 Composable 所代表的意涵。

現在我們還只有 Counter 這一個 Feature。隨著 app 越來越複雜,在後面我們會看到更多的 Feature,以及如何通過 TCA 提供的工具,將它們組合到一起。

使用 .debug() 可以讓我們在控制檯實際看到狀態變化的方式,但如果能用單元測試確保這些變化,會更加高效和有意義。在 Unit Test 裡,我們新增一個測試,來驗證傳送 .increment 時的情況:

swift func testCounterIncrement() throws { let store = TestStore( initialState: Counter(count: Int.random(in: -100...100)), reducer: counterReducer, environment: CounterEnvironment() ) store.send(.increment) { state in state.count += 1 } }

TestStore 是 TCA 中專門用來處理測試的一種 Store。它在接受通過 send 傳送的 Action 的同時,還在內部帶有斷言。如果接收到 Action 後產生的新的 model 狀態和提供的 model 狀態不符,那麼測試失敗。上例中,store.send(.increment) 所對應的 State 變更,應該是 count 增加一,因此在 send 方法提供的閉包部分,我們正確更新了 state 作為最終狀態。

在初始化 Counter 提供 initialState 時,我們傳遞了一個隨機值。通過使用 Xcode 13 提供的“重複測試”功能 (右鍵點選對應測試左側的圖示),我們可以重複這個測試,這可以讓我們通過提供不同的初始狀態,來覆蓋更多的情況。在這個簡單的例子中可能顯得“小題大作”,但是在更加複雜的場景裡,這有助於我們發現一些潛藏的問題。

如果測試失敗,TCA 也會通過 dump 打印出非常漂亮的 diff 結果,讓錯誤一目瞭然:

除了自帶斷言,TestStore 還有其他一些用法,比如用來對應時序敏感的測試。另外,通過配置合適的 Environment,我們可以提供穩定的 Effect 作為 mock。這些課題其實在我們使用其他架構時,也都會遇到,在有些情況下會很難處理。這種時候,開發者們的選擇往往是“如果寫測試太麻煩,那要不就算了吧”。在 TCA 這一套易用的測試套件的幫助下,我們大概很難再用這個藉口逃避測試。大多數時候,書寫測試反而變成一種樂趣,這對專案質量的提升和保障可謂厥功至偉。

Store 和 ViewStore

切分 Store 避免不必要的 view 更新

在這個簡單的例子中,有一個很重要的部分,我決定放到本文最後進行強調,那就是 StoreViewStore 的設計。Store扮演的是狀態持有者,同時也負責在執行的時候連線 State 和 Action。Single source of truth 是狀態驅動 UI 的最基本原則之一,由於這個要求,我們希望持有狀態的角色只有一個。因此很常見的選擇是,整個 app 只有一個 Store。UI 對這個 Store 進行觀察 (比如通過將它設定為 @ObservedObject),攫取它們所需要的狀態,並對狀態的變化作出響應。

通常情況下,一個這樣的 Store 中會存在非常多的狀態。但是具體的 view 一般只需要一來其中一個很小的子集。比如上圖中 View 1 只需要依賴 State 1,而完全不關心 State 2。

如果讓 View 直接觀察整個 Store,在其中某個狀態發生變化時,SwiftUI 將會要求所有對 Store 進行觀察的 UI 更新,這會造成所有的 view 都對 body 進行重新求值,是非常大的浪費。比如下圖中,State 2 發生了變化,但是並不依賴 State 2 的 View 1 和 View 1-1 只是因為觀察了 Store,也會由於 @ObservedObject 的特性,重新對 body 進行求值:

TCA 中為了避免這個問題,把傳統意義的 Store 的功能進行了拆分,發明了 ViewStore 的概念:

Store 依然是狀態的實際管理者和持有者,它代表了 app 狀態的純資料層的表示。在 TCA 的使用者來看,Store 最重要的功能,是對狀態進行切分,比如對於圖示中的 StateStore

```swift 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 將其”切分“出來:

swift 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 ) ) } }

這樣可以限制每個頁面所能夠訪問到的狀態,保持清晰。

最後,再來看這一段最簡單的 TCA 架構下的程式碼:

swift 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,這也是為什麼我們能在 CounterViewbody 直接用它來構建一個 View 的原因。WithViewStore 這個 view,在內部持有一個 ViewStore 型別,它進一步保持了對於 store 的引用。作為 View,它通過 @ObservedObject 對這個 ViewStore 進行觀察,並響應它的變更。因此,如果我們的 View 持有的只是切分後的 Store,那麼原始 Store 其他部分的變更,就不會影響到當前這個 Store 的切片,從而保證那些和當前 UI 不相關的狀態改變,不會導致當前 UI 的重新整理。

當我們在 View 之間自上向下傳遞資料時,儘量保證把 Store 進行細分,就能保證模組之間互不干擾。但是,實際上在使用 TCA 做專案時,更多的情景時我們從更小的模組進行構建 (它會包含自己的一套 Feature),然後再把這些本地內容”新增“到它的上級。所以 Store 的切分將會變得自然而然。現在你可能對這部分內容還有懷疑,但是在後面的幾篇文章中,會逐步深入 feature 劃分和組織,在那裡你可以看到更多的例子。

跨 UI 框架的使用

另一方面,StoreViewStore 的分離,讓 TCA 可以擺脫對 UI 框架的依賴。在 SwiftUI 中,body 的重新整理是 SwiftUI 執行時通過 @ObservedObject 屬性包裝所提供的特性。現在這部分內容被包含在了 WithViewStore 中。但是 StoreViewStore 本身並不依賴於任何特定的 UI 框架。也就是說,我們也可以在 UIKit 或者 AppKit 的 app 中用同一套 API 來使用 TCA。雖然這需要我們自己去將 View 和 Model 繫結起來,會有些麻煩,但是如果你想要儘快嘗試 TCA,卻又不能使用 SwiftUI,也可以在 UIKit 中進行學習。你得到的經驗可以很容易遷移到其他的 UI 平臺 (甚至 web app) 中去。

練習

為了鞏固,我也準備了一些練習。完成後的專案將會作為下一篇文章的起始程式碼使用。不過如果你實在不想進行這些練習,或者不確定是否正確完成,每一篇文章也提供了初始程式碼以供參考,所以不必擔心。如果你沒有跟隨程式碼部分完成這個示例,你可以在這裡找到這次練習的初始程式碼。參考實現可以在這裡找到。

為資料文字新增顏色

為了更好地看清數字的正負,請為數字加上顏色:正數時用綠色顯示,負數時用紅色顯示。

新增一個 Reset 按鈕

除了加和減以外,新增一個重置按鈕,按下後將數字復原為 0。

為 Counter 補全所有測試

現在測試中只包含了 .increment 的情況。請新增減號和重置按鈕的相關測試。