聊一聊可組裝框架( TCA )

語言: CN / TW / HK

highlight: a11y-dark

本文將聊聊一個與建立複雜的 SwiftUI 應用很契合的框架 —— The Composable Architecture( 可組裝框架,簡稱 TCA )。包括它的特點和優勢、最新的進展、使用中的注意事項以及學習路徑等問題。

原文發表在我的部落格 wwww.fatbobman.com

歡迎訂閱我的公共號:【肘子的Swift記事本】

TCA 簡介

本節的內容來自 TCA 官網說明的中文版本

The Composable Architecture ( 簡寫為 TCA ) 讓你用統一、便於理解的方式來搭建應用程式,它兼顧了組裝,測試,以及功效。你可以在 SwiftUI,UIKit,以及其他框架,和任何蘋果的平臺( iOS、macOS、tvOS、和 watchOS )上使用 TCA。

TCA 提供了用於搭建適用於各種目的、複雜度的 app 的一些核心工具,你可以一步步地跟隨它去解決很多你在日常開發中時常會碰到的問題,比如:

  • 狀態管理(State Management) 用簡單的值型別來管理應用的狀態,以及在不同介面呼叫這些狀態,使一個介面內的變化可以立刻反映在另一個介面中。
  • 組裝(Composition) 將龐大的功能拆散為小的可以獨立執行的元件,然後再將它們重新組裝成原來的功能。
  • 副作用(Side Effects) 用最可測試和便於理解的方式來讓 app 的某些部分與外界溝通。
  • 測試(Testing) 除了測試某個功能,還能整合測試它與其他功能組合成為的更復雜的功能,以及用端到端測試來了解副作用如何影響你的應用。這樣就可以有力地保證業務邏輯和預期相符。
  • 工效(Ergnomics) 用一個有最少概念和可動部分,且簡單的 API 來做到上面的一切。

本文將不對 State、Action、Reducer、Store 這些概念做進一步的說明

TCA 的特點和優勢

強大的組裝能力

既然框架被命名為可組裝框架( The Composable Architecture ),那麼必然在組裝能力上有其獨到之處。

TCA 鼓勵開發者將大型功能分解成採用同樣開發邏輯的小元件。每個小元件均可進行單元測試、檢視預覽乃至真機除錯,並通過將元件程式碼提取到獨立模組的方式來進一步改善專案的編譯速度。

所謂的組裝,便是將這些獨立的元件按預設的層級、邏輯粘合到一起組成更加完整功能的過程。

組裝這一概念在多數的狀態管理框架中都存在,而且僅需少量的程式碼便可以提供一些基礎的組裝能力。但有限的組裝能力限制並影響了開發者對複雜功能的切分意願,組裝的初衷並沒有被徹底執行。

TCA 提供了大量的工具來豐富其組裝手段,當開發者發現組裝已不是難事時,在開發的初始階段便會從更小的粒度來思考功能的構成,從而創建出更加強壯、易讀、易擴充套件的應用。

TCA 提供的部分用於組裝的工具:

CasePaths

可以將其理解為 KeyPath 的列舉版本。

在其他 Redux-like 框架中,在組裝上下級元件時需要提供兩個獨立的閉包來對映不同元件之間的 Action ,例如:

```swift func lift( keyPath: WritableKeyPath, extractAction: @escaping (LiftedAction) -> AppAction?, // 將下級元件的 Action 轉換為上級元件的 Action embedAction: @escaping (AppAction) -> LiftedAction, // 將上級 Action 轉換為下級的 Action extractEnvironment: @escaping (LiftedEnvironment) -> AppEnvironment ) -> Reducer { .init { state, action, environment in let environment = extractEnvironment(environment) guard let action = extractAction(action) else { return Empty(completeImmediately: true).eraseToAnyPublisher() } let effect = self(&state[keyPath: keyPath], action, environment) return effect.map(embedAction).eraseToAnyPublisher() } }

let appReducer = Reducer.combine( childReducer.lift(keyPath: .childState, extractAction: { switch $0 { // 需要為每個子元件的 Action 分別對映 case .childAction(.increment): return .increment case .childAction(.decrement): return .decrement default: return .noop } }, embedAction: { switch $0 { case .increment: return .childAction(.increment) case .decrement: return .childAction(.decrement) default: return .noop } }, extractEnvironment: {$0}), parentReducer )

```

CasePaths 為這一轉換過程提供了自動處理的能力,我們僅需在上級元件的 Action 中定義一個包含下級 Action 的 case 即可:

```swift enum ParentAction { case ... case childAction(ChildAction) }

let appReducer = Reducer.combine( counterReducer.pullback( state: .childState, action: /ParentAction.childAction, // 通過 CasePaths 直接完成對映 environment: { $0 } ), parentReducer ) ```

IdentifiedArray

IdentifiedArray 是一個具備字典特徵的類陣列型別。它具備陣列的全部功能和接近的效能,要求其中的元素必須符合 Identifiable 協議,且 id 在 identifiedArray 唯一。如此一來,開發者就可以不依賴 index ,直接以字典的方式,通過元素的 id 訪問資料。

IdentifiedArray 確保了將父元件中狀態( State )中的某個序列屬性切分成獨立的子元件狀態時的系統穩定性。避免出現因使用 index 修改元素而導致的異常甚至應用崩潰的情況。

如此一來,開發者在對序列狀態進行拆分時將更有信心,操作也更加方便。

例如:

```swift struct ParentState:Equatable { var cells: IdentifiedArrayOf = [] }

enum ParentAction:Equatable { case cellAction(id:UUID,action:CellAction) // 在父級元件上建立用於對映子 Action 的 case,使用元素的 id 作為標識 case delete(id:UUID) }

struct CellState:Equatable,Identifiable { // 元素符合 Idntifiable 協議 var id:UUID var count:Int var name:String }

enum CellAction:Equatable{ case increment case decrement }

let parentReducer = Reducer{ state,action,_ in switch action { case .cellAction: return .none case .delete(id: let id): state.cells.remove(id:id) // 使用類似字典的方式操作 IdentifiedArray ,避免出現 index 對應錯誤或超出範圍的情況 return .none } }

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

lazy var appReducer = Reducer.combine( // childReducer.forEach(state: .cells, action: /ParentAction.cellAction(id:action:), environment: { _ in () }), parentReducer )

// 在檢視中,可以直接採用 ForEachStore 來進行切分 ForEachStore(store.scope(state: .cells,action: ParentAction.cellAction(id: action:))){ store in CellVeiw(store:store) } ```

WithViewStore

除了應用於 Reducer、Store 上的各種組裝、切分方法外,TCA 還特別針對 SwiftUI 提供了在檢視內進行進一步細分的工具 —— WithViewStore 。

通過 WithViewStore ,開發者可以在檢視中進一步控制當前檢視所要關注的狀態以及操作,不僅改善了檢視中程式碼的純粹性,也在一定程度減少了不必要的檢視重新整理,提高了效能。例如:

swift struct TestCellView:View { let store:Store<CellState,CellAction> var body: some View { VStack { WithViewStore(store,observe: \.count){ viewState in // 只關注 count 的變化,即使 cellState 中的 name 屬性發生變化,本檢視也不會重新重新整理 HStack { Button("-"){viewState.send(.decrement)} Text(viewState.state,format: .number) Button("-"){viewState.send(.increment)} } } } } }

類似的工具還有不少,更多資料請閱讀 TCA 的官方文件

完善的副作用管理機制

在現實的應用中,不可能要求所有的 Reducer 都是純函式,對於儲存資料、獲取資料、網路連線、記錄日誌等等操作都將被視為副作用( TCA 中稱之為 Effect )。

對於副作用,框架主要提供兩種服務:

  • 依賴注入

0.41.0 版本之前,TCA 對於外部環境的注入方式與大多其他的框架類似,並沒有什麼特別之處,但在新版本中,依賴注入的方式有了巨大的變動,下文中會有更詳細的說明。

  • 副作用的包裝和管理

在 TCA 中,Reducer 處理任何一個 Action 之後都需要返回一個 Effect,開發者可以通過在 Effect 中生成或返回新的 Action 從而形成一個 Action 鏈路。

0.40.0 版本之前,開發者需要將副作用的處理程式碼包裝成 Publisher ,從而轉換成 TCA 可接受的 Effect。從 0.40.0 版本開始,我們可以通過一些預設的 Effect 方法( run、task、fireAndForget 等 )直接使用基於 async/await 語法的非同步程式碼,極大地降低了副作用的包裝成本。

另外,TCA 還提供了不少預設的 Effect ,以方便開發者應對包含複雜且大量副作用的使用場景,例如:timer、cancel、debounce、merge、concatenate 等。

總之,TCA 提供了完善的副作用管理機制,僅需少量的程式碼,便可以在 Reducer 中應對不同的場景需求。

便利的測試工具

相較其在組裝方面的表現,TCA 對測試方面的關注與支援也是它另一大特點。這方面它擁有了其他中小框架所不具備的能力。

在 TCA 或類似的框架中,副作用都是以非同步的方式執行的。這意味著,如果我們想測試一個元件的完整功能,通常無法避免都要涉及非同步操作的測試。

而對於 Redux-like 型別的框架來說,開發者通常無需在測試功能邏輯時進行真正的副作用操作,只需讓 Action -> Reducer -> State 的邏輯準確地執行即可。

為此,TCA 提供了一個專門用於測試的 TestStore 型別以及對應的 DispatchQueue 擴充套件,通過 TestStore ,開發者可以在一條虛擬的時間線上,進行傳送 Action,接收 mock Action,比對 State 變化等操作。不僅穩定了測試環境,而且在某些情況下,可以將非同步測試轉換為同步測試,從而極大地縮短了測試的時間。例如( 下面的程式碼採用 0.41.0 版本的 Protocol 方式編寫 ):

```swift struct DemoReducer: ReducerProtocol { struct State: Equatable { var count: Int }

enum Action: Equatable {
    case onAppear
    case timerTick
}

@Dependency(\.mainQueue) var mainQueue // 注入依賴

var body: some ReducerProtocol<State, Action> {
    Reduce { state, action in
        switch action {
        case .onAppear:
            return .run { send in
                while !Task.isCancelled {
                    try await mainQueue.sleep(for: .seconds(1)) // 使用依賴提供的 queue,方便測試
                    await send(.timerTick)
                }
            }
        case .timerTick:
            state.count += 1
            return .none
        }
    }
}

}

@MainActor final class TCA_DemoReducerTests: XCTestCase { func testDemoStore() async { // 建立 TestStore let testStore = TestStore(initialState: DemoReducer.State(count: 0), reducer: DemoReducer()) // 建立測試 queue ,TestSchedulerOf 是 TCA 為了方便單元測試編寫的 DispatchQueue 擴充套件,支援時間調整功能 let queue = DispatchQueue.test testStore.dependencies.mainQueue = queue.eraseToAnyScheduler() // 修改成測試用的依賴 let task = await testStore.send(.onAppear) // 傳送 onAppear Action await queue.advance(by:.seconds(3)) // 時間向前推移 3 秒中( 測試中並不會佔用 3 秒的時間,會以同步的方式進行) _ = await testStore.receive(.timerTick){ $0.count = 1} // 收到 3 次 timerTick Action,並比對 State 的變化 _ = await testStore.receive(.timerTick){ $0.count = 2} _ = await testStore.receive(.timerTick){ $0.count = 3} await task.cancel() // 結束任務 } } ```

上述程式碼,讓我們無需等待,便可以測試一個本來需要執行三秒才能獲得結果的單元測試。

除了 TestStore 外,TCA 還為測試提供了 XCTUnimplemented( 宣告未實現的依賴方法 )、若干用於測試的新斷言以及方便開發者建立截圖的 SnapshotTesting 工具。

如此一來,開發者將可以通過 TCA 構建更加複雜、穩定的應用。

活躍的社群與詳盡的資料

TCA 目前應該是受歡迎程度最高的基於 Swift 語言開發的該型別框架。截至本文寫作時,TCA 在 GitHub 上的 Star 已經達到了 7.2K 。它擁有一個相當活躍的社群,問題的反饋和解答都十分迅速。

TCA 是從 Point Free 的視訊課程中走出來的,Point Free 中有相當多的視訊內容都與 TCA 有關,涉及當前開發中所面對的問題、解決思路、規劃方案、實施細節等等方面。幾乎沒有其他的框架會有如此多詳盡的伴生內容。這些內容可以除了起到了推廣 TCA 的作用外,也讓廣大開發者逐步瞭解並掌握了 TCA 的各個環節,更加容易投入到 TCA 的社群貢獻中。兩者之間起到了非常好的相互促進作用。

TCA 的最新變化( from 0.40.0 )

最近一段時間,TCA 進行了兩次擁有重大意義的升級( 0.40.0、0.41.0 ),本節將對部分的升級內容做以介紹。

更好的非同步支援

在 0.40.0 之前的版本中,開發者需要將副作用的包裝成 Publisher ,如此一來不僅程式碼量較多,也不利於使用目前日益增多的基於 async/await 機制的 API。本次更新後,開發者將可以在 Reducer 的 Effect 中直接使用這些新式的 API ,在減少了程式碼量的同時,也可以享受到 Swift 語言提供的更好的執行緒協調機制。

通過使用 SwiftUI 的 task 修飾器,TCA 實現了對需要長時間執行的 Effect 的生命週期進行自動管理。

由於 onAppear 和 onDisappear 在某些場合會在檢視的存續期中多處出現,因此使用 task 保持的 Effect 生命週期並不一定與檢視一致

例如,下面的程式碼,在 0.40.0 版本之後,將更加地清晰和自然:

```swift // 老版本 switch action { case .userDidTakeScreenshotNotification: state.screenshotCount += 1 return .none

case .onAppear: return environment.notificationCenter .publisher(for: UIApplication.userDidTakeScreenshotNotification) .map { _ in LongLivingEffectsAction.userDidTakeScreenshotNotification } .eraseToEffect() .cancellable(id: UserDidTakeScreenshotNotificationId.self)

case .onDisappear: return .cancel(id: UserDidTakeScreenshotNotificationId.self) }

// in View

Text("Hello") .onAppear { viewStore.send(.onAppear) } .onDisappear { viewStore.send(.onDisappear) } ```

使用 Task 模式:

```swift switch action { case .task: return .run { send in for await _ in await NotificationCenter.default.notifications(named: UIApplication.userDidTakeScreenshotNotification).values { // 從 AsyncStream 中讀取 await send(.userDidTakeScreenshotNotification) } }

case .userDidTakeScreenshotNotification:
  state.screenshotCount += 1
  return .none
}

}

// in View Text("Hello") .task { await viewStore.send(.task).finish() } // 在 onDisappear 的時候自動結束 ```

另一方面,通過新的 TaskResult( 類似 Result 的機制 )型別,TCA 對 Task 的返回結果進行了巧妙地包裝,讓使用者無需在 Reducer 中使用以前 Catch 的方式來處理錯誤。

Reducer Protocol —— 用宣告檢視的方式來編寫 Reducer

從 0.41.0 開始,開發者可以用全新的 ReducerProtocol 的方式來宣告 Reducer( 上文中介紹測試工具中展示的程式碼 ),並可通過 Dependency 的方式,跨層級的在 Reducer 中引入依賴。

Reducer Protocol 將帶來如下優勢:

  • 更容易理解的定義邏輯

每個 Feature 都擁有自己的名稱空間,其中包含它所需的 State、Action 以及引入的依賴,程式碼的組織更加合理。

  • 更加友好的 IDE 支援

在未使用 Protocol 模式之前,Reducer 是通過一個擁有三個泛型引數的閉包生成的,在此種模式下,Xcode 的程式碼補全功能將不起作用,開發者只能通過記憶來編寫程式碼,效率相當低下。使用了 ReducerProtocol 後,由於所有的需要用到的型別都宣告在一個名稱空間中,開發者將可以充分利用 Xcode 的自動補全高效地進行開發

  • 與 SwiftUI 檢視類似的定義模式

通過使用 result builder 重構了 Reducer 的組裝機制,開發者將採用與宣告 SwiftUI 檢視一樣的方式來宣告 Reducer,更加地簡潔和直觀。由於調整了 Reducer 組裝的構成角度,將從子 Reducer pullback 至父 Reducer 的方式修改為從父 Reducer 上 scope 子 Reducer 的邏輯。不僅更加易懂,而且也避免了一些容易出現的組裝錯誤( 因父子 Reducer 組裝時錯誤的擺放順序所導致 )

  • 更好的 Reducer 效能

新的宣告方式,對 Swift 語言編譯器更加地友好,將享受到更多的效能優化。在實踐中,對同一個 Action 的呼叫,採用 Reducer Protocol 的方式所建立的呼叫棧更淺

  • 更加完善的依賴管理

採用了全新的 DependencyKey 方式來宣告依賴( 與 SwiftUI 的 EnvironmentKey 非常相似),從而實現了同 EnvironmentValue 一樣的可以跨 Reducer 層級的依賴引入。並且,在 DependencyKey 中,開發者可以同時定義用於 live、test、preview 三種場景分別對應的實現,進一步簡化了在不同場景下調整依賴的需求

注意事項

學習成本

同其他具備強大功能的框架一樣,TCA 的學習成本是不低的。儘管瞭解 TCA 的用法並不需要太多的時間,但如果開發者無法真正地掌握其內在的組裝邏輯,很難寫出讓人滿意的程式碼。

貌似 TCA 為開發者提供了一種從下至上的開發途徑,但如果沒有對完整功能進行良好地構思,到最後會發現無法組裝出預想的效果。

TCA 對開發者的抽象和規劃能力要求較高,切記不要簡單學習後就投入到開發具備複雜需求的生產實踐中。

效能

在 TCA 中,State、Action 都被要求符合 Equatable 協議,並且同很多 Redux like 解決方案一樣,TCA 無法提供對引用值型別狀態的支援。這意味著,在必須使用引用型別的一些場景,如果仍想保持單一 State 的邏輯,需要對引用型別進行值轉換,在此種情況下,將有一定的效能損失。

另外,採用 WithViewStore 關注特定屬性的機制在內部都是通過 Combine 來進行的。當 Reducer 的層級較多時,TCA 也需要付出不小的成本進行切分和比對的工作。一旦其所付出的代價超出了優化的結果,便會出現效能問題。

最後,TCA 目前仍無法應對高頻次的 Action 呼叫,如果你的應用可能會產生高頻次的 Action ( 每秒幾十次 ),那麼就需要對事件源進行一定的限制或調整。否則就會出現狀態不同步的情況。

如何學習 TCA

儘管 TCA 在很大程度上減少了在檢視中使用其他依賴項( 符合 DynamicProperty 協議 )的機會,但開發者仍應對 SwiftUI 提供的原生依賴方案有深刻的認識和掌握。一方面在很多輕量開發中,我們不需要使用如此重量級的框架,另一方面,即使在使用 TCA 的時候,開發者仍需要利用這些原生依賴作為 TCA 的補充。在 TCA 提供的 CaseStudies 程式碼中,已經充分地展示了這一點。

如果你是 SwiftUI 的初學者,並且對 Redux 或 Elm 也沒有多少了解,可以先嚐試使用一些比較輕量級的 Redux-like 框架。在對這種開發模式有了一定的熟悉後,再學習 TCA 。我推薦大家可以閱讀 Majid 創作的有關 Redux-like 的 系列文章

王巍有關 TCA 的系列文章 —— TCA - SwiftUI 的救星? 也是極好的入門資料,建議對 TCA 感興趣的開發者進行閱讀。

TCA 專案中提供了不少的範例程式碼,從最簡單的 Reducer 建立 到功能完善的 上架應用。這些範例程式碼也隨著 TCA 的版本更新而不斷變化,其中不少已經使用 Reducer Protocol 進行了重構。

當然,想了解有關 TCA 最新、最深入的內容還是需要觀看 Point Free 網站上的視訊課程。這些視訊課程都提供了完整的文字版本以及對應的程式碼,即使你的聽力有限也能通過文字版本掌握所有的內容。

如果你有訂閱 Point Free 課程的打算,可以考慮使用我的 指引連結

總結

按照計劃,TCA 在不久之後將使用 async/await 程式碼替換掉當前剩餘的 Combine 程式碼( Apple 的閉原始碼 )。這樣它將可以成為一個支援多平臺的框架。沒準屆時 TCA 將有機會被移植到其他語言。

希望本文能夠對你有所幫助。同時也歡迎你通過 TwitterDiscord 頻道 或部落格的留言板與我進行交流。

我正以聊天室、Twitter、部落格留言等討論為靈感,從中選取有代表性的問題和技巧製作成 Tips ,釋出在 Twitter 上。每週也會對當周部落格上的新文章以及在 Twitter 上釋出的 Tips 進行彙總,並通過郵件列表的形式傳送給訂閱者。

訂閱下方的 郵件列表,可以及時獲得每週的 Tips 彙總。

原文發表在我的部落格 wwww.fatbobman.com

歡迎訂閱我的公共號:【肘子的Swift記事本】