LoginSignup
157
99

More than 3 years have passed since last update.

DevFestの資料の記事版です。ステップごとにサンプルアプリの差分付きで説明します。
なぜDependency Injectionが必要なのか から始め、Dagger Hiltの説明、実践的なプラクティスまで説明していきます!

Dependency Injection(DI)とはなにか

なぜDIが必要なのか

DI、ちょっと難しいイメージありますが、そもそもなんで必要なんでしょうか?
作っているのが動画再生するアプリでVideoPlayerというクラスがあるとしましょう。
VideoPlayerのクラスの中にデータベースやcodecなどがハードコードされています。

コード: https://github.com/takahirom/hilt-sample-app/commit/8c36602aaa4e27d8f10c81e2808f0ff452f1c8a4#diff-bbc9d28d8bcbd080a704cacc92b8cf37R19

class VideoPlayer {
    // データベースにビデオの一覧が保存されている (Roomというライブラリを使っている)
    private val database = Room
        .databaseBuilder(
            App.instance,
            VideoDatabase::class.java,
            "database"
        )
        .createFromAsset("videos.db")
        .build()
    // 使えるコーデック一覧
    private val codecs = listOf(FMP4, WebM, MPEG_TS, AV1)

    private var isPlaying = false

    fun play() {
...
    }
}

使うときにはこれだけで、十分シンプルに見えます。

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val videoPlayer = VideoPlayer()
        videoPlayer.play()
    }
}

しかし、これだけだと開発を進めていく中で以下のような問題が起こります。

  • VideoPlayerを再度使いたい場合、データベースやコーデックがハードコードされているので、データベースを変えて再生したりなどができない。
  • 実行時やデバッグで交換できない。
  • テストでも交換できない。例えばテストではメモリ内のDB使いたい場合などは困る。
  • さまざまな依存関係がコードの中に入ってくるのでコードが見にくくなり、リファクタリングを難しくする。

この問題を簡単に回避するために簡単なDIを試してみましょう!

簡単にDIしてみよう

ここでDependency Injection(DI)です。日本語だと依存性の注入です。
最初は簡単にコンストラクタインジェクションというコンストラクタを使って依存性を注入する方法を紹介します。
簡単にVideoPlayerのコンストラクタに依存関係を渡してあげるだけです。
他にもsetterによって注入する方法をsetter injectionといいます。

コンストラクタインジェクションの例
差分: https://github.com/takahirom/hilt-sample-app/commit/a1fdef28515d158577313b90f7c2590bd5905366

VideoPlayerは依存関係が交換可能で、シンプルになった!

class VideoPlayer(
    private val database: VideoDatabase,
    private val codecs: List<Codec>
) {
    private var isPlaying = false

    fun play() {
...
    }
}

コンストラクタインジェクションを使うと使う側で、そのクラスの依存関係を先に作ってからでないとインスタンス化することができません。

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val videoDatabase = Room
            .databaseBuilder(
                this,
                VideoDatabase::class.java,
                "database"
            )
            .createFromAsset("videos.db")
            .build()
        val codecs = listOf(FMP4, WebM, MPEG_TS, AV1)
        val videoPlayer = VideoPlayer(videoDatabase, codecs)
        videoPlayer.play()
    }
}

いろんな画面で、VideoPlayerを作るたびにこのようなコードを書く必要があるので、これは ボイラープレートとなりえます
この処理をよく見ると"VideoPlayerを作るための処理"と"VideoPlayer#playを呼び出す処理"があることが分かります。

VideoPlayerを作るための処理

"VideoPlayerを作るための処理"は、ただ、他の型やクラスを作るためのロジックで、これを"コンストラクションロジック"と呼びます。

val videoDatabase = Room
    .databaseBuilder(
        this,
        VideoDatabase::class.java,
        "database"
     )
     .createFromAsset("videos.db")
     .build()
val codecs = listOf(FMP4, WebM, MPEG_TS, AV1)
val videoPlayer = VideoPlayer(videoDatabase, codecs)

VideoPlayer#playを呼び出す処理

"VideoPlayer#playを呼び出す処理"は、アプリの価値を作るためのロジックとなり、"ビジネスロジック"と呼びます。ここではコンストラクションロジック以外をこう呼ばせてください。

videoPlayer.play()

コンストラクションロジックとビジネスロジックが一緒になっていると、コードを追ったり、読んだりするのを難しくします。
またそれはクラスを読む人にとってあまり意味のないものであることが多いです。
Dependency Injection(DI)を行うライブラリでは、このコンストラクションロジックとビジネスロジックを分離することができます。

DIについてまとめ

  • さまざまな良い影響があるのでDIを行おう!
  • インスタンス化のためのボイラープレートを避けるためにDIライブラリを使おう!

AndroidではDIが難しい

フレームワークがActivityなどのインスタンスを作ってしまいます。例えば、startActivityなどするとインスタンスが作られてしまいます。(コンストラクタをいじれません。)

// Androidのフレームワークによって勝手にActivityがインスタンス化される
startActivity(Intent(context, MainActivity::class))

AndroidのAPI Level 28からFactoryでActivityを作れるようになるなど、改善はされていますが、Android 9以上でないと動作しないので、現状では現実的ではないです。

Daggerがこれまでの解決策でした。

上位1万のアプリで74%がDagger使っており、今の解決策としてはこれがメインです。
しかし、アンケートによると49%のユーザーがより良いDIの解決策を必要としていたようです。

どんなDIの解決策が必要だったか?

  • Opinionated(意見を持った。)
    決めるのを楽にして、使うのを楽にする (後述)
  • セットアップが簡単
    (DaggerはAndroidではなく、Java用だったため、Daggerは設定が難しかった)
  • 重要な部分にフォーカスできる

これがDagger Hiltが生まれたメインの理由のようです。

Dagger Hilt

Dagger HiltはDaggerの上に構築されたライブラリです。
Daggerの良いところを使うことができます。
GoogleのAndroidXチームとDaggerチームで共同で作られています。

  • AndroidにおけるDIの標準化
    どのようにAndroidでDIするのかを標準化(standardize)します。
  • Dagger上に構築
    Daggerのためのコードを生成します。
  • アノテーションベース
    Hiltに対して、アノテーションで何をしたいのかを伝えます。
  • ツールサポート
    Android Studio 4.1以降では左側にガターアイコンが表示される。例えば依存関係がどこから来たのかがわかります。
  • AndroidX Extension
    ViewModelとWorkManagerで使える。他のライブラリも追加予定です。

Dagger HiltをVideoPlayerの例で使ってみよう

どうにかして、Dagger HiltにVideoPlayerの作り方を教えないといけないですが、どのように教えたら良いでしょうか?
一番基本的なDagger Hiltへのインスタンスの作り方の教え方は、コンストラクタに@Injectをつけることでです。
現状はVideoPlayerに依存関係をもたせていません。
Dagger Hiltはこのクラスをただインスタンス化するだけなので、作ることができます。

class VideoPlayer @Inject constructor() {
    private var isPlaying = false

    fun play() {
...
    }
}

次にこのアプリはHiltで動くということをHiltに教えないといけません。
AndroidのApplicationクラスで@HiltAndroidAppを使うと、このアプリがHiltで動くということを教えられます。また、@HiltAndroidAppを使うと内部的にはComponentが作られます。このComponentとは、コンストラクションロジックと、作ったインスタンスを保持する部分です。(Componentについては後でもう一度触れます。)

@HiltAndroidApp
class VideoApp : Application()

Activtyのコンストラクタがいじれないということでしたが、その対応としてActivityに@AndroidEntryPointをつけます。

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

@AndroidEntryPointをつけることで、3つのことをHiltに対して教えます。

  • "このActivityはInjectionを行う。このActivityはDependency Injectionを使う"
  • "ActivityのComponentをこのActivityに追加。"
  • "HiltからDependencyを持ってくる"

変数に@Injectアノテーションを付けています。
これはHiltからInjectされることを意味します。"Actiivtyが作られたときに、VideoPlayerをInjectして" とHiltに教えている形です。
そして、onCreateメソッドではVideoPlayerを呼ぶなど好きなことができます。

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    // ↓ Dagger HiltによってonCreateでInjectされる
    @Inject lateinit var videoPlayer: VideoPlayer
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        videoPlayer.play()
    }
}

差分: https://github.com/takahirom/hilt-sample-app/commit/6a8a3711808e806e5953712adeb19b11cb73c3a9#diff-bbc9d28d8bcbd080a704cacc92b8cf37R24

魔法?どこでフィールドに代入されるの?

少しだけ中身の仕組みを知っておいたほうが分かりやすいと思うので、説明しておきます。
@AndroidEntryPointがついているActivityはHiltによって変換されます。
Hiltによって変換後にMainActivityとAppCompatActivityの間に
生成されたHilt_MainActivityが入ります。
Hilt_MainActivityのonCreateの中でフィールドにInjectされます。

image.png

Hiltによって変換後のコード

@AndroidEntryPoint
class MainActivity : Hilt_MainActivity() { // Hilt_MainActivityになっている
    @Inject lateinit var videoPlayer: VideoPlayer
    override fun onCreate(savedInstanceState: Bundle?) {
        // この中でフィールドにInjectされる
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        videoPlayer.play()
    }
}


VideoPlayerにVideoDatabaseをInjectさせるには?

これで一応MainActivityでVideoPlayerが使えるようになりました。ただ、これではVideoPlayerがDatabaseなどにアクセスできません。どのようにしていけばいいでしょうか?

// ↓ Databaseなどを持っていない
class VideoPlayer @Inject constructor() {
    private var isPlaying = false

    fun play() {
...
    }
}

もし、VideoDatabaseのコンストラクタを変更することができるのであれば、同様にVideoDatabaseに @Injectをつけて、Hiltにインスタンスの作り方を教えられます。Daggerが勝手に依存関係のインスタンス、VideoDatabaseを先に作ってからVideoPlayerをインスタンス化してくれます。

class VideoPlayer @Inject constructor(
  private val database: VideoDatabase
) {
    private var isPlaying = false

    fun play() {
...
    }
}
class VideoDatabase @Inject constructor() {
...
}

ただ今回はRoomによって作られるインスタンスになるので、コンストラクタをいじって@Injectをつけることができません。そのためどうにかしてDagger Hiltに以下のVideoDatabaseの作り方を教えないといけません。

val videoDatabase = Room
    .databaseBuilder(
        this,
        VideoDatabase::class.java,
        "database"
     )
     .createFromAsset("videos.db")
     .build()

そこでModuleを使います。Moduleを使うことで、Hiltにインスタンスの作り方を教えることができます。
@Module@InstallInというアノテーションを付けるただのクラスです。
そのModuleにはメソッドを追加します。
Moduleにあるメソッドは料理のレシピだと考えると分かりやすいです。VideoDatabaseの型を作るレシピになり、このレシピをHiltに教えています。
このレシピを@InstallInSingletonComponentに置きます。SingletonComponent は ApplicationのComponentに追加するということです。

メソッドをよく見ると@Providesと書いてあることが分かります。HiltにどのようにVideoDatabaseを作るのかを教えるメソッドということを教えています。
HiltがVideoDatabaseを作る必要があるときに、このメソッドを実行してインスタンスを返します。
ちなみに、contextクラスのインスタンスが@ApplicationContextで提供されていますが、Hiltで事前定義されたクラスもいくつかあり、Hiltがいくつかのインスタンスを提供してくれます。
差分: https://github.com/takahirom/hilt-sample-app/commit/c85a6f668a0bf447c0a4b119f4f6d8cc8c2cff80

@Module
@InstallIn(SingletonComponent::class)
object DataModule {
    @Provides
    fun provideVideoDB(@ApplicationContext context: Context): VideoDatabase {
        return Room
            .databaseBuilder(
                context,
                VideoDatabase::class.java,
                "database"
            )
            .createFromAsset("videos.db")
            .build()
    }
}

さて、この SingletonComponentが出てきましたが、 Componentについてもう少し触れておきましょう。

Component

Componentは以下のことができます。

  • どのようにオブジェクトをビルドするのかのロジックを持っています。
    VideoDatabaseはこうやって作る。。など
    VideDatabase : Room.databaseBuilder() … .build()
  • インスタンス生成の順序のロジックを持っています。

    "VideoDatabaseの後にVideoPlayerを生成する。"など

  • スコープによってインスタンスを使い回します。 (後述)

Dagger Hilt標準のComponent

HiltはOpnionedであると言いましたが、Dagger Hiltには標準のComponentが存在し、Componentの構造について迷わなくて良くなっています。
この図はComponentの階層を表しており、アプリケーション全体のSingletonComponent、画面回転でも生き残るActivityRetainedCompoent、Activityと紐づくActivityComponentなどのComponentがある構造になっています。
上についているアノテーションはスコープアノテーションです。これについては後ほど説明します
例えば、SingletonComponentやConfigration Changeでも生き残るActivityRetainedComponent、ActivityComponent、そしてFragmentComponentなどが付随します。

image.png
https://dagger.dev/hilt/components より

今回の例ではSingletonComponentにVideoPlayerの作り方とVideoDatabaseの作り方が入っており、その作成順序も入っています。
image.png

インスタンスを共有したいときはどうするのか

例えば現状ではVideoDatabaseは現在使われるたびにインスタンス化されてしまいます。

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    @Inject
    lateinit var videoPlayer: VideoPlayer
    @Inject
    lateinit var videoPlayer2: VideoPlayer
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        println(videoPlayer.database)
        // VideoDatabase_Impl@764b474 ← ハッシュコードが違う
        println(videoPlayer2.database)
        // VideoDatabase_Impl@a945d9d ← ハッシュコードが違う
    }
}

class VideoPlayer @Inject constructor(
    val database: VideoDatabase
)
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
    @Provides
    fun provideVideoDB(@ApplicationContext context: Context): VideoDatabase {
        return ...
    }
}

コネクションを使いまわしたいなど様々な理由で、インスタンスを使いまわしたいことがあります。また今回の例とは関係ないですが、Androidの通信で一般的に使われるOkHttpはインスタンスをアプリ全体で使いまわすとパフォーマンスが良くなります。 (https://square.github.io/okhttp/4.x/okhttp/okhttp3/-ok-http-client/ より)

Activity内で共有したい場合は、@ActivityScopedを使うことでActivityComponentで保持されるため、
Activity内で同じインスタンスを使い回せます。この@ActivityScopedをScope Annotationといいます

これでActivity内でインスタンスを共有できます。
差分: https://github.com/takahirom/hilt-sample-app/commit/f895dfac123a0317b9e0e247af3a48b57388ad5d

@Module
@InstallIn(ActivityComponent::class)
object DataModule {
    @ActivityScoped
    @Provides
    fun provideVideoDB(@ApplicationContext context: Context): VideoDatabase {
...
    }
}

@ActivityScopedではActivityが違えば別のインスタンスになってしまいます。アプリ全体で使いまわしたいときは@SingletonのScope Annotationを使うことでSingletonComponentで保持されるため、アプリ全体で使えます。
差分: https://github.com/takahirom/hilt-sample-app/commit/64cc1b50388cf9c79fba26a774ae86efb1f093bc

@Module
@InstallIn(SingletonComponent::class)
object DataModule {
    @Singleton
    @Provides
    fun provideVideoDB(@ApplicationContext context: Context): VideoDatabase {
...
    }
}

VideoDtabaseはSingletonComponent内でインスタンスも保持されるようになりました。

image.png

Hiltが管理していないクラスから、Hiltの依存関係を使いたい時

Hiltが管理しているMainActivityやVideoPlayerクラスではHiltから依存関係を取得できますが、Hiltが管理していないクラスでは取得が難しい場合があります。
例えば、ContentProviderクラスや、他のライブラリが生成するクラス、Dagger Hiltにマイグレーションしているときの既存のクラスなどです。
ここで、EntryPointという仕組みが使えます。EntryPointを使うことで、HiltのもつComponentが持つ依存関係にアクセスすることができます。

これはHiltで管理できていないActivityでHiltがコンストラクションロジックをもつVideoPlayerを使う例となります。
差分: https://github.com/takahirom/hilt-sample-app/commit/d66fb46b395b0c9b6a98ff91bd55f3c4f12c99c9

class NonHiltActivity : AppCompatActivity() {
    @EntryPoint // @EntryPointをつける。
    @InstallIn(SingletonComponent::class)
    interface NonHiltActivityEntryPoint {
        fun videoPlayer(): VideoPlayer
    }

    override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
        super.onCreate(savedInstanceState, persistentState)
        val entryPoint = EntryPointAccessors.fromApplication(
            applicationContext,
            NonHiltActivityEntryPoint::class.java
        )
        val videoPlayer = entryPoint.videoPlayer()
        videoPlayer.play()
    }
}

テスト

一般的なテストの書き方と同じように自分で対象のオブジェクトnewしてテストを書くことができます。
この場合、自分で依存関係を先に作らなくてはいけません。
差分: https://github.com/takahirom/hilt-sample-app/commit/068082bf7bcb20ecbb1258ac6a3027988d624303

    @Test
    fun normalTest() {
        // メモリ内にDBを作る
        val database = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            VideoDatabase::class.java
        ).build()
        val videoPlayer = VideoPlayer(database)

        videoPlayer.play()

        assertThat(videoPlayer.isPlaying, `is`(true))
    }

Hiltを使うと、このように自分で依存関係を作らずにHiltにインスタンスを作らせることができます。
しかし、今回は実際のDatabaseを使わずにメモリ内にDatabaseを使ってそれを使いたいです。どのようにするでしょうか?

@HiltAndroidTest
class VideoPlayerTest {
    @get:Rule
    var hiltAndroidRule = HiltAndroidRule(this)

    @Inject
    lateinit var videoPlayer: VideoPlayer

    @Test
    fun play() {
        hiltAndroidRule.inject()

        videoPlayer.play()

        assertThat(videoPlayer.isPlaying, `is`(true))
    }

ここで@UninstallModules(DataModule::class)することで、Dagger HiltからDataModuleが持つVideoDatabaseの作り方を忘れさせることができます
そして、テストの中で、新たなModuleを定義することで、Databaseを提供することができます。 テストの外でModuleを宣言するとテスト全体でInstallされます。
差分: https://github.com/takahirom/hilt-sample-app/commit/4c862ee62e8dfc133ea6e7e3ff0735c0497cfb6a

@HiltAndroidTest
@UninstallModules(DataModule::class)
@RunWith(RobolectricTestRunner::class)
class VideoPlayerTest {
    @InstallIn(SingletonComponent::class)
    @Module
    class TestDataModule {
        @Provides
        fun provideVideoDatabase(): VideoDatabase {
            return Room.inMemoryDatabaseBuilder(
                ApplicationProvider.getApplicationContext(),
                VideoDatabase::class.java
            ).build()
        }
    }

Daggerからの Dagger Hiltマイグレーションについて

Daggerを知っている人向けの話になりますので、Dagger分からんという方は、へーっていうぐらいで見てください。

DaggerからDagger Hiltに一歩ずつマイグレーションしていく方法について触れておきます。

Hiltへの導入準備

Daggerのライブラリをバージョンアップしておく

→ 普通にマイグレーションするだけです。

Daggerのコンポーネントの状況を見てみる

Daggerのコンポーネントの図を出すツールは昔からいろんなプラグインがあります。
Dagger SPI(Service provider interface)というDagger内部の
情報を取り出せるDaggerのAPIのようなものがあるので、それを使っているツールで確認することをおすすめします。

https://github.com/arunkumar9t2/scabbard
https://github.com/Snapchat/dagger-browser
など

そしてある程度、どのコンポーネントが、どのコンポーネントに対応付けられそうかを見てみます。
scabbardを使った図の例
image.png

今回の状況の前提

例とする状況はApplicationレベルのコンポーネントのAppComponentがあり、その下にActivityごとのComponentがたくさんあるような形になっているとします。
(Daggerだとコンポーネントの形が標準化されていないので、そもそものComponentの形もさまざまになります。)

AppComponentの作り方でModuleを引数を渡す形で作っている場合はそれをやめる

Dagger Hiltはこのモジュールを渡し方して作る作り方をサポートしていないためやめる必要があります。Moduleの引数をなくして参照してあげることで可能です。
Daggerのドキュメント的にもそれは良くないらしいです。(don't do thisって書いてあります https://dagger.dev/dev-guide/testing )

NG

    DaggerAppComponent
      .builder()
      .networkModule(networkModule)
    .build()

OK

    DaggerAppComponent.factory()
      .create(application)

Hiltを導入してAppComponentを置き換える

ApplicationレベルのComponentをDagger HiltのSingletonComponentに置き換える

Dagger Hiltのライブラリを導入します。これは基本的なドキュメントをご確認ください。
https://dagger.dev/hilt/migration-guide

少しずつマイグレーションしていく場合はdisableModulesHaveInstallInCheckを入れる必要があります。

Dagger Hiltで標準だと@InstallInが入っていない既存のモジュールがあるときにエラーになってしまいます。このオプションを入れるとそのエラーを出さなくして、ただライブラリを入れるだけということができます。

 javaCompileOptions {
      annotationProcessorOptions {
           // ↓ **ここが+=になっていないとDagger Hiltのプラグインがargumentを追加するのでハマるので注意**
           arguments += [
               "dagger.hilt.disableModulesHaveInstallInCheck": "true"
           ]
      }
  }

Dagger HiltのEntryPointはComponentから依存関係をとってくるだけでなく、サブコンポーネントを作ることもできるので、その機能を使って置き換えます。 (ここは説明飛ばします。)
差分: https://github.com/takahirom/hilt-sample-app/commit/8e542f191bb50ce50db30cb2a72a569f7d17b178

@Subcomponent
interface JustDaggerComponent {
    @Subcomponent.Factory
    interface Factory {
        fun create(): JustDaggerComponent
    }

    fun inject(justDaggerActivity: JustDaggerActivity)
}

@InstallIn(SingletonComponent::class)
@EntryPoint
interface JustDaggerEntryPoint {
    fun activityComponentFactory(): JustDaggerComponent.Factory
}

class JustDaggerActivity : AppCompatActivity() {
    @Inject lateinit var videoPlayer: VideoPlayer
    override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
        super.onCreate(savedInstanceState, persistentState)
        // old: appComponent.justDaggerComponent().inject(this)
        val entryPoint = EntryPointAccessors.fromApplication(
            applicationContext,
            JustDaggerEntryPoint::class.java
        )
        entryPoint.activityComponentFactory().create().inject(this)

        videoPlayer.play()
    }
}

既存のActivityのComponentをHiltに置き換えていく

基本的に @AndroidEntryPointをつけて、既存のDaggerの処理を外していくということになります。
JustDaggerActivityの形をMainActivityの形に変えていきます。

マイグレーションについては、他にもいろいろありますが、コードラボが詳しいので考えている方はCodelabをやってみてください。

Dagger HiltとJetpackの連携

よく開発で利用されるViewModelやWorkManagerといったJetpackのComponentと連携するライブラリが提供されていて、それを利用することができます。
差分: https://github.com/takahirom/hilt-sample-app/commit/1bec3370fec0fd5b4233db1884e8427bcf91a540
Androidアプリの開発ではViewModelをよく使います。まずはViewModelについて見ていきましょう。
ViewModelではコンストラクタに変更が加えられますが、通常、ProviderやFactoryなどを経由して作成するため難しいですが、この部分をDagger Hiltはうまく隠蔽して簡単にViewModelを作ってくれます。

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    private val videoPlayerViewModel: VideoPlayerViewModel by viewModels()
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        videoPlayerViewModel.play()
    }
}

class VideoPlayerViewModel @ViewModelInject constructor(
    private val videoPlayer: VideoPlayer
) : ViewModel() {
    fun play() {
        videoPlayer.play()
    }
}

Dagger Hilt 実践プラクティス

Google(or Googler)のサンプルを参照する

迷ったら、サンプルアプリ達を見てみましょう。

Architecture Samples
https://github.com/android/architecture-samples/tree/dev-hilt
Goole I/Oアプリ
https://github.com/google/iosched
Sunflower
https://github.com/android/sunflower
chrisbanes/tivi
https://github.com/chrisbanes/tivi

fastInitモードが有効になるので、影響を確認する

Dagger HiltはComponentの形が標準化されたことによって、たくさんのSingletonComponentなどのコンポーネントに、この型はこう作るなどのバインディングが入るようになります。
通常、Daggerではこのバインディングの数が増えると増えるだけインスタンス化に時間がかかります。Dagger Hiltを入れたタイミングで通常のモードではなくfastInitモードが有効になることで、これが時間がかからなくなります。しかし、この処理にはトレードオフもあるようなので、リリース後にFirebase PerformanceやAndroid Vitalsなどで確認してみましょう。

    val PROCESSOR_OPTIONS = listOf(
      "dagger.fastInit" to "enabled",


生成されるコードの比較

fastInitがdisabledになっている場合
public final class DaggerApp_HiltComponents_SingletonC extends App_HiltComponents.SingletonC {
  private Provider<Context> provideContextProvider;

  private Provider<VideoDatabase> provideVideoDBProvider;

  private DaggerApp_HiltComponents_SingletonC(
      ApplicationContextModule applicationContextModuleParam) {

    initialize(applicationContextModuleParam);
  }
...

  @SuppressWarnings("unchecked")
  private void initialize(final ApplicationContextModule applicationContextModuleParam) {
    this.provideContextProvider = ApplicationContextModule_ProvideContextFactory.create(applicationContextModuleParam);
    this.provideVideoDBProvider = DoubleCheck.provider(DataModule_ProvideVideoDBFactory.create(provideContextProvider));
  }


  @Override
  public VideoPlayer videoPlayer() {
    return new VideoPlayer(provideVideoDBProvider.get());
  }

fastInitがenabled
Providerが値を保持する代わりにComponentが値を保持するようになる。

public final class DaggerApp_HiltComponents_SingletonC extends App_HiltComponents.SingletonC {
  private final ApplicationContextModule applicationContextModule;

  private volatile Object videoDatabase = new MemoizedSentinel();

  private DaggerApp_HiltComponents_SingletonC(
      ApplicationContextModule applicationContextModuleParam) {
    this.applicationContextModule = applicationContextModuleParam;
  }

  private VideoDatabase videoDatabase() {
    Object local = videoDatabase;
    if (local instanceof MemoizedSentinel) {
      synchronized (local) {
        local = videoDatabase;
        if (local instanceof MemoizedSentinel) {
          local = DataModule_ProvideVideoDBFactory.provideVideoDB(ApplicationContextModule_ProvideContextFactory.provideContext(applicationContextModule));
          videoDatabase = DoubleCheck.reentrantCheck(videoDatabase, local);
        }
      }
    }
    return (VideoDatabase) local;
  }

  @Override
  public VideoPlayer videoPlayer() {
    return new VideoPlayer(videoDatabase());
  }


詳細画面などでIDを渡していきたい場合はどうするのか?

Dagger HiltではComponentの構造が標準化されているため、例えばEpisodeDetailComponentを作って、そこで画面詳細IDを配布というようなことは難しいです。
これに対してさまざまなやり方が考えられますが、Googleのサンプルでのやり方は一つのようです。
Daggerを使って配布せず、直接渡す方法です。
HiltのComponentを作るための公式のページに、バックグラウンドタスクの話で少し文脈は違うのですが、"だいたいは自分で渡したほうがシンプルで十分"という話が出てきます。
一番渡しがちなAssisted InjectについてはただViewModelに渡すときは少しだけ工夫することができます。

for most background tasks, a component really isn’t necessary and only adds complexity where simply passing a couple objects on the call stack is simpler and sufficient.

コールスタックで引数を渡すだけのほうがシンプルで十分で、Componentは複雑さを増すだけ。ということを言っています。

Architecture Samples
https://github.com/android/architecture-samples/blob/dev-hilt/app/src/main/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailFragment.kt#L90
Iosched
https://github.com/google/iosched/blob/b428d2be4bb96bd423e47cb709c906ce5d02150f/mobile/src/main/java/com/google/samples/apps/iosched/ui/speaker/SpeakerViewModel.kt#L101
Sunflower
https://github.com/android/sunflower/blob/2bbe628f3eb697091567c3be8f756cfb7eb7258a/app/src/main/java/com/google/samples/apps/sunflower/PlantDetailFragment.kt#L55
chrisbanes/tivi
https://github.com/chrisbanes/tivi/blob/27348c6e4705c707ceaa1edc1a3080efa06109ae/ui-showdetails/src/main/java/app/tivi/showdetails/details/ShowDetailsFragment.kt#L60

ViewModelにコンストラクタで値を渡す

“IDなどを直接渡していく”ということでしたが、ViewModelのコンストラクタで渡せないと、lateinitにしたり、nullableにして少し安全じゃない形になっちゃいますよね?
AssistedInjectというライブラリで、コンストラクタ引数を渡す実装ができる。(今後Daggerにも組み込まれるそうです)

差分: https://github.com/takahirom/hilt-sample-app/commit/6584808f8fe13cc92317df50d413f828d1dfdf00

DaggerはAssistedInjectと呼ばれるものを対応しようとしています。これはInjectするときに、プログラムから引数で値を渡せるというものです。
https://github.com/google/dagger/issues/1825

これを先に使えるライブラリがあります。
https://github.com/square/AssistedInject

具体的にはGooglerの以下のgistの内容を使ってViewModelにコンストラクタで値を渡すことができます。
https://gist.github.com/manuelvicnt/437668cda3a891d347e134b1de29aee1

本質的に理解しようとすると大変なので、仕組みが気になる方は以下を読んでみてください。
https://qiita.com/takahirom/items/f28ceb7a6d4e69e4dafe

EntryPointの定義場所

EntryPointは、基本的にはGoogleのサンプルでは使われていないようです。
大きいアプリのマイグレーションなどでは使われると思われるので、紹介しておきます。
EntryPointはどこにでも書くことができますが、どこに書くのがいいでしょうか?
依存関係を取得するときに使うEntryPointは、使う場合には必要な依存関係のみを取得したほうが依存するオブジェクトを少なくできるので、基本的には取得する場所に定義して利用していきましょう

class NonHiltActivity : AppCompatActivity() {
    @EntryPoint
    @InstallIn(SingletonComponent::class)
    interface NonHiltActivityEntryPoint {
        fun videoPlayer(): VideoPlayer
    }

    override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
        super.onCreate(savedInstanceState, persistentState)
        val entryPoint = EntryPointAccessors.fromApplication(
            applicationContext,
            NonHiltActivityEntryPoint::class.java
        )
        val videoPlayer = entryPoint.videoPlayer()
        videoPlayer.play()
    }
}

マルチモジュール

まだちゃんとしたベストプラクティスがあるわけではないと思いますが、Dagger Hiltとマルチモジュールについて考えておきます。以下のようなモジュール構成があったとします。
image.png

Applicationクラスをコンパイルする Gradleモジュールは、
すべての Hilt モジュールおよびコンストラクタ インジェクションで注入するクラスを
推移的依存関係に含める必要があります

https://developer.android.com/training/dependency-injection/hilt-multi-module?hl=ja より

ということなので、以下のようにルートのモジュールからDaggerのModuleを持つGradleモジュールへの参照が必要になります
この部分に関して、真ん中の形が色んなパターンで無駄に依存関係を増やさずに動くので、いいのかなとは思うのですが、まだベストプラクティスと言えるものはないです。
ただ、モジュールを作ったときにクラスパスに含めるだけで@InstallInされたModuleがコンポーネントにインストールされて使えるので、すごく楽で、本当に使いやすいです。

image.png

参考
Googleの方のchrisbanes/tiviのアプリのappモジュールからの参照
ルートのGradleモジュールから各モジュールに参照していることが分かる。
image.png

Hiltでビルドできる?できない?みたいな実験する環境作るのめんどうですよね?Hiltが導入済みのアプリで実験したくなったらこのサンプルプロジェクトを用意しているので、実験してみてください。
https://github.com/takahirom/dagger-transitive-playground/tree/hilt

テストのプラクティス

実際の依存関係を使おう

Hilt Testing Philosophyというページが有り、Dagger Hiltを使ってテストをする際のプラクティスが書いてあります。これがかなり主張を含んだもので面白いのでぜひ読んでみてください。
https://dagger.dev/hilt/testing-philosophy
こちらに自分のメモがあります。
https://qiita.com/takahirom/items/a3e406b067ad645605da

これによると2つ言いたいことがあるようです。

  • ユーザーからの観点でテストする
    ユーザーとは実際のユーザーもクラスのユーザーも、APIのユーザーも含む。internalなメソッド名や実装になど依存せず、テストが壊れることがユーザーの観点から変更があったことを意味する
  • 実際の依存関係を利用する

なぜ実際の依存関係を使うか?

  • 実際の依存関係は本当の問題を捕捉しやすい。モックのように古いまま残されたりしない。
  • "ユーザーからの観点でテスト"と組み合わせることで、同じカバレッジでもっと少ないテストの量で書くことができる
  • テストが壊れることが、FakeやMockの設定ミスによる問題による問題の代わりに、実際の問題を指し示す (そして、逆に言えばテストがパスすることはコードがちゃんと動くことを意味する)
  • "ユーザーからの観点でテスト"と"実際の依存関係を使う"は相性が良い。依存関係を入れ替えないため。

どうやってDagger Hiltで実際の依存関係を使うか?

普通にテストして実際の依存関係を使おうとすると依存関係を作るためのボイラープレートが発生します
PlayerFragmentを作るためにViewModelのFactoryが必要で、ViewModelを作るにはVideoPlayerが必要で、VideoPlayerを作るにはVideoDatabaseが必要になるなどを書いていくと。。大変になります。

launchFragment {
    // たいへん!
    PlayerFragment().apply {
        videoPlayerViewModelAssistedFactory =
            object : VideoPlayerViewModel.AssistedFactory {
                override fun create(videoId: String): VideoPlayerViewModel {
                    return VideoPlayerViewModel(
                        videoPlayer = VideoPlayer(
                            database = Room.inMemoryDatabaseBuilder(
                                ApplicationProvider.getApplicationContext(),
                                VideoDatabase::class.java
                            ).build()
                        ),
                        videoId = "video_id"
                    )
                }
            }
    }
}
onView(withText("playing")).check(matches(isDisplayed()))

紹介したように、Dagger Hiltを使ってテストでもInjectを行うことで実際の依存関係を使ってテストをすることが可能になります。

@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
@UninstallModules(DataModule::class)
class AndroidPlayerFragmentTest {
    @InstallIn(SingletonComponent::class)
    @Module
    class TestDataModule {
        @Provides
        fun provideVideoDatabase(): VideoDatabase {
            return Room.inMemoryDatabaseBuilder(
                ApplicationProvider.getApplicationContext(),
                VideoDatabase::class.java
            ).build()
        }
    }

    @get:Rule
    var hiltAndroidRule = HiltAndroidRule(this)

    @Test
    fun play() {
        hiltAndroidRule.inject()
        launchFragmentInHiltContainer<PlayerFragment> {
        }
        onView(withText("playing")).check(matches(isDisplayed()))
    }

さまざまなデメリットがあるのでTestでのCustom Applicationクラスはやめよう

一応@CustomTestApplicationでカスタムアプリケーションを使ってテストできます。
テスト用のTestAppクラスなどを作っているのであればHiltと組み合わせると
さまざまなデメリットが発生します。また一般的にやめておいたほうが良さそうです。

  • テストでは以下の問題があるため、:カスタムアプリケーションでは@Injectのフィールドを使えない*。(コンパイルエラー)
  • Applicationはテストをまたいで生存してしまうので、テストをまたいだ状態のリークを起こす
  • 子が親に依存するテストになってしまう ので、テストの独立性を高めるためにやめておくべき。など

Dagger Hiltの細かいTips

HiltAndroidAutoInjectRuleなどを用意しておくと自分でInjectを呼ばなくても動くようになります。

@get:Rule val hiltAndroidAutoInjectRule = HiltAndroidAutoInjectRule(this)

class HiltAndroidAutoInjectRule(testInstance: Any) : TestRule {
  private val hiltAndroidRule = HiltAndroidRule(testInstance)
  private val delegate = RuleChain
    .outerRule(hiltAndroidRule)
    .around(HiltInjectRule(hiltAndroidRule))

  override fun apply(base: Statement?, description: Description?): Statement {
    return delegate.apply(base, description)
  }
}
class HiltInjectRule(val rule: HiltAndroidRule) : TestWatcher() {
  override fun starting(description: Description?) {
    super.starting(description)
    rule.inject()
  }
}

まとめ

以下について話してきました。

  • なぜDIを使うのか
  • なぜDagger Hiltなのか
  • 基本的な使い方や概念
  • テスト
  • マイグレーション
  • 実践プラクティス
  • Tips

Dagger Hiltを使うことでさまざまないい効果が見込めるので、Dagger Hiltを使ってアプリを作ってみましょう!

参考

公式ウェブページ
https://dagger.dev/hilt/
https://developer.android.com/training/dependency-injection/hilt-android?hl=ja

Android Dependency Injection
https://www.youtube.com/watch?v=B56oV3IHMxg
Dagger Hilt Deep Dive
https://www.youtube.com/watch?v=4di2TTqeCrE

Architecture Samples
https://github.com/android/architecture-samples/tree/dev-hilt
Goole I/Oアプリ
https://github.com/google/iosched
Sunflower
https://github.com/android/sunflower
chrisbanes/tivi
https://github.com/chrisbanes/tivi

157
99
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
157
99