エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Swift6対応で見えた課題とswift-dependenciesへのDI移行戦略

【マルチデバイスチーム ブログリレー4日目】

こんにちは、マルチデバイスチームの藤原です。

私たちのチームでは、複数のSwift製のアプリを開発しており、Swift 6への対応を少しずつ進めています。 その過程で、依存性注入(DI: Dependency Injection)ライブラリとして利用してきた Needle が生成するコードと、将来的にSwiftで必須となる existential any *1 *2 との相性問題が顕在化してきました。

コード生成に依存するライブラリは言語仕様に大きな変更があると互換性の問題に悩まされることがよくあると思います。 今後のSwiftの進化に柔軟に対応していくために、できる限りコード生成を伴わない、Swiftネイティブの機能を活かしたライブラリを選定するというのは、1つの重要な指針となりそうです。
そこで、チームのいくつかのプロジェクトでは、以前から注目していた swift-dependencies への移行を決断しました。

今回は、Needleからswift-dependenciesへの移行について、具体的な方針、そしてその過程で得られた知見をご紹介します。

それぞれのDI方法の違い

DIの実現方法は様々です。例えばAndroidアプリの開発では Dagger Hilt のようなアノテーションプロセッサを用いたコンパイル時にコードを自動生成するタイプのDIがよく使われます。
それに対して初期のSwiftでは、その安全性を重視する設計思想や言語仕様の制約ゆえコンパイル時にコード生成するようなDIは実現困難でした。 このため、Swiftでは独自のアプローチが模索されてきました。

Needleのアプローチ:Protocolとコード生成による解決

Needle はUberによって開発されたSwift向けのDIシステムです。
コードを生成するコマンドラインツールと、生成コードをアプリに統合するためのフレームワークの2つの部品からなっています。

github.com

NeedleのDIは、依存関係を Protocol を用いて定義し、それに基づいてコンパイル前に専用のジェネレータがSwiftコードを自動生成することによって実現します。 この自動生成されたコードによって依存関係の解決とインスタンスの注入を行います。

protocol MyDependency: Dependency {
    // DIしたいオブジェクトを列挙する
    var myService: MyService { get }
}

final class MyComponent: Component<MyDependency> {
    var myViewModel: MyViewModel {
        // 自動生成されたコードによって、dependencyプロパティがDIしたいオブジェクトを保持した状態となる
        MyViewModel(myService: dependency.myService)
  }
}

Needleはコード生成時に依存関係が解決されるため、実行前に型エラーや依存関係の不足を発見できるというメリットがあります。また、Uberという大規模な組織の開発でも利用されてきた実績も大きいでしょう。
しかし、コード生成に依存していることや、CLIツールのセットアップの手間などのデメリットもあります。

swift-dependenciesのアプローチ:Property WrapperによるモダンなDI

swift-dependencies はPoint-Freeによって開発されたライブラリです。 同社の提唱するThe Composable Architecture(TCA)と呼ばれるSwift向けのアプリケーションアーキテクチャの一部として提供されていましたが、DIに関する部分のみ独立したライブラリとして切り出されました。

github.com

Swift 5.1で導入された言語機能である Property Wrapper (プロパティラッパー) を活用することで、DIを実現します。

final class MyViewModel: ObservableObject {
  @Dependency(\.myService) var myService // この1行を書くだけ!

  func performAction() {
    myService.doSomething()
  }
}

上記のように、@Dependency というプロパティラッパーを付与するだけで、宣言的にDIすることができます。 コード生成は一切行わないため、言語仕様に変更があった場合でも影響を受けにくいです。

移行方針

swift-dependenciesには様々な利点がありますが、コード生成が不要になる点を重視して、移行は以下の方針を取ることにしました。

1. 他のDIライブラリへの再移行の可能性も考慮し、依存は最小限にイニシャライザインジェクションを基本とする

swift-dependenciesはとても素晴らしいライブラリ(もちろんNeedleも)ですが、将来的にさらに良い選択肢が登場する可能性もゼロではありません。 Appleが公式にDIライブラリを出す可能性もあるでしょう。そのため、特定のDIライブラリに強く結合しすぎない設計を心がけました。
具体的には、DIされる側のクラスでは可能な限りイニシャライザインジェクション(コンストラクタインジェクション)を維持することにしました。
swift-dependenciesの @Dependency プロパティラッパーは、主に依存性を解決しインスタンスを組み立てるところ(私たちのプロジェクトでは主に ViewControllerBuilder のようなViewのファクトリクラス)に使用を限定します。 swift-dependenciesの機能を直接利用する箇所を限定することで、将来的な変更コストを低減させるのが狙いです。

2. ユニットテストではswift-dependenciesを直接利用しない

ViewModelやUseCaseなどのビジネスロジック層のユニットテストでは、依存オブジェクトをモックに差し替えてテストを行います。 この際、swift-dependenciesの @Dependency を介さず、イニシャライザを通じて直接モックオブジェクトを注入します。 これにより、テストコードが特定のDIライブラリに依存することを避け、テストの独立性を高めることができます。
移行前の状態でも元々イニシャライザを通してモックオブジェクトを注入していたため、今回の移行ではテストへの影響を皆無にできます。

swift-dependencies移行の恩恵

swift-dependenceisは、イニシャライザインジェクション不要でプロパティの宣言のみでDIできたり、テスト用のモックを注入する機能もあります。
前述の方針ではそれらのメリットを捨てたように見えるかもしれませんが、以下のように十分な恩恵を享受できると考えました。

  • Swiftの言語仕様アップデートへの追従性向上
    • ExistentialAny はもちろん、将来的な言語の進化に対しても、コード生成がない分、柔軟かつ迅速に対応できるようになる
  • 運用コストの低減
    • コード生成のためのCLIインストールやXcode設定などのあれこれから解放される
  • 学習コストの低さ
    • swift-dependenciesのAPIは非常にシンプルなため、@DependencyDependencyKey の概念を理解すればすぐに使いこなせるようになる
    • プロジェクトへの新規参画者にとっても、DIの仕組みが理解しやすい

移行手順

Needleからswift-dependenciesへの具体的な移行作業は、以下のステップで進めました。

1. Needleの木構造記述をフラットな依存関係定義へ

Needleでは、Component が他の Component をプロパティとして持ち、木構造のような依存関係グラフを形成していました。

// NeedleのComponent例
protocol ParentDependency: Dependency {}

class ParentComponent: Component<ParentDependency> {
    var parentService: ParentService {
        return ParentService()
    }

    // 子コンポーネントのインスタンス化
    var childComponent: ChildComponent {
        return ChildComponent(parent: self)
    }
}

protocol ChildDependency: Dependency {
    var parentService: ParentService { get } // 必要な依存性を親から引き継ぐ
}

class ChildComponent: Component<ChildDependency> {
    var childService: ChildService() {
        return ChildService()
    }

    var childViewModel: ChildViewModel {
        return ChildViewModel(
            childService: childService,
            parentService: dependency.parentService // 親から注入された依存性を利用
        )
    }
}

swift-dependenciesでは、注入するオブジェクトを DependencyKey を使って個別に定義し、DependencyValues に登録します。
移行前は注入するオブジェクトの定義はNeedleの木構造に準じた場所で行っていましたが、移行後はフラットな管理方法となるため、まずはNeedleを利用したまま木構造の根本でフラットに管理するように移動させました。

protocol RootDependency: Dependency {}

class RootComponent: Component<RootDependency> {
    var parentService: ParentService {
        return ParentService()
    }

    var childService: ChildService() {
        return ChildService()
    }
}

swift-dependenciesに移行する際には、最終的に以下のようなコードに変更することになります。

// swift-dependenciesのDependencyValuesの拡張の実装例

// ServiceはServiceでまとめる
extension DependencyValues {
    // DependencyValuesへの登録

    var parentService: ParentService {
        self[CommonServiceKey.self]
    }

    var childService: ChildService {
        self[CommonServiceKey.self]
    }

    // DependencyKeyで実際のインスタンスを定義

    private enum ParentServiceKey: DependencyKey {
        static let liveValue: ParentService = ParentService()
    }

    private enum ChildServiceKey: DependencyKey {
        static let liveValue: ChildService = ChildService()
    }
}

2. ViewControllerBuilderの置き換えを機械的に進める

ViewControllerBuilder は、特定のViewControllerを生成する責務を持ち、その際に必要な依存性を解決してViewControllerのイニシャライザに渡します。

移行前 (Needle):

// Builderプロトコル
protocol HogeViewControllerBuilder {
    func build() -> UIViewController
}

protocol HogeDependency: Dependency {
    var hogeUseCase: HogeUseCase { get }
}

final class HogeDiComponent: Component<HogeDependency>, HogeViewControllerBuilder {
    // HogeViewControllerを構築するメソッド
    func build() -> UIViewController {
        // hogeUseCaseは親から引き継いだ依存性から注入する
        let viewModel = HogeViewModel(useCase: dependency.hogeUseCase)
        return HogeViewController(viewModel: viewModel)
    }
}

移行後 (swift-dependencies):

// Builderプロトコル (変更なし)
protocol HogeViewControllerBuilder {
    func build() -> UIViewController
}

class HogeViewControllerBuilderImpl: HogeViewControllerBuilder {
    // DependencyValuesから必要な依存性を@Dependencyで取得
    @Dependency(\.hogeUseCase) var hogeUseCase

    func build() -> UIViewController {
        let viewModel = HogeViewModel(useCase: hogeUseCase)
        return HogeViewController(viewModel: viewModel)
    }
}

この置き換えは、比較的機械的に行うことができました。NeedleのComponentで注入されていた依存性を、@Dependency プロパティラッパーを使って DependencyValues から取得するように変更します。

3. Needle関連のコードを削除

全てのViewControllerBuilderおよび関連するクラスのDIがswift-dependenciesに置き換わったら、Needle関連のあれこれをプロジェクトから削除します。

  • Needleの生成コード
  • registerProviderFactories() などのセットアップコード
  • コード生成CLIのインストールや実行スクリプト、またその手順の説明など

まとめ

Needleからswift-dependenciesへのDIライブラリ移行は、Swift6対応をきっかけとしたものでしたが、結果として コード生成への依存を断ち切ったことによる将来のSwiftへの適応力向上コード簡素化による開発体験の向上 などのポジティブな変化をもたらしてくれました。 今後もSwiftの新しい機能を積極的にキャッチアップし、より良い開発体験とプロダクト品質の向上を目指していきたいと思います。

もし、同じようにコード生成系のDIライブラリからの移行を検討されている方がいらっしゃれば、この記事が少しでも参考になれば幸いです。

We are hiring!

エムスリーでは、iOSに限らずスマホアプリエンジニアを絶賛募集中です。 興味を持たれた方は是非お問い合わせください。

speakerdeck.com

jobs.m3.com

*1:SE-0335: Introduce Existential Any

*2:当初はSwift6で必須化されるという話もあったが現在では延期となっている