複数画面間での状態の共有をどのように実装するかは、iOSアプリ開発において一つの大きな議論のテーマではないでしょうか?
TCAは複数画面間での状態の共有という課題に対してどのようなソリューションを提供するのか、サンプルから学んでいきましょう。
サンプルアプリの画面
サンプルアプリは以下の機能を提供します。
- SegmentedControlでCounter画面とProfile画面を切り替えることができる
- Counter画面
- プラスボタンとマイナスボタンでカウントの値を増減できる
- 「Is this Prime?」ボタンをタップすると、カウントの値が素数かどうかをアラートで表示する
- Profile画面
- カウントの値の現在値、最大値、最小値を表示する
- プラスボタンもしくはマイナスボタンがタップされた合計回数を表示する
- 「Reset」ボタンがタップされると上記統計値をリセットする
Counter画面でプラスボタンとマイナスボタンをタップすると、即座にProfile画面で統計データが算出されます。一方、Profile画面で「Reset」ボタンがタップされると、Counter画面のカウントの値が即座に0になります。
2つの画面で状態が常に同期されていることがわかります。TCAを使ってどのように実装しているのでしょうか?
Stateの実装に注目する
これまでいくつかサンプルアプリをご紹介してきましたが、本サンプルはStateの実装が特徴的です。
Stateの構成を図にしました。SharedStateがCounter画面のStateであるCounterStateとProfile画面のStateであるProfileStateを保持しています。
CounterStateとProfileStateでカウントの値に関するプロパティ(count
やmaxCount
)をそれぞれで持っている形になっていますが、実際は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のプロパティの値を渡していますね。
counter
とprofile
でそれぞれ個別にStored Propertyで実装されていると、Reducerで2つのStateを同期する処理を書く必要がありますが、このような実装をすることで自動で状態を同期することができます。
Reducerの実装
続いてReducerの実装を見てみます。
以前の記事でも触れたように、Reducerは自身が管轄する画面・コンポーネントのStateにのみ状態変化を起こせるように設計されています。
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だけに関心を持たせるようにすることができ、意図しないところで状態変化が起こるリスクを減らすことができます。