依存性注入 (Dependency Injection) を使う動機について
「Carは Engineを使用する」 という仕様があったとして、
class Car {
val engine = Engine()
}
みたいに書くと、 Engineの挙動に色々障害を起こさせてテストするのにいちいちコードを修正しなきゃいけなくって、それを書き戻すことを忘れたら悲惨なことになるよね、という話がある。
class Car {
// val engine = Engine()
val engine = EngineWithTrouble() // 特定の条件で失敗するEngineのテスト。テストが終わったらもとに戻すこと
}
テスト目的以外でも、Engineが抽象クラスで、普通のエンジン、水素エンジンを使い分けたいってこともあるかもしれない。これも Carのコードを修正することになる。
class Car {
// val engine = Engine()
val engine = HydrogenEngine()
}
いちいち Carのコードをいじってるのは落ち着きがない。こういう実験をしているとどこかでうっかり元に戻すのを忘れて、リリースしてから大慌てすることになる。
依存性注入 (Dependency Injection)
上記の問題をどう解決するかというと、方法は2つ。
- Carのコンストラクタで Engineを渡す
- 直接代入ではない別の方法で engineフィールドに Engineをセットする
これらを総称して「依存性の注入 (Dependency Injection)」という名前が付けられている。
前者を「コンストラクタインジェクション」、後者を「フィールドインジェクション」という。
コンストラクタインジェクションはこう
// Carの外で作った Engineのインスタンスが渡される
class Car(private val engine: Engine) {
}
フィールドインジェクションはこう
class Car {
lateinit var engine = <Engineのインスタンスをどこかで作り、ここにセットされる>
}
いずれにしても、「Engineは Carの外で用意して Carに与える」ということである。 EngineWithTrouble や HydrogenEngine への切り替えは外で行うことになるので、 Carは書き換える必要がなくなる。
となると、次に問題となるのは
- 誰が Engineを作るのか?
- どうやってそれを Carに渡すのか?
ということ。
Hilt
この問題を解決するため、 Androidには Hiltというライブラリが用意されている。
どうやって Engineを Carに与えるのか
Hiltを使うと、先程の「どうやってそれを Carに渡すのか?」という問題は以下のように解決できる。
コンストラクタインジェクションを使って Engineを Carに注入するにはこう書く。
class Car @Inject constructor (private val engine: Engine) {
}
フィールドインジェクションを使って Engineを Carに注入するにはこう書く。
class Car {
@Inject lateinit var engine : Engine
}
ここにオブジェクトを入れて欲しい」という箇所に @Inject
という修飾子を付けるだけ。
※ hiltの制約で、privateメンバにはフィールドインジェクションは行えない。
誰が Engineを作るのか
次の問題は「誰が Engineを作るのか?」ということだが、これは Engineクラスがどう提供されているかで方法が変わる。
状況 | 対応 |
---|---|
クラスのコードが自分の管理下にある場合 | Injectアノテーション |
コードは管理下にあるけど、classでなく interfaceのサブクラスを提供したい場合 | (bindするための)モジュールを作る |
他の人が作ったライブラリのオブジェクトを提供したい場合 | (provideするための)モジュールを作る |
クラスのコードが自分の管理下にある場合
コンストラクタに Injectアノテーションを付ける。
class Engine @Inject constructor() { ... }
インターフェースを具象化したサブクラスを提供したい場合
Engineが interfaceの場合、上記の方法が使えない。
interface Engine { ... } // ← これのインスタンスを作ることが出来ない
class GasolineEngine : Engine { ... }
class HydrogenEngine : Engine { ... }
「Engineを要求されたら HydrogenEngineを返すようにしたい」ときにはどうしたらよいか。
こういうときは、オブジェクトの作り方を指示する モジュール を作る。
モジュールはオブジェクトをどう作るかの指示書みたいなもの。
interface Engine { ... }
class GasolineEngine @Inject constructor() : Engine { ... }
class HydrogenEngine @Inject constructor() : Engine { ... }
@InstallIn(SingletonComponent::class)
@Module
abstract interface EngineModule {
@Binds
abstract fun bindEngine(engine : HydrogenEngine) : Engine
}
「@Module
を付けた abstract classを宣言する」「要求される型、それに対して返す実装の型の組み合わせを @Binds
で定義する(バインドする)」が鍵。上記の例の場合、 「Engineを要求されたら HydrogenEngineを返す」ようバインドしている」ことを意味する。
書き方がちょっと直感的でないがこういうものらしい。
バインドの定義はこのように bindXYZ のような関数で書くのが習わし。
バインドはモジュールの中でいくつでも行える。
@InstallIn
は 「このモジュールをインストールする先のコンポーネント」を意味する。これは後述する。SingletonComponent
についても後で。
- Engineの生成に引数が必要なときは? → 後述
- Engineの生成にcontextが必要なときは? → 後述
他の人が作ったライブラリのオブジェクトを提供したい場合
ソースコードを書き換えることができないライブラリのオブジェクトを提供したい場合は、 モジュールのオブジェクト を作ることで対応する。
@InstallIn(SingletonComponent::class)
@Module
object EngineModule {
@Provides
fun provideEngine() = HydrogenEngine()
}
@Provides
はModuleの中にいくつあってもいい。
Engineの生成に引数が必要なんだけど?
Engineの生成に他のオブジェクト(以下の場合 Plug)が必要という場合は普通にある。
class Engine (val plug : Plug) { ... }
その場合、必要なクラスも @Inject
の対象にする。
コンストラクタを定義できるなら、こう
class Engine @Inject constructor(val plug : Plug) { ... }
class Plug @Inject constructor() { ... }
Plugが interfaceなら、こう
interface Plug { ... }
class PlugImpl @Inject constructor() : Plug { ... }
@InstallIn(SingletonComponent::class)
@Module
abstract interface EngineModule {
@Binds
abstract fun bindEngine(engine : GasolineEngine) : Engine
@Binds
abstract fun bindPlug(plug : PlugImpl) : Plug
}
上記の例はすべてのバインドを EngineModuleに書いたが、PlugModuleを新たに定義してもいい。この辺の粒度は、何をどこまで分離してテストしたいかによって変わる。チームで相談して理解しやすい大きさにまとめると良い。
Engineの生成にcontextが必要なんだけど?
@ApplicationContext
アノテーションを使う。
@ActivityContext
というアノテーションもある。必要な方を。
interface Engine { ... }
class GasolineEngine @Inject constructor(@ApplicationContext appContext: Context) : Engine { ... }
class HydrogenEngine @Inject constructor(@ApplicationContext appContext: Context) : Engine { ... }
@Module
object EngineModule {
@Provides
fun provideEngine(@ApplicationContext appContext: Context) {
return Engine(appContext)
}
}
コンポーネント
(注:この辺ちょっと理解が怪しい)
Androidの場合、このような依存性の注入は特定のクラスを起点にして行われる。特定のクラスとは Application, Activity, Fragment, ViewModelなどである。
それらのクラスに対して、「コンポーネント」が用意される。コンポーネントは先に説明したモジュールを管理し、クラスがオブジェクトを要求したときにモジュールの中から適切なオブジェクト生成方法(bind, prodiveなど)を選んでクラスに提供する。
モジュールをどのコンポーネントに所属させるかを指定するのが @InstallIn
アノテーション。
よく使いそうなのは以下の通り
- SingletonComponent ... Applicationのサブクラス。アプリケーションの生成時から最後まで存在する
- ActivityComponent ... アクティビティのライフサイクルと同期する
- ViewModelComponent ... ViewModelのライフサイクルと同期する
- FragmentComponent ... Fragmentのライフサイクルと同期する
以下、公式より引用。
Hilt コンポーネント | インジェクションの対象 |
---|---|
SingletonComponent | Application |
ActivityRetainedComponent | なし |
ViewModelComponent | ViewModel |
ActivityComponent | Activity |
FragmentComponent | Fragment |
ViewComponent | View |
ViewWithFragmentComponent |
@WithFragmentBindings アノテーションが付いた View |
ServiceComponent | Service |
依存性を注入したいオブジェクトと注入先を意識して、適切なコンポーネントを選択する必要がある。とはいっても SingletonComponent, ViewModelComponent, FragmentComponent で大概のアプリは対処できると思う。
スコープ
Hiltで依存性を注入する際、何も指定しないとバインド関数は「スコープ設定されていない」状態にある。この状態では、依存関係がリクエストされるたびに新しいインスタンスが生成される。
それでは困る場合もある。代表的なのは Singletonだろう。アプリケーションでただ一つのインスタンスを用意したいのに毎回別のオブジェクトが作られたら困る。
そのために Hiltでは「スコープ」を使って、以下のように書く。 @Singleton
がスコープを指定している。
@InstallIn(SingletonComponent::class)
@Module
object EngineModule {
@Singleton
@Provides
fun provideEngine() = Engine()
}
SingletonComponent
で provideするメソッド provideEngineには @Singleton
をつけるのは冗長では? と感じるが、こう書く必要がある。
多分 SingletonComponent というコンポーネントの命名自体に問題がある。 ApplicationComponentとでも付けておけば混乱も起きなかっただろうに
...という疑問を ChatGPTにぶつけてみた。真偽はわからない → https://chatgpt.com/share/672eb166-5940-8006-a6e1-941544e1dca5
ActivityScoped, FragmentScopedなどもあるらしい (使ったことないから知らない)。多分おなじアクティビティ(フラグメント)で要求されたら、同じオブジェクトを返してくれるのだと思われる。
GasolineEngine と HydrogenEngine を同時に提供したいんだけど
参考 : https://developer.android.com/codelabs/android-hilt#8
こんな Engineのクラスを定義したとする。
interface Engine {}
class GasolineEngine @Inject constructor() : Engine {
override fun toString(): String {
return "GasolineEngine"
}
}
class HydrogenEngine @Inject constructor() : Engine {
override fun toString(): String {
return "HydrogenEngine"
}
}
GasolineEngine, HydrogenEngine を同時に提供したいときは 修飾子(Qualifier) をつける。
モジュールの定義は以下のようにする。
@Qualifier
annotation class Gasoline
@Qualifier
annotation class Hydrogen
@InstallIn(SingletonComponent::class)
@Module
abstract interface EngineModule {
@Gasoline
@Binds
abstract fun bindGasolineEngine(engine: GasolineEngine): Engine
@Hydrogen
@Binds
abstract fun bindHydrogenEngine(engine: HydrogenEngine): Engine
}
そしてインジェクトするところで @Gasoline
, @Hydrogen
を指定する。
@HiltAndroidApp
class MyApplication @Inject constructor() : Application() {
@Gasoline
@Inject
lateinit var gasolineEngine: Engine
@Hydrogen
@Inject
lateinit var hydrogenEngine: Engine
override fun onCreate() {
super.onCreate()
println(gasolineEngine.toString())
println(hydrogenEngine.toString())
}
}
仕上げ : エントリポイント
Hiltのルールとして、HiltAndroidApp
アノテーションを付けた Applicationのサブクラスを持つ必要がある。 manifestへの登録も忘れずに。
@HiltAndroidApp
class MyApplication : Application() { ... }
最後に、依存性を注入したいクラスにエントリポイント @AndroidEntryPoint
もしくは @HiltViewModel
を設定する。
@AndroidEntryPoint
class MainActivity : AppCompatActivity() { ... }
@AndroidEntryPoint
class MainFragment : Fragment() { ... }
@HiltViewModel
class MainViewModel : ViewModel() { ... }
その他
テストの方法はどうするの? とか、Hilt でサポートされていないクラスに依存関係を注入する必要もある、といった場合もあるが割愛する。そのへんは公式のドキュメントなどを見てほしい。
参考文献
- https://developer.android.com/training/dependency-injection/hilt-android
- https://developer.android.com/codelabs/android-hilt
- https://a4rcvv.net/hilt-android/
- https://aakira.app/blog/2020/05/dagger-hilt/
余談 : 「依存性注入」って名前が分かりにくい
この名前自身が理解の妨げになってるんじゃないのかと思うことがしばしばある。そういうことを考えていたのは自分だけではないようだ。