依存関係の注入(Dependency Injection : DI)を使うと、モジュール間を疎結合に保つことができる、テストが容易になる、などさまざまなメリットがあります。
基本的な使い方は以下の記事を参照ください。
DIを使うと、Injectするインスタンスを自由に変更できるので、デバッグビルドとリリースビルドで動作を変更することもできます。ここではその方法を説明します。
方法1. debug/release に異なる内容のModuleを配置する
DIに限ったことではないですが、ビルドバリアントごとにソースファイルを分けることができます。デフォルトでは src/mainが共通、src/debugがデバッグビルド時のみ、src/releaseがリリースビルド時のみ、ビルド対象となります。
src/debugとsrc/releaseに同一FQCNのクラス(パッケージ名、クラス名が同一のクラス)を配置することで、ビルドバリアントごとにクラスの実装を差し替えることができます。
これを利用して異なるModuleを配置することで提供するインスタンスを変更できます。(Moduleの場合、提供する型で依存関係の解決を行うため、同一FQCNである必要はありません)
@InstallIn(SingletonComponent::class)
@Module
object DebugModule {
@Singleton
@Provides
fun providesDebug(): DebugRepository = ActualDebugRepository()
}
@InstallIn(SingletonComponent::class)
@Module
object DebugModule {
@Singleton
@Provides
fun providesDebug(): DebugRepository = NoopDebugRepository()
}
こうすることで、デバッグビルドではActualDebugRepositoryが、リリースビルドではNoopDebugRepositoryがインジェクトされ、挙動を変更できます。
これが一番実現の簡単な方法かと思いますが、個人的にはあまりオススメしません。
理由は、AndroidStudioでの開発中は特定のビルドバリアントしか参照できないからです。
通常はdebugバリアントで作業をしていると思います。この状態では、src/debugのファイルのみ参照され、src/release配下の同名ファイルは直接参照できず「見えない」状態になります。当該クラスの編集時、release側の存在に気づかず変更を行ってしまい、リリースビルドができない。または、リリースビルドの場合にのみ問題が発生する。といった問題がよく起こります。
「見えない」ソースの存在を意識し続けることは難しく、問題が起こりやすい状況といえます。
可能であれば、デバッグ時のみ実装を「追加する」だけで実現したいものです。
方法2. Optional Bindingを使う
デバッグ時の動作を変更することが目的なら、リリースビルド時はそれがない状態を作れれば良いはずです。
デバッグ時にのみインスタンスがインジェクトされ、リリースビルド時は何もインジェクトされない、という動作を実現するにはOptional Bindingの仕組みを使います。
mainソースに、DebugRepositoryがオプション(任意)の依存関係であることをHiltに伝えるためのModuleを配置します。(これがないと、releaseビルド時にDebugRepositoryの提供元が見つからず、ビルドエラーとなってしまいます。@BindsOptionalOfは「この型はインジェクトされない場合もある」ということをHiltに宣言する役割を持ちます)
@InstallIn(SingletonComponent::class)
@Module
interface DebugOptionalModule {
@BindsOptionalOf
fun bindsOptional(): DebugRepository
}
debugソースにインスタンスを提供するModuleを配置します
@InstallIn(SingletonComponent::class)
@Module
object DebugModule {
@Singleton
@Provides
fun providesDebug(): DebugRepository = ActualDebugRepository()
}
Injectionを受ける側はjava.util.Optionalを使って宣言します。
@Inject
lateinit var repository: Optional<DebugRepository>
...
if (repository.isPresent) {
...
}
こうすることで、デバッグビルド時のみインスタンスがあり、リリースビルド時は空、という状態を作ることができます。
なお、Optionalは0 or 1であり、2つ以上のインスタンスが提供された場合はビルドエラーとなります。
Kotlinでの開発では java.util.Optional を扱うことは少なく、Nullable型を期待してしまうところですが、Nullable型を直接インジェクトすることはサポートされていません。また、 java.util.Optional が扱えるのは Java 8 以降であり、AndroidではAPI 24以上が必要です。
そのため、こちらの方法よりも次項の方法をオススメします。
方法3. Multi Bindingを使う
0 or 1 のインジェクションであれば前項のOptional Bindingで実現できますが、Android開発では少し扱いにくいです。また、状況によっては2つ以上のインスタンスを渡したい、という場合もあるでしょう。
たとえば、OkHttpのInterceptorとして、デバッグ時にはロギングと、レスポンスのチェッカーと、APIのリクエストや応答の差し替え、といったように複数のInterceptorを差し込みたいというのはよくあることかと思います。
そういった場合はMultiBindingが使えます。
(1つ以上のインスタンスが必ず提供される場合は不要ですが)
以下のようにmainソースに、デフォルトで空のSetを提供するModuleを配置します。
これにより、何も提供されなかった場合でも、Set自体はインジェクト可能な状態となり、インジェクションエラーを防げます。
@InstallIn(SingletonComponent::class)
@Module
object DebugOptionalModule {
@Provides
@ElementsIntoSet
fun provideEmpty(): Set<DebugRepository> = emptySet()
}
デバッグ時のみ追加したいインスタンスをdebugソース上で以下のように実装します。
@InstallIn(SingletonComponent::class)
@Module
object DebugModule {
@Singleton
@Provides
@IntoSet
fun providesDebug(): DebugRepository = ActualDebugRepository()
}
複数のインスタンスを提供する場合、複数箇所でModuleを実装しても良いですし
@InstallIn(SingletonComponent::class)
@Module
object DebugModule1 {
@Singleton
@Provides
@IntoSet
fun providesDebug(): DebugRepository = ActualDebugRepository1()
}
@InstallIn(SingletonComponent::class)
@Module
object DebugModule2 {
@Singleton
@Provides
@IntoSet
fun providesDebug(): DebugRepository = ActualDebugRepository2()
}
1つのModuleで、複数@IntoSetを使っても良いです。
@InstallIn(SingletonComponent::class)
@Module
object DebugModule {
@Singleton
@Provides
@IntoSet
fun providesDebug1(): DebugRepository = ActualDebugRepository1()
@Singleton
@Provides
@IntoSet
fun providesDebug2(): DebugRepository = ActualDebugRepository2()
}
Setでまとめて、@ElementsIntoSetを使うこともできます。
@InstallIn(SingletonComponent::class)
@Module
object DebugModule {
@Singleton
@Provides
@ElementsIntoSet
fun providesDebug(): Set<DebugRepository> = setOf(
ActualDebugRepository1(),
ActualDebugRepository2(),
)
}
インジェクトを受ける側はSetで受け取るようにします。
型引数についている@JvmSuppressWildcardsは、KotlinからJavaバイトコードに変換される際、自動的にSet<? extends DebugRepository>のようにワイルドカード(? extends ...)が付与されてしまい、Hiltが要求する型 (Set<DebugRepository>) と厳密に一致せずインジェクトできなくなる問題を防ぐために付与します。
@Inject
lateinit var repositories: Set<@JvmSuppressWildcards DebugRepository>
MultiBindingでは複数のインスタンスがインジェクトされる場合もあるため、0 or 1 のインスタンスを期待する場合は以下のように利用します。
if (repositories.size >= 2) throw IllegalStateException()
repositories.firstOrNull()?.let {
...
}
複数インジェクトされることも期待する場合は、forEachなどで処理します。
repositories.forEach {
...
}
注意点として、MultiBindingではSetで提供されるように、複数のインスタンスの順序が保証されません。順序を制御したい場合は、priorityなどの情報を付与したデータクラスを渡し、利用側でソートするなどの工夫が必要です。
以上です。