💉

TCAにあらかじめ用意されているLibrary Dependenciesについて

2022/12/12に公開

この記事は、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で取り出せるようにしている(のだと思います)。

https://zenn.dev/yimajo/articles/e9f72549270873

(ただ、TCAは上記の記事よりももっと優れた実用的な仕組みでDI管理を実現しています)

利用方法

利用方法はドキュメントに書かれています

https://github.com/pointfreeco/swift-composable-architecture/blob/main/Sources/ComposableArchitecture/Documentation.docc/Articles/DependencyManagement.md#registering-your-own-dependencies

本題: 用意されているオブジェクトの実例

今回の記事は、その仕組を使ってTCA自体があらかじめDIのために用意している副作用実行オブジェクト(Library Dependencies)もあるので、それについて書いておきます。つまり、バケツリレーをしなくなった上に、あらかじめよくあるオブジェクトはすでにDI用として使えるようになっているというわけです。

https://github.com/pointfreeco/swift-dependencies/tree/main/Sources/Dependencies/DependencyValues

自分で作っちゃいそうもしくは、用意されているものが副作用であることに気づきづらいものについて下記に詳しく書いておきます。

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()を実行するクロージャを呼び出します。

https://github.com/pointfreeco/swift-composable-architecture/blob/0.42.0/Sources/Dependencies/Dependencies/Date.swift#L71

https://github.com/pointfreeco/swift-composable-architecture/blob/0.42.0/Sources/Dependencies/Dependencies/Date.swift#L47

感想

これを見るまで副作用はその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としてて、これでダークモード指定なんかを実装してる

https://github.com/pointfreeco/isowords/blob/bb0a73d20495ca95167a01eeaaf591a540120ce2/Sources/UIApplicationClient/LiveKey.swift

DeviceIdentifierを取得

https://github.com/pointfreeco/isowords/blob/4323e320d481619f057cabc3f68bf017c6303422/Sources/DeviceId/DeviceId.swift

AudioPlayerClient

https://github.com/pointfreeco/isowords/blob/4323e320d481619f057cabc3f68bf017c6303422/Sources/AudioPlayerClient/LiveKey.swift

おわりに

TCAに用意されているDependenciesはPRで増えていく傾向にあるので、知らない間に増えることもあるはずです。たまに見ると発見があります。

Discussion