背景
如图是苹果官方的 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 给咱们带来的新技术 ☀️。
参考资料
- TCA 项目
- TCA – SwiftUI 的救星?(系列)
- 聊一聊可拼装结构( TCA )
- The Composable Architecture (可拼装架构)