背景
如图是苹果官方的 SwiftUI 数据流转过程:
- View 上产生一个事件到 Action;
- Action 改变 State(数据);
- State 更新 View 的展示:使用 @State、@ObservedObject 等,来对 State 数据和 View 绑定操作。
此设计主要表达了 SwiftUI 是 数据驱动 UI,这个概念在传统的 iOS 开发中很新颖,在框架的选择上我们是用 MVC、MVP、MVVM 呢?貌似都不合适。
不用担心,已经有两位大神设计和开发出来一个合适的框架了:TCA,该项目在 Github 上已有 7.4k Star,让我们来详细了解下吧。
概念
TCA (The Composable Architecture)可组合架构: 让你用统一、便于理解的方式来搭建应用程序,它兼顾了组装,测试,以及功效。你可以在 SwiftUI,UIKit,以及其他框架,和任何苹果的平台(iOS、macOS、tvOS、和 watchOS)上使用 TCA。
整体架构
从这个图中我们可以观察到:
- View 持有 Store;
- View 使用 Store 构建 ViewStore;
- View 使用 ViewStore 进行值绑定;
- View 给 ViewStore 发送事件:
- ViewStore 调用 Store 发送事件;
- Store 调用 Store.Reducer 发送事件,
Store.Reducer 实现事件
,并更新Store.State 数据
;
- ViewStore 通过观察了 Store.State 数据,监听到值更新,通知 View 刷新 UI。
接下来我们从使用来开始了解。
1. 使用步骤
步骤一: 定义 State
、定义 Action
、定义 Reducer
并实现 Action 事件:
struct RecipeList: ReducerProtocol {
// MARK: - State
struct State: Equatable {
var recipeList: IdentifiedArrayOf<RecipeInfoRow.State> = []
var isLoading: Bool = true
var expandingId: Int = -1
}
// MARK: - Action
enum Action {
case loadData //!< 加载数据
case loadRecipesDone(TaskResult<[RecipeViewModel]>)
case rowTap(selectId: Int) //!< 行点击
}
// MARK: - Reducer
private enum CancelID {}
var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
switch action {
case .loadData:
state.isLoading = true
return .task {
await .loadRecipesDone(TaskResult { try await LoadRecipeRequest.loadAllData() })
.cancellable(id: CancelID.self)
case .loadRecipesDone(result: let result):
...
return .none
case .rowTap(let selectId):
state.expandingId = state.expandingId != selectId ? selectId : -1
return .none
}
}
}
}
步骤二: View
:使用 ViewStore
做值绑定和发送事件:
struct RecipeListView: View {
let store: StoreOf<RecipeList>
var body: some View {
WithViewStore(store) { viewStore in
if viewStore.recipeList.isEmpty {
if viewStore.isLoading {
LoadingView().offset(y: -40)
.onAppear {
viewStore.send(.loadData)
}
} else {
RetryButton {
viewStore.send(.loadData)
}.offset(y: -40)
}
} else {
... // 列表
}
}
}
}
步骤三: 外部调用:
RecipeListView(store: Store(
initialState: RecipeList.State(),
reducer: RecipeList())
以上三个步骤使用起来很简单,主要 定义 State/Action/Reducer
,然后 View 使用 ViewStore
做数据展示和发送事件即可。
至于 ViewStore,如何给 View 提供这个便利,如何管理 State/Action/Reducer 的呢?
- State 如何被值绑定?
- Reducer 如何接收 Action 并更新 State 的值?
要了解这些,需要涉及的类型: State/Action/Reducer、Store、ViewStore、WithViewStore。
2. 主要源码
首先我们从入口开始看,View 是有个 store 属性的,让我们从 Store 的初始化开始理解。
Store:State/Action/Reducer
上面的使用中我们已经了解到:State 是数据
、Action 只是个枚举定义
、Reducer 实现 Action 方法
。我们不考虑 Action 这个枚举定义,他们和 Store 的关系如下:
持有关系:Store —> State 和 Reducer。
// Store的两种生成方式:1.state和reducer 2.父Store的scope 🍭等下再看
// Store使用CurrentValueSubject持有state,private持有reducer.故不能根据Store直接读State或发送Action
// Store的send方法是调用reduce执行
// 从Store获取State,使用scope方法获取,在闭包中回调 🍭等下再看
public final class Store<State, Action> {
private let reducer: any ReducerProtocol<State, Action>
var state: CurrentValueSubject<State, Never>
init<R: ReducerProtocol>(
initialState: R.State,
reducer: R,
mainThreadChecksEnabled: Bool
) where R.State == State, R.Action == Action {
self.state = CurrentValueSubject(initialState)
self.reducer = reducer
self.threadCheck(status: .`init`)
}
public func scope<ChildState, ChildAction>( // 🍭等下再看
state toChildState: @escaping (State) -> ChildState,
action fromChildAction: @escaping (ChildAction) -> Action
) -> Store<ChildState, ChildAction> {
self.threadCheck(status: .scope)
return self.reducer.rescope(self, state: toChildState, action: fromChildAction)
}
public func scope<ChildState>( // 🍭等下再看
state toChildState: @escaping (State) -> ChildState
) -> Store<ChildState, Action> {
self.scope(state: toChildState, action: { $0 })
}
// 发送事件
func send(
_ action: Action,
originatingFrom originatingAction: Action? = nil
) -> Task<Void, Never>? {
let effect = self.reducer.reduce(into: ¤tState, action: action)
switch effect.operation {
...
}
}
}
由此看 Store 主要是持有着 State 和 Reducer,然而 View 中做值绑定和发送事件是通过 ViewStore 进行的?
ViewStore
关系:ViewStore 的初始化接收 Store。
// ViewStore接收Store,并监听Store的State变化和发送Action
// SwiftUI和UIKit都可以使用:Store 和 ViewStore 的分离,让 TCA 可以摆脱对 UI 框架的依赖
// 1.用publisher方式持有Store.State并订阅其变化,然后发送自己改变的消息(便于WithViewStore监听到刷新)
// 2.持有_send闭包是执行store.send,便于发送Action事件
// 3.bingding方便keypath的使用
public final class ViewStore<State, Action>: ObservableObject {
public private(set) lazy var objectWillChange = ObservableObjectPublisher()
private let _send: (Action) -> Task<Void, Never>?
fileprivate let _state: CurrentValueRelay<State>
private var viewCancellable: AnyCancellable?
// 初始化
public init(
_ store: Store<State, Action>,
removeDuplicates isDuplicate: @escaping (State, State) -> Bool
) {
self._send = { store.send($0) }
self._state = CurrentValueRelay(store.state.value)
self.viewCancellable = store.state
.removeDuplicates(by: isDuplicate)
.sink { [weak objectWillChange = self.objectWillChange, weak _state = self._state] in
guard let objectWillChange = objectWillChange, let _state = _state else { return }
objectWillChange.send()
_state.value = $0
}
}
// 发送事件
@discardableResult
public func send(_ action: Action) -> ViewStoreTask {
.init(rawValue: self._send(action))
}
// 绑定
public func binding<Value>(
get: @escaping (State) -> Value,
send action: Action
) -> Binding<Value> {
self.binding(get: get, send: { _ in action })
}
}
final class CurrentValueRelay<Output>: Publisher {
}
由此可见,ViewStore 主要是监听 Store 的 State 变化、调用 Store 发送 Action。
现在我们知道了 State 是数据,Reducer 处理事件,Store 持有前两者,ViewStore 通过 Store 来管理前两者(为什么要设计 ViewStore 我们稍后再说)。我们先来看一下 View 中还有个写法 WithViewStore 是什么呢?为什么这么设计呢?
WithViewStore
持有关系:WithViewStore —> ViewStore。
为什么又要有一层持有关系呢?因为如果你在 View
的 body
中这样写,是不会有 State
值更新然后 View
刷新的效果的。
struct RecipeListView: View {
let store: StoreOf<RecipeList>
var body: some View {
let viewStore = ViewStore(store)
}
}
这就是 WithViewStore
要起到的作用:
// WithViewStore 把纯数据 Store 转换为 SwiftUI 可观测的数据
// 1.接受的闭包要满足View协议 2.闭包回调出viewStore 3.private持有viewStore,用@ObservedObject观察并相应body刷新
public struct WithViewStore<ViewState, ViewAction, Content> {
@ObservedObject private var viewStore: ViewStore<ViewState, ViewAction> // 观察ViewStores刷新body
private let content: (ViewStore<ViewState, ViewAction>) -> Content
init(
store: Store<ViewState, ViewAction>,
removeDuplicates isDuplicate: @escaping (ViewState, ViewState) -> Bool,
content: @escaping (ViewStore<ViewState, ViewAction>) -> Content,
) {
self.content = content
self.viewStore = ViewStore(store, removeDuplicates: isDuplicate)
}
public var body: Content {
return self.content(ViewStore(self.viewStore))
}
}
看完源码我们就明白了:ViewStore 监听了 Store 的 State 和处理 Action,但是需要 WithViewStore 观察 ViewStore 的变化并刷新 body。
总结,再梳理下这个流程:
View
—> WithViewStore —> ViewStore —> Store —>State/Action/Reducer
。
我们使用时只需要关注前后两步,TCA 会为我们做很多处理来支持这一切:值绑定、发送事件、刷新 body。
所有步骤我们都通过源码分析结束,我们看这个过程中 ViewStore 和 Store 貌似冲突而多余了?刚刚源码中也有一点内容没太明白,Store 的 scope 切分指的是什么?
3. Store 和 ViewStore
切分 Store 避免不必要的 view 更新
不用看这个小标题,我们先顺着思路来。
首先我们思考下,SwiftUI 和 UIKit 中的开发有很大一点不同的是,它是不需要持有子 View 的,就意味着当它刷新的时候,其子 View 会重新创建。
举个例子:
// 一个出现时会加载Loading页的list
struct RecipeListView: View {
let store: StoreOf<RecipeList>
var body: some View {
WithViewStore(store) { viewStore in
if viewStore.recipeList.isEmpty {
LoadingView() // loading页
} else {
... // 列表
}
}
}
}
// 一个RootView,展示list
struct RecipeRootView: View {
let store: StoreOf<RecipeList>
@ObservedObject var global: GlobalUtil = GlobalUtil.shared
var body: some View {
VStack {
Text(String(format: "开关状态: %d", global.showEnglishName))
RecipeListView() // ①会重新加载
RecipeListView(store: store) // ②不会重新加载
}
}
}
如上代码,在全局一个开关 global.showEnglishName 改动下,因为 RootView 的 Text 控件绑定了它的值,所以 RootView 会重新刷新。因为 Text 控件和 RecipeListView 控件都是未持有的,所以也会重新创建。这种情况下,如果类似 UIKit 的写法不传参,即 ① 就会重新loading,② 因为持有的 store 对象 RootView 没有重新创建而保存着。
Case1. 因此,为了保证数据的持久存在,我们要思考办法。很常见的选择是,整个 app 只有一个 Store
来保存数据,在 SwifUI 中即 State。所有的 View 都观察这个 Store 来展示和刷新 body:
如图所示(图采用文章),这样每个 View,无论一级还是二级,都全局观察一个 store,使用一个数据源,例如:
class Store: ObservableObject {
@Published var state1 = State1()
@Published var state2 = State2()
func dispatch(_ action: AppAction) {
// 处理分发所有事件
}
}
struct View1: View {
@EnvironmentObject var store: Store
var state: State1 { store.state1 }
}
struct View2: View {
@EnvironmentObject var store: Store
var state: State2 { store.state2 }
}
这样的写法最大的问题就是:如果 View1 监听的 State1 属性改变了,View2 由于观察到了 State 也会随着改变,可谓是牵一发而动全身。
Case2. TCA 的 ViewStore 就是为了避免这个问题:Store 依然是状态的实际管理者和持有者,它代表了 app 状态的纯数据层的表示。Store 最重要的功能,是对状态进行切分
,比如对于图示中的 State 和 Store:
在将 Store 传递给子页面或下一级页面时,可以使用 .scope
将其“切分”出来:
struct AppState {
var state1: State1
var state2: State2
}
struct AppAction {
var action1: Action1
var action2: Action2
}
let store = Store(
initialState: AppState( /* */ ),
reducer: appReducer,
environment: ()
)
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
)
)
}
}
如此上图就变成了这样(图采用文章):
这里的原理是:
- 下一个 View1 持有的只是切分后的 Store,这个 Store 是每次随 View1 的创建而重新创建的,由于 State1 还是上一个持有者创建的,所以保证了数据还是不会受到 View1 重新创建的影响;
- Case1 是通过使用 @EnvironmentObject(和 @ObservedObject 作用一样都是观察数据,范围不同,具体不细说了)观察全局 Store 的变化来更新 body 的。Case2 划分后的 Store 呢?根据前面的源码我们可以知道答案:TCA 通过 WithViewStore 把一个代表纯数据的 Store 转换为 ViewStore;WithViewStore 是个 view,接受的闭包也满足 View 协议时,
WithViewStore 通过 @ObservedObject 观察这个 ViewStore 来更新 body
。如此保证了使用 TCA 的 View 刷新 body; - 最后,因为 View1 通过 WithViewStore 观察的是 ViewModel 来刷新 body,因此也避免了 Case1 的“牵一发而动全身”的问题。
跨 UI 框架的使用
Store 和 ViewStore 的分离,让 TCA 可以摆脱对 UI 框架的依赖。
- 在如上 SwiftUI 中,body 的刷新是
WithViewStore
通过 @ObservedObject 对 ViewStore 的监听; - 那么 ViewStore 和 Store 并不依赖于 SwiftUI 框架;
- 因此,UIKit 或者 AppKit 同样可以使用 TCA,结合
Combine
来进行订阅绑定即可。
例如:
class CounterViewController: UIViewController {
let viewStore: ViewStoreOf<Counter>
private var cancellables: Set<AnyCancellable> = []
init(store: StoreOf<Counter>) {
self.viewStore = ViewStore(store)
super.init(nibName: nil, bundle: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
...
self.viewStore.publisher
.map { "\($0.count)" }
.assign(to: \.text, on: countLabel) // viewStore值绑定
.store(in: &self.cancellables)
}
@objc func decrementButtonTapped() {
self.viewStore.send(.decrementButtonTapped) // viewStore发送事件
}
}
4. 其他特性
关于绑定
struct RecipeList: ReducerProtocol {
class State: Equatable {
var searchText: String = ""
var recipeList: IdentifiedArrayOf<RecipeInfoRow.State> = []
var displayList: IdentifiedArrayOf<RecipeInfoRow.State> {
if searchText.isEmpty {
return recipeList
}
return recipeList.filter { rowState in
rowState.model.name.contains(searchText)
}
}
}
enum Action {
case search(String)
}
var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
switch action {
case .search(let text):
state.searchText = text
return .none
}
}
}
}
struct RecipeListView: View {
var body: some View {
TextField("搜索", text: viewStore.binding(
get: \.searchText,
send: { RecipeList.Action.search($0) }
))
}
}
viewStore.binding 方法接受 get 和 send 两个参数,它们都是和当前 ViewStore 及绑定 view 类型相关的泛型函数。在特化 (将泛型在这个上下文中转换为具体类型) 后:
- get: (Counter) -> String 负责为对象 View (这里的 TextField) 提供数据;
- send: (String) -> CounterAction 负责将 View 新发送的值转换为 ViewStore 可以理解的 action,并发送它来触发 counterReducer。
Effect
Effect 解决的则是 reducer 输出阶段的副作用:如果在 Reducer 接收到某个行为之后,需要作出非状态变化的反应,比如发送一个网络请求、向硬盘写一些数据、或者甚至是监听某个通知等,都需要通过返回 Effect 进行。 Effect 定义了需要在纯函数外执行的代码,以及处理结果的方式:一般来说这个执行过程会是一个耗时行为,行为的结果通过 Action 的方式在未来某个时间再次触发 reducer 并更新最终状态。TCA 在运行 reducer
的代码,并获取到返回的 Effect
后,负责执行它所定义的代码,然后按照需要发送新的 Action
。
Effect 到底存在在哪个步骤?我们上面源码看到的一个事件处理过程如下:
// View
viewStore.send(Action)
// ViewStore
store.send($0)
// Store 调用reducer执行action,并接收返回值Effect,继续处理
// reducer.reduce(::)上面例子中Reducer中的实现,例如网络请求事件return的就是个耗时Effect
let effect = self.reducer.reduce(into: State, action: action)
switch effect.operation {
case .none:
break
case let .publisher(publisher):
...
}
Effect 定义:
public struct Effect<Action, Failure: Error> {
enum Operation {
case none
case publisher(AnyPublisher<Action, Failure>)
case run(TaskPriority? = nil, @Sendable (Send<Action>) async -> Void)
}
let operation: Operation
}
除了这三个基础的 Operation,Effect 还扩展了 Debouncing、Deferring、Throttling、Timer。
总结
至此,我们已经了解了 SwiftUI 中 TCA 的简单使用、关键组件的源码理解,以及框架设计的背后思想。
不过尽管 TCA 项目当前已经 7.3k Star,也还是在快速发展和演进中:当前 Release 版本打的很频繁、之前有的概念比如 Environment、pullback 现在已经删除了(喵神的文章有提到,当前最新 Release 版没了,具体可以查看库的 Deprecations.swift 详细记录)。
让我们一边期待它的完善,一边学习和思考适合 SwiftUI 的架构方式,拥抱 Apple 给我们带来的新技术 ☀️。