Androidで依存関係の注入(Dependency Injection : DI)を行う場合、第一候補に上がるのはDagger Hiltだと思います。
公式ドキュメントも充実しており、Codelabも提供されています。ただ、私の場合、自分のアプリにどうやって適用すればいいのかなかなかイメージができず、理解するのにかなり苦労してしまいました。
こういう説明をされていれば自分も理解が捗ったかもしれない、という内容を書いてみようと思います。
ただの自己満足なので、逆に分かりにくかったらごめんなさい。
インターフェイスと実装を分離する
依存関係の注入を使おうとする動機を端的に言えばインターフェイスと実装を分離したい。ということだと思います。
たとえば、以下のようにRepositoryクラスを定義して
class FooRepository {
override fun doSomething() {
// doSomething
}
}
以下のようにインスタンスを作成すると、利用箇所がこの実装クラスと密結合してしまいます。
val fooRepository: FooRepository = FooRepository()
これを避けるため、インターフェイスを定義し
interface FooRepository {
fun doSomething()
}
以下のように実装を分離したとしても
class FooRepositoryImpl : FooRepository {
override fun doSomething() {
// doSomething
}
}
利用箇所でこの実装クラスのコンストラクターを利用してしまうと、結局密結合になってしまいます。
val fooRepository: FooRepository = FooRepositoryImpl()
このような密結合関係ができると、FooRepositoryの実装を変更した際、利用箇所すべてを修正しなければならないなど、保守性や拡張性に問題が出てしまいます。
SOLIDの「Dependency Inversion Principle(依存関係逆転の原則)」にあるように、使う側が使われる側に依存するのは避けた方が良いです。インターフェイスにのみ依存するようにしましょう。
しかし、コンストラクターを呼び出さずにどうやって?を実現するひとつの方法が依存関係の注入です。
Dagger Hiltを使う準備をする
Hiltの使い方だけ知りたいという場合はこのセクションを読み飛ばしてください。
プロジェクトのbuild.gradle.kts
でkspとhiltの利用を宣言します。
plugins {
...
id ("com.google.devtools.ksp") version "1.9.23-1.0.20" apply false
id ("dagger.hilt.android.plugin") version "2.51.1" apply false
}
hiltはid指定で読み込めないため、settings.gradle.kts
のpluginManagementに以下の記述を追加しておきましょう。
pluginManagement {
...
resolutionStrategy {
eachPlugin {
when (requested.id.id) {
"dagger.hilt.android.plugin" ->
useModule("com.google.dagger:hilt-android-gradle-plugin:${requested.version}")
}
}
}
}
モジュールのbuild.gradle.kts
では以下のようにpluginの宣言とdependenciesへの追加を行います。
plugins {
...
id ("com.google.devtools.ksp")
id ("dagger.hilt.android.plugin")
}
dependencies {
...
implementation("com.google.dagger:hilt-android:2.51.1")
ksp("com.google.dagger:hilt-android-compiler:2.51.1")
}
すでにApplicationクラスを実装している場合は、そのクラスに、ない場合は中身が空でよいので作成し、@HiltAndroidApp
というアノテーションを付与します。
@HiltAndroidApp
class App : Application()
新規作成した場合は、AndroidManifest.xml
で指定するのを忘れずに。
<application
android:name=".App"
注入するインスタンスの作成方法をHiltに伝える
最初の説明で使用した、FooRepository
のインターフェイスにFooRepositoryImpl
のインスタンスを注入するという例で説明します。
interface FooRepository {
fun doSomething()
}
class FooRepositoryImpl : FooRepository {
override fun doSomething() {
// doSomething
}
}
そのためには、FooRepository
のインスタンスが要求されたとき、どのインスタンスを渡せば良いのか。また、そのインスタンスはどのように作るのか。をHiltに伝えておかねばなりません。
コンストラクターインジェクション
FooRepository
が実装クラスの場合、コンストラクターインジェクションによってインスタンスの作成方法をHiltに伝えられます。
コンストラクターインジェクションを行うにはコンストラクターに@Inject
を付与します。
class FooRepository @Inject constructor() {
引数がない場合も、@Inject
は必要です。
引数がある場合、Hiltがインスタンスを渡してくれます。そのためには作成方法をHiltが知っていなければなりません。
この記述は、コンストラクター引数にインスタンスを注入することによってこのクラスのインスタンスを作成できる、とHiltに伝えています。
しかし、FooRepository
がinterface
やabstruct class
の場合、インスタンスが作れませんので、この方法は使えません。
Hilt Module @Provides
今回はインターフェイスに実装クラスのインスタンスを注入するので、FooRepository
に対しては、FooRepositoryImpl
のインスタンスを注入すれば良いことと、FooRepositoryImpl
のインスタンスを作る方法をHiltに伝えます。
そのためにHilt Moduleを作成します。
@InstallIn(SingletonComponent::class)
@Module
class FooRepositoryModule {
@Provides
fun provideFooRepository(): FooRepository = FooRepositoryImpl()
}
ここではHilt Moduleは実装メソッドを持つため、class
で定義していますが、object
でも良いです。また、interface
でもデフォルトメソッドを持てるので、interface
やabstruct class
でも良いです。いずれでもHiltがよしなに使ってくれます。
Hilt Moduleとなるクラスには@Module
と@InstallIn
を付与します。@InstallIn
の引数には、このモジュールのインストール先コンポーネントを指定しますが、この説明は長くなるので後で説明します。この段階では@InstallIn(SingletonComponent::class)
と書けば最低限の動作はするぐらいに思っておいてください。
Hilt Moduleには@Provides
を付与したメソッドを定義しています。
この記述で、FooRepository
に注入するはこのメソッドの戻り値を渡せば良いと伝えています。
Hiltはインスタンスが必要になると、このメソッドを呼び出してインスタンスを取得するというだけなので、コンストラクター呼び出しでなくてもよいです。インラインクラスを書いたり、ファクトリーメソッドを呼び出してもよいです。
別管理のSingletonを返すということもできますが、インスタンスの寿命管理はHiltに任せた方がよいのでやむを得ない事情がない限り避けた方が良いでしょう。寿命管理については後ほど説明します。
@Provides
のメソッドは引数を取ることもできます。コンストラクターインジェクション同様、Hiltがインスタンスを注入するので、そのインスタンス作成方法をHiltが知っていなければなりません。
Hilt Module @Binds
もうひとつ@Binds
を使う方法があります。
以下のように記述します。
@InstallIn(SingletonComponent::class)
@Module
interface FooRepositoryModule {
@Binds
fun bindFooRepository(impl: FooRepositoryImpl): FooRepository
}
@Binds
を使う場合、メソッドは実装を持たないため、Hilt Moduleは具象クラスにはできません。abstruct class
かinterface
にします。どちらでもかまいません。
この記述で、FooRepository
に注入するのはFooRepositoryImpl
のインスタンスであることをHiltに教えています。FooRepository
のインスタンスが必要ならFooRepositoryImpl
のインスタンスを探せばよい、と伝えているわけです。
この方法ではインスタスの作成方法までは伝えられません。
前項までに説明した、コンストラクターインジェクションか、@Provides
でインスタンス作成方法をHiltに伝えなけらばなりません。
コンストラクターインジェクションを使う場合、FooRepositoryImpl
クラスを以下のようにしておきます。
class FooRepositoryImpl @Inject constructor() {
これで、FooRepository
に注入するのはFooRepositoryImpl
のインスタンス。
FooRepositoryImpl
のインスタンスはコンストラクターインジェクションで作れる。
これで情報がそろいます。
(コンストラクターが引数を取る場合、次はそのインスタンスを探して、と全部の情報がそろうまで探し続けます)
@Binds
は対応するインターフェイスの読み替えをしているだけなので、具象クラスを伝えなくても良いです。以下のようにFooRepository
→ FooBarRepository
→ FooBarRepositoryImpl
と指定することもできます。
interface FooRepository
interface FooBarRepository: FooRepository
class FooBarRepositorImpl(): FooBarRepository
@InstallIn(SingletonComponent::class)
@Module
interface FooRepositoryModule {
@Binds
fun bindFooRepository(impl: FooBarRepository): FooRepository
}
@InstallIn(SingletonComponent::class)
@Module
class FooBarRepositoryModule {
@Binds
fun provideFooBarRepository(): FooBarRepository = FooBarRepositoryImpl()
}
インスタンスのスコープを指定する
Hiltのデフォルトでは、依存関係の注入を行う度に新しいインスタンスが作成されます。
状態を持たないオブジェクトはそれで良いのですが、キャッシュを持っている、スレッドプールを持っている、リソースの排他制御が必要、など状態を持つオブジェクトを扱いたい場合や、インスタンスの作成コストが高い場合は都合が良くないです。
そういった場合は、インスタンスのスコープを指定すると、そのスコープ内でインスタンスを共有されるようになります。
インスタンスの紐付け方法それぞれで、スコープを指定するアノテーションをつけられます。
@InstallIn(SingletonComponent::class)
を指定している場合、@Singleton
が使えます。
@Singleton
を指定すると、プロセス内でひとつだけインスタンスが作られ、すべての注入箇所で共有されるようになります。
@Singleton
class FooRepository @Inject constructor() {
@InstallIn(SingletonComponent::class)
@Module
class FooRepositoryModule {
@Singleton
@Provides
fun provideFooRepository(): FooRepository = FooRepositoryImpl()
}
@InstallIn(SingletonComponent::class)
@Module
interface FooRepositoryModule {
@Singleton
@Binds
fun bindFooRepository(impl: FooRepositoryImpl): FooRepository
}
スコープ指定には@Singleton
以外にもありますが、@InstallIn
と併せて指定する必要があります。詳細は後で説明します。
依存関係を注入する場所をHiltに伝える
Androidクラスにアノテーションを付与する
Hiltは以下のAndroidクラスに対して依存関係を注入できます。
Androidクラス | アノテーション |
---|---|
Application | @HiltAndroidApp |
Service | @AndroidEntryPoint |
BroadcastReceiver | @AndroidEntryPoint |
Activity | @AndroidEntryPoint |
Fragment | @AndroidEntryPoint |
View | @AndroidEntryPoint |
ViewModel | @HiltViewModel |
依存関係の注入を行うには、注入を行いたいクラスと、その上位コンポーネントすべてにアノテーションを付与します。
付与するアノテーションは、Applicationは@HiltAndroidApp
、ViewModelは@HiltViewModel
、それ以外は@AndroidEntryPoint
です。
上位というのは、
- Applicationに注入したい場合は、Application
- Activityに注入したい場合は、そのActivityとApplication
- Fragmentに注入したい場合は、そのFragmentとattachされるActivityとApplication
- ViewModelに注入したい場合
- ActivityViewModelの場合は、使用するActivityとApplication
- FragmentViewModelの場合は、使用するFragmentと、そのFragmentがattachされるActivityとApplication
というイメージです。ServiceとBroadcastReceiverの上位はApplicationで、Viewは通常Activityですが、@AndroidEntryPoint
とともに@WithFragmentBindings
が付与している場合は、Fragmentになります。
フィールドインジェクションを使う
前述のAndroidクラスのうち、ViewModel以外ではフィールドインジェクションを使います。
たとえばActivityの場合、以下のように記述します。
@AndroidEntryPoint
class FooActivity : AppCompatActivity() {
@Inject
lateinit var fooRepository: FooRepository
このフィールドにFooRepository
のインスタンスを代入してくれ、と、Hiltに伝えています。
これだけで、実装クラスのインスタンスが注入されます。
lateinit
と書いているとおり、コンストラクターの段階では注入が完了していません。
注入されるのはsuper.onCreate
の実行後となりますので、これより前にアクセスできない点に注意しましょう。
コンストラクターインジェクションを使う
ViewModel
の場合、コンストラクターインジェクションを使います。
ViewModel
クラスに@HiltViewModel
アノテーションをつけ、コンストラクターに@Inject
を付与します。
@HiltViewModel
class FooViewModel @Inject constructor(
private val fooRepository: FooRepository,
) : ViewModel()
これで、HiltがViewModel
のインスタンスを作ってくれるようになり、コンストラクターに実装クラスのインスタンスを代入してくれます。
AndroidViewModel
でApplication
を受け取りたい場合や、SavedStateHandle
を使いたい場合も問題ありません。
以下のようにコンストラクター引数を列挙しておけば、適切な引数でViewModelのインスタンスを作成してくれます。
@HiltViewModel
class ActivityViewModel @Inject constructor(
application: Application,
savedStateHandle: SavedStateHandle,
private val fooRepository: FooRepository,
) : AndroidViewModel(application)
以上の方法で、依存関係を注入する場所をHiltに教えることができます。
Androidクラス以外で依存関係の注入を利用する
HiltがサポートしているAndroidクラス以外でもHiltが管理するインスタンスを使いたい場合があります。
その場合は、EntryPointインターフェイスを定義します、インターフェイスには@EntryPoint
を@InstallIn
と併せて付与します。
@InstallIn(SingletonComponent::class)
@EntryPoint
interface SomeClassEntryPoint {
fun fooRepository(): FooRepository
}
インスタンスはEntryPointAccessors
を使って以下のように指定します。
EntryPointに@InstallIn(SingletonComponent::class)
を指定した場合は、Applicationから取得します。以下のようにfromApplication
を使ってEntryPointのインスタンスを取得し、メソッドを呼び出すことで、Hilt管理のインスタンスを取得できます。この場合、引数はContextです。
EntryPointAccessors
.fromApplication<SomeClassEntryPoint>(this)
.fooRepository()
複数の箇所で利用する場合、EntryPointを共通化したくなりますが、EntryPointのインターフェイスが必要な理由は利用箇所にあるため、利用箇所ごとにEntryPointを定義すべきとされています。
EntryPointそのものでは無く、そのインターフェイスは共通化してもよいでしょう。
以下のように注入先ごとにProviderのインターフェイスを定義しておき、
interface FooRepositoryProvider {
fun fooRepository(): FooRepository
}
interface BarRepositoryProvider {
fun barRepository(): BarRepository
}
EntryPointにこれらProviderを継承させるという方法です。
@InstallIn(SingletonComponent::class)
@EntryPoint
interface SomeClassEntryPoint: FooRepositoryProvider, BarRepositoryProvider
スコープとコンポーネント
@InstallIn
で指定するコンポーネントと、スコープの関係性は少し複雑で、理解してしまえば簡単ですが、そこまでのハードルが高いように思います。私はここで躓いてしまいました。
EntryPointのインストール先コンポーネント
比較的分かりやすいのが、EntryPointのインストール先コンポーネントだと思います。ここから説明します。
Hiltが対応していないクラスでHilt管理のインスタンスを取得するために使用するのが、EntryPointです。
そのEntryPointは、Hilt対応クラスから取得します。EntryPointのインストール先コンポーネントは、その取得元となるAndroidクラスと一致させなければなりません。
対応関係は以下です。
Androidクラス | コンポーネント | EntryPointAccessorsのメソッド |
---|---|---|
Application | SingletonComponent | fromApplication |
Activity | ActivityComponent | fromActivity |
Fragment | FragmentComponent | fromFragment |
View | ViewComponent | fromView |
Applicationから取得したい場合は、SingletonComponent
にインストールし、fromApplication
で取得します。
Activityから取得したい場合は、ActivityComponent
にインストールし、fromActivity
で取得します。
Moduleのインストール先コンポーネント
Moduleのインストール先コンポーネントは、依存関係の注入をどの範囲で使うかを指定するもの、と理解するのがわかりやすいかと思います。
そして、この指定はスコープを扱う場合にとくに重要です。
Hiltの説明ページにもありますが、スコープ、コンポーネント、Androidクラスの対応関係は以下のようになっています。
Moduleのインストール先コンポーネントよりも下位の場所で利用できるようになります。
SingletonComponent
が根っこにありますが、SingletonComponent
を指定するのがもっとも利用範囲が広く、すべてのコンポーネントで利用できます。
ServiceComponent
を指定した場合、これより下位のコンポーネントはないため、Serviceでのみ利用できるという意味になります。
ActivityComponent
を指定した場合、Activity、Fragment、Viewで使用でき、ViewModelでは使用不可、となります。
インスタンスのスコープ
先にちらっと説明したように、Hiltのデフォルトでは注入ごとに新規インスタンスが作成されますが、ひとつのインスタンスを一定の範囲で共有したい場合はスコープを使います。
スコープを指定すると、その対応コンポーネント以下の範囲で同一インスタンスが注入されます。
たとえば、@ActivityScoped
を指定すると、Activityとその配下のFragment/Viewの間で同一インスタンスが注入されるようになります。
スコープの指定は、コンポーネントと一致させます。
たとえば、@ActivityScoped
はライフサイクルがActivityと同一なので、Activityより上位で使うことはできません。
つまり、@ActivityScoped
を使う場合は、@InstallIn(ActivityComponent::class)
を指定します。
コンストラクターインジェクションの場合もスコープの指定ができますが、@InstallIn
は使えません。
スコープに合わせて自動的に決定されるようで、@ActivityScoped
をつけたコンストラクターインジェクションのインスタンスは、Activityより上位で使えなくなります。
当然ながらスコープを設定するとインスタンスの寿命が長くなり、リソースを逼迫する可能性が出てきます。また、安易に状態を持たせてしまうと目に見えない依存関係が生まれてしまうなど、技術的負債になりかねません。スコープを設定するのは必要最小限となるようにしましょう。
ユースケースごとの対応
Contextを注入したい
Androidの開発をする上で、どうしても必要になってしまうのがContextですね。
HiltはデフォルトでContextを知っており、注入してくれます。
@ApplicationContext
を指定すると、ApplicationContextが渡されます。
class FooRepository @Inject constructor(
@ApplicationContext
context: Context,
) {
他に、@ActivityContext
を指定することで、Activityをcontextとして受け取ることもできます。
ただし、インストール先コンポーネントがActivityComponent
でなければ使えません。
依存関係の注入を遅延実行したい
フィールドインジェクションにせよ、コンストラクターインジェクションにせよ、そのクラスの処理が開始した段階でインスタンスが作成されます。しかし、ある処理のあとにインスタンスを作ってほしい、という場合もあります。
Applicationで注入されたインスタンスを使いたいが、そのインスタンスは、ログ出力やデバッグ用の設定が終わってから作ってほしい。ということはよくあるのではないでしょうか?
その場合、Lazyを使うことで対応できます。注入してほしい型を型引数として指定したLazy型に注入します。
@Inject
lateinit var fooRepository: Lazy<FooRepository>
Lazyが渡された段階では、まだインスタンスは作られていません。
get()
が最初にコールされたときにインスタンスが作成されます。以降のget()
呼び出しでは同じインスタンスが返るようになります。
注意点として、KotlinでLazy
と書くと、kotlin.reflect.KProperty.Lazy
になってしまいます。import dagger.Lazy
を忘れないようにしましょう。
複数のインスタンスを提供して使い分けたい
Contextの注入で、アノテーションによって注入されるcontextの種類を指定しました。
同様に、ひとつのインターフェイスに対して複数のインスタンスを提供し、注入するインスタンスをアノテーションによって使い分けるということもできます。
たとえば、以下のように、Foo
インターフェイスに対して、FooBar
とFooBaz
の2つの実装があるとします。
interface Foo
class FooBar: Foo
class FooBaz: Foo
どちらのインスタンスを使用するかのアノテーションを定義し、それぞれのアノテーションに対応するインスタンスを提供するModuleを作ります。
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class Bar
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class Baz
@InstallIn(SingletonComponent::class)
@Module
class FooModule {
@Bar
@Provides
fun provideFooBar(): Foo = FooBar()
@Baz
@Provides
fun provideFooBaz(): Foo = FooBaz()
}
利用箇所で、@Inject
に加えてここで定義したアノテーションを指定することで、注入されるインスタンスを変更できます。
@Inject
@Bar
lateinit var fooBar: Foo
@Inject
@Baz
lateinit var fooBaz: Foo
同一クラスでもパラメーターの異なるインスタンスを出し分けたい場合にも適用できます。
Multibindingを行いたい
これが必要となるケースはあまりないかもしれませんが、ちょっとはまったので説明します。
特定のイベントを受け取るインスタンスを複数のモジュールから提供したい、のように、複数のインスタンスをコレクションに注入したいという場合もあるでしょう。
その場合、@IntoSet
(Setの一要素としてインスタンスを提供する)や@ElementsIntoSet
(Setに追加する要素を複数提供する)が使えます。
@InstallIn(SingletonComponent::class)
@Module
class FooModule {
@IntoSet
@Provides
fun provideFooBar(): Foo = FooBar()
@ElementsIntoSet
@Provides
fun provideFooBaz(): Set<Foo> = setOf(FooBaz(), FooQux())
}
注入を受け取る側は、以下のようにSetで受け取ります。
@Inject
lateinit var fooSet: Set<@JvmSuppressWildcards Foo>
こうすることで、FooBar
FooBaz
FooQux
の3つのインスタンスがfooSet
に注入されます。
ポイントはSetの型引数についいているアノテーションです。
注入するインスタンスの型と注入先が一致していれば何もしなくて良いのですが、
サブクラスやインターフェイスの実装クラスを渡す場合は、型が一致せずエラーになってしまいます。
それを回避するため型引数に@JvmSuppressWildcards
をつけています。
Multibindingの詳細については以下を参照
以上です。