【Codelab】Using Hilt in your Android app メモ
https://codelabs.developers.google.com/codelabs/android-hilt/#0
1.Introduction
このコードラボでは、大規模プロジェクトにスケールする強固で拡張性の高いアプリケーションを作成するための依存性の注入(dependency injection (DI))の重要性について学びます。依存関係を管理するためのDIツールとしてHiltを使用します。
依存性の注入(dependency injection (DI))はプログラミングで広く使われているテクニックで、Android開発にも適しています。DIの原則に従うことで、良いアプリアーキテクチャの基礎を築くことが出来ます。
DIを実践することで次の利点があります。
コードの再利用性向上
リファクタリングの容易性向上
テストの容易性向上
Hiltは、あなたのプロジェクトでDIを使用する際のボイラープレートを軽減するAndroid用のDIライブラリです。手動でDIするには、すべてのクラスとその依存関係を手作業で構築し、コンテナを使用して依存関係を再利用して管理する必要があります。
Hiltは、プロジェクト内のすべてのAndroidコンポーネントにコンテナを提供し、コンテナのライフサイクルを自動的に管理することで、アプリケーションでDIを行う標準的な方法を提供します。これは、Daggerのような一般的なDIライブラリを活用することで行われます。
このコードラボで作業している間に問題(バグ、文法エラー、不明瞭な言葉遣いなど)に遭遇した場合は、コードラボの左下隅にある「間違いを報告する」リンクから問題を報告してください。
(そんなリンクは無い気がする)
Find more information about Dependency Injection here:
Fundamentals of Dependency Injection
Manual Dependency Injection in Android
What you'll learn
AndroidアプリでのHiltの使い方。
より堅牢で持続可能なアプリを作るための関連するヒルトのコンセプト。
同一型に複数のバインディングをqualifiersで追加する方法。
Hiltがサポートしていないクラスのコンテナ内の要素に@EntryPointでアクセスする方法。
Hiltを使用したアプリケーションをUnitテストとinstrumentationテストでテストする方法。
2. Getting set up
https://codelabs.developers.google.com/codelabs/android-hilt/#1
このコードラボでは、ユーザーのインタラクションを単純にログに記録し、データをDBに保存するためにRoomを使用するアプリケーションにHiltを追加します。
Project set up
サンプルプロジェクトは複数のブランチからなります。
master: コードラボのスタート地点
solutin: コードラボのソリューション
マスターブランチから始めて、自分のペースでコーデラボのステップを踏んでいくことをお勧めします。
コードラボでは、プロジェクトに追加しなければならないコードのスニペットが提示されます。いくつかの場所ではコメントで明示的に言及されているコードを削除しなければならないこともあります。
3. Adding Hilt to the project
https://codelabs.developers.google.com/codelabs/android-hilt/#2
Why Hilt?
コードを見てみると、LogApplicationクラスに格納されているServiceLocatorクラスのインスタンスが見えます。ServiceLocatorは、必要とする依存関係を作成して保存します。これは、アプリのライフサイクルにアタッチされた依存関係のコンテナと考えることができます。
code:kotlin
class ServiceLocator(applicationContext: Context) {
// 依存関係を作成する = インスタンスを作成する
// 依存関係を保存する = プロパティとして保持する
private val logsDatabase = Room.databaseBuilder(
applicationContext,
AppDatabase::class.java,
"logging.db"
).build()
val loggerLocalDataSource = LoggerLocalDataSource(logsDatabase.logDao())
fun provideDateFormatter() = DateFormatter()
fun provideNavigator(activity: FragmentActivity): AppNavigator {
return AppNavigatorImpl(activity)
}
}
コンテナは、コードベース内の依存関係の提供を担当し、アプリの他のインスタンスの作成方法を知っているクラスです。インスタンスを作成してライフサイクルを管理することで、それらのインスタンスを提供するために必要な依存関係のグラフを管理します。
コンテナは、それが提供する型のインスタンスを取得するためのメソッドを公開します。これらのメソッドは常に異なるインスタンスを返すこともあれば、同じインスタンスを返すこともあります(シングルトンかどうかだと思われる)。メソッドが常に同じインスタンスを提供する場合は、その型がコンテナにスコープされていることを意味します。
Android DIガイダンスで説明されているように、Service Locatorsは比較的小さなボイラプレートコードから始まりますが、スケールしにくいです。スケールするAndroidアプリを開発するには、Hiltを使用する必要があります。
Hiltは、手動で作成したであろうコード(ServiceLocatorクラスのコードなど)を生成することで、Androidアプリケーションで手動 DIまたはService Locatorパターンを使用する不要なボイラプレートコードを削除します。
Service Locatorパターンについては、AndroidのDependency Injectionガイドを参照してください。
次のステップでは、Hiltを使用してServiceLocatorクラスを置き換えます。その後、プロジェクトに新機能を追加して、Hiltの機能をさらに掘り下げていきます。
Hilt in your project
Hiltはmasterブランチで既に設定済みです。
以下のコードは既に作成されているので、プロジェクトに含める必要はありません。とはいえ、AndroidアプリでHiltを使うために必要なものを見てみましょう。
ライブラリの依存関係とは別に、Hiltはプロジェクト内で設定されているGradleプラグインを使用します。ルートのbuild.gradle ファイルを開き、クラスパスに以下のHilt依存関係があることを確認してください。
code:build.gradle
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プラグインの下にプラグインを追加して指定します。
code:app:build.gradle
...
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'
android {
...
}
最後に、Hiltの依存を同じapp/build.gradleに追加します。
code:app:build.gradle
...
dependencies {
...
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
}
プロジェクトをビルドして同期すると、Hiltを含むすべてのライブラリがダウンロードされます。Hiltを使い始めましょう!
4. Hilt in your Application
https://codelabs.developers.google.com/codelabs/android-hilt/#3
LogApplicationクラスのServiceLocatorのインスタンスを使用して初期化する方法と同様に、アプリのライフサイクルにアタッチするコンテナを追加するには、Applicationクラスに@HiltAndroidAppをアノテーションする必要があります。
code:kotlin
@HiltAndroidApp
class LogApplication : Application() {
...
}
@HiltAndroidAppはHiltのコード生成をトリガーします。アプリケーションコンテナはアプリの親コンテナであり、他のコンテナはそれが提供する依存関係にアクセスできることを意味します。
これでアプリがHiltを使う準備が出来ました!
5. Field injection with Hilt
https://codelabs.developers.google.com/codelabs/android-hilt/#4
依存関係をクラス ServiceLocatorからオンデマンドで取得する代わりに、Hiltを使用して依存関係を提供します。クラスの ServiceLocatorへの呼び出しを置き換えてみましょう。
ui/LogsFragment.ktファイルを開きます。LogsFragmentはonAttachでフィールドを生成します。LoggerLocalDataSourceとDateFormatterのインスタンスをServiceLocatorを使用して手動で生成する代わりに、Hiltを使用してこれらのインスタンスを生成して管理することができます。
LogsFragmentでHiltを使うには、@AndroidEntryPointアノテーションをつける必要があります。
code:kotlin
@AndroidEntryPoint
class LogsFragment : Fragment() {
...
}
@AndroidEntryPointでアノテーションすると、Androidクラスのライフサイクルに沿った依存関係のコンテナが作成されます。
Hiltは現在、以下のAndroidのコンポーネントをサポートしています。Application (@HiltAndroidAppを使用)、Activity、Fragment、View、Service、BroadcastReceiverです。HiltはFragmentActivity を継承するActivity (AppCompatActivityのようなもの) とJetpackのFragmentを継承するFragmentのみをサポートしており、Androidプラットフォームの(現在は非推奨となっている)Fragmentはサポートしていません。
Hiltでは、@AndroidEntryPointで、LogsFragmentのライフサイクルにアタッチされた依存関係コンテナを作成して、LogsFragmentにインスタンスを注入できるようにします。Hiltで注入されたフィールドを取得するにはどうすればいいのでしょうか?
インジェクションしたいフィールド (loggerやdateFormatter など) に@Injectアノテーションを付けることで、異なる型のインスタンスをHiltにインジェクションさせることができます。これをフィールドインジェクション(field injection)と呼びます。
code:kotlin
@AndroidEntryPoint
class LogsFragment : Fragment() {
@Inject lateinit var logger: LoggerLocalDataSource
@Inject lateinit var dateFormatter: DateFormatter
...
}
フィールドインジェクションを実行するには、Hiltでprovide(またはインジェクション)したいAndroidクラスのフィールドに @Injectアノテーションを使用します。Hiltによるフィールドインジェクションはprivateなプロパティで無い必要があります。
Hiltがこれらのフィールドへのインスタンスの代入を担当してくれるので、populateFieldsメソッドはもう必要ありません。クラスからこのメソッドを削除しましょう。Hiltは、自動生成されたLogsFragmentの依存関係コンテナ内に構築されたインスタンスを使用して onAttach()でこれらのフィールドへインスタンスを代入します。
code:kotlin
@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()
}
...
}
Androidクラスがどのライフサイクルコールバックでインジェクションされるかについての詳細は、Component lifetimesのセクションを参照してください。
フィールドインジェクションを実行するには、Hiltはこれらの依存関係のインスタンスを提供(provide)する方法を知る必要があります。この場合、HiltはLoggerLocalDataSourceとDateFormatterのインスタンスを提供する方法を知る必要があります。しかし、Hiltはこれらのインスタンスを提供する方法をまだ知りません。
Tell Hilt how to provide dependencies with @Inject
ServiceLocator.ktファイルを開いてServiceLocatorがどのように実装されているかを確認します。provideDateFormatter()をコールすると、常に異なるDateFormatterのインスタンスが返されることがわかります。
これは、私たちがHiltで実現したい動作とまったく同じです。幸いなことに、DateFormatterは他のクラスに依存していないので、今のところ推移的(transitive)な依存関係について心配する必要はありません。
Hiltに型のインスタンスを提供する方法を伝えるには、注入したいクラスのコンストラクタに@Injectアノテーションを追加します。
util/DateFormatter.ktファイルを開き、DateFormatterのコンストラクタに@Injectアノテーションをつけます。Kotlinのコンストラクタにアノテーションをつけるには、constructorキーワードも必要になることを覚えておきましょう。
code:kotlin
class DateFormatter @Inject constructor() { ... }
これで、HiltはDateFormatterのインスタンスを提供する方法を知っています。同じことをLoggerLocalDataSourceで行う必要があります。data/LoggerLocalDataSource.ktファイルを開き、そのコンストラクタに@Injectをアノテーションします。
code:kotlin
class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
...
}
Hiltが持っている、異なる型のインスタンスを提供する方法に関する情報もbindingと呼ばれています。
現時点では、Hiltには2つのbindingがあります: 1) DateFormatter と 2) LoggerLocalDataSource
もう一度ServiceLocatorクラスを開くと、public LoggerLocalDataSourceフィールドがあることがわかります。つまり、ServiceLocatorは呼ばれるたびに常に同じインスタンスのLoggerLocalDataSourceを返すということです。これが「インスタンスをコンテナにスコープする」というものです。Hiltではどうすればいいのでしょうか?
6. Scoping instances to containers
https://codelabs.developers.google.com/codelabs/android-hilt/#5
アノテーションを使用してインスタンスをコンテナにスコープすることができます。Hiltは異なるライフサイクルを持つ異なるコンテナを作成できるので、それらのコンテナにスコープする異なるアノテーションがあります。
アプリケーションコンテナにインスタンスをスコープするアノテーションは@Singletonです。このアノテーションを使用すると、型が他の型の依存関係として使用されているか、フィールドインジェクションが必要かに関わらず、アプリケーションコンテナは常に同じインスタンスを提供するようになります。
同じロジックをAndroidクラスにアタッチされたすべてのコンテナに適用することができます。スコーピングアノテーションの一覧はドキュメントを参照してください。例えば、Actovotyコンテナに常に同じ型のインスタンスを提供したい場合は、その型に@ActivityScopedをアノテーションします。
上で述べたように、アプリケーションコンテナが常にLoggerLocalDataSourceの同じインスタンスを提供するようにしたいので、そのクラスに@Singletonというアノテーションを付けます。
code:kotlin
@Singleton
class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
...
}
階層内の上位のコンテナで利用可能なbindingは、階層内の下位レベルでも利用可能です。
そのため、アプリケーション コンテナでLoggerLocalDataSourceのインスタンスが利用可能な場合は、ActivityコンテナやFragmentコンテナでも利用可能になります。
これで、HiltはLoggerLocalDataSourceのインスタンスを提供する方法を知りました。しかし、今回はこの型には遷移的な依存関係があります。LoggerLocalDataSourceのインスタンスを提供するために、HiltはLogDaoのインスタンスを提供する方法も知る必要があります。
しかし、LogDaoはインターフェイスです。インターフェイスはコンストラクタを持たないのでコンストラクタに@Inject をアノテーションすることができません。この型のインスタンスを提供する方法をHiltに伝えるにはどうすればいいのでしょうか?
7. Hilt modules
https://codelabs.developers.google.com/codelabs/android-hilt/#6
モジュールは、Hiltにバインディングを追加するため、言い換えれば、異なる型のインスタンスを提供する方法をHiltに伝えるために使用します。Hiltモジュールでは、プロジェクトに含まれていないインターフェイスやクラスなど、コンストラクタでインジェクションできない型のバインディングを追加します。例えば、OkHttpClientのインスタンスを作成するには、そのビルダーを使用する必要があります。
Hiltモジュールは、@Moduleと@InstallInでアノテーションされたクラスです。@Moduleはこれがモジュールであることを Hiltに伝え、@InstallIn はHilt Componentを指定することで、どのコンテナでバインディングを利用できるかを伝えます。Hilt Componentはコンテナと考えることができ、コンポーネントの全リストはこちらを参照してください。
Hiltで注入できる各Androidクラスには、関連するHilt Componentがあります。たとえば、Applicationコンテナは ApplicationComponentに関連付けられ、FragmentコンテナはFragmentComponentに関連付けられています。
Creating a Module
バインディングを追加できるHiltモジュールを作ってみましょう。hiltパッケージの下にdiという新しいパッケージを作成し、その中にDatabaseModule.ktというファイルを作成します。
LoggerLocalDataSourceはApplicationコンテナにスコープされているので、LogDaoバインディングはApplicationコンテナで利用できる必要があります。この要件は、@InstallInアノテーションを使用して、それに関連付けられたHiltコンポーネントのクラスを渡すことで指定します(例えば、ApplicationComponent:classのように)。
code:kotlin
package com.example.android.hilt.di
@InstallIn(ApplicationComponent::class)
@Module
object DatabaseModule {
}
ServiceLocatorクラスの実装では、logDatabase.logDao()を呼び出すことでLogDaoのインスタンスを取得します。したがって、LogDaoのインスタンスを提供するために、AppDatabaseクラスへの推移的な依存関係を持っています。
Kotlinでは、@Provides関数のみを含むモジュールはobjectクラスにすることができます。このようにして、Providerは最適化され、生成されたコードにほぼインライン化されます。
Providing instances with @Provides
Hiltモジュールで関数に@Providesというアノテーションを付けることで、コンストラクタで注入できない型を提供する方法を Hiltに伝えることができます。
アノテーションされた@Provides関数のbodyは、Hiltがその型のインスタンスを提供する必要があるたびに実行されます。Provides-annotated関数の戻り値の型は、hiltにバインディングの型、またはその型のインスタンスを提供する方法を伝えます。関数のパラメータは、型の依存関係です。
ここでは、この関数はDatabaseModuleクラスに含まれています。
code:kotlin
@Module
object DatabaseModule {
@Provides
fun provideLogDao(database: AppDatabase): LogDao {
return database.logDao()
}
}
上のコードは、LogDaoのインスタンスを提供する際にdatabase.logDao()を実行する必要があることをHiltに伝えています。AppDatabaseは推移的な依存関係にあるので、その型のインスタンスを提供する方法 Hiltに伝える必要があります。AppDatabaseはRoomによって生成されるため、プロジェクトが所有していない別のクラスです。なのでServiceLocatorクラスのデータベースインスタンスを構築する方法と同様に、@Provides関数を使用して提供できます。
code:kotlin
@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コンテナにはデフォルトのバインディングのセットが付属しており、カスタムバインディングに依存性として注入することができます。applicationContextのケースでは、アクセスするためにフィールドに@ApplicationContextをアノテーションする必要があります。
定義済みのバインディングのリストは、ドキュメントのこのページで見ることができます。
メモ: hiltにはqualifiersとしてApplicationContextとActivityContextのみが含まれていた。
https://gyazo.com/fdb055e01b0789f173835e95385f3059
Running the app
これで、HiltはLogsFragmentのインスタンスを注入するために必要な情報をすべて持っています。しかし、アプリを実行する前に、Hiltが動作するためにはFragmentをホストしているActivityを意識する必要があります。MainActivityに@AndroidEntryPointをアノテーションする必要があります。
ui/MainActivity.ktファイルを開き、MainActivityに@AndroidEntryPointをアノテーションします。
code:kotlin
@AndroidEntryPoint
class MainActivity : AppCompatActivity() { ... }
これで、アプリを実行して、以前のようにすべてが正常に動作することを確認することができます。MainActivityからServiceLocator呼び出しを削除するために、アプリのリファクタリングを続けてみましょう。
9. Providing interfaces with @Binds
https://codelabs.developers.google.com/codelabs/android-hilt/#7
MainActivityは、ServiceLocatorからAppNavigatorのインスタンスを取得し、provideNavigator(activity: FragmentActivity)関数を呼び出します。
AppNavigatorはインターフェースなので、コンストラクタインジェクションは使えません。Hiltにインターフェイスに使用する実装を指定するには、Hilt モジュール内の関数で @Binds アノテーションを使用します。
@Bindsはabstruct関数をアノテーションしなければなりません(abstrict関数なのでコードは含まれておらず、クラスも抽象化されている必要があります)。abstruct関数の戻り値の型は、実装を提供したいインターフェース(例:AppNavigator)です。実装は、インターフェースの実装タイプ(例:AppNavigatorImpl)をパラメータを追加することで指定します。
以前に作成したDatabaseModuleクラスに情報を追加できるのか、それとも新しいモジュールが必要なのか?新しいモジュールを作成しなければならない理由は複数あります。
より良い整理のために、モジュールの名前はそれが提供する情報の種類を伝えるべきです。例えば、DatabaseModuleという名前のモジュールにnavigationのバインディングを含めるのは意味がありません。
DatabaseModuleモジュールはApplicationComponentにインストールされ、Applicationコンテナ内でバインディングが利用できるようになります。新しいナビゲーション情報(つまりAppNavigator)は、Activityから特定の情報を必要とします(AppNavigatorImplは依存関係としてActivityを持っているため)。したがって、ApplicationコンテナではなくActivityコンテナにインストールする必要があります。Activityの情報がそこにあるからです。
Hiltモジュールは、non-staticバインディングメソッド(staticでは無いメソッド)とabstructバインディングメソッドの両方を含むことができないので、@Binds と@Providesアノテーションを同じクラスに配置することはできません。
diフォルダ内にNavigationModule.ktというファイルを新規作成します。そこで、上で説明したように@Moduleと@InstallIn(ActivityComponent::class)でアノテーションされたNavigationModuleという抽象クラスを作成してみましょう。
code:korlin
@InstallIn(ActivityComponent::class)
@Module
abstract class NavigationModule {
@Binds
abstract fun bindNavigator(impl: AppNavigatorImpl): AppNavigator
}
モジュールの中に、AppNavigatorのバインディングを追加することができます。これはabstruct関数で、Hiltに通知するインターフェース(つまりAppNavigator)を返し、パラメータはそのインターフェースの実装(つまりAppNavigatorImpl)です。
メモ:
解説にあったように@Providesと@Bindsは共存できない。
上記のコードは@Providesを使う場合次のようになる。
code:kotlin
@InstallIn(ActivityComponent::class)
@Module
object NavigationModule {
@Provides
fun bindNavigator(): AppNavigator = AppNavigatorImpl()
}
あとは、AppNavigatorImplのインスタンスを提供する方法をHiltに伝える必要があります。このクラスはコンストラクタをインジェクトすることができるので、コンストラクタに@Injectとアノテーションを付けるだけです。
code:kotlin
class AppNavigatorImpl @Inject constructor(
private val activity: FragmentActivity
) : AppNavigator {
...
}
AppNavigatorImplはFragmentActivityに依存します。ActivityコンテナにはAppNavigatorインスタンスが提供されているので(ActivityComponentにNavigationModuleがインストールされているので、FragmentコンテナやViewコンテナでも利用可能です)、FragmentActivityは定義済みのバインディングとして提供されているので、すでに利用可能です。
メモ: hiltのActivityModuleはこうなっている。
code:kotlin
@Module
@InstallIn(ActivityComponent.class)
abstract class ActivityModule {
@Binds
@ActivityContext
abstract Context provideContext(Activity activity);
@Provides
@Reusable
static FragmentActivity provideFragmentActivity(Activity activity) {
try {
return (FragmentActivity) activity;
} catch (ClassCastException e) {
throw new IllegalStateException(
"Expected activity to be a FragmentActivity: " + activity, e);
}
}
private ActivityModule() {}
}
Using Hilt in the Activity
これで、HiltはAppNavigatorインスタンスを注入できるようになるためのすべての情報を持っています。MainActivity.ktファイルを開き、以下のようにします。
navigatorフィールドを@InjectでアノテーションしてHiltで取得します。
private修飾子を削除します。
onCreateでのnavigatorの初期化コードを削除します。
新しいコードは次のようになります。
code:kotlin
@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)
}
}
...
}
Running the app
アプリを起動して、予想通りの動作を確認することができます。
Finishing the refactoring
依存関係を取得するためにServiceLocatorを使用している唯一のクラスはButtonsFragmentです。Hiltはすでに ButtonsFragmentが必要とするすべての型を提供する方法を知っているので、クラス内でフィールドインジェクションを実行するだけです。
今まで学んできたように、hiltでクラスにフィールドインジェクションさせるためには次の手順を行う必要があります。
ButtonsFragmentに@AndroidEntryPointをアノテーションします。
loggerとnavigatorのフィールドからprivate修飾子を削除し、@Injectでアノテーションします。
フィールドの初期化コード(onAttachおよびpopulateFieldsメソッドなど)を削除します。
ButtonsFragmentのコードです。
code:kotlin
@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のインスタンスは、型がApplicationコンテナにスコープされているので、LogsFragmentで使用したものと同じになることに注意してください。しかし、AppNavigatorのインスタンスは、それぞれのActivityコンテナにスコープされていないため、MainActivityのインスタンスとは異なります。(scopeを指定していないのでインスタンスがInjectの度に作り直されるという意味だと思う。)
この時点で、ServiceLocatorクラスはもはや依存関係を提供しないので、プロジェクトから完全に削除できます。唯一の使用法はLogApplicationクラスのインスタンスを保持する点ですが、もう必要ないのでクラスをクリーンにしましょう。LogApplicationクラスを開き、ServiceLocatorの使用を削除します。
Applicationクラスの新しいコードは次のようになります。
code:kotlin
@HiltAndroidApp
class LogApplication : Application()
ここで、ServiceLocatorクラスをプロジェクトから完全に削除してください。ServiceLocatorはまだテストで使われているので、AppTestクラスからもその用途を削除してください。
Basic content covered
今学んだことは、AndroidアプリケーションでHiltをDIツールとして使用するのに十分なはずです。
今後は、アプリに新機能を追加して、より高度なhiltの機能をさまざまな状況で使用する方法を学びます。
9. Qualifiers
https://codelabs.developers.google.com/codelabs/android-hilt/#8
プロジェクトからServiceLocatorクラスを削除しHiltの基本を学んだので、アプリに新しい機能を追加して他のHiltの機能を探ってみましょう。
このセクションでは次のことを学びます。
Activityコンテナへのスコープのかけ方。
Qualifiersとは何か、どのような問題を解決するのか、どのように使うのか。
これを示すために、アプリ内で別の動作が必要です。アプリのセッション中にのみログを記録することを意図して、データベースからインメモリリストにログのストレージを交換します。
LoggerDataSource interface
データソースをインターフェースに抽象化してみましょう。dataフォルダの下にLoggerDataSource.ktというファイルを新規作成し、以下の内容で作成します。
code:kotlin
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()
}
LoggerLocalDataSourceはButtonsFragmentとLogsFragmentの両方で使用されています。代わりにLoggerDataSource のインスタンスを使用するために、それらを使用するためにリファクタリングする必要があります。
LogsFragmentを開き、LoggerDataSource型のロガー変数を作成します。
code:kotlin
@AndroidEntryPoint
class LogsFragment : Fragment() {
@Inject lateinit var logger: LoggerDataSource
...
}
ButtonsFragmentも同様です。
code:kotlin
@AndroidEntryPoint
class ButtonsFragment : Fragment() {
@Inject lateinit var logger: LoggerDataSource
...
}
次に、LoggerLocalDataSourceにこのインターフェースを実装させてみましょう。data/LoggerLocalDataSource.ktファイルを開いてLoggerDataSourceインターフェイスを実装するようにしてメソッドをオーバーライドでマークします。
code:kotlin
@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というファイルを以下の内容で新規作成します。
code:kotlin
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()
}
}
Scoping to the Activity container
LoggerInMemoryDataSourceを実装として使用できるようにするには、この型のインスタンスを提供する方法をHiltに伝える必要があります。先ほどと同様に、クラスのコンストラクタに@Injectをアノテーションします。
code:kotlin
class LoggerInMemoryDataSource @Inject constructor(
) : LoggerDataSource { ... }
私たちのアプリケーションは1つのActivity(シングルアクティビティ・アプリケーションとも呼ばれます)だけで構成されているので、ActivityコンテナにLoggerInMemoryDataSourceのインスタンスを持ち、そのインスタンスを各Fragment間で再利用する必要があります。
LoggerInMemoryDataSourceをActivityコンテナにスコープすることで、in-memory logging動作を実現できます。各コンテナでは、依存関係として、またはフィールドインジェクションのためにロガーが必要とされるときに、LoggerInMemoryDataSourceの同じインスタンスが提供されます。また、コンポーネント階層以下のコンテナにも同じインスタンスが提供されます。
コンポーネントへのスコープのドキュメントに従って、Activityコンテナに型をスコープするには、型に@ActivityScopedをアノテーションする必要があります。
code:kotlin
@ActivityScoped
class LoggerInMemoryDataSource @Inject constructor(
) : LoggerDataSource { ... }
今のところ、HiltはLoggerInMemoryDataSourceとLoggerLocalDataSourceのインスタンスを提供する方法を知っていますが、LoggerDataSourceはどうでしょうか?HiltはLoggerDataSourceが要求されたときにどの実装を使えばいいのかわかりません。
前のセクションで知っているように、モジュールの@Bindsアノテーションを使用して、どちらの実装を使用するかをHiltに伝えることができます。しかし、同じプロジェクトで両方の実装を提供する必要がある場合はどうでしょうか?例えば、アプリの実行中にLoggerInMemoryDataSourceとLoggerLocalDataSourceを使用する場合です。
Two implementations for the same interface
diフォルダ内にLoggingModule.ktという新しいファイルを作成してみましょう。LoggerDataSourceの異なる実装は異なるコンテナにスコープされているので、同じモジュールを使うことはできません。LoggerInMemoryDataSourceはActivityコンテナに、LoggerLocalDataSourceはApplicationコンテナにスコープされています。
幸いなことに、先ほど作成した同じファイルで両方のモジュールのバインディングを定義することができます。
code:kotlin
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が型のバインディングとして使われている場合は、型内のスコーピングアノテーションはもう使われないので、実装クラス(LoggerLocalDataSource, LoggerInMemoryDataSource)から削除しても構いません。
ここでプロジェクトをビルドしようとすると、DuplicateBindingsのエラーが表示されます!
error: [Dagger/DuplicateBindings] com.example.android.hilt.data.LoggerDataSource is bound multiple times
これはLoggerDataSource型がFragmentに注入されているのですが、同じ型のバインディングが 2 つあるため、Hilt はどちらの実装を使えばいいのかわかりません。どのようにしてヒルトはどちらを使えばいいのかを知ることができるのでしょうか?
Using qualifiers
同じ型の異なる実装 (複数のバインディング) を提供する方法をHiltに伝えるには、Qualifiersを使うことができます。Qualifierは、バインディングを識別するために使用されるアノテーションです。
各Qualifierはバインディングを識別するために使用されるので、実装ごとにQualifierを定義する必要があります。Androidクラスに型を注入したり、他のクラスの依存関係として型を持つ場合は、曖昧さを避けるためにQualifierのアノテーションを使用する必要があります(bindingのメソッドがパラメータとして型を取る場合にもQualifierを付けましょうということ)。
Qualifierは単なるアノテーションなので、モジュールを追加したLoggingModule.ktファイルで定義することができます。
code:kotlin
package com.example.android.hilt.di
@Qualifier
annotation class InMemoryLogger
@Qualifier
annotation class DatabaseLogger
これらのQualifierは、それぞれの実装を提供する@Binds(必要な場合は@Provides)関数に注釈を付けなければなりません。コード全体を見て、@BindsメソッドでのQualifierの使用法に注目してください。
code:kotlin
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
}
重要: @DatabaseLoggerQualifierはApplicationComponentにインストールされているので、LogApplicationクラスに注入することができます。しかし、@InMemoryLoggerはActivityComponentにインストールされているので、Applicationコンテナはそのバインディングを知らないので、LogApplicationクラスに注入することはできません。
また、これらのQualifierは、注入したい実装とのinjectionポイントで使用する必要があります。この場合、FragmentsではLoggerInMemoryDataSourceの実装を使用します。LogsFragmentを開き、Loggerフィールドで@InMemoryLoggerの修飾子を使用して、LoggerInMemoryDataSourceのインスタンスを注入するようにHiltに指示します。
code:kotlin
@AndroidEntryPoint
class LogsFragment : Fragment() {
@InMemoryLogger
@Inject lateinit var logger: LoggerDataSource
...
}
ButtonsFragment にも同じようにします。
code:kotlin
@AndroidEntryPoint
class ButtonsFragment : Fragment() {
@InMemoryLogger
@Inject lateinit var logger: LoggerDataSource
...
}
使用するデータベースの実装を変更したい場合は、注入されたフィールドに@InMemoryLoggerの代わりに@DatabaseLogger をアノテーションする必要があります。
Running the app
アプリを起動してボタンと対話し、「すべてのログを見る」画面に表示される適切なログを観察することで、行ったことを確認することができます。ログはデータベースに保存されなくなりました。セッション間のログは保持されず、アプリを閉じて再度開くとログ画面は空になります。
https://gyazo.com/414c46c84dee99eacc8c1219202af688
10. UI Testing
https://codelabs.developers.google.com/codelabs/android-hilt/#9
アプリが完全にHiltに移行できたので、プロジェクト内にあるインストルメンテーションテストも移行します。アプリの機能を確認するテストは、app/androidTestフォルダにあるAppTest.ktファイルにあります。それを開いてみてください。
プロジェクトからServiceLocatorクラスを削除したためにコンパイルされないことがわかります。クラスから@After tearDownメソッドを削除して、使わなくなったServiceLocatorへの参照を削除します。
androitTestのテストはエミュレータ上で実行しています。happyPathテスト(例外的な条件やエラー条件を備えていないデフォルトのシナリオ)では、「ボタン1」のタップがデータベースに記録されていることを確認しています。アプリはインメモリデータベースを使用しているため、テスト終了後は全てのログが消えます。
UI Testing with Hilt
Hiltは、本番のコードで起こるようにUIテストに依存関係を注入します。
Hiltを使用したテストでは、テストごとに新しいコンポーネントのセットが自動的に生成されるため、メンテナンスが不要です。
Adding the testing dependencies
Hiltは、テスト固有のアノテーションを持つ追加のライブラリを使用しています。これは、コードのテストを容易にするために hilt-android-testingという名前で、プロジェクトに追加する必要があります。さらに、HiltはandroidTestフォルダ内のクラスのコードを生成する必要があるため、アノテーション プロセッサもそこで実行できる必要があります。これを有効にするには、app/build.gradle ファイルに 2 つの依存関係を含める必要があります。
これらの依存関係を追加するには、app/build.gradleを開き、依存関係セクションの一番下にこの設定を追加します。
code:app/build.gradle
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"
}
Custom TestRunner
Hiltを使用したInstrumentedテストは、HiltをサポートするApplicationで実行する必要があります。ライブラリにはすでに HiltTestApplicationが付属しており、これを使用して UIテストを実行することができます。テストで使用するApplicationを指定するには、プロジェクト内に新しいテストランナーを作成します。
androidTestフォルダの下にあるAppTest.ktファイルと同じ階層で、CustomTestRunnerという新しいファイルを作成します。CustomTestRunnerはAndroidJUnitRunnerを拡張したもので、以下のように実装されています。
code:kotlin
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の内容をこれに置き換えてください。
code:app/build.gradle
android {
...
defaultConfig {
...
testInstrumentationRunner "com.example.android.hilt.CustomTestRunner"
}
...
}
これで、UIテストでHiltを使用する準備が整いました!
Running a test that uses Hilt
次に、エミュレータのテストクラスがHiltを使用するためには、次の2点が必要です。
1. 各テストのHiltコンポーネントの生成を担当する@HiltAndroidTest`にアノテーションを付けます。
2. コンポーネントの状態を管理するHiltAndroidRuleを使用して、テストでインジェクションを実行します。
AppTestに追加してみましょう。
code:AppTest.kt
@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
class AppTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
...
}
さて、クラス定義やテストメソッド定義の横にある再生ボタンを使ってテストを実行すると、設定していればエミュレータが起動してテストが通過します。
フィールドインジェクションやテスト中のバインディングの置き換えなど、テストや機能の詳細については、ドキュメントをチェックしてください。
11. @EntryPoint annotation
https://codelabs.developers.google.com/codelabs/android-hilt/#10
このセクションでは、Hiltがサポートしていないクラスに依存関係を注入するために使用する@EntryPointアノテーションの使用方法を学びます。
前に見たように、Hiltは最も一般的なAndroidコンポーネントをサポートしています。しかし、Hiltが直接サポートしていないクラスやHiltを使用できないクラスでフィールドインジェクションを行う必要があるかもしれません。
そのような場合は@EntryPointを使用します。エントリポイントとは、Hiltを使用して依存関係を注入できないコードからHilt が提供するオブジェクトを取得できる境界の場所です。それは、コードが最初にHiltで管理されているコンテナに入るポイントです。
The use case
アプリケーションプロセスの外にログをエクスポートできるようにしたいと思います。そのためには、ContentProviderを使用する必要があります。ContentProviderを使用して、ユーザーが特定のログ(idを指定して)またはアプリからのすべてのログをクエリすることを許可しています。データを取得するためにRoomデータベースを使用します。したがって、LogDaoクラスは、データベースのカーソルを使用して必要な情報を返すメソッドを公開する必要があります。LogDao.ktファイルを開き、以下のメソッドをインターフェースに追加します。
code:kotin
@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というファイルを作成し、以下の内容で作成します。
code:kotlin
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 ApplicationコンテナからLogDaoを取得して実装する必要があります。 しかし、HiltはActivityのように@AndroidEntryPointを使用したContentProviderへのインジェクションをサポートしていません。
これにアクセスするためには、@EntryPointでアノテーションされた新しいインターフェイスを作成する必要があります。
@EntryPoint in action
エントリポイントは、必要なバインディング型(qualifierを含む)ごとにアクセサメソッドを持つインターフェイスです。また、エントリポイントをインストールするコンポーネントを指定するには、インターフェイスに@InstallInをアノテーションしなければなりません。
最善の方法は、それを使用するクラスの内部に新しいエントリポイントのインターフェイスを追加することです。そのため、LogsContentProvider.ktファイルにインターフェイスをインクルードします。
code:kotlin
class LogsContentProvider: ContentProvider() {
@InstallIn(ApplicationComponent::class)
@EntryPoint
interface LogsContentProviderEntryPoint {
fun logDao(): LogDao
}
...
}
インターフェイスには@EntryPointがアノテーションされており、ApplicationComponentにインストールされていることに注目してください。インターフェースの内部では、アクセスしたいバインディングのメソッドを公開しています。
エントリーポイントにアクセスするには、EntryPointAccessorsの適切なスタティックメソッドを使用します。パラメータには、コンポーネント・インスタンスまたはコンポーネント・ホルダーとして動作する@AndroidEntryPointオブジェクトのいずれかを指定します。パラメータとして渡すコンポーネントとEntryPointAccessors静的メソッドの両方が、@EntryPointインターフェースの@InstallInアノテーションのAndroidクラスと一致していることを確認してください。
さて、上のコードで足りないgetLogDaoメソッドを実装しましょう。上で定義したLOGSContentProviderEntryPointクラスのエントリーポイントインターフェースを使ってみましょう。
code:kotlin
class LogsContentProvider: ContentProvider() {
...
private fun getLogDao(appContext: Context): LogDao {
val hiltEntryPoint = EntryPointAccessors.fromApplication(
appContext,
LogsContentProviderEntryPoint::class.java
)
return hiltEntryPoint.logDao()
}
}
applicationContextを静的なEntryPoints.getメソッドと@EntryPointでアノテーションされているインターフェイスのクラスに渡す方法に注目してください。
12. Congratulations!
You're now familiar with Hilt and you should be able to add it to your Android app. In this codelab you learned about:
How to set up Hilt in your Application class using @HiltAndroidApp.
How to add dependency containers to the different Android lifecycle components using @AndroidEntryPoint.
How to use modules to tell Hilt how to provide certain types.
How to use qualifiers to provide multiple bindings for certain types.
How to test your app using Hilt.
When @EntryPoint is useful and how to use it.
#codelab #dagger #dagger_hilt #di #hilt