uber/needle を使いはじめるためのメンタルモデル

概要

最近 uber/needle を使う機会があったので入門しました。この記事では needle を使い始めるためにざっくり持っておくと便利なイメージのようなものをまとめます。

uber/needle とは

needle は Swift で DI を行うためのライブラリです。類似ライブラリの中でもけっこう有名な方で、 iOS アプリに DI の仕組みを入れようとなったときに利用するライブラリの検討候補には上がってくるのではないかと思います。

DI は依存関係を実行時に解決する動的な DI とビルド時に解決する静的な DI にざっくり分けられますが、 needle は後者の静的な DI をコード生成により実現しています。静的な DI では実行時に存在するはず依存が取得できなくてクラッシュするような問題が発生しないので、それが needle のメリットの1つとなっています。

needle は、コード生成を行う CLI とアプリに統合する NeedleFoundation フレームワークの2つの部品からなっています。CLI は手動でコマンドラインで実行することもできますが、ちゃんとしたアプリにおいては Build Phases のどこかで実行するのがよいでしょう。

インストールの方法は README にまとまっています。

ComponentDependency

needle の README を読んでみると少し難しそうな雰囲気がただよっていますが、実際に入門してみると非常に簡単で理解するべき概念は ComponentDependency の2つだけです。

ドキュメント を読むと Component はスコープだとか階層構造を作るとかいろいろ書いてありますが、入門時には「画面を作ることに責任を持つもの」と考えておくとわかりやすいと思っています。必ずしもそうする必要はないですが、画面 A / B / C と3つ作るときには Component も対応させて A / B / C の3つを作ることが多く、公式のサンプルでもそうなっています。

Component が画面を作るぞ!と思ったところで無から画面を作るわけにはいかなくて、普通は API なりデータベースなりに依存することになります。この依存をまとめたものが Dependency です。1つの Component には1つの Dependency が対応するので、例えば画面 A を作る場合には AComponentADependency を用意することになります。画面 A が必要とする依存が ADependency にまとめられ、それを利用して AComponent が画面を生成します。

画面を作ってみる : ComponentDependency

実際に needle の ComponentDependency を使って SwiftUI で画面を作ってみます。題材として、ランダムな番号をいくつか表示する画面を作ることにします。まず、 View を以下のように作ってしまいましょう。

// NumbersScreen.swift

import SwiftUI

struct NumbersScreen: View {
    @State private var randomNumbers: [Int] = []

    var body: some View {
        NavigationView {
            List {
                ForEach(randomNumbers, id: \.self) { number in
                    Text(number, format: .number)
                }
            }
            .toolbar {
                ToolbarItem {
                    Button("Refresh") {
                        refreshRandomNumbers()
                    }
                }
            }
        }
        .onAppear {
            refreshRandomNumbers()
        }
    }

    private func refreshRandomNumbers() {
        // TODO: 番号を取得して randomNumbers に代入する
    }
}

ランダムな番号を取得して画面に反映するメソッドである refreshRandomNumbers は一旦 TODO にしてありますが、 View 自体はこれで完成です。

先ほども書いたように、 needle において画面を作ることに責任を持つのは Component なので、 NumbersScreen を生成する NumbersComponent を作ることにします。 ComponentNeedleFoundationComponent クラスを継承して作ります。

// NumbersScreen.swift

final class NumbersComponent: Component<EmptyDependency> {
    func build() -> some View {
        NumbersScreen()
    }
}

Component がどうやって画面を作るかは needle が定めているわけではないので、好きに作って OK です。今回の NumbersComponent では build というメソッドに NumbersScreen を生成して返してもらっています。 DependencyComponent と1対1に対応しますが、これは Component の型パラメータが Dependency を受け取ることにより実現されています。ひとまず現状の NumbersScreen は何も依存を必要としていないので、空の依存を表す NeedleFoundation 組み込みの EmptyDependency を利用しています。

それでは、先ほど TODO にしていた NumbersScreen#refreshRandomNumbers を埋めていきます。そのために、どこかからランダムな番号を取ってくる必要があります。いったん具体的な実装のことを考えずに protocol だけ定義することにします。

// RandomNumberFetcher.swift

protocol RandomNumbersFetcherProtocol {
    func fetch() -> [Int]
}

これを NumberScreen に持たせ、 refreshRandomNumbers の中で呼べば OK です。

// NumbersScreen.swift

struct NumbersScreen: View {
    // ...

    private let randomNumbersFetcher: any RandomNumbersFetcherProtocol

    init(randomNumbersFetcher: any RandomNumbersFetcherProtocol) {
        self.randomNumbersFetcher = randomNumbersFetcher
    }

    // ...

    private func refreshRandomNumbers() {
        randomNumbers = randomNumbersFetcher.fetch()
    }
}

View 側のコード変更としてはこれがすべてです。次に誰が NumberScreenRandomNumbersFetcherProtocol を渡すのかという問題が出てきます。 NumberScreen を作る責任は NumbersComponent にあるので、最終的には NumbersComponent に渡してもらう必要があります。しかし、 needle では Component は必ずしも画面のための依存を生成する必要はなく、こういう依存がほしいよというのを Dependency として宣言しておくだけで OK です。これにより、 Component の中で使える dependency というプロパティの中に望みの依存が入ってくることになります。

// NumbersScreen.swift

protocol NumbersDependency: Dependency {
    var randomNumbersFetcher: any RandomNumbersFetcherProtocol { get }
}

final class NumbersComponent: Component<NumbersDependency> {
    func build() -> some View {
        NumbersScreen(randomNumbers Fetcjer: dependency.randomNumbersFetcher)
    }
}

以上で、ランダムな番号を取得して表示する画面を作ることができました。もちろん、 RandomNumbersFetcherProtocol を満たす実体の実装をしていないのでアプリとしては動作しないですが、画面の実装としては完了していて、たとえば RandomNumbersFetcherProtocol のモックを作ってあげればプレビューやテストなどで画面の動作確認をすることもできます。

ここまでの例で ComponentDependency についてなんとなく理解することができたと思います。画面が必要とする依存が Dependency で、その Dependency を元に画面を生成する責任を持つのが Component です。

アプリを動作させる : BootstrapComponent

続いて、実際にアプリを動作させていきます。まずは、 RandomNumbersFetcherProtocol を実装してしまいます。その具体的な実装は needle を学ぶにあたっては関係ないので、その場で適当に Int を5つ生成して配列に入れて返すことにします。

// RandomNumbersFetcher.swift

final class RandomNumbersFetcher: RandomNumbersFetcherProtocol {
    func fetch() -> [Int] {
        Array((0..<100).shuffled().prefix(5))
    }
}

ここまでで

  • 画面(NumbersScreen
  • ComponentNumbersComponent
  • DependencyNumbersDependency
  • Dependency が必要とする依存(RandomNumbersFetcherProtocol とその実装の RandomNumbersFetcher

が揃ったことになります。アプリとして動作するための TODO として、RandomNumberFetcher を実際に生成して NumbersComponent に渡してあげる作業が残っています。

needle において Component は親子関係のツリーを作っていて、すべての Component はそのツリーのどこかに属することになります。そして、ある Component が依存を必要とするとき、その依存の protocol を自分の Dependency に書いておくだけで、その Component のツリー上の祖先の Component が提供するその protocol の実装が取得できることになっています。これが needle による DI の核となる機能です。

これまで、 NumbersComponent は必要な依存を受け取って NumbersScreen を生成するという責任しか持っていませんでした。しかし、一般の Component は自分の子孫が利用する依存の実装を提供する責任も併せ持っているというわけです。

Component の親子関係の中で特別なものとしてBootstrapComponent があります。BootstrapComponent は、 Component の親子関係のツリーのルート、つまりすべての Component の親です。今回作っているアプリにはまだ NumbersComponent しかないので、ツリーのルートに BootstrapComponent があり、その唯一の子として NumbersComponent があることになります。

そのため、 NumbersComponentRandomNumbersFetcher を提供する役割を持っているのは BootstrapComponent です。 BootstrapComponentRootComponent という名前で実装しましょう。

// NumbersApp.swift

final class RootComponent: BootstrapComponent {
    var numbersComponent: NumbersComponent {
        NumbersComponent(parent: self)
    }

    public var randomNumbersFetcher: any RandomNumbersFetcherProtocol {
        RandomNumbersFetcher()
    }
}

親子関係を作るために RootComponent のプロパティとして NumbersComponent を生成し、 parent パラメータに自らを渡しています。これで、 needle に RootComponent が子として NumbersComponent を持つことが伝わります。

次に、 NumbersComponent が必要とする依存である randomNumbersFetcher をプロパティとして持たせます。 needle の働きにより NumbersDependencyrandomNumbersFetcher にこの実装が入ってくることになります。今回は RootComponentNumbersComponent に直接の親子関係がありますが、直接の子でなくても子孫であれば先祖が提供する依存を取得することができます。例えば NumbersComponent の子として NumberDetailComponent を作ったとして、 NumberDetailComponentRootComponent が提供する randomNumbersFetcherDependency を通じて利用できるということです。 ただし、依存の提供に関して以下の2点に注意する必要があります。

  • 提供側(RootComponent)と受け取り側(NumbersDependency)は同じプロパティ名で依存を持つ必要がある
  • 子孫の Component に依存が渡されるためには提供される依存のアクセスレベルが public である必要がある

以上の仕組みを実現するために needle の CLI コマンドを実行してコード生成を行います。以下のコマンドで、 NumbersApp 以下に存在するコードから Component の親子関係を読みとって、必要なコードを NumbersApp/NeedleGenerated.swift に生成してくれます。

needle generate NumbersApp/NeedleGenerated.swift NumbersApp/

生成された NeedleGenerated.swift をプロジェクトに加え、アプリ起動時に registerProviderFactories という関数を呼ぶことで needle の仕組みが有効になります。

最後に、 RootComponentインスタンスを生成する作業も合わせて、アプリのエンドポイントは以下のようにします。

// NumbersApp.swift

@main
struct NumbersApp: App {
    private let rootComponent: RootComponent

    init() {
        registerProviderFactories()
        self.rootComponent = RootComponent()
    }

    var body: some Scene {
        WindowGroup {
            rootComponent.numbersComponent.build()
        }
    }
}

これで、アプリが正しく動作するようになりました。実行すると、 rootComponent.numbersComponent が生成する NumbersScreen が表示されますが、この NumbersScreenRootComponent が提供する randomNumbersFetcher を利用することで動作していて、めでたいです。

誰がどの依存を提供するか

今回は RootComponentrandomNumbersFetcher を提供してもらいました。これにより、今後 RootComponent の子孫が増えてもその全員が randomNumbersFetcher を利用することができるというメリットがあります。しかし、冷静に考えると現段階では randomNumbersFetcher を使っているのは NumbersScreen だけなので NumbersComponent が自ら提供することも可能です。

final class NumbersComponent: Component<NumbersDependency> {
    public var randomNumbersFetcher: any RandomNumbersFetcherProtocol {
        RandomNumbersFetcher()
    }

    func build() -> some View {
        NumbersScreen(randomNumbersFetcher: randomNumbersFetcher)
    }
}

Component のツリーの中で誰がどの依存を提供するかはとくに定められていないので、アプリの都合によって自由に決めることができます。

まとめ

ここまでで needle を使い始めるにあたって理解しておくべき基本的なことはおおよそ見てこれたと思います。

needle において理解するべきは ComponentDependency です。 ComponentBootstrapComponent をルートとする親子関係のツリーを作ります。個々の Component は、

  • 子孫が利用する依存の実装を提供する責任
  • 祖先が提供してくれる依存を Dependency を利用して画面を作る責任

を持ちます。 needle のコード生成により、祖先が提供する依存が子孫の Dependency に入ってくきます。

より詳しいことは公式のドキュメントを参照しましょう。

github.com