TCAにあらかじめ用意されているLibrary Dependenciesについて
この記事は、The Composable Architecture Advent Calendar 2022 12/12の記事です。
はじめに
TCAにはv0.39.0から、DI管理が容易になる仕組みが追加されました。
v0.39.0未満のDIはTCAのEnvironmentを使い、親子関係のあるReducerにおいて、子が必要なDependencyを親が持たなければいけないという制約がありました。しかし、新しいやり方ではDIするオブジェクトをバケツリレーせず取り出すことができます。
ふるいTCAのEnvironmentバケツリレー
下記はv0.39.0未満の際のEnvironmentバケツリレーのイメージです。MainがrootですべてのDIするオブジェクトを保持して孫へリレーします。
「え?バケツリレーすればいいのでは?」と思うかもしれませんが、実際は機能が増えるほどEnvironmentで用意する副作用実行のオブジェクトは増えます。なので、Rootである親は基本的にすべてを知っておく必要があります。さらに中間の子のReducerには必要がない場合であっても、子や孫が必要であればEnvironmentを持たなければいけません。それはReducerができることを想像以上に見せてしまうため、可読性を下げることになります。言い換えると、バケツリレーしないということはReducerごとに必要なオブジェクトをプロパティラッパーで表現できる、という感じです。
TCAのDIの仕組みと利用方法
本題の前に大雑把な仕組みと利用方法を書いておきます。
おおざっぱな仕組み
だいたいの仕組みについてはグローバルにオブジェクトを用意し、キーを決めてSwiftの機能であるプロパティラッパーによってReducerProtocolで取り出せるようにしている(のだと思います)。
(ただ、TCAは上記の記事よりももっと優れた実用的な仕組みでDI管理を実現しています)
利用方法
利用方法はドキュメントに書かれています
本題: 用意されているオブジェクトの実例
今回の記事は、その仕組を使ってTCA自体があらかじめDIのために用意している副作用実行オブジェクト(Library Dependencies)もあるので、それについて書いておきます。つまり、バケツリレーをしなくなった上に、あらかじめよくあるオブジェクトはすでにDI用として使えるようになっているというわけです。
自分で作っちゃいそうもしくは、用意されているものが副作用であることに気づきづらいものについて下記に詳しく書いておきます。
DateGenerator
実行時の日にちだって副作用なのだよ。
常に実行時のDateを取得する
struct DateView: View {
let store: StoreOf<DateFeature>
var body: some View {
WithViewStore(store) { viewStore in
VStack {
Text("now: \(viewStore.text)")
.onAppear {
viewStore.send(.refresh)
}
Button {
viewStore.send(.refresh)
} label: {
Text("refresh")
}
}
}
}
}
struct DateFeature: ReducerProtocol {
@Dependency(\.date) var date
struct State: Equatable {
var text = "string"
}
enum Action: Equatable {
case refresh
}
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
switch action {
case .refresh:
state.text = "\(date.now.description)" // nowを呼び出すたびに時間が取り出せる
return .none
}
}
}
now
プロパティは呼び出されるたびに、都度Date()を実行するクロージャを呼び出します。
感想
これを見るまで副作用はそのActionの引数から渡すほうが純粋関数になって良いと思ってました。つまりcase refresh(Date)
で引数からDateを取得するほうが良いのかなと。しかし、Reducerの関数内でdate.now
直接使ってる。当然これでもテストコードやプレビュー時には常に同じ任意のDateに置き換えることが可能です。つまり入力に対して一定の出力になるため、Reducerは純粋関数ではないが参照透過性は高いってこと、なのかなあ。
UUIDGenerator
UUID生成も副作用!
常に別のUUIDを取得
struct UUIDView: View {
let store: StoreOf<UUIDFeature>
var body: some View {
WithViewStore(store) { viewStore in
VStack {
Text("\(viewStore.text)")
.onAppear {
viewStore.send(.refresh)
}
Button {
viewStore.send(.refresh)
} label: {
Text("refresh")
}
}
}
}
}
struct UUIDFeature: ReducerProtocol {
@Dependency(\.uuid) var uuid
struct State: Equatable {
var text = "string"
}
enum Action: Equatable {
case refresh
}
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
switch action {
case .refresh:
state.text = "\(uuid())" // 呼び出すたびに別のUUID
return .none
}
}
}
感想
var uuid
は実際の型はUUIDGenerator
で、callAsFunction
を使っていてそれそのものをuuid()
とするとUUID()
が実行されています。
WithRandomNumberGenerator
乱数生成も副作用!そりゃそうだ!
seedを指定
struct RandomNumberView: View {
let store: StoreOf<RandomNumberFeature>
var body: some View {
WithViewStore(store) { viewStore in
VStack {
Text("\(viewStore.text)")
.onAppear {
viewStore.send(.refresh)
}
Button {
viewStore.send(.refresh)
} label: {
Text("refresh")
}
}
}
}
}
struct RandomNumberFeature: ReducerProtocol {
@Dependency(\.withRandomNumberGenerator) var withRandomNumberGenerator
struct State: Equatable {
var text = "string"
}
enum Action: Equatable {
case refresh
}
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
switch action {
case .refresh:
withRandomNumberGenerator { generator in
state.text = "\(Int.random(in: 1...6, using: &generator))"
}
return .none
}
}
}
感想
乱数をDIするのではなく、seedをDIするようになっていて興味深いですね。
mainQueue
mainQueue取得という行為も実行中に実際に起こる副作用。
1秒ごとのインターバルの例
struct MainQueueTimerView: View {
let store: StoreOf<MainQueueTimerFeature>
var body: some View {
WithViewStore(store) { viewStore in
VStack {
Text("\(viewStore.text)")
Button {
viewStore.send(.refresh)
} label: {
Text("refresh")
}
}
}
}
}
struct MainQueueTimerFeature: ReducerProtocol {
@Dependency(\.mainQueue) var mainQueue
struct State: Equatable {
var text = "string"
var counter = 0
}
enum Action: Equatable {
case refresh
case timerTicked
}
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
switch action {
case .refresh:
return .run { send in
for await _ in self.mainQueue.timer(interval: .seconds(1)) {
await send(.timerTicked)
}
}
case .timerTicked:
state.counter += 1
state.text = "\(state.counter)秒後"
return .none
}
}
}
感想
mainQueueはCombineベースのコードを書く場合には当然使うとは思いますが、上記の例はSwift Concurrency的にインターバルで使うこともできるという例ですね。
おまけ: isowordsではどんなDepndenciesを用意しているのか
isowordsで使っていて、どんなアプリでも使えそうなものを書いておきます
ApplicationClient
UIApplicationをClientとしてて、これでダークモード指定なんかを実装してる
DeviceIdentifierを取得
AudioPlayerClient
おわりに
TCAに用意されているDependenciesはPRで増えていく傾向にあるので、知らない間に増えることもあるはずです。たまに見ると発見があります。
Discussion