はじめに
最近SwiftUIの登場とともに注目を集めている設計パターンThe Composable Architecture(以下TCA)を理解するにあたって、EffectやStoreなど重要な機能がある中でPullbackが行う処理について理解に少し時間がかかったため詳細をまとめようと思いました。
対象
- TCAの概要は理解した方
- Pullbackやcombineへの理解を深めたい方
- Point-Freeで1時間かけないでざっと復習したい方(絶対見た方が良い)
TCAをまだ知らない方
もしまだTCAにガッツリ触れられていない場合は以下のサイトをおすすめします。
- Learn More at pointfreeco/swift-composable-architecture
- Swiftによるアプリ開発のためのThe Composable Architectureがすごく良いので紹介したい
Pullbackは何をしているのか
それぞれ小さく分割されているReducerのState
とAction
を取り出してグローバルに使えるAppState
とAppAction
へ型を変更することで、 別々のReducerを統合できる形にしています。
どうしてそもそもグローバルに使えるReducerに変更するのか。
Stateをどうやって取り出すのか。
Actionをどうやって取り出すのか。
これらを見ていきたいと思います。
小さく分かれたReducer
シンプルなカウントアップアプリをサンプルとして見ていきます。(分かりやすくするためにPoint-Freeのカウントアップアプリと機能を少し変えています)
機能
- カウントアップ・カウントダウン
- 素数の判定
- お気に入り登録
AppReducer
さて、いわゆるアプリのビジネスロジックを全て受け持つappReducer
があるとします。
func appReducer(value: inout AppState, action: AppAction) -> Void {
switch action {
case .counter(.decrTapped):
// カウントダウン
case .counter(.incrTapped):
// カウントアップ
case .primeModal(.calculateIsPrimeTapped):
// 素数判定
case .primeModal(.saveFavoritePrimeTapped):
// 素数判定した数字をお気に入りに登録
case let .favoritePrimes(.deleteFavoritePrimes(indexSet)):
// お気に入りから削除
}
}
AppAction
は3つのcase(.counter
,.primeModal
,.favoritePrimes
)を持っていることが分かります。
それぞれのActionが別画面から発火されるものであるとしたら、将来的なappReducer
の肥大化も見据えて各機能ごとにReducerを分割したいのが自然な流れだと思います。
appReducerを分割する
例えばcounterReducerであればカウント時に必要なStateとAction以外は不必要です。
つまり、Reducerを分割する際に各ReducerがグローバルなAppState
とAppAction
を持っていたらそのReducerの役割に対して持っている値の影響範囲が広すぎます。
そのため、それぞれ個別のStateとActionに切り出します。
func counterReducer(value: inout CounterState, action: CounterAction) -> Void {}
func primeModalReducer(value: inout PrimeModalState, action: PrimeModalAction) -> Void {}
func favoriteReducer(value: inout FavoriteState, action: FavoriteAction) -> Void {}
分割したReducerを統合する
分割したは良いものの、最終的にはAppState
とAppAction
を受け取るappReducer
として分割したReducerを統合しなければいけません。
そこで各Reducerを統合するのに使われるのがcombine
です。
func combine<Value, Action>(
// Reducerはいくつでも受け渡すことが出来る
_ reducers: (inout Value, Action) -> Void...
) -> (inout Value, Action) -> Void {
return { value, action in
for reducer in reducers {
reducer(&value, action)
}
}
}
// But..
let appReducer = combine(
counterReducer,
primeModalReducer,
favoriteREducer
)
// Error - Cannot convert value
しかし問題があります。combine
する各ReducerのValue, Action
は同じでなければcombine
出来ません。
つまり、現状CounterState
やFavoriteState
に分かれている値を最終的にAppState
とAppAction
の型に変更して統合可能な型にしなければいけません。
そこで行うのがPullbackの処理です。
StateのPullback
まずはStateのPullbackを見ていきます。
AppState
の中にそれぞれのStateをComputed Propertyとして追加します。
struct AppState {
var counterState: CounterState {
get { //.. }
set { //.. }
}
var primeModalState: PrimeModalState {
get { //.. }
set { //.. }
}
//..
}
こうすることでcounterState
がWritableKeyPath
として取得出来るようになります。
// StateをPullbackする
func pullback<LocalValue, GlobalValue, Action>(
// 渡されたreducerとそのLocalValue(State)
_ reducer: @escaping (inout LocalValue, Action) -> Void,
value: WritableKeyPath<GlobalValue, LocalValue>
) -> (inout GlobalValue, Action) -> Void {
return { globalValue, action in
reducer(&globalValue[keyPath: value], action)
}
}
let appReducer = combine(
pullback(counterReducer, value: \.counterState),
pullback(primeModalReducer, value: \.primeModalState),
//..
)
これで各ReducerのStateは違えど、統合できそうです。
しかし、この実装だとStateをPullbackしただけでActionに関しては同じ型であることが前提です。
次にActionも含めてPullbackする実装をみていきましょう。
ActionのPullback
ActionがStateと異なる点は、Stateはstruct
でActionはenum
であるという点です。
つまりStateの時に使用したKeypathが使えません。
そこで、enumでのKeypathを可能にするのが同じくPoint-Freeが公開しているOSS、pointfreeco/swift-case-pathsです。
// StateとAction両方をPullbackする
func pullback<GlobalValue, LocalValue, GlobalAction, LocalAction>(
_ reducer: @escaping (inout LocalValue, LocalAction) -> Void,
value: WritableKeyPath<GlobalValue, LocalValue>,
action: CasePath<GlobalAction, LocalAction>
) -> (inout GlobalValue, GlobalAction) -> Void {
return { globalValue, globalAction in
guard let localAction = action.extract(from: globalAction) else { return }
reducer(&globalValue[keyPath: value], localAction)
}
}
enum AppAction {
case counter(CounterAction)
case primeModal(PrimeModalAction)
//..
}
let appReducer = combine(
pullback(counterReducer, value: \.counterState, action: \.counter)
pullback(primeModalReducer, value: \.primeModalState, action: \.primeModal)
//..
)
これでやっとStateとActionの両方を**AppState
とAppAction
へPullbackすることが出来ました。**
そもそもPullbackの実装について
Pullbackはmap
と逆の処理を行うため、元々Point-Freeがcontramap
として定義していたものを直感的に分かりやすくするためにPullback
へ命名変更しました。
圏論の「引き戻し」= Pullbackにコンセプトが基づいているため、この名前に決定されました。
この記事ではここまでですが、そもそもPullbackの思想が気になる方は以下の記事を読むと面白いかもしれません。
以上は自分の理解になりますので、もしご指摘等あればコメントいただけると喜びます。