この記事は、The Composable Architecture Advent Calendar 2022 12/13の記事です。
iOSエンジニアの金子です。
8月末から約3ヶ月間育休を取得していまして、最近復帰しました。
僕が育休で仕事から離れて家族との時間を楽しんでいる間、TCAで大幅なアップデートがリリースされ、復帰してからはそのキャッチアップに必死の毎日です...!
TCAの利用体験が大きく変わる直近の大幅アップデートには、主に以下の2つが含まれています。
- Swift Concurrencyのサポート
- ReducerProtocolの導入
これまでTCAはCombineに依存した作りになっていましたが、Swift Concurrencyがリリースされたことにより、今後は徐々にCombineへの依存度を緩め、やがてはCombineを完全にDeprecatedにしてConcurrencyを使用するようにユーザを誘導していくと宣言しています。
さらに、Reducerについても大幅な変更が入りました。
今回は新たに導入されたReducerProtocolに関して、そもそもなぜReducerProtocolが導入されたのか、その背景について、Point-Freeの解説動画を参考に整理してみたいと思います。
- はじめに
- Featureの構成が悩ましい
- コンパイラがうまく機能しない
- Reducer実装が読みづらい、Reducerを構成するオペレーターの正しい使い方を強制できていない
- 依存関係更新が面倒
- パフォーマンスとスタックサイズ
- まとめ
はじめに
既存のReducerがSoft-DeprecatedとなりReducerProtocolが導入された理由は、既存のReducerがいくつかの問題を抱えていたからです。
以下でその問題について触れていきます。
既存のReducerはしばらくはコンパイル時に警告も出ないSoft-Deprecated状態ですが(ただし、AnyReducerへのリネームが必要)、将来的には完全に非推奨となるとのことです。
※Soft-Deprecatedはコード上で以下のように表現されていました
@available(iOS, deprecated: 9999.0, message: ...) public struct AnyReducer<State, Action, Environment> {
Featureの構成が悩ましい
みなさんはFeatureを実装するとき、どのような構成にしていますか?
TCAのサンプルやisowordsに従うと、以下のような構成になるかと思います。
struct FeatureState {} enum FeatureAction {} struct FeatureEnvironment {} let featureReducer = Reducer<FeatureState, FeatureAction, FeatureEnvironment> { state, action, environment in .none }
NewsPicksではFeatureCoreという名前空間を作り、その中にState等を実装する構成を採用しています。
extensionで細かく分けたりして、読みやすさを考慮しています。
enum FeatureCore {} // MARK: - State extension FeatureCore { struct State {} } // MARK: - Action extension FeatureCore { enum Action {} } // MARK: - Environment extension FeatureCore { struct Environment {} } // MARK: - Reducer extension FeatureCore { static let reducer = Reducer<State, Action, Environment> { state, action, environment in .none } }
個人的には名前空間が無い構成よりも読みやすく、Reducer内では型推論が効いて単にActionとするだけで済むのでFeature名が長い場合にはそれなりに効果があるかなと思います。
ただし、View側では完全修飾で書かないとダメなので、すごく効果的かというとそうでもないです。
struct FeatureView: View { let store: Store<FeatureCore.State, FeatureCore.Action> }
実際、TCAのコミュニティでも上記2つのパターンのどちらも採用されているようです。
ReducerProtocolを導入すると、以下のように変わります。
State等を格納する統一された場所が提供され、View側の実装もシンプルです。
内部の細かな仕組みはここでは触れませんが、構成で悩まなくていいし、コード量も格段に減っていい感じですね!
// MARK: - Feature struct Feature: ReducerProtocol { struct State {} enum Action {} func reduce(into state: inout State, action: Action) -> EffectTask<Action> {} } // MARK: - View struct FeatureView: View { let store: StoreOf<Feature> }
もう一つの問題は、Reducer内で共通ロジックをどこに実装するかです。
TCAで推奨されてるのは、以下のようなヘルパー関数を用意する方法のようです。
解説動画では、stateとenvironmentを関数に渡さなければいけない煩わしさを問題として挙げていました。
extension FeatureCore { static let reducer = Reducer<State, Action, Environment> { state, action, environment in switch action { case .buttonTapped: return sharedLogic( state: &state, environment: environment ) case .otherButtonTapped: return sharedLogic( state: &state, environment: environment ) } } // 共通ロジックを持つヘルパー関数 private static func sharedLogic(state: inout State, environment: Environment) -> Effect<Action, Never> { ... } }
ちなみにNewsPicksでは、Reducer内にComputedプロパティや関数を実装するやり方をしてきました。
こちらはstateやenvironmentを渡す必要がない上、上記のヘルパー関数を実装する方法とできることに差はないように思いますが、僕がわかっていないダウンサイドがあるかもしれません。。
今後推奨されてる方法もちょっと試してみようと思います。
extension FeatureCore { static let reducer = Reducer<State, Action, Environment> { state, action, environment in func sharedLogic() -> Effect<Action, Never> { ... } switch action { case .buttonTapped: return sharedLogic() case .otherButtonTapped: return sharedLogic() } }
まだ追えていませんが、この共通ロジックをどう実装するか問題もReducerProtocolでは解決されているはずです。
コンパイラがうまく機能しない
現在のReducerの実装スタイルだと、コンパイラがうまく機能しないという問題があります。
例えば、以下のような警告が、Reducer内では表示されません。
func f() { var x = 1 // Initialization of variable 'x' was never used; consider replacing with assignment to '_' or removing it }
case .buttonTapped: var x = 1 // 警告が出ない return .none
さらには、コード補完も効かなくなる場合があります。
例えばReducer内でenvironmentを参照しようとしたとき、envと打っても候補にenvironmentが出てきません。
case .buttonTapped: env // environmentが補完できない return .none
これはなかなかストレスです。
ReducerProtocolを導入すると、これらの問題が解決できます(上記例は解決されてることを手元でも確認しました)。
Reducer実装が読みづらい、Reducerを構成するオペレーターの正しい使い方を強制できていない
3つのタブを持つタブベースのアプリを想定しましょう。
各タブのドメインは以下のようにモデル化できます。
struct TabAState {} enum TabAAction {} struct TabAEnvironment {} let tabAReducer = Reducer<TabAState, TabAAction, TabAEnvironment> { _, _, _ in .none } struct TabBState {} enum TabBAction {} struct TabBEnvironment {} let tabBReducer = Reducer<TabBState, TabBAction, TabBEnvironment> { _, _, _ in .none } struct TabCState {} enum TabCAction {} struct TabCEnvironment {} let tabCReducer = Reducer<TabCState, TabCAction, TabCEnvironment> { _, _, _ in .none }
そして、上位ドメインではこれらのタブドメインを1つに結合することができます。
struct AppState { var tabA: TabAState var tabB: TabBState var tabC: TabCState } enum AppAction { case tabA(TabAAction) case tabB(TabBAction) case tabC(TabCAction) } enum AppEnvironment {} let appReducer = Reducer<AppState, AppAction, AppEnvironment>.combine( tabAReducer.pullback( state: \.tabA, action: /AppAction.tabA, environment: { _ in .init() } ), tabBReducer.pullback( state: \.tabB, action: /AppAction.tabB, environment: { _ in .init() } ), tabCReducer.pullback( state: \.tabC, action: /AppAction.tabC, environment: { _ in .init() } ) )
解説動画では、Reducerへの明示的な型の指定や、コンマの管理が煩雑でノイズであると説明しています。
ReducerProtocolでは、Result Builderを使用することによって読みやすさの改善がなされています。
ReducerProtocolを適用すると、上記のappReducerの実装はこう変わります。
struct App: ReducerProtocol { // Struct、Actionの実装は省略 var body: some ReducerProtocol<State, Action> { Scope(state: \.tabA, action: /Action.tabA) { TabA() } Scope(state: \.tabB, action: /Action.tabB) { TabB() } Scope(state: \.tabC, action: /Action.tabC) { TabC() } }
SwiftUIのビューの実装を見ているようですね!
後述するオプショナルなReducerを結合するパターンも入ってくると、Reducerの実装がかなり読みやすくなっていることがわかります。
既存のReducerでは、読みづらさの問題に加え、Reducer.optional()等のオペレーターを正しく使用しないことによって検知が難しいバグを生んでしまうという問題もあります。
あるFeatureドメインに、独自のモーダルを表示させるケースがあるとします。
モーダルは別ドメインに分離していて、ReducerはmodalReducerとして実装されているとします。
enum Feature { struct State { var modal: ModalState? … } enum Action { case modal(ModalAction) … } static let reducer = Reducer<State, Action, Environment>.combine( Reducer { state, action, environment in … }, modalReducer .optional() .pullback( state: \.modal, action: /Action.modal, environment: { _ in .init() } ) ) }
この実装には問題があるのですが、コンパイルは問題なく通るので、アプリを実行してランタイムの警告が表示されるまで、それに気づくことができません。
現在のReducerの順序だと、モーダルに関するアクションがこのReducerに到着したときに、先にFeatureのReducerが反応してモーダルのStateをnilにしてしまう可能性があります。
そうなると、モーダルのReducerはこのアクションに反応することができません。
TCAではこのときランタイムの警告を表示します。
この挙動についてはドキュメントで明示され、ランタイムの警告でもオプショナルなReducerは親Reducerの前に結合する必要があることを示してくれますが、そもそもAPIがこのようなミスを犯すことがないように設計されているのがベストです。
ReducerProtocolでは、Result Builderを利用することでこの問題に対応しています。
struct Feature: ReducerProtocol { // Struct、Actionの実装は省略 var body: some ReducerProtocol<State, Action> { Reduce { state, action in ... } .ifLet(state: \.modal, action: /Action.modal) { Modal() } } }
ifLetオペレーターを使うことで、Reducerの結合順序を意識する必要はなく、モーダルのstateがnon-nilのときだけ動作することが保証されます。
解説動画ではこの他にforEachの例も解説しています。
ReducerProtocolの導入により、Reducerの実装がはるかに読みやすく、保守しやすいものになったことがわかります。
依存関係更新が面倒
プロジェクトにTCAを採用していると、最も面倒だと思うものの一つに依存関係の更新作業があると思います。
例えば、以下のようなコンポーネントの階層を持ったアプリを想定しましょう。
App ├── ChildFeature1 └── ChildFeature2 └── GrandChildFeature1 └── GrandChildFeature2
ここで、GrandChildFeature2に新しい依存関係を追加するとしましょう。
extension GrandChildFeature2 { struct Environment { ... let newsRepository: NewsRepository // 追加 init( ..., newsRepository: NewsRepository ) { ... self.newsRepository = newsRepository } }
コンパイルできるようにするには、その親コンポーネントであるChildFeature2、そしてルートのコンポーネントのAppに同じ依存関係を追加する必要があります。
さらには、各Featureをプレビューするためのコードやテストコードにも影響が及びますし、階層がもっと深いところで依存関係の更新が発生すると、もはや地獄です。
解説動画の中でもこの作業はabsolute pain(強い苦痛?)と言っていて、ライブラリはこれをもっと簡単にするために何かをすべきだと言っています。
実際、ReducerProtocolではSwiftUIのEnvironmentValuesに似た、依存関係の管理を簡単にするための仕組みが導入されました。
パフォーマンスとスタックサイズ
既存のReducerは、escaping closureをイニシャライザに渡すことよって初期化します。
しかし、Swiftはメソッドで行われるような最適化をescaping closureに対しては行わないようです。
アクションをデバッグしようとするとわかりやすいですが、スタックフレームが大量に積まれていることがわかります。
ReducerProtocolに移行すると、スタックがフラット化され、メモリ使用量も削減できるとのことです。
これまでTCAで開発してきて、Reducerのデバッグがやりづらかった印象がありますが、Reducerの作りに原因があったんですね。
ReducerProtocolに移行することで、デバッグもしやすくなるようなのでこれは嬉しいアップデートです。
まとめ
既存のReducerには以下の5つの問題がありました。
- Featureの構成が悩ましい
- コンパイラがうまく機能しない
- Reducer実装が読みづらい、Reducerを構成するオペレーターの正しい使い方を強制できていない
- 依存関係更新が面倒
- パフォーマンスとスタックサイズ
どの問題もTCAでアプリ開発をしていて実感していたことでした。
TCAはよくできたアーキテクチャだなと思いつつも、振り返ると開発者体験を損ねる問題がそれなりにあったなと思います。
ReducerProtocolに移行することで、これらの問題をすべて解決できるとのことです。
TCAが素晴らしいなと思うのは、これだけ大きなアップデートにも関わらず、既存のコードと100%互換性があるように設計してくれていることです。
丁寧なマイグレーションガイドが用意されており、コードも一気に修正するのではなく、段階的に少しずつ移行することができるようになっています。
TCAの導入は、アプリのアーキテクチャがライブラリに大きく依存するため、とても勇気のいる決断です。
アーキテクチャが優れているというだけでなく、このようにライブラリ利用者のことをしっかりサポートしてくれるというのは、とても安心できる点です。
NewsPicksではReducerProtocolへの移行はこれから少しずつやっていこうとしているところです。
開発者体験が劇的に変わるであろう新しいTCAを楽しんでいきたいと思います。