背景

如图是苹果官方的 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。

整体架构

源码探索SwiftUI框架—TCA

从这个图中咱们能够调查到:

  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:

源码探索SwiftUI框架—TCA

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

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

源码探索SwiftUI框架—TCA

这儿的原理是:

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

参考资料

  • TCA 项目
  • TCA – SwiftUI 的救星?(系列)
  • 聊一聊可拼装结构( TCA )
  • The Composable Architecture (可拼装架构)