19
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

SwiftAdvent Calendar 2020

Day 19

The Composable ArchitectureでPullbackは何をしているのか

Last updated at Posted at 2020-12-19

はじめに

最近SwiftUIの登場とともに注目を集めている設計パターンThe Composable Architecture(以下TCA)を理解するにあたって、EffectやStoreなど重要な機能がある中でPullbackが行う処理について理解に少し時間がかかったため詳細をまとめようと思いました。

対象

  • TCAの概要は理解した方
  • Pullbackやcombineへの理解を深めたい方
  • Point-Freeで1時間かけないでざっと復習したい方(絶対見た方が良い)

TCAをまだ知らない方

もしまだTCAにガッツリ触れられていない場合は以下のサイトをおすすめします。

Pullbackは何をしているのか

それぞれ小さく分割されているReducerのStateActionを取り出してグローバルに使えるAppStateAppActionへ型を変更することで、 別々の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がグローバルなAppStateAppActionを持っていたらその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を統合する

分割したは良いものの、最終的にはAppStateAppActionを受け取る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出来ません。
つまり、現状CounterStateFavoriteStateに分かれている値を最終的にAppStateAppActionの型に変更して統合可能な型にしなければいけません。

そこで行うのがPullbackの処理です。

StateのPullback

まずはStateのPullbackを見ていきます。
AppStateの中にそれぞれのStateをComputed Propertyとして追加します。

struct AppState {
  var counterState: CounterState {
    get { //.. }
    set { //.. }
  }
  var primeModalState: PrimeModalState {
    get { //.. }
    set { //.. }
  }
  //..
}

こうすることでcounterStateWritableKeyPathとして取得出来るようになります。

// 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の両方を**AppStateAppActionへPullbackすることが出来ました。**

そもそもPullbackの実装について

Pullbackはmapと逆の処理を行うため、元々Point-Freeがcontramapとして定義していたものを直感的に分かりやすくするためにPullbackへ命名変更しました。
圏論の「引き戻し」= Pullbackにコンセプトが基づいているため、この名前に決定されました。
この記事ではここまでですが、そもそもPullbackの思想が気になる方は以下の記事を読むと面白いかもしれません。

以上は自分の理解になりますので、もしご指摘等あればコメントいただけると喜びます。

参照

19
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?