Android アプリでの Hilt の使用

この Codelab では、信頼性が高く、大規模なプロジェクトに対して拡張可能なアプリを作成するための依存関係注入(DI)の重要性を学びます。依存関係の管理には、DI ツールとして Hilt を使用します。

依存関係注入はプログラミングで広く使用されている手法で、Android 開発にも適しています。DI の原則に従うことで、優れたアプリ アーキテクチャの土台を築くことができます。

依存関係注入を実装すると、次のようなメリットがもたらされます。

  • コードを再利用できる
  • リファクタリングが容易になる
  • テストが容易になる

Hilt は Android 用の独自の依存性注入ライブラリです。これを使うことで、プロジェクトで DI を手動で行うためのボイラープレートが減ります。手動で依存関係注入を行うには、各クラスとその依存関係を手作業で作成し、コンテナを使用して依存関係の再利用と管理を行う必要があります。

Hilt は、プロジェクトのすべての Android コンポーネントにコンテナを提供し、コンテナのライフサイクルを自動的に管理することで、アプリで DI 注入を行うための標準的な方法を定義します。これを実現するには、一般的な DI ライブラリ Dagger を利用します。

この Codelab で問題(コードのバグ、文法的な誤り、不明確な表現など)が見つかった場合は、Codelab の左下隅にある [誤りを報告] から問題を報告してください。

前提条件

  • Kotlin 構文を使用した経験がある。
  • アプリで依存関係注入が重要な理由を理解している。

学習内容

  • Android アプリで Hilt を使用する方法。
  • 持続可能なアプリの作成に関連する Hilt の概念。
  • 修飾子を使用して、同じ型に複数のバインディングを追加する方法。
  • @EntryPoint を使用して、Hilt でサポートされていないクラスからコンテナにアクセスする方法。
  • 単体テストとインストルメンテーション テストを通じて、Hilt を使用するアプリをテストする方法。

必要なもの

  • Android Studio 4.0 以降。

コードの取得

次のコマンドで、GitHub から Codelab のコードを取得します。

$ git clone https://github.com/googlecodelabs/android-hilt

または、リポジトリを ZIP ファイルとしてダウンロードすることもできます。

ZIP をダウンロード

Android Studio を開く

この Codelab には Android Studio 4.0 以降が必要です。Android Studio をダウンロードする必要がある場合は、こちらからダウンロードします。

サンプルアプリの実行

この Codelab では、アプリに Hilt を追加します。このアプリは、ユーザーの操作を記録し、Room を使用してローカル データベースにデータを保存します。

次の手順に沿って、Android Studio でサンプルアプリを開いてください。

  • zip アーカイブをダウンロードした場合は、ファイルをローカルに展開します。
  • Android Studio でプロジェクトを開きます。
  • execute.png [Run] ボタンをクリックして、エミュレータを選択するか、Android デバイスを接続します。

ご覧のように、番号が付けられたボタンを操作するたびにログが作成されて保存されます。[See All Logs] 画面に、以前のすべての操作のリストが表示されます。ログを削除するには、[Delete Logs] ボタンをタップします。

プロジェクトの設定

このプロジェクトには複数の GitHub ブランチがあります。

  • master は、チェックアウトまたはダウンロードしたブランチであり、Codelab の出発点です。
  • solution には、この Codelab の解答があります。

master ブランチのコードから始め、マイペースで Codelab を進めることをおすすめします。

Codelab の途中には、プロジェクトに追加する必要があるコード スニペットを記載しています。場所によってはコードを削除する必要もありますが、この部分はコード スニペットのコメントに明示的に記載されています。

git を使用して solution ブランチを取得するには、次のコマンドを使用します。

$ git clone -b solution https://github.com/googlecodelabs/android-hilt

または、次の場所から解答コードをダウンロードします。

最終版のコードをダウンロードする

よくある質問

Hilt を使う理由

開始用コードを見ると、ServiceLocator クラスのインスタンスが LogApplication クラスに格納されていることがわかります。ServiceLocator は、それを必要とするクラスによってオンデマンドで取得される依存関係を作成して格納します。このクラスは、依存関係のコンテナと考えることができます。このコンテナは、アプリの破棄に伴って破棄されるため、アプリのライフサイクルにアタッチされます。

Android DI に関するガイダンスで説明されているように、サービス ロケータは、最初は比較的少量のボイラープレート コードから始められますが、スケーリングへの対応力は不十分です。大規模な Android アプリを開発するには、Hilt を使用する必要があります。

Hilt では、手動で作成する必要のあったコード(ServiceLocator クラスのコードなど)が生成され、Android アプリで手動による DI またはサービス ロケータ パターンを使用する際に必要なボイラープレートが不要になります。

次の手順では、Hilt を使用して ServiceLocator クラスを置き換えます。その後、プロジェクトにさらに新しい機能を追加し、他の Hilt 機能を調べます。

プロジェクトでの Hilt

Hilt はすでに master ブランチ(ダウンロードしたコード)で設定されています。以下に示すコードはすでに実行されているため、プロジェクトに追加する必要はありませんが、Android アプリで Hilt を使用する場合に必要なものを確認する意味で紹介します。

Hilt では、ライブラリの依存関係とは別に、プロジェクトに設定されている Gradle プラグインを使用します。ルートの build.gradle ファイルを開き、クラスパスで次の Hilt の依存関係を参照します。

buildscript {
    ...
    ext.hilt_version = '2.28-alpha'
    dependencies {
        ...
        classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
    }
}

次に、app モジュールで gradle プラグインを使用するには、app/build.gradle ファイル内で、このプラグインをファイル上部の kotlin-kapt プラグインの下に追加して指定します。

...
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'

android {
    ...
}

最後に、Hilt の依存関係は、次のように、同じ app/build.gradle ファイル内のプロジェクトに含まれています。

...
dependencies {
    ...
    implementation "com.google.dagger:hilt-android:$hilt_version"
    kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
}

すべてのライブラリは、Hilt を含めて、プロジェクトのビルドと同期の際にダウンロードされます。では、Hilt を使ってみましょう。

LogApplication クラスで使用および初期化されている ServiceLocator のインスタンスの場合と同じように、アプリのライフサイクルにアタッチされたコンテナを追加するには、Application クラスに @HiltAndroidApp アノテーションを付ける必要があります。LogApplication.kt を開き、アノテーションをクラスに追加してください。

@HiltAndroidApp
class LogApplication : Application() {
    ...
}

@HiltAndroidApp は、Hilt のコード生成をトリガーします。これには、依存関係注入を使用できるアプリの基本クラスも含まれます。アプリケーション コンテナはアプリの親コンテナであることから、他のコンテナは、このコンテナの提供する依存関係にアクセスできます。

これで、アプリで Hilt を使用できるようになりました。

クラスで ServiceLocator からオンデマンドで依存関係を取得する代わりに、これらの依存関係を提供する Hilt を使用します。まず、クラスからの ServiceLocator の呼び出しを置き換えます。

ui/LogsFragment.kt ファイルを開きます。LogsFragmentonAttach でフィールドの入力を行います。ServiceLocator を使用して LoggerLocalDataSourceDateFormatter のインスタンスを手動で入力する代わりに、Hilt を使用して、これらの型のインスタンスを作成し、管理することができます。

LogsFragment で Hilt を使用するには、@AndroidEntryPoint アノテーションを付けます。

@AndroidEntryPoint
class LogsFragment : Fragment() {
    ...
}

Android クラスに @AndroidEntryPoint アノテーションを付けると、Android クラスのライフサイクルに沿った依存関係コンテナが作成されます。

@AndroidEntryPoint を使用すると、Hilt は、LogsFragment のライフサイクルにアタッチされる依存関係コンテナを作成し、インスタンスを LogsFragment に注入できるようになります。Hilt で注入するフィールドはどのように指定するのでしょうか。

注入したいフィールド(loggerdateFormatter)に @Inject アノテーションを付けることで、Hilt にさまざまな型のインスタンスを注入させることができます。

@AndroidEntryPoint
class LogsFragment : Fragment() {

    @Inject lateinit var logger: LoggerLocalDataSource
    @Inject lateinit var dateFormatter: DateFormatter

    ...
}

これがいわゆるフィールド注入です。

Hilt ではこれらのフィールドが自動的に入力されるため、populateFields メソッドは不要になりました。次のように、クラスからこのメソッドを削除しましょう。

@AndroidEntryPoint
class LogsFragment : Fragment() {

    // Remove following code from LogsFragment

    override fun onAttach(context: Context) {
        super.onAttach(context)

        populateFields(context)
    }

    private fun populateFields(context: Context) {
        logger = (context.applicationContext as LogApplication).serviceLocator.loggerLocalDataSource
        dateFormatter =
            (context.applicationContext as LogApplication).serviceLocator.provideDateFormatter()
    }

    ...
}

内部では、Hilt は自動生成した LogsFragment の依存関係コンテナで作成されたインスタンスを onAttach() ライフサイクル メソッドでこれらのフィールドに入力します。

フィールド注入を実行するには、これらの依存関係のインスタンスを提供する方法を Hilt が把握している必要があります。この例では、Hilt は LoggerLocalDataSourceDateFormatter のインスタンスを提供する方法を把握する必要があります。しかし、これらのインスタンスを提供する方法は Hilt にはまだわかりません。

@Inject を使って依存関係の提供方法を Hilt に指示する

ServiceLocator.kt ファイルを開き、ServiceLocator の実装をご覧ください。provideDateFormatter() の呼び出しにより、常に DateFormatter の異なるインスタンスが返されているのがわかります。

これは、Hilt で行う動作とまったく同じ動作です。DateFormatter は他のクラスに依存しないため、現時点では推移的な依存関係について心配する必要はありません。

特定の型のインスタンスを提供する方法を Hint に指示するには、注入させるクラスのコンストラクタに @Inject アノテーションを追加します

util/DateFormatter.kt ファイルを開き、DateFormatter のコンストラクタに @Inject アノテーションを付けます。Kotlin でコンストラクタにアノテーションを付けるには、次のように、constructor キーワードも必要になります。

class DateFormatter @Inject constructor() { ... }

これにより、Hilt は DateFormatter のインスタンスを提供する方法を把握します。LoggerLocalDataSource についても同様に処理します。data/LoggerLocalDataSource.kt ファイルを開き、そのコンストラクタに @Inject アノテーションを付けます。

class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
    ...
}

ServiceLocator クラスをもう一度開くと、パブリック LoggerLocalDataSource フィールドがあることがわかります。つまり、ServiceLocator が呼び出されるたびに常に LoggerLocalDataSource の同じインスタンスが返されます。これは「インスタンスのスコープをコンテナに設定する」と呼ばれます。Hilt ではこれをどのように実現しているのでしょうか。

アノテーションを使用して、インスタンスのスコープをコンテナに設定できます。Hilt ではライフサイクルごとに異なるコンテナが生成されるため、スコープをこれらのコンテナに設定するためにさまざまなアノテーションを使用できます。

インスタンスのスコープをアプリケーション コンテナに設定するアノテーションは @Singleton です。このアノテーションを使用すれば、その型が別の型の依存関係として使用されているかどうか、フィールド注入が必要かどうかにかかわらず、アプリケーション コンテナは常に同じインスタンスを提供します。

Android クラスにアタッチされたすべてのコンテナに同じロジックを適用できます。スコープ設定されるすべてのアノテーションのリストについては、こちらのドキュメントをご覧ください。たとえば、アクティビティ コンテナが常に型の同じインスタンスを提供するようにしたい場合は、その型に @ActivityScoped アノテーションを付けます。

上記のようにアプリケーション コンテナが常に同じ LoggerLocalDataSource のインスタンスを提供するようにしたいので、そのクラスに @Singleton アノテーションを付けます。

@Singleton
class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
    ...
}

これで Hilt は、LoggerLocalDataSource のインスタンスを提供する方法を把握します。ただし、今回の型には推移的な依存関係があります。LoggerLocalDataSource のインスタンスを提供するには、Hilt は LogDao のインスタンスを提供する方法も把握している必要があります。

ただし、LogDao はインターフェースであり、インターフェースにはコンストラクタがないため、@Inject アノテーションをコンストラクタに付けることはできません。このようなインスタンスについては、どのように Hilt に指示すればよいでしょうか。

モジュールは、Hilt にバインディングを追加するために使用されます。つまり、さまざまな型のインスタンスを提供する方法を Hillt に指示するために使用されます。Hilt モジュールには、コンストラクタで注入できない型(プロジェクトに含まれていないインターフェースやクラスなど)にバインディングを追加できます。たとえば OkHttpClient は、そのビルダーを使用してインスタンスを作成する必要があります。

Hilt モジュールは、@Module@InstallIn のアノテーションが付けられたクラスです。@Module はこれがモジュールであることを Hilt に指示し、@InstallIn は Hilt コンポーネントを指定することでバインディングを使用できるコンテナを Hilt に指示します。Hilt コンポーネントはコンテナと考えることができます。コンポーネントの完全なリストについては、こちらをご覧ください。

Hilt で注入できる Android クラスごとに、関連付けられた Hilt コンポーネントがあります。たとえば、Application コンテナは ApplicationComponent に関連付けられ、Fragment コンテナは FragmentComponent に関連付けられます。

モジュールの作成

Hilt モジュールを作成しましょう。ここではバインディングを追加できます。hilt パッケージの下に di という新しいパッケージを作成し、パッケージ内に DatabaseModule.kt という新しいファイルを作成します。

LoggerLocalDataSource のスコープがアプリケーション コンテナに設定されているため、アプリケーション コンテナで LogDao バインディングを利用できるようにする必要があります。この要件は、@InstallIn アノテーションを使用して、このアノテーションに関連付けられている Hilt コンポーネントのクラス(ApplicationComponent:class)を渡すことで指定します。

package com.example.android.hilt.di

@InstallIn(ApplicationComponent::class)
@Module
object DatabaseModule {

}

ServiceLocator クラスの実装では、logsDatabase.logDao() を呼び出すことで LogDao のインスタンスを取得します。そのため、LogDao のインスタンスを提供するために、AppDatabase クラスに対する推移的な依存関係を作成しました。

@Provide を使用したインスタンスの提供

Hilt モジュールの関数に @Provides アノテーションを付けると、コンストラクタで注入できない型を提供する方法を Hilt に指示できます。

@Provides でアノテーションが付けられた関数の本体は、Hilt がその型のインスタンスを提供する必要があるたびに実行されます。@Provides アノテーションが付けられた関数の戻り値の型は、バインディングの型、つまりその型のインスタンスを提供する方法を Hilt に指示します。関数パラメータはその型の依存関係です。

この例では、DatabaseModule クラスにこの関数を追加します。

@Module
object DatabaseModule {

    @Provides
    fun provideLogDao(database: AppDatabase): LogDao {
        return database.logDao()
    }
}

上記のコードでは、LogDao のインスタンスを提供する際に、database.logDao() を実行する必要があることを Hilt に指示しています。推移的な依存関係として AppDatabase があるため、その型のインスタンスを提供する方法を Hilt に指示する必要があります。

AppDatabase は、Room によって生成されるため、プロジェクトにない別のクラスです。したがって、データベース インスタンスを ServiceLocator クラスで構築するのと同様に、@Provides 関数を使用してこのクラスを提供することもできます。

@Module
object DatabaseModule {

    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext appContext: Context): AppDatabase {
        return Room.databaseBuilder(
            appContext,
            AppDatabase::class.java,
            "logging.db"
        ).build()
    }

    @Provides
    fun provideLogDao(database: AppDatabase): LogDao {
        return database.logDao()
    }
}

常に Hilt が同じデータベース インスタンスを提供するようにするには、@Provides provideDatabase メソッドに @Singleton アノテーションを付けます。

各 Hilt コンテナには、Hilt がカスタム バインディングに依存関係として注入できるデフォルト バインディングのセットが用意されています。たとえば applicationContext の場合、アクセスするにはフィールドに @ApplicationContext アノテーションを付ける必要があります。

アプリの実行

これで、Hilt は LogsFragment にインスタンスを注入するために必要なすべての情報を得ました。ただし、Hilt を適切に動作させるには、アプリを実行する前に Fragment をホストする Activity を Hilt に把握させる必要があります。MainActivity@AndroidEntryPoint アノテーションを付ける必要があります。

ui/MainActivity.kt ファイルを開き、MainActivity@AndroidEntryPoint アノテーションを付けます。

@AndroidEntryPoint
class MainActivity : AppCompatActivity() { ... }

これで、アプリを実行して、すべてが以前と同じように機能するかを確認できます。

次に、アプリをリファクタリングして、MainActivity から ServiceLocator 呼び出しを削除しましょう。

MainActivity は、provideNavigator(activity: FragmentActivity) 関数を呼び出す ServiceLocator から AppNavigator のインスタンスを取得します。

AppNavigator はインターフェースのため、コンストラクタ注入を使用できません。 インターフェースに使用する実装を Hilt に指示するには、Hilt モジュール内の関数に @Binds アノテーションを使用します

抽象関数には @Binds アノテーションを付ける必要があります(抽象関数であるため、コードが含まれておらず、クラスも抽象化する必要があります)。抽象関数の戻り値の型は、実装したいインターフェース(AppNavigator)です。実装を指定するには、インターフェースの実装型(AppNavigatorImpl)に一意のパラメータを追加します。

前に作成した DatabaseModule クラスに情報を追加する方法や、新しいモジュールを使用する必要性を検討します。新しいモジュールを作成する理由はいくつかあります。

  • モジュール名は、整理しやすいように、提供する情報のタイプを表すように指定します。たとえば、DatabaseModule という名前のモジュールにナビゲーション バインディングを含めるのは合理的ではありません。
  • DatabaseModule モジュールは ApplicationComponent にインストールされるため、アプリケーション コンテナでバインディングを使用できます。新しいナビゲーション情報(AppNavigator )では、Activity に固有の情報が必要です(AppNavigatorImpl が依存関係として Activity を持つため)。そのため、Application コンテナではなく Activity コンテナActivity に関する情報がある)をインストールする必要があります
  • Hilt モジュールには、非静的バインディング メソッドと抽象バインディング メソッドを両方含めることはできないため、@Binds アノテーションと @Provides アノテーションを同じクラスに配置することはできません。

di フォルダ内に NavigationModule.kt という新しいファイルを作成します。次に、NavigationModule という新しい抽象クラスを作成し、上記のように @Module アノテーションと @InstallIn(ActivityComponent::class) アノテーションを付けます。

@InstallIn(ActivityComponent::class)
@Module
abstract class NavigationModule {

    @Binds
    abstract fun bindNavigator(impl: AppNavigatorImpl): AppNavigator
}

モジュール内で AppNavigator のバインディングを追加できます。これは抽象関数であり、Hilt に通知しているインターフェース(AppNavigator)を返し、関数のパラメータは、そのインターフェースの実装(AppNavigatorImpl)です。

ここで、AppNavigatorImpl のインスタンスを提供する方法を Hilt に指示する必要があります。このクラスではコンストラクタ注入を実行できるため、コンストラクタに @Inject アノテーションを付けるだけで完了します。

navigator/AppNavigatorImpl.kt ファイルを開き、次の手順を行います。

class AppNavigatorImpl @Inject constructor(
    private val activity: FragmentActivity
) : AppNavigator {
    ...
}

AppNavigatorImplFragmentActivity に依存します。AppNavigator インスタンスは Activity コンテナで提供されます(NavigationModuleActivityComponent にインストールされているため、Fragment コンテナと View コンテナでも利用できます)。FragmentActivity は、事前定義されたバインディングとして提供されており、すでに利用できます。

アクティビティでの Hilt の使用

これで、Hilt には AppNavigator インスタンスを注入するために必要なすべての情報が含まれています。MainActivity.kt ファイルを開いて次の手順を行います。

  1. Hilt が取得できるように navigator フィールドに @Inject アノテーションを付ける
  2. private の可視性修飾子を削除する
  3. onCreate 関数内の navigator 初期化コードを削除する

新しいコードは次のようになります。

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject lateinit var navigator: AppNavigator

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        if (savedInstanceState == null) {
            navigator.navigateTo(Screens.BUTTONS)
        }
    }

    ...
}

アプリの実行

アプリを実行して、想定どおりに動作するかを確認します。

リファクタリングの終了

現在、ServiceLocator を使用して依存関係を取得する唯一のクラスは ButtonsFragment です。Hilt は、ButtonsFragment で必要なすべての型を提供する方法をすでに把握しているため、このクラスでフィールド注入を実行するだけで完了します。

すでに説明したように、Hilt がこのクラスにフィールドを注入するには、以下を行う必要があります。

  1. ButtonsFragment@AndroidEntryPoint アノテーションを付ける
  2. logger フィールドと navigator フィールドから非公開修飾子を削除し、@Inject アノテーションを付ける
  3. フィールド初期化コード(onAttach メソッドと populateFields メソッド)を削除する

ButtonsFragment のコードは次のとおりです。

@AndroidEntryPoint
class ButtonsFragment : Fragment() {

    @Inject lateinit var logger: LoggerLocalDataSource
    @Inject lateinit var navigator: AppNavigator

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_buttons, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        ...
    }
}

この型のスコープはアプリケーション コンテナに設定されているため、LoggerLocalDataSource のインスタンスは LogsFragment で使用したインスタンスと同じです。ただし、AppNavigator のインスタンスは MainActivity のインスタンスと異なります。そのスコープを対応する Activity コンテナに設定していないためです。

この時点で ServiceLocator クラスでは依存関係が提供されなくなったため、プロジェクトから完全に削除できます。これはインスタンスを保持していた LogApplication クラスでのみ使用します。このクラスは不要になったため、クリーンアップします。

LogApplication クラスを開き、ServiceLocator の使用を削除します。Application クラスの新しいコードは次のとおりです。

@HiltAndroidApp
class LogApplication : Application()

いつでも ServiceLocator クラスをプロジェクトから完全に削除できるようになりました。ServiceLocator はまだテストで使用されているため、AppTest クラスからもその使用を削除します。

これまでの基本的な内容

これまでに学んだ知識で、Android アプリで依存性注入ツールとして Hilt を使用できるはずです。

続いて、アプリに新しい機能を追加して、Hilt のより高度な機能をさまざまな状況で使用する方法を見ていきましょう。

ここまでで、プロジェクトから ServiceLocator クラスを削除し、また Hilt の基本を学びました。次に、アプリに新しい機能を追加して、他の Hilt 機能を見てみましょう。

このセクションでは、次の内容について説明します。

  • アクティビティ コンテナにスコープを設定する方法
  • 修飾子の仕組み、修飾子が解決する問題、その使用方法

この部分を示すには、アプリで別の動作が必要になります。アプリのセッション中にログだけを記録する目的で、ログの保存先をデータベースからメモリ内リストに切り替えます。

LoggerDataSource インターフェース

データソースをインターフェースに抽象化してみましょう。data フォルダの下に、LoggerDataSource.kt という名前の新しいファイルを作成し、次の内容を含めます。

package com.example.android.hilt.data

// Common interface for Logger data sources.
interface LoggerDataSource {
    fun addLog(msg: String)
    fun getAllLogs(callback: (List<Log>) -> Unit)
    fun removeLogs()
}

LoggerLocalDataSourceButtonsFragmentLogsFragment の両方の Fragment で使用されます。LoggerDataSource のインスタンスを使用するには、これらをリファクタリングして使用する必要があります。

LogsFragment を開き、logger 変数の型を LoggerDataSource に設定します。

@AndroidEntryPoint
class LogsFragment : Fragment() {

    @Inject lateinit var logger: LoggerDataSource
    ...
}

ButtonsFragment についても同様に処理します。

@AndroidEntryPoint
class ButtonsFragment : Fragment() {

    @Inject lateinit var logger: LoggerDataSource
    ...
}

次に、LoggerLocalDataSource でこのインターフェースを実装します。data/LoggerLocalDataSource.kt ファイルを開き、次の手順を行います。

  1. LoggerDataSource インターフェースを実装する
  2. このメソッドを override でマークする
@Singleton
class LoggerLocalDataSource @Inject constructor(
    private val logDao: LogDao
) : LoggerDataSource {
    ...
    override fun addLog(msg: String) { ... }
    override fun getAllLogs(callback: (List<Log>) -> Unit) { ... }
    override fun removeLogs() { ... }
}

ここでは、LoggerInMemoryDataSource という LoggerDataSource の別の実装を作成します。これにより、ログがメモリに保存されます。data フォルダの下に、LoggerInMemoryDataSource.kt という名前の新しいファイルを作成し、次の内容を含めます。

package com.example.android.hilt.data

import java.util.LinkedList

class LoggerInMemoryDataSource : LoggerDataSource {

    private val logs = LinkedList<Log>()

    override fun addLog(msg: String) {
        logs.addFirst(Log(msg, System.currentTimeMillis()))
    }

    override fun getAllLogs(callback: (List<Log>) -> Unit) {
        callback(logs)
    }

    override fun removeLogs() {
        logs.clear()
    }
}

スコープの Activity コンテナへの設定

実装の詳細として LoggerInMemoryDataSource を使用できるようにするには、この型のインスタンスを提供する方法を Hilt に指示する必要があります。以前と同様に、クラス コンストラクタに @Inject アノテーションを付けます。

class LoggerInMemoryDataSource @Inject constructor(
) : LoggerDataSource { ... }

アプリには Activity が 1 つしか含まれていない(シングル Activity アプリとも呼ばれる)ため、Activity コンテナ内に LoggerInMemoryDataSource のインスタンスを作成し、そのインスタンスを Fragment で再利用する必要があります。

LoggerInMemoryDataSource のスコープを Activity コンテナに設定することで、メモリ内のログの動作を実現できます。Activity が作成されるたびに、独自のコンテナ(異なるインスタンス)が作成されます。各コンテナで、logger を依存関係として、またはフィールド注入用に使用する必要がある場合、LoggerInMemoryDataSource の同じインスタンスが提供されます。また、コンポーネント階層の下のコンテナにも、同じインスタンスが提供されます。

スコープをコンポーネントに設定するドキュメントに従って、特定の型のスコープを Activity コンテナに設定するには、型に @ActivityScoped アノテーションを付ける必要があります。

@ActivityScoped
class LoggerInMemoryDataSource @Inject constructor(
) : LoggerDataSource { ... }

現時点で、Hilt は LoggerInMemoryDataSourceLoggerLocalDataSource のインスタンスを提供する方法を把握していますが、LoggerDataSource はどうでしょうか。LoggerDataSource がリクエストされたときにどの実装を使用すべきかを Hilt は把握していません。

前のセクションで説明したように、モジュール内で @Binds アノテーションを使用して、使用する実装を Hilt に指示できます。ただし、同じプロジェクトで両方の実装を提供する必要がある場合はどうなるでしょうか。たとえば、アプリの実行中に LoggerInMemoryDataSource を使用し、ServiceLoggerLocalDataSource を使用する場合があります。

同じインターフェースの 2 つの実装

di フォルダに LoggingModule.kt という新しいファイルを作成しましょう。LoggerDataSource の各実装のスコープは異なるコンテナに設定されているため、同じモジュールを使用することはできません。LoggerInMemoryDataSource のスコープは Activity コンテナに設定され、LoggerLocalDataSource のスコープは Application コンテナに設定されます。

幸い、先ほど作成したファイル内で両方のモジュールのバインディングを定義できます。

package com.example.android.hilt.di

@InstallIn(ApplicationComponent::class)
@Module
abstract class LoggingDatabaseModule {

    @Singleton
    @Binds
    abstract fun bindDatabaseLogger(impl: LoggerLocalDataSource): LoggerDataSource
}

@InstallIn(ActivityComponent::class)
@Module
abstract class LoggingInMemoryModule {

    @ActivityScoped
    @Binds
    abstract fun bindInMemoryLogger(impl: LoggerInMemoryDataSource): LoggerDataSource
}

型がスコープ設定されている場合、@Binds メソッドにはスコープ設定アノテーションが必要です。そのため、上記の関数には @Singleton アノテーションと @ActivityScoped アノテーションが付けられます。@Binds または @Provides が型のバインディングとして使用されている場合、その型のスコープ設定アノテーションは使用されなくなるため、他の実装クラスから削除できます。

この時点でプロジェクトをビルドしようとすると、DuplicateBindings エラーが表示されます。

error: [Dagger/DuplicateBindings] com.example.android.hilt.data.LoggerDataSource is bound multiple times

これは、LoggerDataSource 型が Fragment に注入されていますが、同じ型に対して 2 つのバインディングがあり、Hilt はどの実装を使用すべきか把握していないためです。Hilt が使用する方法を把握する方法は、次のとおりです。

修飾子の使用

同じ型の異なる実装(複数のバインディング)を提供する方法を Hilt に指示するには、修飾子を使用します。

各修飾子はバインディングを識別するために使用されるため、実装ごとに修飾子を定義する必要があります。この型を Android クラスに注入する場合や、他のクラスの依存関係として利用する場合、あいまいさを回避するために、修飾子アノテーションを使用する必要があります。

修飾子は単なるアノテーションであるため、モジュールを追加した LoggingModule.kt ファイルで次のように定義できます。

package com.example.android.hilt.di

@Qualifier
annotation class InMemoryLogger

@Qualifier
annotation class DatabaseLogger

次に、これらの修飾子を使用して、各実装を提供する @Binds(または必要な場合は @Provides)関数にアノテーションを付ける必要があります。完全なコードを確認し、@Binds メソッドに修飾子が使用されていることに注意してください。

package com.example.android.hilt.di

@Qualifier
annotation class InMemoryLogger

@Qualifier
annotation class DatabaseLogger

@InstallIn(ApplicationComponent::class)
@Module
abstract class LoggingDatabaseModule {

    @DatabaseLogger
    @Singleton
    @Binds
    abstract fun bindDatabaseLogger(impl: LoggerLocalDataSource): LoggerDataSource
}

@InstallIn(ActivityComponent::class)
@Module
abstract class LoggingInMemoryModule {

    @InMemoryLogger
    @ActivityScoped
    @Binds
    abstract fun bindInMemoryLogger(impl: LoggerInMemoryDataSource): LoggerDataSource
}

また、注入する場合、これらの修飾子は注入する実装に使用する必要があります。この例では、FragmentLoggerInMemoryDataSource の実装を使用します。

LogsFragment を開き、logger フィールドに @InMemoryLogger 修飾子を使用して、LoggerInMemoryDataSource のインスタンスを注入するよう Hilt に指示します。

@AndroidEntryPoint
class LogsFragment : Fragment() {

    @InMemoryLogger
    @Inject lateinit var logger: LoggerDataSource
    ...
}

ButtonsFragment についても同様にします。

@AndroidEntryPoint
class ButtonsFragment : Fragment() {

    @InMemoryLogger
    @Inject lateinit var logger: LoggerDataSource
    ...
}

使用するデータベース実装を変更するには、注入されたフィールドに @InMemoryLogger アノテーションではなく @DatabaseLogger アノテーションを付けるだけです。

アプリの実行

アプリを実行し、ボタン操作を通じて対応するログが「See all logs」画面に表示されるかどうかを見て、これまでの作業の結果を確認できます。

ログはデータベースに保存されなくなることに注意してください。異なるセッション間でログが保持されることはありません。アプリを閉じてもう一度開くとログ画面が空白になります。

アプリが Hilt に完全に移行されたので、プロジェクト内にあるインストルメンテーション テストも移行できるようになりました。このテストは、アプリの機能をチェックするためのものであり、app/androidTest フォルダの AppTest.kt ファイルに配置されています。これを開きます。

プロジェクトから ServiceLocator クラスを削除したため、テストをコンパイルできないことがわかります。クラスから @After tearDown メソッドを削除して、不要になった ServiceLocator への参照を削除します。

androitTest テストをエミュレータで実行します。happyPath テストでは、「Button 1」のタップがデータベースに記録されたことを確認します。アプリがメモリ内データベースを使用しているため、テストが終了すると、すべてのログが表示されなくなります。

Hilt を使用した UI テスト

Hilt は、本番環境のコードと同様に、UI テストに依存関係を注入します。

Hilt を使用したテストでは、テストごとに新しいコンポーネントのセットが自動的に生成されるため、メンテナンスは不要です

テストの依存関係の追加

Hilt ではテストに役立つ、テスト固有のアノテーションを含む追加のライブラリ(hilt-android-testing と呼ばれる)を使用しており、これをプロジェクトに追加する必要があります。また、Hilt は androidTest フォルダにクラスのコードを生成する必要があるため、アノテーション プロセッサがそこで動作できる必要があります。この設定を有効にするには、app/build.gradle ファイルに 2 つの依存関係を含める必要があります。

これらの依存関係を追加するには、app/build.gradle を開き、dependencies セクションの一番下に次の設定を追加します。

...
dependencies {

    // Hilt testing dependency
    androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
    // Make Hilt generate code in the androidTest folder
    kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version"
}

カスタム TestRunner

Hilt を使用したインストゥルメント化テストは、Hilt をサポートする Application で実行する必要があります。このライブラリには、UI テストの実行に使用できる HiltTestApplication がすでに用意されています。テストで使用する Application を指定するには、プロジェクト内に新しいテストランナーを作成します。

同じレベルで、AppTest.kt ファイルは androidTest フォルダの下にあり、CustomTestRunner という名前の新しいファイルが作成されます。CustomTestRunnerAndroidJUnitRunner から拡張され、次のように実装されます。

class CustomTestRunner : AndroidJUnitRunner() {

    override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
        return super.newApplication(cl, HiltTestApplication::class.java.name, context)
    }
}

次に、このテストランナーをインストルメンテーション テストに使用するようプロジェクトに指示する必要があります。これは、app/build.gradle ファイルの testInstrumentationRunner 属性で指定します。ファイルを開き、デフォルトの testInstrumentationRunner コンテンツを次のように置き換えます。

...
android {
    ...
    defaultConfig {
        ...
        testInstrumentationRunner "com.example.android.hilt.CustomTestRunner"
    }
    ...
}
...

これで、UI テストで Hilt を使用できるようになりました。

Hilt を使用したテストの実行

エミュレータ テストクラスで Hilt を使用するには、以下を行う必要があります。

  1. @HiltAndroidTest アノテーションを付ける。このアノテーションは、各テストに Hilt コンポーネントを生成させるものです。
  2. HiltAndroidRule を使用する。このルールは、コンポーネントの状態を管理し、テストの注入に使用されるものです。

AppTest に以下を追加しましょう。

@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
class AppTest {

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    ...
}

クラス定義またはテストメソッドの定義の横にある再生ボタンを使用してテストを実行すると、エミュレータが起動されます。エミュレータを構成していればテストに合格します。

フィールド注入やテストのバインディングの置き換えなど、テストと機能について詳しくは、こちらのドキュメントをご覧ください。

この Codelab のセクションでは、Hilt でサポートされていないクラスに依存関係を注入するために使用される @EntryPoint アノテーションの使用方法を学習します。

前述のように、Hilt ではほとんどの一般的な Android コンポーネントがサポートされています。ただし、Hilt で直接サポートされていないクラスや Hilt を使用できないクラスでは、フィールド注入を実行する必要があります。

このような場合は、@EntryPoint を使用します。エントリ ポイントとは、Hilt を使用して依存関係を注入できないコードから Hilt が提供するオブジェクトを取得できる境界の場所です。コード内で Hilt で管理するコンテナが開始される最初のポイントです。

ユースケース

アプリケーション プロセス以外でログをエクスポートできるようにしましょう。そのためには、ContentProvider を使用する必要があります。ここでは、ユーザーが ContentProvider を使用して、特定のログ(id を指定)またはアプリ内のすべてのログをクエリすることだけを許可します。データの取得には Room データベースを使用します。したがって LogDao クラスは、データベース Cursor を使用して必要な情報を返すメソッドを提供する必要があります。LogDao.kt ファイルを開き、次のメソッドをインターフェースに追加します。

@Dao
interface LogDao {
    ...

    @Query("SELECT * FROM logs ORDER BY id DESC")
    fun selectAllLogsCursor(): Cursor

    @Query("SELECT * FROM logs WHERE id = :id")
    fun selectLogById(id: Long): Cursor?
}

次に、新しい ContentProvider クラスを作成し、query メソッドをオーバーライドして、ログを含む Cursor を返す必要があります。新しい contentprovider ディレクトリの下に LogsContentProvider.kt という新しいファイルを作成し、次の内容を含めます。

package com.example.android.hilt.contentprovider

import android.content.ContentProvider
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.UriMatcher
import android.database.Cursor
import android.net.Uri
import com.example.android.hilt.data.LogDao
import dagger.hilt.EntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ApplicationComponent
import java.lang.UnsupportedOperationException

/** The authority of this content provider.  */
private const val LOGS_TABLE = "logs"

/** The authority of this content provider.  */
private const val AUTHORITY = "com.example.android.hilt.provider"

/** The match code for some items in the Logs table.  */
private const val CODE_LOGS_DIR = 1

/** The match code for an item in the Logs table.  */
private const val CODE_LOGS_ITEM = 2

/**
 * A ContentProvider that exposes the logs outside the application process.
 */
class LogsContentProvider: ContentProvider() {

    private val matcher: UriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
        addURI(AUTHORITY, LOGS_TABLE, CODE_LOGS_DIR)
        addURI(AUTHORITY, "$LOGS_TABLE/*", CODE_LOGS_ITEM)
    }

    override fun onCreate(): Boolean {
        return true
    }

    /**
     * Queries all the logs or an individual log from the logs database.
     *
     * For the sake of this codelab, the logic has been simplified.
     */
    override fun query(
        uri: Uri,
        projection: Array<out String>?,
        selection: String?,
        selectionArgs: Array<out String>?,
        sortOrder: String?
    ): Cursor? {
        val code: Int = matcher.match(uri)
        return if (code == CODE_LOGS_DIR || code == CODE_LOGS_ITEM) {
            val appContext = context?.applicationContext ?: throw IllegalStateException()
            val logDao: LogDao = getLogDao(appContext)

            val cursor: Cursor? = if (code == CODE_LOGS_DIR) {
                logDao.selectAllLogsCursor()
            } else {
                logDao.selectLogById(ContentUris.parseId(uri))
            }
            cursor?.setNotificationUri(appContext.contentResolver, uri)
            cursor
        } else {
            throw IllegalArgumentException("Unknown URI: $uri")
        }
    }

    override fun insert(uri: Uri, values: ContentValues?): Uri? {
        throw UnsupportedOperationException("Only reading operations are allowed")
    }

    override fun update(
        uri: Uri,
        values: ContentValues?,
        selection: String?,
        selectionArgs: Array<out String>?
    ): Int {
        throw UnsupportedOperationException("Only reading operations are allowed")
    }

    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
        throw UnsupportedOperationException("Only reading operations are allowed")
    }

    override fun getType(uri: Uri): String? {
        throw UnsupportedOperationException("Only reading operations are allowed")
    }
}

getLogDao(appContext) 呼び出しはコンパイルされないことがわかります。これは、Hilt アプリケーション コンテナから LogDao の依存関係を取得することで実装する必要があります。ただし Hilt では、ContentProvider への注入に対してすぐに使用できるサポートを提供していません。Activity の場合、@AndroidEntryPoint の使用などのサポートを提供します。

このクラスにアクセスするには、@EntryPoint アノテーションを使用して新しいインターフェースを作成する必要があります。

@EntryPoint の使い方

エントリ ポイントは、必要なバインディング タイプのアクセサ メソッドをすべて含むインターフェースです(修飾子を含む)。また、エントリ ポイントをインストールするコンポーネントを指定するには、インターフェースに @InstallIn アノテーションを付ける必要があります。

エントリ ポイント インターフェースを使用するクラスに新しいインターフェースを追加することをおすすめします。このため、このインターフェースを LogsContentProvider.kt ファイルに追加します。

class LogsContentProvider: ContentProvider() {

    @InstallIn(ApplicationComponent::class)
    @EntryPoint
    interface LogsContentProviderEntryPoint {
        fun logDao(): LogDao
    }

    ...
}

Application コンテナのインスタンスからの依存関係が必要であるため、このインターフェースには @EntryPoint アノテーションが付けられ、ApplicationComponent にインストールされます。インターフェース内で、アクセスするバインディングのメソッド(この例では LogDao)を提供します。

エントリ ポイントにアクセスするには、EntryPointAccessors の適切な静的メソッドを使用します。パラメータは、コンポーネント インスタンスか、コンポーネント ホルダーとして機能する @AndroidEntryPoint オブジェクトのいずれかにします。パラメータとして渡すコンポーネントと EntryPointAccessors 静的メソッドの両方を、@EntryPoint インターフェースの @InstallIn アノテーションで指定した Android クラスと一致させてください。

これで、上記のコードに含まれていない getLogDao メソッドを実装できます。上記の LogsContentProviderEntryPoint クラスで定義されたエントリ ポイント インターフェースを使用してみましょう。

class LogsContentProvider: ContentProvider() {
    ...

    private fun getLogDao(appContext: Context): LogDao {
        val hiltEntryPoint = EntryPointAccessors.fromApplication(
            appContext,
            LogsContentProviderEntryPoint::class.java
        )
        return hiltEntryPoint.logDao()
    }
}

applicationContext が静的 EntryPoints.get メソッドや @EntryPoint アノテーションが付いたインターフェースのクラスにどのように渡されるかに注目してください。

これで、Hilt について理解し、Android アプリに追加できるようになりました。このコードラボでは次の内容を学びました。

  • @HiltAndroidApp を使用してアプリケーション クラスに Hilt を設定する方法
  • @AndroidEntryPoint を使用して各種 Android ライフサイクル コンポーネントに依存関係コンテナを追加する方法
  • モジュールを使用して、特定の型を提供する方法を Hilt に指示する方法
  • 修飾子を使用して特定の型に複数のバインディングを提供する方法
  • Hilt を使用してアプリをテストする方法
  • @EntryPoint の用途と使用方法