先日TCAで簡単なサンプルアプリを作ってみたのですが、まだまだTCAの理解が曖昧なところが多々あるため、もう一度サンプルコードを一から丁寧に読んでいます。
理解できたところは順次アウトプットしていこうと思います。
まずは、一番単純なサンプルであるカウンターサンプル(01-GettingStarted-Counter
)について、私なりに説明してみたいと思います。
アクションが処理される流れ
カウンターサンプルは、プラスボタンをタップすると数字がカウントアップされ、マイナスボタンをタップすると数字がカウントダウンされるだけの単純な画面です。
このボタンタップというアクションによってビューの状態(ここでは真ん中の数字)が変更される流れを追ってみたいと思います。
カウンターサンプルのビューは以下のように実装されています。ビューのルートはWithViewStoreで、こいつの配下に実際の画面のビューを定義しています。プラスボタンをタップしたときに、{ viewStore.send(.incrementButtonTapped) }
というクロージャが実行されるようになっています。
struct CounterView: View { let store: Store<CounterState, CounterAction> var body: some View { WithViewStore(self.store) { viewStore in HStack { Button("−") { viewStore.send(.decrementButtonTapped) } Text("\(viewStore.count)") .font(Font.body.monospacedDigit()) Button("+") { viewStore.send(.incrementButtonTapped) } } } } }
ViewStore.send
というメソッドでアクション(CounterAction.incrementButtonTapped
)を通知すると、TCAの内部を経由して、最終的に以下のReducerにアクションが通知されます。
通知されたのはincrementButtonTapped
なので、count
の値を1増やします。count
はこの画面の状態を表すStateです。
let counterReducer = Reducer<CounterState, CounterAction, CounterEnvironment> { state, action, _ in switch action { case .decrementButtonTapped: state.count -= 1 return .none case .incrementButtonTapped: state.count += 1 return .none } }
ビュー側ではText("\(viewStore.count)")
という形でcount
を参照しています。
viewStore
はWithViewStoreのプロパティで、@ObservedObjectで宣言されているため、値の変更を検知して画面を再描画することができます。このため、アクションによってcount
が変化すると、それに伴って画面上の数字も変化します。
ViewStoreのプロパティとしてローカルのStateが参照できる仕組み
カウンターサンプル画面のStateはCounterStateとして定義されています。
struct CounterState: Equatable { var count = 0 }
さきほどこのStateのcount
というプロパティを何気なくViewStoreのプロパティとして参照していましたが(viewStore.count
)、なぜこのようなことが可能なのでしょうか?
カウンターサンプルのビューにおけるViewStoreは、以下のように定義されています。
ViewStore<CounterState, CounterAction>
ViewStoreはstate
というプロパティを持っていて、こいつの実体はCounterStateのインスタンスです。なので、viewStore.state.count
という感じで参照することが可能です。
しかし、サンプルコードではviewStore.count
で参照しています。
これは、SwiftのDynamic Member Lookupという機能で実現されています。Dynamic Member Lookupはコンパイル時には存在しないプロパティへのアクセスをランタイム時に可能にする仕組みです。
ViewStoreの実装を見てみると、クラス宣言に@dynamicMemberLookupアトリビュートがついていて、さらにsubscript(dynamicMember:)メソッドが実装されています。これによってviewStore.count
という参照が可能になっていたんですね。
@dynamicMemberLookup public final class ViewStore<State, Action>: ObservableObject { ... public subscript<LocalState>(dynamicMember keyPath: KeyPath<State, LocalState>) -> LocalState { self.state[keyPath: keyPath] }
Dynamic Member Lookupについてはこちらの記事が詳しくて、勉強させていただきました。
アクションがReducerに通知されるまでのTCA内部の流れ
アクションがReducerによって処理されてStateが変更される流れは最初に説明しましたが、アクションがどうやってReducerまで通知されるのか、そのTCA内部の仕組みについてちょっと触れたいと思います。
アクションの通知はViewStore.send
を起点としますが、ViewStore.send
の実体はStore.send
です。
ViewStoreのイニシャライザとsendメソッドの実装を見るとわかります。
public init( _ store: Store<State, Action>, removeDuplicates isDuplicate: @escaping (State, State) -> Bool ) { ... self._send = store.send ... } ... public func send(_ action: Action) { self._send(action) }
ではStore.send
の実装を見てみましょう。途中細かいロジックがありますが、ポイントはここですね。
StoreはStateとReducerをプロパティとして保持していて、ReducerにStateとアクションを渡して処理させています。カウンターサンプルの例でいうと、StateはCounterState、ReducerはcounterReducerですね。
func send(_ action: Action) { ... let effect = self.reducer(&self.state.value, action) ...
これでアクションがReducerまで通知されて処理される流れが概ね理解できました。
まとめ
カウンターサンプルを例に、TCAの仕組みについて説明してみました。
今回は1つのサンプルに着目したので触れませんでしたが、TCAのCaseStudiesサンプルでは複数のサンプルをまとめて一つのアプリとして構成しているので、StateやReducerはそれぞれのサンプルの画面ごとに存在していて、それらを統合している部分があります(RootStateやrootReducerなど)。
そのあたりは次回以降の記事で触れていきたいと思います。