プロジェクトでの Hiltの採用にあたり、 Daggerで全く理解できてなかった DIについて自習したときのメモ。
間違いがあったら指摘してください。
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のコードをいじってるのはなんか落ち着きがない。
それでいいじゃんって割り切ってしまうのもありだろうけど、Carのコードには手を加えず他のところから「この Engineを使え」って指示を受ける方が見通しが良くならない? という考えもわかる。「使うエンジンの種類を知りたければ指示書を確認してくれ」というのは筋が通っている。
自分のような古い人間には「元のコードにベタで書いてるほうが間違いないでしょう...そんなにコード量も増えないよ?」という引っかかりがとても強い。 けど、「テストコードを間違えて混入してしまう」という問題にはベタ書きでは対処できないのは理解できる。
Dependency Injection
上記の問題をどう解決するかというと、方法は2つ。
- Carのコンストラクタで Engineを渡す
- 直接代入ではない別の方法で engineフィールドに Engineをセットする
前者を「コンストラクタインジェクション」、後者を「フィールドインジェクション」という。
これらを総称して「依存性の注入 (Dependency Injection)」という名前が付けられている。
コンストラクタインジェクションはこう
class Car(private val engine: Engine) {
}
フィールドインジェクションはこう
class Car {
private val engine = <Engineのインスタンスをどこかで作り、ここに挿入する>
}
いずれにしても、「Engineは Carの外で作る」という前提がある。
となると、次に問題となるのは
- 誰が Engineを作るのか?
- どうやってそれを Carに渡すのか?
ということ。
Hiltという Dependency Injectionライブラリ
Androidには Hiltという Dependency Injectionのライブラリが(かなり前に)追加された。
Hiltは Daggerという Dependency Injectionのライブラリを土台にして作られている。 Daggerは結構前から使われていて安定性も良く人気があるが、理解するハードルが高く 「勉強する手間の割に得られる利益が少ない」というものだった。少なくとも自分には。
Hiltはそんな Daggerの難しいところを、使う上での決まり事をいくつか増やすことで比較的簡単に使えるようにしたもの、と理解している。
Hiltを使うと、先程の「どうやってそれを Carに渡すのか?」という問題は以下のように解決できる。
「ここにオブジェクトを入れて欲しい」という箇所に @Inject
という修飾子を付けるだけ。
class Car @Inject constructor (private val engine: Engine) {
}
class Car {
@Inject val engine : Engine
}
これで問題の半分は解決した。あとは「誰が Engineを作るのか?」ということだが、これは Engineの生成方法次第で、方法がいくつかに分かれている。
- ソースコードが全部自分の管理下にある場合
- コードは管理下にあるけど、classでなく interfaceのサブクラスを提供したい場合
- 他の人が作ったライブラリのオブジェクトを提供したい場合
ソースコードが全部自分の管理下にある場合
コンストラクタに Injectアノテーションを付ける。
class Engine @Inject constructor() { ... }
interfaceのサブクラスを提供したい場合
Engineが interfaceの場合、コンストラクタが存在しないので上記の方法が使えない。
interface Engine { ... } // ← constructorを定義できない
class GasolineEngine : Engine { ... }
class HydrogenEngine : Engine { ... }
こういうときは、オブジェクトの作り方を指示する モジュール を作る。
モジュールはオブジェクトをどう作るかの指示書みたいなもの。
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
で定義する」が鍵。
@Binds
はModuleの中にいくつあってもいい。
@InstallIn
は「このモジュールをインストールするコンポーネント」だが、これは後述する。SingletonComponentについても後で。
- Engineの生成に引数が必要なときは? → 後述
- Engineの生成にcontextが必要なときは? → 後述
他の人が作ったライブラリのオブジェクトを提供したい場合
Engine自体が外で作られたライブラリだと、GasolineEngine、HydrogenEngine のコンストラクタ宣言に Injectを追加できない。こういうときは、モジュールのオブジェクト を作ることで対応する。
@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)
}
}
とか。
コンポーネント
オブジェクト生成方法の指示書であるモジュールは、必ず コンポーネント に所属する、というルールがある。コンポーネントは何種類かあり、どのコンポーネントに所属させるかを指定するのが @InstallIn
アノテーション。
よく使いそうなのは以下の通り
- SingletonComponent ... Applicationのサブクラス。アプリケーションの生成時から最後まで存在する。
- ActivityComponent ... アクティビティのライフサイクルと同期するコンポーネント
- ActivityRetainedComponent ... ViewModelのライフサイクルと同期するコンポーネント
- FragmentComponent ... Fragmentのライフサイクルと同期するコンポーネント
完全なリスト → https://developer.android.com/training/dependency-injection/hilt-android#generated-components
スコープ
Hiltは基本的に要求があれば指示書を元にオブジェクトを生成するが、アプリケーションで唯一のオブジェクト(要するにシングルトン)が欲しいんだけど、という場合がある。そういうときはスコープを調整する。
以下の例では、 @Singleton
アノテーションを使うことで、 アプリケーション中で Engineオブジェクトは一個だけになり、必要とされる場所で同じオブジェクトが使われる。
@InstallIn(SingletonComponent::class)
@Module
object EngineModule {
@Singleton
@Provides
fun provideEngine() = Engine()
}
ActivityScoped, FragmentScopedなどもあるらしい (使ったことないから知らない)。多分おなじアクティビティ(フラグメント)で要求されたら、同じオブジェクトを返してくれるのだと思われる。
→ https://developer.android.com/training/dependency-injection/hilt-android#component-scopes
GasolineEngineも HydrogenEngineも提供したいんだけど
識別子を定義して、モジュールと使用箇所でそれを指定する。
→ https://developer.android.com/codelabs/android-hilt#8
仕上げ : エントリポイント
Hiltのルールとして、HiltAndroidApp
アノテーションを付けた Applicationのサブクラスを持つ必要がある。 manifestへの登録も忘れずに。
@HiltAndroidApp
class MyApplication : Application() { ... }
最後に、依存性を注入したいクラスにエントリポイント @AndroidEntryPoint
もしくは @HiltViewModel
を設定する。
@AndroidEntryPoint
class MainActivity : AppCompatActivity() { ... }
@AndroidEntryPoint
class MainFragment : Fragment() { ... }
@HiltViewModel
class MainViewModel : ViewModel() { ... }
その他
テストの方法はどうするの? とか、Hilt でサポートされていないクラスに依存関係を注入する必要もある、といった場合もあるが割愛する。そのへんは参考文献を見てほしい。