The Composable Architecture(TCA)で複数画面で状態を共有する方法

f:id:bamboohero:20210604121614p:plain

複数画面間での状態の共有をどのように実装するかは、iOSアプリ開発において一つの大きな議論のテーマではないでしょうか?

TCAは複数画面間での状態の共有という課題に対してどのようなソリューションを提供するのか、サンプルから学んでいきましょう。

01-GettingStarted-SharedState



サンプルアプリの画面

f:id:bamboohero:20210604070050g:plain


サンプルアプリは以下の機能を提供します。

  • SegmentedControlでCounter画面とProfile画面を切り替えることができる
  • Counter画面
    • プラスボタンとマイナスボタンでカウントの値を増減できる
    • 「Is this Prime?」ボタンをタップすると、カウントの値が素数かどうかをアラートで表示する
  • Profile画面
    • カウントの値の現在値、最大値、最小値を表示する
    • プラスボタンもしくはマイナスボタンがタップされた合計回数を表示する
    • 「Reset」ボタンがタップされると上記統計値をリセットする


Counter画面でプラスボタンとマイナスボタンをタップすると、即座にProfile画面で統計データが算出されます。一方、Profile画面で「Reset」ボタンがタップされると、Counter画面のカウントの値が即座に0になります。

2つの画面で状態が常に同期されていることがわかります。TCAを使ってどのように実装しているのでしょうか?


Stateの実装に注目する

これまでいくつかサンプルアプリをご紹介してきましたが、本サンプルはStateの実装が特徴的です。

bamboo-hero.com

bamboo-hero.com

bamboo-hero.com


Stateの構成を図にしました。SharedStateがCounter画面のStateであるCounterStateとProfile画面のStateであるProfileStateを保持しています。

f:id:bamboohero:20210604075004p:plain:w500

CounterStateとProfileStateでカウントの値に関するプロパティ(countmaxCount)をそれぞれで持っている形になっていますが、実際はProfileStateがCounterStateの値を参照している形です。

struct SharedState: Equatable {
  var counter = CounterState()
  var currentTab = Tab.counter

  // [1]
  var profile: ProfileState {
    get {
      ProfileState(
        currentTab: self.currentTab,
        count: self.counter.count,
        maxCount: self.counter.maxCount,
        minCount: self.counter.minCount,
        numberOfCounts: self.counter.numberOfCounts
      )
    }
    set {
      self.currentTab = newValue.currentTab
      self.counter.count = newValue.count
      self.counter.maxCount = newValue.maxCount
      self.counter.minCount = newValue.minCount
      self.counter.numberOfCounts = newValue.numberOfCounts
    }
  }

  struct CounterState: Equatable {
    var alert: AlertState<SharedStateAction.CounterAction>?
    var count = 0
    var maxCount = 0
    var minCount = 0
    var numberOfCounts = 0
  }

  struct ProfileState: Equatable {
    private(set) var currentTab: Tab
    private(set) var count = 0
    private(set) var maxCount: Int
    private(set) var minCount: Int
    private(set) var numberOfCounts: Int

    fileprivate mutating func resetCount() {
      self.currentTab = .counter
      self.count = 0
      self.maxCount = 0
      self.minCount = 0
      self.numberOfCounts = 0
    }
  }

  enum Tab { case counter, profile }
}

[1]
profileはComputed Propertyで実装されています。
getterではProfileStateを毎回生成して返しています。ProfileStateのイニシャライザにはCounterStateのプロパティの値を渡していますね。
counterprofileでそれぞれ個別にStored Propertyで実装されていると、Reducerで2つのStateを同期する処理を書く必要がありますが、このような実装をすることで自動で状態を同期することができます。


Reducerの実装

続いてReducerの実装を見てみます。

以前の記事でも触れたように、Reducerは自身が管轄する画面・コンポーネントのStateにのみ状態変化を起こせるように設計されています

bamboo-hero.com


sharedStateCounterReducerはCounterStateに、sharedStateProfileReducerはProfileStateにのみ、それぞれ状態変化を起こすことができます。

// [1]
let sharedStateCounterReducer = Reducer<
  SharedState.CounterState, SharedStateAction.CounterAction, Void
> { state, action, _ in
  switch action {
  case .alertDismissed:
    state.alert = nil
    return .none

  case .decrementButtonTapped:
    state.count -= 1
    state.numberOfCounts += 1
    state.minCount = min(state.minCount, state.count)
    return .none

  case .incrementButtonTapped:
    state.count += 1
    state.numberOfCounts += 1
    state.maxCount = max(state.maxCount, state.count)
    return .none

  case .isPrimeButtonTapped:
    state.alert = .init(
      title: isPrime(state.count)
        ? .init("👍 The number \(state.count) is prime!")
        : .init("👎 The number \(state.count) is not prime :(")
    )
    return .none
  }
}

// [2]
let sharedStateProfileReducer = Reducer<
  SharedState.ProfileState, SharedStateAction.ProfileAction, Void
> { state, action, _ in
  switch action {
  case .resetCounterButtonTapped:
    state.resetCount()
    return .none
  }
}

let sharedStateReducer = Reducer<SharedState, SharedStateAction, Void>.combine(
  sharedStateCounterReducer.pullback(
    state: \SharedState.counter,
    action: /SharedStateAction.counter,
    environment: { _ in () }
  ),
  sharedStateProfileReducer.pullback(
    state: \SharedState.profile,
    action: /SharedStateAction.profile,
    environment: { _ in () }
  ),
  Reducer { state, action, _ in
    switch action {
    case .counter, .profile:
      return .none
    case let .selectTab(tab):
      state.currentTab = tab
      return .none
    }
  }
)

[1]
プラスボタンやマイナスボタンがタップされたら、state.countの値を変更します。
sharedStateCounterReducerはCounterStateのみを扱っているので、ProfileStateの値は変更していません。ですが、前項で見たように上位層のStateであるSharedStateでProfileStateはCounterStateの値を参照しているので、データの同期ができるようになっています。

[2]
Profile画面の「Reset」ボタンがタップされると、state.resetCount()が呼ばれてcount等の値をリセットします。
sharedStateProfileReducerはProfileStateのみを扱っているので、CounterStateの値は変更していません。ですが、SharedStateで実装されているprofileのsetterでCounterStateの値を同期するようにしているのでデータの同期がされます。


Couter画面とProfile画面はそれぞれ自身が管理するStateにのみ関心があるように設計します。2つの画面間でのデータ共有は、上位層のStateで行います。

ある機能(画面)が別の機能(画面)のStateに依存しないようにすることで、その機能の再利用性が高まります。TCAのライブラリに従うことで、自然にこうした設計ができるようになっているのがポイントですね。


まとめ

TCAで複数画面で状態を共有する方法について、サンプルをもとに説明しました。

各画面のStateを持つ上位層のStateでデータを同期するような実装をすることで、それぞれの画面は自身のStateだけに関心を持たせるようにすることができ、意図しないところで状態変化が起こるリスクを減らすことができます。