The Composable Architecture(TCA)のカウンターサンプルを理解する

f:id:bamboohero:20210527023038p:plain

先日TCAで簡単なサンプルアプリを作ってみたのですが、まだまだTCAの理解が曖昧なところが多々あるため、もう一度サンプルコードを一から丁寧に読んでいます。

bamboo-hero.com


理解できたところは順次アウトプットしていこうと思います。

まずは、一番単純なサンプルであるカウンターサンプル(01-GettingStarted-Counter)について、私なりに説明してみたいと思います。



アクションが処理される流れ

カウンターサンプルは、プラスボタンをタップすると数字がカウントアップされ、マイナスボタンをタップすると数字がカウントダウンされるだけの単純な画面です。

f:id:bamboohero:20210527011732g:plain


このボタンタップというアクションによってビューの状態(ここでは真ん中の数字)が変更される流れを追ってみたいと思います。

カウンターサンプルのビューは以下のように実装されています。ビューのルートは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についてはこちらの記事が詳しくて、勉強させていただきました。

dev.classmethod.jp


アクションが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など)。

そのあたりは次回以降の記事で触れていきたいと思います。