LoginSignup
7
6

Dagger Hiltを使いましょう

Posted at

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の利用を宣言します。

build.gradle.kts
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に以下の記述を追加しておきましょう。

settings.gradle.kts
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への追加を行います。

app/build.gradle.kts
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 というアノテーションを付与します。

App.kt
@HiltAndroidApp
class App : Application()

新規作成した場合は、AndroidManifest.xmlで指定するのを忘れずに。

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に伝えています。

しかし、FooRepositoryinterfaceabstruct 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でもデフォルトメソッドを持てるので、interfaceabstruct 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 classinterfaceにします。どちらでもかまいません。

この記述で、FooRepositoryに注入するのはFooRepositoryImplのインスタンスであることをHiltに教えています。FooRepositoryのインスタンスが必要ならFooRepositoryImplのインスタンスを探せばよい、と伝えているわけです。

この方法ではインスタスの作成方法までは伝えられません。
前項までに説明した、コンストラクターインジェクションか、@Providesでインスタンス作成方法をHiltに伝えなけらばなりません。

コンストラクターインジェクションを使う場合、FooRepositoryImplクラスを以下のようにしておきます。

class FooRepositoryImpl @Inject constructor() {

これで、FooRepositoryに注入するのはFooRepositoryImplのインスタンス。
FooRepositoryImplのインスタンスはコンストラクターインジェクションで作れる。
これで情報がそろいます。
(コンストラクターが引数を取る場合、次はそのインスタンスを探して、と全部の情報がそろうまで探し続けます)

@Bindsは対応するインターフェイスの読み替えをしているだけなので、具象クラスを伝えなくても良いです。以下のようにFooRepositoryFooBarRepositoryFooBarRepositoryImpl と指定することもできます。

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のインスタンスを作ってくれるようになり、コンストラクターに実装クラスのインスタンスを代入してくれます。
AndroidViewModelApplicationを受け取りたい場合や、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インターフェイスに対して、FooBarFooBazの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の詳細については以下を参照


以上です。

7
6
0

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
7
6