パート1: TCAでactionをsendして実行されるまで
サンプル
単純なViewとReducerを用意します。ボタンを押したらviewStoreでsendするだけの簡単な構成です。Viewを起点にしてまずStoreから見ていきましょう。
import SwiftUI
import ComposableArchitecture
struct AppReducer: Reducer {
struct State: Equatable {
var count: Int = .zero
public init() {}
}
enum Action: Equatable {
case buttonTap
}
func reduce(into state: inout State, action: Action) -> ComposableArchitecture.Effect<Action> {
switch action {
case .buttonTap:
state.count += 1
return .none
}
}
}
struct ContentView: View {
private let store: StoreOf<AppReducer> = .init(initialState: .init(), reducer: { AppReducer() })
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
VStack {
Button("カウント") {
viewStore.send(.buttonTap)
}
Text(String(viewStore.count))
}
.padding()
}
}
}
Store
StoreOf
まず最初に目につくのはStoreOfです。StoreOf<R>はStoreのtypealiasで、StateとActionを明示的に指定せずReducerを指定すれば良いだけになっています。
public typealias StoreOf<R: Reducer> = Store<R.State, R.Action>
A convenience type alias for referring to a store of a given reducer's domain.
Store
次にsendを読み解くのに必要なStoreのメンバ変数を見ていきましょう。
Storeはreducerの他に以下の状態変数を持っています。
- アクションを制御するための変数
- 副作用を処理するための変数
- Scopeなどの親子関係を維持するための変数
イニシャライザを確認しつつ簡単にそれぞれのメンバ変数を見ていきます。
public final class Store<State, Action> {
private var bufferedActions: [Action] = []
@_spi(Internals) public var effectCancellables: [UUID: AnyCancellable] = [:]
var _isInvalidated = { false }
private var isSending = false
var parentCancellable: AnyCancellable?
private let reducer: any Reducer<State, Action>
@_spi(Internals) public var state: CurrentValueSubject<State, Never>
bufferedActions
viewStore.send(.buttonTap)などのようにactionをsendすると、このbufferdActionに溜まっていきます。isSendingとセットで使用され、このisSendingがtrueの場合、送られたきたactionを実行せずにこのbufferdActionに溜めていきます。
effectCancellables
effectCancellablesは、reduceのreturnにおいて、.publisherで返すような場合に、そのcancellableを保持しておきます。.runや.noneにおいては使用されません。
func reduce(into state: inout State, action: Action) -> ComposableArchitecture.Effect<Action> {
switch action {
case .buttonTap:
state.count += 1
return .publisher {
Just(.increment)
.delay(for: 1, scheduler: DispatchQueue.main)
}
case .increment:
state.count += 1
return .none
}
}
なお、副作用を表すEffect型はoperationとしてrun、publisher、noneの3つしかありません。
public struct Effect<Action> {
@usableFromInline
enum Operation {
case none
case publisher(AnyPublisher<Action, Never>)
case run(TaskPriority? = nil, @Sendable (_ send: Send<Action>) async -> Void)
}
@usableFromInline
let operation: Operation
@usableFromInline
init(operation: Operation) {
self.operation = operation
}
}
_isInvalidated
これはifLetやPresentatinoStateに使用されているようです。今回の例ではあまり絡みはないので割愛します。
isSending
bufferedActionsと一緒に使用されます。使用用途は同上です。
parentCancellable
Scopeなどで子のReuducerを管理する場合、resopeなどでこのparentCancellableに親のstateを紐づけます。
@inlinable
func rescope<ScopedState, ScopedAction, RescopedState, RescopedAction>(
_ store: Store<ScopedState, ScopedAction>,
state toRescopedState: @escaping (ScopedState) -> RescopedState,
action fromRescopedAction: @escaping (RescopedState, RescopedAction) -> ScopedAction?,
removeDuplicates isDuplicate: ((RescopedState, RescopedState) -> Bool)?
) -> Store<RescopedState, RescopedAction> {
...
// ↓ここ
childStore.parentCancellable = store.state
.dropFirst()
.sink { [weak childStore] newValue in
guard !reducer.isSending, let childStore = childStore else { return }
let newValue = toRescopedState(newValue)
guard isDuplicate.map({ !$0(childStore.state.value, newValue) }) ?? true else {
return
}
childStore.state.value = newValue
}
...
}
reducer
storeを生成する際に渡したreducerです。
private let store: StoreOf<AppReducer> = .init(initialState: .init(), reducer: { AppReducer() })
なんでclosureで渡してるんだっていうところですが、ここはReducerを実装する際にreduceとbodyを選択できますがそこで見ようかと思います。今はとりあえずreducerが渡されると考えます。
@ReducerBuilder<State, Action> reducer: () -> R,
state
parentCancellableで見たように、このstateを介して親の状態変更を子に伝えます。
WithViewStore
実際にsendするときは、WithViewStoreが返すviewStoreからsendしています。
こいつは何者でしょうか?
struct ContentView: View {
private let store: StoreOf<AppReducer> = .init(initialState: .init(), reducer: { AppReducer() })
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
VStack {
Button("カウント") {
viewStore.send(.buttonTap)
}
Text(String(viewStore.count))
}
.padding()
}
}
}
WithViewStoreのコメントにも書かれている通り、storeの状態管理を実際には手動でこのように行うことができます。WithViewStoreはこのヘルパーです。
struct ProfileView: View {
let store: StoreOf<Profile>
@ObservedObject var viewStore: ViewStoreOf<Profile>
init(store: StoreOf<Profile>) {
self.store = store
self.viewStore = ViewStore(store, observe: { $0 })
}
var body: some View {
Text("\(self.viewStore.username)")
// ...
}
}
Debug用のコードを除いた状態でみるとその様子が分かりやすいかと思います。
public struct WithViewStore<ViewState, ViewAction, Content: View>: View {
private let content: (ViewStore<ViewState, ViewAction>) -> Content
@ObservedObject private var viewStore: ViewStore<ViewState, ViewAction>
ViewStore
WithViewStoreがメンバとして持っているViewStoreを見ていきます。
ViewStoreはstateの管理とactionのsendを司ります。ViewStoreはカスタムのObservableObjectです。
public final class ViewStore<ViewState, ViewAction>: ObservableObject {
public private(set) lazy var objectWillChange = ObservableObjectPublisher()
let _isInvalidated: () -> Bool
private let _send: (ViewAction) -> Task<Void, Never>?
fileprivate let _state: CurrentValueRelay<ViewState>
private var viewCancellable: AnyCancellable?
これによって何をやりたいかはイニシャライザをみるとわかります。
Viewの更新をstateで監視したい変数に限って行いたいためにカスタムのObservableObjectとしてViewStoreが定義されています。
public init<State>(
_ store: Store<State, ViewAction>,
observe toViewState: @escaping (_ state: State) -> ViewState,
removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool
) {
self._send = { store.send($0, originatingFrom: nil) }
self._state = CurrentValueRelay(toViewState(store.state.value))
self._isInvalidated = store._isInvalidated
self.viewCancellable = store.state
.map(toViewState)
.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
}
}
PublisherにはremoveDuplicatesという便利な関数が用意されており、これによって前回の値と異なる場合のみにpublishしてしてくれます。
public func removeDuplicates(by predicate: @escaping (Self.Output, Self.Output) -> Bool) -> Publishers.RemoveDuplicates<Self>
デフォルトでは次のように==が指定されています。
public init<State>(
_ store: Store<State, ViewAction>,
observe toViewState: @escaping (_ state: State) -> ViewState,
@ViewBuilder content: @escaping (_ viewStore: ViewStore<ViewState, ViewAction>) -> Content,
file: StaticString = #fileID,
line: UInt = #line
) {
self.init(
store: store.scope(state: toViewState, action: { $0 }),
removeDuplicates: ==,
content: content,
file: file,
line: line
)
}
別途指定できるイニシャライザもあるので、下の事例のようにidが変更した時だけviewの更新をかけたい場合(不用意なViewの更新を抑制したい場合)は使ってみた方が良さそうです。
肝心のsendは次のとおりで、_sendは、Storeのsendを呼び出しますので、再度Storeを見にいきます。
@discardableResult
public func send(_ action: ViewAction) -> StoreTask {
.init(rawValue: self._send(action))
}
self._send(action)はここを見ます。
public final class ViewStore<ViewState, ViewAction>: ObservableObject {
...
private let _send: (ViewAction) -> Task<Void, Never>?
...
public init<State>(
_ store: Store<State, ViewAction>,
observe toViewState: @escaping (_ state: State) -> ViewState,
removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool
) {
self._send = { store.send($0, originatingFrom: nil) }
...
}
}
これでsendに至るまでの前準備が終わりました。後編からはこのStoreのsendメソッドを見てみます。
Discussion