💉

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

2022/12/12に公開約8,400字

この記事は、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-composable-architecture/tree/main/Sources/Dependencies/Dependencies

自分で作っちゃいそうもしくは、副作用であることに気づきづらいことは下記でしょう。

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

ログインするとコメントできます