源码探索SwiftUI框架—TCA

4,729 阅读4分钟

背景

如图是苹果官方的 SwiftUI 数据流转过程

  1. View 上产生一个事件到 Action;
  2. Action 改变 State(数据);
  3. 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。

整体架构

1667551256420.png

从这个图中我们可以观察到:

  1. View 持有 Store;
  2. View 使用 Store 构建 ViewStore;
  3. View 使用 ViewStore 进行值绑定
  4. View 给 ViewStore 发送事件
    • ViewStore 调用 Store 发送事件;
    • Store 调用 Store.Reducer 发送事件,Store.Reducer 实现事件,并更新 Store.State 数据
  5. 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 的呢?

  1. State 如何被值绑定?
  2. 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: &currentState, 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。

为什么又要有一层持有关系呢?因为如果你在 Viewbody 中这样写,是不会有 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:

1665304855354.png

如图所示(图采用文章),这样每个 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
      )
    )
  }
}

如此上图就变成了这样(图采用文章):

1665304841527.png

这里的原理是:

  • 下一个 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 给我们带来的新技术 ☀️。

参考资料