TCA-SwiftUI 的救星之一
本文轉載自微信公眾號「Swift社區」,作者王巍 (onevcat)。轉載本文請聯系Swift社區公眾號。
前言
打算用幾篇文章介紹一下 TCA (The Composable Architecture[1]),這是一種看起來非常契合 SwiftUI 的架構方式。
四年多前我寫過一篇關于使用單向數據流來架構 View Controller[2] 的文章,因為 UIKit 中并沒有強制的 view 刷新流程,所以包括綁定數據在內的很多事情都需要自己動手,這為大規模使用造成了不小的障礙。而自那時過了兩年后, SwiftUI 的發布才讓這套機制有了更加合適的舞臺。在 SwiftUI 發布初期,我也寫過一本相關的書籍[3],里面使用了一些類似的想法,但是很不完善。現在,我想要回頭再看看這樣的架構方式,來看看最近一段時間在社區幫助下的進化,以及它是否能成為現下更好的選擇。
對于以前很少接觸聲明式或者類似架構的朋友來說,其中有一些概念和選擇可能不太容易理解,比如為什么 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 教程里關于數據傳遞[4]和狀態管理[5]的部分,足夠指導開發者構建出穩定和可擴展的 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 的推薦[6]準備一組 preview 的數據然后打開 View 文件去挨個檢查 Preview 的結果 (雖然有一些自動化工具[7]幫我們解放雙眼,但嚴肅點兒,別笑,Apple 在這個 session 里原本的意思就是讓我們去查渲染結果!)。
我們真的需要一種架構,來讓 SwiftUI 的使用更加輕松一些。
從 Elm 獲得的啟示
我估摸著前端開發的圈子一年能大約能誕生 500 多種架構[8]。如果我們需要一種新架構,那去前端那邊抄一下大抵是不會錯的。結合 SwiftUI 的特點,Elm[9] 就是非常優秀的”抄襲“對象。
說實話,要是你現在正好想要學習一門語言,那我想推薦的就是 Elm。不過雖然 Elm 是一門通用編程語言[10],但可以說這門語言實際上只為一件事服務,那就是 Elm 架構 ( The Elm Architecture, TEA)。一個最簡單的 counter 在 Elm 中長成這個樣子:
- 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:
- enum Msg {
- case increment
- case decrement
- }
- typealias Model = Int
- func update(msg: Msg, model: Model) -> (Model, Cmd<Msg>) {
- 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 的部分,我們會在系列后面的文章再談到這個內容):
- 用戶在 view 上的操作 (比如按下某個按鈕),將會以消息的方式進行發送。Elm 中的某種機制將捕獲到這個消息。
- 在檢測到新消息到來時,它會和當前的 Model 一并,作為輸入傳遞給 update 函數。這個函數通常是 app 開發者所需要花費時間最長的部分,它控制了整個 app 狀態的變化。作為 Elm 架構的核心,它需要根據輸入的消息和狀態,演算出新的 Model。
- 這個新的 model 將替換掉原有的 model,并準備在下一個 msg 到來時,再次重復上面的過程,去獲取新的狀態。
- 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 的內容替換為
- struct Counter: Equatable {
- var count: Int = 0
- }
- enum CounterAction {
- case increment
- case decrement
- }
- struct CounterEnvironment { }
- // 2
- let counterReducer = Reducer<Counter, CounterAction, CounterEnvironment> {
- 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<Counter, CounterAction>
- var body: some View {
- WithViewStore(store) { viewStore in
- HStack {
- // 1
- Button("-") { viewStore.send(.decrement) }
- Text("\(viewStore.count)")
- Button("+") { viewStore.send(.increment) }
- }
- }
- }
- }
基本上就是對上面 Elm 翻譯的偽 SwiftUI 代碼進行了一些替換:Model -> Counter,Msg -> CounterAction,update(msg:model:) -> counterReducer,view(model:) -> ContentView.body。
Reducer,Store 和 WithViewStore 是 TCA 中的類型:
- Reducer 是函數式編程中的常見概念,顧名思意,它將多項內容進行合并,最后返回單個結果。
- ContentView 中,我們不直接操作 Counter,而是將它放在一個 Store 中。這個 Store 負責把 Counter (State) 和 Action 連接起來。
- CounterEnvironment 讓我們有機會為 reducer 提供自定義的運行環境,用來注入一些依賴。我們會把相關內容放到后面再解釋。
上面的代碼中 1 至 3,恰好就對應了 TEA 組成部件中對應的部分:
1.發送消息,而非直接改變狀態
任何用戶操作,我們都通過向 viewStore 發送一個 Action 來表達。在這里,當用戶按下 “-“ 或 “+” 按鈕時,我們發送對應的 CounterAction。選擇將 Action 定義為 enum,可以帶來更清晰地表達意圖。但不僅如此,它還能在合并 reducer 時帶來很多便利的特性,在后續文章中我們會涉及相關話題。雖然并不是強制,但是如果沒有特殊理由,我們最好跟隨這一實踐,用 enum 來表達 Action。
2.只在 Reducer 中改變狀態
我們已經說過,Reducer 是邏輯的核心部分。它同時也是 TCA 中最為靈活的部分,我們的大部分工作應該都是圍繞打造合適的 Reducer 來展開的。對于狀態的改變,應且僅應在 Reducer 中完成:它的初始化方法接受一個函數,其類型為:
- (inout State, Action, Environment) -> Effect<Action, Never>
inout 的 State 讓我們可以“原地”對 state 進行變更,而不需要明確地返回它。這個函數的返回值是一個 Effect,它代表不應該在 reducer 中進行的副作用,比如 API 請求,獲取當前時間等。我們會在下一篇文章中看到這部分內容。
3.更新狀態并觸發渲染 在 Reducer 閉包中改變狀態是合法的,新的狀態將被 TCA 用來觸發 view 的渲染,并保存下來等待下一次 Action 到來。在 SwiftUI 中,TCA 使用 ViewStore (它本身是一個 ObservableObject) 來通過 @ObservedObject 觸發 UI 刷新。
有了這些內容,整個模塊的運行就閉合了。在 Preview 的部分傳入初始的 model 實例和 reducer 來創建 Store:
- struct ContentView_Previews: PreviewProvider {
- static var previews: some View {
- CounterView(
- store: Store(
- initialState: Counter(),
- reducer: counterReducer,
- environment: CounterEnvironment()
- )
- }
- }
最后,在 App 的入口將 @main 的內容也替換成帶有 store 的 CounterView,整個程序就可以運行了:
- @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 加上這個調用:
- 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 時的情況:
- 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 更新
在這個簡單的例子中,有一個很重要的部分,我決定放到本文最后進行強調,那就是 Store 和 ViewStore 的設計。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 最重要的功能,是對狀態進行切分,比如對于圖示中的 State 和 Store:
- 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
- )
- )
- }
- }
這樣可以限制每個頁面所能夠訪問到的狀態,保持清晰。
最后,再來看這一段最簡單的 TCA 架構下的代碼:
- 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 的刷新。
當我們在 View 之間自上向下傳遞數據時,盡量保證把 Store 進行細分,就能保證模塊之間互不干擾。但是,實際上在使用 TCA 做項目時,更多的情景時我們從更小的模塊進行構建 (它會包含自己的一套 Feature),然后再把這些本地內容”添加“到它的上級。所以 Store 的切分將會變得自然而然。現在你可能對這部分內容還有懷疑,但是在后面的幾篇文章中,會逐步深入 feature 劃分和組織,在那里你可以看到更多的例子。
跨 UI 框架的使用
另一方面,Store 和 ViewStore 的分離,讓 TCA 可以擺脫對 UI 框架的依賴。在 SwiftUI 中,body 的刷新是 SwiftUI 運行時通過 @ObservedObject 屬性包裝所提供的特性。現在這部分內容被包含在了 WithViewStore 中。但是 Store 和 ViewStore 本身并不依賴于任何特定的 UI 框架。也就是說,我們也可以在 UIKit 或者 AppKit 的 app 中用同一套 API 來使用 TCA。雖然這需要我們自己去將 View 和 Model 綁定起來,會有些麻煩,但是如果你想要盡快嘗試 TCA,卻又不能使用 SwiftUI,也可以在 UIKit 中進行學習。你得到的經驗可以很容易遷移到其他的 UI 平臺 (甚至 web app) 中去。
練習
為了鞏固,我也準備了一些練習。完成后的項目將會作為下一篇文章的起始代碼使用。不過如果你實在不想進行這些練習,或者不確定是否正確完成,每一篇文章也提供了初始代碼以供參考,所以不必擔心。如果你沒有跟隨代碼部分完成這個示例,你可以在這里[11]找到這次練習的初始代碼。參考實現可以在這里[12]找到。
為數據文本添加顏色
為了更好地看清數字的正負,請為數字加上顏色[13]:正數時用綠色顯示,負數時用紅色顯示。
添加一個 Reset 按鈕
除了加和減以外,添加一個重置按鈕,按下后將數字復原為 0。
為 Counter 補全所有測試
現在測試中只包含了 .increment 的情況。請添加減號和重置按鈕的相關測試。
參考資料
[1]The Composable Architecture: https://github.com/pointfreeco/swift-composable-architecture
[2]使用單向數據流來架構 View Controller: https://onevcat.com/2017/07/state-based-viewcontroller/
[3]相關的書籍: https://objccn.io/products/swift-ui
[4]數據傳遞: https://developer.apple.com/tutorials/app-dev-training/passing-data-with-bindings
[5]狀態管理: https://developer.apple.com/tutorials/app-dev-training/managing-state-and-life-cycle
[6]推薦: https://developer.apple.com/videos/play/wwdc2019/233/
[7]自動化工具: https://www.raywenderlich.com/24426963-snapshot-testing-tutorial-for-swiftui-getting-started
[8]誕生 500 多種架構: https://www.zhihu.com/question/314536318
[9]Elm: https://elm-lang.org/
[10]通用編程語言: https://zh.wikipedia.org/wiki/%E9%80%9A%E7%94%A8%E7%BC%96%E7%A8%8B%E8%AF%AD%E8%A8%80
[11]練習初始代碼: https://github.com/onevcat/CounterDemo/releases/tag/part-1-start
[12]參考實現: https://github.com/onevcat/CounterDemo/releases/tag/part-1-finish
[13]加上顏色: https://developer.apple.com/documentation/swiftui/view/foregroundcolor(_:)