個人的な Effective TCA
TCA はいくつかの API によって、コードの書き方にある程度の制限を設けてはくれますが、それでも正しい形で活用しないとコードはすぐに読みづらくなっていくと感じています。
そして、今まで TCA を使ってきたり、数多くの Discussion を見たりしてきて、TCA の利用におけるプラクティスを抑えておくことは、TCA を使ったコードの設計を良い状態に保つことに繋がると思っているため、自分が開発している時に意識している何点かを本記事に書いておこうと思います。(あわよくば、この記事を誰かに説明する時に利用しようと思っています)
あくまで、個人的なものなのでプロジェクトによっては適さない場合があります。
また、思いついたことがあったり、何か新しいことがわかった際には追記していこうと思います。
列挙している順序について特に意味はありません。
プラクティス集
Action は「何が起こったか」ベースで命名すること
これは TCA の Discussion で話されているもので、個人的にはこれを意識するだけでもコードの見通しやテストのしやすさが改善すると感じており、強く推奨したいプラクティスです。
例えば、非常にシンプルな例として「カウントアプリ」があった場合に、その機能の Action 名を incrementCount
と命名するのではなく、incrementButtonTapped
と命名した方が良いという話です。
この例では、incrementCount
は「何をしたいか」を表す命名になっていることに対して、incrementButtonTapped
は「何が起こったか」を表す命名になっています。
このように、「何が起こったか」ベースで命名すると色々良いことがあって、詳しくは Discussion の中でも話されていますが、
-
incrementButtonTapped
Action のハンドリングで追加の要件が発生した場合に、柔軟に対応できる - Action の数を減らすことに繋がる
- テストが書きやすくなる・読みやすくなる
一つずつ簡単に説明します。
まず 1 ですが、例えば Action を incrementCount
としてしまった場合、この Action は文字通り count
を increment
する処理しか相応しくないものとなってしまいます。
もし incrementButtonTapped
と命名していたのであれば、将来「increment button がタップされた時に API リクエストしたい」という要件が仮に追加された場合、incrementButtonTapped
は increment button がタップされたということしか表していないため、API リクエストの処理をこの Action のハンドリングに追加しても不思議ではない構造になっていると思います。
このように、一つの Action で表現できる処理が柔軟になり、変更に強い設計にしやすいと考えられます。
次に 2 ですが、これも先ほどと同じ例を用いると、
- 「何をしたいか」ベースで命名した場合、
incrementCount
とrequestForCountAPI
のような二つの Action を定義する必要がある - 「何が起こったか」ベースで命名した場合、
incrementButtonTapped
のみで十分
という違いがあります。
例としてはシンプルすぎてメリットが掴みづらいかもしれないですが、この話は機能が複雑になればなるほど重要性が増していくものだと思っています。
また、TCA で Action を send するコストは低いものではないため、不要な Action を減らす機会になるというメリットもあります。
最後に 3 ですが、「何が起こったか」ベースで Action を作ることを徹底すれば、テストも書きやすくなったり読みやすくなったりするメリットがあります。
例えば、少しあり得ない話ですが increment button がタップされた際に、
- count が 1 増える
- API リクエストが行われる
- タイマーが動き出す
のような処理を行いたい場合、「何をしたいか」ベースで命名してしまうと、これら三つに対応する Action を定義する必要があります。
もし三つ定義した場合、例として以下のようなコードになると考えられます。
await store.send(.incrementCount)
await store.receive(.requestAPI)
await store.receive(.startTimer)
await store.receive(.apiResponse) // API リクエストのレスポンスを受け取った
await store.receive(.timerTicked) // タイマーが指定の秒数動いた
これを「何が起こったか」ベースで Action を作るようにした場合、以下のようにできそうです。
await store.send(.incrementButtonTapped)
await store.receive(.apiResponse)
await store.receive(.timerTicked)
「何をしたいか」ベースで命名した場合、テストコードを書く際に実装の詳細がテストに漏れ出しすぎてしまい、テストを読み書きするコストが高いと思います。
一方で、「何が起こったか」ベースで命名した場合は、「ユーザーイベントである increment button がタップされたことをきっかけに、二つのイベントが起こっている」のようにある程度イベントの流れを捉えやすくなり、テストを書く手間も減少することが期待できそうです。
この辺り、自分自身でも例として微妙なものを出してしまっている自覚があるため、気力があるタイミングで例をわかりやすいものに修正したいと思っています🙇♂️
Action は使い回さずに、共通利用するロジックは function に切り出すこと
こちらについては、以前関連する記事を書きました。
↑ の記事の「Sharing logic with actions」が伝えたいことの全てなので、本記事では割愛します。
先ほどの「Action は「何が起こったか」ベースで命名すること」でも説明したように、TCA における Action の send が低コストではないことは、こちらの話にも通じますが共通で認識しておきたいことです。
View Action・Delegate Action を活用すること
こちらについても、以前関連する記事を書きました。
↑ の記事では、TCAFeatureAction
と呼ばれる View
/ Internal
/ Delegate
三種の Action を紹介していますが、その中でも Delegate Action は特に重要だと考えています。
Delegate Action が何なのかについては上記の記事を参照して頂ければと思いますが、Delegate Action は TCA 公式のチュートリアルでも採用されていたり、TCA の Examples の中では比較的新しい SyncUps でも採用されていたり、多くの Discussion で Point-Free の中の方達が推奨している姿勢を見せてくれていたり、採用する価値は高いと思っています。
TCA はドメイン (Reducer) を小さく分割して結合することを強みとしており、分割したドメイン間のコミュニケーションも Action を通じることで容易に実現できる一方で、分割していけばいくほどドメイン間のコミュニケーションに利用される Action の見通しは悪くなっていきがちです。
そのため、「ドメイン間のコミュニケーション」という目的で利用される Delegate Action の namespace は分けるようにしておくと、コードの見通しが良くなっていくと感じています。
ちなみに TCA 1.7 では View
のための Action を表現するための ViewAction
が追加され、Discussion で紹介されている ViewAction
を手軽に実現できるようになりました。
ViewAction
を利用することで、View から送信できる Action を制限することができたり、Action の見通しを良くすることもできるので、積極的に活用できると良いと思っています。
Effect.send
は極力利用しないこと
Effect.send
は Reducer 内で別の Action を発火させる際に便利なので、もしかしたら頻繁に利用してしまっている方もいるかもしれません。
しかし、TCA の Effect.send
のドキュメントコメントをよく見てみると「Effect.send
はなるべく利用しないようにすること」に近い旨が記載されています。
このコメントには、ここまでに紹介した二つの話が関係していることがわかります。
- ロジック共有の用途で
Effect.send
を利用しないこと (その場合は function を利用すること) -
Effect.send
は Delegate Action のみの利用に制限すること
Effect.send
は Action を簡単に発火することができ便利ですが、おおよそのケースで代替手段が存在します。
例えば、わざわざ Effect.send
して新しい Action を発火するのではなく、前の Action のハンドリングで必要な処理を済ませたり、前述したように function を切り出してそれを利用するなどの手段があります。
Effect.send
は Delegate Action に絞って活用するようにして、コードの見通し・パフォーマンスを向上させるようにしましょう。
Parent Reducer から Child Action を直接呼ばないこと
こちらについても、以前関連する記事を書きました。
これについても、説明したい内容は上記の記事にほとんど書いてあるため、本記事では割愛しようと思います。
関連する話として、最近 Performance のドキュメントに、Parent Reducer から Child Action を呼ぶための方法として reduce
を直接呼ぶ方法が追記されていました。
この方法は Performance の問題は改善できるものの、「何が起こったか」という命名から外れた Action を増やすことにつながってしまうため、個人的には避けたいなと感じています。
この方法を推奨している理由については近いうちに Discussion などで聞いてみたいと思っています。
Tree-based・Stack-based navigation のための API を活用すること
Tree-based・Stack-based navigation が何なのかについては、以下の二つの記事で説明しているため、そちらを参照して頂ければと思います。
TCA では Tree-based / Stack-based navigation のために、それぞれ PresentationReducer
/ StackReducer
という仕組みが用意されています。
これらを利用せずとも Navigation は表現できますが、これらの API を利用することで、
- 無駄の少ない形で Navigation の状態管理ができる
- 統一された API で Navigation が表現できる
- うまく使い分ければマルチモジュールの循環参照問題を解決できる
- Effect のキャンセル処理をやってくれる
-
@Dependency(\.dismiss)
が利用できるようになる
など様々なメリットがあるため、TCA を利用しているのであれば、Navigation API を利用しない手はないと思います。
また、特に PresentationReducer
を利用する場合においては、State
に何個も @PresentationState
を分けて定義するのではなく、enum で Navigation の State を管理して、無駄のない状態にしましょう。
この話についての詳細は以下に記載されています。
Reducer を分割・結合するための方法を理解して利用すること
TCA は前述したように、ドメインを細かく分割しやすく結合しやすい構造にできることを強みとしているため、そのための API をしっかり理解して、適切に Reducer を分割できるようになっておくことが重要だと感じています。
TCA に慣れてからは、ある程度分割方針などは固まってくる気はしますが、慣れていない場合そもそもどのような分割・結合方法があるのか、どのように分割・結合すれば良いのかという点で悩むことも多い気がしています。
TCA にはいくつかの Reducer 分割・結合のための API が存在しているため、それをざっと把握しておくと、分割・結合の方針みたいなものが見えてくると思います。
そのため、ここでは TCA の Reducer 分割・結合のための API を簡単に紹介します。
Scope
-
ifLet
(optional な状態のためのもの) -
forEach
(IdentifiedArray
を利用した状態のためのもの) -
ifLet
(PresentationState
のためのもの) -
forEach
(StackState
のためのもの)
結構種類があって混乱する方もいると思うので、この記事でも簡単に紹介しておきます。
ℹ️ 各種 API の詳細
Scope
まず Parent domain に Child domain を埋め込むための最も基本的な API である Scope です。
これはドキュメントの例にあるように、optional ではない Child domain を Parent domain に埋め込むことに適しています。
例えば、ある画面があったとして、その画面の Header がやたら複雑みたいな話の時に、以下のように Scope
を使って domain を分割し、結合できます。
@Reducer
struct SomeReducer {
@ObservableState
struct State: Equatable {
var header: Header.State
// ...
}
enum Action {
case header(Header.Action)
// ...
}
var body: some ReducerOf<Self> {
Scope(state: \.header, action: \.header) {
Header()
}
Reduce { state, action in
// 自身の Reducer の処理
}
}
}
ifLet (optional な状態のためのもの)
次に optional な状態のための API である ifLet
です。
後述の @PresentationState
のための ifLet
もあり、少し紛らわしい部分がありますが、単純な optional な状態のために利用できるのがここで説明する ifLet
です。
この ifLet
は、ある時は optional である可能性があるような Child domain を Parent domain に埋め込む際に利用します。
例えば「画面の表示時は必要な状態が取得できてないため描画できないが、API 通信の結果必要な状態が取得できたので、描画できるようになった domain」などを表現するのに適しています。
TCA の Example では、トグルボタンによって Parent View に埋め込まれている Counter View の State に値が入ったり nil になったりして、Counter View が表示されたり表示されなくなったり、という挙動を表現する際に ifLet
を利用しています。
forEach (IdentifiedArray を利用した状態のためのもの)
次に IdentifiedArray
を利用した状態のための forEach
です。
この forEach
にも StackState
のための別の同名の API が存在しているため、少し紛らわしいかもしれません。
forEach
については、TCA の Examples でいうと Todos というアプリがわかりやすいと思います。
この Todos は TODO 管理アプリみたいなものなのですが、Todo 一つ一つを表現するための Todo
という Child domain があって、Todo List 全体を管理するための Todos
という Parent domain があり、Parent domain に複数の Child domain を埋め込んでいるという状態になっています。
要するに、「大きな List 画面を管理する Reducer と List 内の一つ一つの Row を管理するための Reducer を分けたい」みたいなシーンの時に利用することが多いです。
ifLet (PresentationState のためのもの)
次に @PresentationState
のための ifLet
です。
これが先ほど紹介した optional な状態のための ifLet
と同じ命名になっているのは、@PresentationState
も optional で状態を表現するためだと思います。(詳しくは TCA の Navigation ドキュメントを参照)
この ifLet
は前述の Tree-based navigation を表現するための API となっており、そのドキュメントを参照して頂けるのが一番わかりやすいと思います。
forEach (StackState のためのもの)
最後は StackState
のための forEach
です。
こちらも先ほど紹介した IdentifiedArray
のための forEach
と同じ命名になっていますが、この forEach
に利用する StackState
が IdentifiedArray
と同様に Collection として表現されているため同じ命名になっているのだと思います。
この forEach
は前述の Stack-based navigation を表現するための API となっており、こちらもそのドキュメントを参照して頂けるのが一番わかりやすいと思います。
TCA で domain を分割・結合するための方法は数種類あることを利用例を交えながら説明しましたが、これらの API をざっと理解しておくだけでも、Reducer の分割・結合のための幅が増えるので、Reducer の分割・結合に悩んでいる方は API を把握するところから始めると良さそうかなと思っています。
多くの Feature で共有したい State は Sharing state のための API を利用すること
ある程度の機能を持ったアプリを開発していると、いくつかの Reducer を跨いで共有したい値 = Sharing state のようなものをどうにかして管理したくなってくるタイミングがあると思います。
TCA には、そのような Sharing state を管理するための機能としていくつかの API が用意されました。
詳細については、上記の公式ドキュメントにかなり詳しく記載されているので参照いただければと思います。
Sharing state のための API は非常に便利で使いやすいものとなっているため、TCA を利用していて Redcuer を跨いで共有したい値が出てきた時などは活用できると良いと思います。
もちろん @Shared
を気軽に利用すると、アプリ内のグローバルな Sharing state を増やすことにはなってしまうので、気軽に利用するのではなく一定の判断基準を持って利用したり、@SharedReader
等を用いた一定の制限を設けた利用方法も活用したり、type safe に扱えるようにするなどの工夫等も行えると良いと思っています。(いずれもドキュメントに記載されています)
今のところ自分は Sharing state のための API を以下のようなことを実現するために利用していて、どこかのタイミングで記事などにできたらまたこの記事にも追記できればと思います。
- AppStorage の値の読み書き
- NotificationCenter に流れる値をリアルタイムに監視する
- Feature Flag として利用している Firebase Remote Config をリアルタイムに監視する
📁 Sharing State の機能が追加される前に書いていた内容
TCA を利用してそこそこの規模のアプリを作っていると、いくつかの Feature で特定の State を共有したいという状況に遭遇することがあると思います。
そのような場合、実装の可能性として自分が見つけているものは以下の三通りです。
- State をバケツリレーする
- computed property を利用する
- SwiftUI の
EnvironmentObject
を利用する -
SharedStateClient
のような Dependency を作成して利用する
まず 1 は最もシンプルな方法だと思います。
単純に必要な Feature に対してひたすら共有したい State をバケツリレーしていくだけです。
ただ、もちろんこの方法は手間がかかりますし、本来共有したくない Feature にもその State を共有しなければいけないですし、State の更新などにおける漏れも発生しやすいため、できれば避けるべきだと思います。
次に 2 の方法ですが、これは TCA の Examples の 01-GettingStarted-SharedState に書かれているものになります。
この方法は computed property を利用して、共有したい State を表現することで、State の更新漏れが起きない仕組みを実現しています。
しかし、この方法は computed property の定義コストが高かったり、共有したい状態がネストした Feature にまで影響するような場合は、かなり厳しいコードになってしまうと思います。
次に 3 の EnvironmentObject
を利用する方法ですが、これも以下の二点から個人的には採用が厳しいかなと思っています。
-
EnvironmentObject
の設定漏れでアプリが正しく動かなくなる可能性が高い - TCA の状態管理と
EnvironmentObject
での状態管理が二分化してしまい、コードの見通しが悪くなる
最後に 4 の方法は、TCA の Discussion で話されている内容となっています。
Point-Free の中の方が提案しているものとなっており、複数の Feature 間で State を共有するのであれば、個人的にもこの方法が良さそうだと思っています。
具体的には、共有したい State を管理するための SharedStateClient
のような Dependency を定義し、State を利用したい Feature で @Dependency(\.sharedStateClient)
を利用するという方法となっています。
SharedStateClient
の具体的な実装例として、isowords では以下のように PassthroughSubject
を利用したものが導入されているようでした。
これについては、Point-Free の Slack community で話されていました。
また、多くの Discussion や上記の Slack などで「TCA における Shared State の管理方法」については議論されていて、例えば以下では Observation
によって、何らかの新しい解決策が思い浮かぶかもしれないというような話も出ており、今後も色々模索されていきそうな雰囲気です。
TCA において、State
の管理にまつわるこの課題は大きなテーマなので、色々考えて探っていくのは面白そうです。
(WIP) Reducer を跨ぐ通知の手段として NotificationCenter の活用を検討すること
🚧 WIP 🚧
withDependencies
を簡単に利用できる形で initialize すること
Store は補完が効く形・TCA で Store
を initialize する際の書き方はいくつかありますが、個人的には以下のような形で書くことをお勧めしたいです。
let store = Store(
initialState: SomeReducer.State(...)
) {
SomeReducer()
}
上記の書き方でポイントとなるのは以下の二点です。
-
initialState
に指定する引数を.init(...)
と書くのではなく、SomeReducer.State(...)
と明示的に書いている -
SomeReducer
を trailing closure を使って提供する
まず、1 についてですが、.init
を使うとシンプルに書ける一方で、補完が効かなくなってしまいます。(仮に効くとしても、効かない場合が多いはず)
State の initialize では、何らかの値を渡しておきたいシーンが出てくると思いますが、そのような場合に元から SomeReducer.State
と書いておいてくれると、コンパイラの力を借りやすくて助かります。
次に 2 についてですが、SomeReducer
を trailing closure を使って提供しておくと、任意の Dependency を override したい時に withDependencies
を使ってスッキリ書きやすくなります。
// このように書けば良い
let store = Store(
initialState: SomeReducer.State(...)
) {
SomeReducer()
} withDependencies: {
$0.someDependency = ...
}
withDependencies
を使わなくても Dependency を override する方法はいくつか存在しますが、個人的にはスッキリするのでこの方法を好んでいます。
テストサポート機能を理解して利用すること
TCA はテストサポート機能が充実しています。
TestStore
を利用して、await store.send(...)
したり await store.receive(...)
したり、dependency を差し替えたりすることで簡単にテストが書けることは README にも記載されていて、わかりやすいテストサポート機能ですが、他にもいくつかのテストサポート機能を備えています。
ここで紹介したい他のテストサポート機能は以下の二つです。
exhaustivity
useMainSerialExecutor
exhaustivity
については、少し古いですが以前に記事を書いているので、詳しくはこちらを参照してください。(確かその当時は存在していなかったため、記事には書かれていないですが、部分的に exhaustivity
を off にしてテストできる withExhaustivity
という機能も今は増えています)
useMainSerialExecutor
は、v0.56.0 で TCA に導入されたテストサポート機能です。
本来 withTaskGroup
や Effect.merge
を使って複数の並列処理を実行する場合、その内部で実行される処理の順序は不定になり、テストが難しい状態になりますが、useMainSerialExecutor
を利用すると、テストにおいてはその実行順序を一定に保つことができ、テストが書けるようになります。
初期の頃の TCA では、確かこの useMainSerialExecutor
はオプトインの機能だったはずですが、今の TCA では TestStore
を initialize するタイミングでデフォルトで有効になるようになっています。
ある程度詳しい話は以下のブログなどに記載されています。
useMainSerialExecutor
に関する話は結構面白いので、記事にまとめたいなと思っているのですが、なかなか時間が割けていません...。どこかで書けたら、ここにも記事へのリンクを貼りたいと思います。
AlertState は extension を定義して Test などで使い回せるようにすること
TCA では swiftui-navigation で提供されている AlertState
を利用することで簡単に Alert を表現できますが、AlertState
への代入を直接行うのではなく、以下のように表現するのがおすすめです。
↑ のように定義することで、state.destination = .alert(.dataFailedToLoad)
のように利用できるようになるため、テストなどで AlertState
を assertion する時もそれを使い回せて便利です。
もちろん ConfirmationDialogState
でも同じようなことができます。
Previews と Unit Test をしっかり書くこと
TCA は Testing が強みであると度々主張されていますが、Unit Test をしっかり書くことで設計は綺麗になりやすいと思います。(TCA に限らずですが)
TCA を使っているのにテストしにくい構造になっているということは、かなりの確率で設計に問題があるはずだと思っています。
日頃から Unit Test を書いていればテストしにくい構造に気づきやすくなり、しかもいつの間にかテストも整っているという嬉しい状態になることができます。
そして、Xcode Previews が導入されて以降、Unit Test と同じくらい Previews のコードを書くことが、iOS アプリ開発においては重要なことだと個人的に感じています。(WWDC の何かのセッションでもそんなことが話されていました)
基本的にテストしやすい構造になっていれば Preview しやすいコードになっていると感じていますし、その逆もそうであると感じています。
幸い、TCA は Testing が強みであり、基本的には swift-dependencies を利用して依存関係を制御することから、Unit Test・Xcode Previews は活用しやすい状態になっていると思うので、どんどん書いていきましょう。
変更履歴
記事の性質上、色々追記したり削除したりしそうなので、大きく変更した点があればここにタイムラインを追記しておこうと思います。
- 2023/11/26
- 「AlertState は extension を定義して Test などで使い回せるようにすること」を追加しました。
- 2023/12/3
- 「
Effect.send
は極力利用しないこと」を追加しました。
- 「
- 2024/3/7
- 「Delegate Action を活用すること」に
ViewAction
について追記しました。 - 「Parent Reducer から Child Action を呼ばないこと」に
reduce
を呼ぶ方法について追記しました。 - 「Reducer を分割・結合するための方法を理解して利用すること」のコードの最新化、
SwitchStore
についての記述の削除を行いました。 - 「多くの Feature で共有したい State は Dependency での管理を検討すること」に注意文を追加しました。
- 全体的にリンクが切れている部分を修正しました。
- 「Delegate Action を活用すること」に
- 2024/9/23
- 「Delegate Action を活用すること」を「View Action・Delegate Action を活用すること」に変更し、内容も少しだけ修正しました。
- Sharing state のための API についての記載を追加しました。
- 「Reducer を跨ぐ通知の手段として NotificationCenter の活用を検討すること」というセクションのみ追加しました。(内容はそのうち書く予定です)
- 全体的に表現を修正しました。
Discussion