近々Dagger Hiltについて話す機会があるので、少し調べています。
また、最近のGoogleの動画からいろいろインプットしていっています。
Deep Diveですが初心者にも分かりやすいのではと思いました。1時間半ある動画でしたが、かなり気になるところを色々話してくれています。個人的にDaggerの説明でよく使われるCoffeeShopの例よりも100倍分かりやすい例えで説明してくれているので、ぜひ見てみてください!
https://www.youtube.com/watch?v=4di2TTqeCrE
なぜDependency Injection?
データーベースなどがハードコードされている例
class MusicPlayer {
private val db = SQLiteDatabase()
private val codecs = listOf(CodecH264(), CodecFLAC())
fun play(id: String) { ... }
}
fun main() {
val player = MusicPlayer()
player.play("...")
}
コンストラクタを呼び出して、play()を呼ぶだけ。シンプル。依存関係もハードコードされているので何もしなくて良い。
ただ、たくさんの問題が起こる。
MusicPlayerを再度使いたい場合、データベースがハードコードされているので別のMusicPlayerでは使えなかったり、違うコーデックが使えなかったりする。
またそれらを実行時や他のなにかでも交換できない。
テストも問題になる。データーベースがハードコードされているので、例えばメモリ内のDB(In memory database)を使いたい場合は不可能となる。
またこれはさまざまな依存関係やクラスの動作を一目で確認できないため、リファクタリングを難しくする。
Construction InjectionによるDependency Injectionの例
コンストラクタで依存を受け取るためConstruction Injectionと呼ばれる。setterで渡すsetter injectionもある。
class MusicPlayer (
private val db: Database,
private val codecs: List<Codec>
)
fun play(id: String) { ... }
}
上記の場合、初期化時に明示的に依存関係が書かれているので、以下のように先に他のオブジェクトを作る必要がある。
このようになにかオブジェクトを作るときにはその依存関係を作る必要がある。
fun main() {
private val db = SQLiteDatabase()
private val codecs = listOf(CodecH264(), CodecFLAC())
val player = MusicPlayer(db, codecs)
player.play("...")
}
ただ、このようなMusicPlayerをなんども手動で依存関係も作る場合はボイラープレートとなりえる。
コンストラクションロジックとは = ただ、他の型やクラスを作るためのロジック
ビジネスロジックとは = アプリケーションの価値を作るもの
- ビジネスロジックからコンストラクションロジックを分離するべき。
コードを追ったり、読んだりするのを難しくする。またそれはクラスを読む人にとってあまり意味のないものである。 - Dependency Injection(DI)では、DIコンテナがこの役割。
DIではコンストラクションロジックはビジネスロジックから分離される。
separation of concerns(関心事の分離)やsingle responsibility principle(単一責任の原則)があるが、この責任のあるクラスがDIコンテナとなる。
DIについてまとめ
- 良い利益があるので、GoogleはDependency Injectionを推奨
- ボイラープレートを避けるためにDIライブラリが推奨される。
なぜAndroidではDIが難しいのか?
FrarmeworkのクラスがAndroidのSDKの中で作られてしまうため。
API 28ではFactoryが追加されているが、現実的ではない。これがDaggerやHiltをおすすめする理由。
Daggerについて
去年はDaggerをおすすめしたが、今はHiltがおすすめ。
Daggerはおすすめしたが、もっといい解決策を探した。Daggerは設定が難しい。またDaggerでは同じことをする方法が複数存在する。
49%の開発者が、より良いDIの方法を探していた。
どのような解決策を望んでいたか?
- Opinionated(意見を持った。)
決めるのを楽にして、使うのを楽にする (後述) - セットアップが簡単(DaggerはAndroidではなく、Java用だったので、Daggerは設定が難しかった)
- 重要な部分にフォーカスできる
これがメインのHiltが生まれた理由。
Dagger Hilt
Dagger HiltはDaggerの上に構築されたライブラリ。
Daggerの良いところを使うことができる。またAndroidXチームとDaggerチームで共同で作られている。
- AndroidにおけるDIの標準化
どのようにAndroidでDIするのかを標準化(standardize)する。 - Daggerの上に構築されている。
- アノテーションベース
Hiltに対して、アノテーションで何をしたいのかを伝える。 - ツールサポート
Android Studio 4.1以降では左側にガターアイコンが表示される。例えば依存関係がどこから来たのかがわかる。 - AndroidX Extension
ViewModelとWorkManagerで使える。他のライブラリも追加予定。
Setup
どうやってMusicPlayerを組み立て方をHiltに伝えるのか?
コンストラクタを変更できるのであれば、以下のように @Inject
をコンストラクタにつける。これが基本的なHiltへこのオブジェクトの作り方の教え方となる。このクラスの依存関係は、以下のコードでは現状はなにもない。何も依存がないため、Hiltはこのクラスを明らかに作ることができる。
class MusicPlayer @Inject constructor() {
fun play(id: String) { ... }
}
そして、別のアノテーション @HiltAndroidApp
はApplicationクラスにつける。これはHiltとAnnotion Processorに"このアプリはHiltを使うぞ"と教える。そして、これはdependency injection container(DIコンテナ)をアプリケーションクラスに追加する。DIコンテナはどのようにオブジェクトを作るのかのロジックとインスタンスを持っている。
@HiltAndroidApp
class MusicApp : Application()
もしHiltをActivityで使いたい場合。
Activityではコンストラクタにアクセスできないということを言ったが、そのため@AndroidEntryPoint
をActivityにつける。
これによって、3つHiltに教える。
- "このActivityはInjectionを行う。このActivityはDependencyInjectionを使う"
- "別のdependency contaienrをこのActivityに追加。"
@AndroidEntryPoint
class PlayActivity : AppCompatActiivty() {
- HiltからDependencyを持ってくる
変数に@Inject
アノテーションを付けている。これはHiltからInjectされることを意味する。
"Actiivtyが作られたときに、MusicPlayerをInjectして" と言っていることになる。
そして、onCreateメソッドはMusicPlayerを呼ぶなど好きなことができる。
@AndroidEntryPoint
class PlayActivity : AppCompatActiivty() {
@Inject lateinit var player: MusicPlayer
override fun onCreate(savedInstanceState: Bundle) {
super.onCreate(savedInstanceState)
player.play("...")
}
もう少し複雑な例
MusicPlayerがMusicDatabaseに依存しているとする。
Hiltが、MusicPlayerを作るためには、どのようにMusicDatabaseを作ることができるのかを知っている必要がある。
なぜならMusicDatabaseはMusicPlayerの依存となるため。
class MusicPlayer @Inject constructor(
private val db: MusicDatabase
) {
fun play(id: String) { ... }
}
- どのようにHiltにMusicDatabaseを作るかを教えるか?
→ もしMusicDatabaseのコンストラクタを変えられるのであれば、MusicPlayerと同じように コンストラクタで@Inject
を使うことで解決できる。
しかしこのDatabaseがJetpackライブラリのRoomから作られたりなど別のところから来る場合はどうだろうか?
Roomの場合はコンストラクタにアクセスすることができず、@Inject
アノテーションを付けることができない。
→ この場合はモジュールを使う必要がある。モジュールはただのクラスで、@Module
アノテーションを付ける。Installingと呼ばれるアノテーションを付ける。
モジュールにはメソッドを追加する。
モジュールのメソッドはレシピだと考えれば分かりやすい、この型をビルドするレシピはこれ!みたいな。基本的にこれはこのモジュールでレシピをHiltに教えている。
そして、この情報をApplicationComponentに置く(= Application LevelのDependency Containerに置く)
@Module
@InstallIn(ApplicationComponent::class)
object DataModule {
...
}
@Provides
がついたメソッドが書いてある。ここではHiltにどのようにMusicDatabaseを作るのかを教えている。
これによってHiltがDatabaseの作り方を知る必要があるときに、レシピの情報がここにあるので、ここに来て、このコードを実行して、依存関係を提供する。
@Module
@InstallIn(ApplicationComponent::class)
object DataModule {
@Provides
fun provideMusicDB(@ApplicationContext context: Context) =
Room.databaseBuilder(context, MusicDatabase::class.java, "music.db")
.build()
}
Component
ApplicationComponentなどが登場しましたが、これについてもう少し見てみよう。
Application ComponentなどはDIコンテナとして考えられる。
説明したようにこのコンテナはどのようにオブジェクトをビルドするのかのロジックをハンドルする。
例えばMusicPlayerにはMusicDatabaseが必要になるなど、Componentはインスタンスの生成の順序などのロジックを持っている。
これがどのように内部での仕組みになっているのか?
たくさんのファクトリーパターンに従ったファクトリーがある。そのファクトリーはどのようにインスタンスを作るかを知っている。
例えば、MusicDatabaseのファクトリーを持っていて、他にMusicPlayerのファクトリーもある。そして、それをつなげて一緒に動くようにする。
このファクトリーはHiltによって明らかに小さくなった。そしてファクトリーが必要になるまでは使われない最適化もされる。また、スコープなどによってインスタンスを使い回すロジックもある。
HiltはなぜOpinionedなのか?
Dagger HiltにはComponent、DIコンテナが付随するため。
ApplicationComponentやConfigrationChangeでも生き残るActivityRetainedComponent、ActivityComponent、そしてFragmentComponentが付随する。
ApplicationやActivityはフレームワークのコンポーネントなので、ライフサイクルがある。そしてApplicationComponentはApplicationクラスのライフサイクルに従う。AcitivtyComponentはActivityのライフサイクルに従う。
そしてサービスやViewといった特定の部分のComponentもある。
スコープ
(端折り気味)
現状、MusicDatabaseのインスタンスがInjectされるたびに、ModuleのprovideMusicDBによって配布されてしまうため、2つActivityがあって両方でMusicDBがInjectされていた場合、合別々のインスタンスが保持されてしまう。しかしConnectionを共有したかったり、同期だったりで一つのインスタンスを共有したかったりする。
@Module
@InstallIn(ApplicationComponent::class)
object DataModule {
@Provides
fun provideMusicDB(@ApplicationContext context: Context) =
Room.databaseBuilder(context, MusicDatabase::class.java, "music.db")
.build()
}
これをどのようにHiltに伝えればよいのか?
@Singleton
アノテーションを付ける必要がある。クラスか、コンストラクターか、モジュールの関数で使う。これがスコーピングアノテーションとなる。
これを使うことでインスタンスが共有される。このようなスコープのアノテーションは@Singleton
の他に@ActivityRetained
、@ActivityScoped
などそれぞれのコンポーネントが違ったComponentを持っている。
@Module
@InstallIn(ApplicationComponent::class)
object DataModule {
@Provides
@Singleton // **←**
fun provideMusicDB(@ApplicationContext context: Context) =
Room.databaseBuilder(context, MusicDatabase::class.java, "music.db")
.build()
}
Entry Point
Hiltによって作られた依存関係とHiltの依存関係以外の部分から取得するのは直接Hiltではサポートされていない。
そういうところにEntry Pointが使われる。
基本的に依存関係のグラフやDIコンテナににアクセスすることに使える。
例えば他のアプリとのコンテンツの共有に使われるContentProviderはApplicationのonCreateより先に呼ばれてしまったり、少しライフサイクルが特殊だったりするので、Hiltでサポートされていない。しかしContentProviderでMusicDatabaseを使いたい場合は存在する。MusicDatabaseはApplicationComponentから取得できることがわかっている。このような場合にEntryPointが使われる。
EntryPointはただのinterfaceでEntryPointのアノテーションがついており、どこから情報を取得するかをInstallInで指定する。
基本的にHiltはfun getMusicDatabase(): MusicDabase
に対してコードを生成してくれる。
class PlaylistContentProvider: ContentProvider() {
@EntryPoint
@InstallIn(ApplicationComponent::class)
interface PlaylistCPEntryPoint {
fun getMusicDatabase(): MusicDabase
}
}
そして実行時は、EntryPointAccessorsを使って、このサンプルではApplicationコンポーネントを使って、Interfaceのクラスを渡して、Hiltが生成したinterfaceの実装を取得できる。そしてgetMusicDatabaseを使ってHiltの中にある依存関係を取得できる。
class PlaylistContentProvider: ContentProvider() {
@EntryPoint
@InstallIn(ApplicationComponent::class)
interface PlaylistCPEntryPoint {
fun getMusicDatabase(): MusicDabase
}
override fun query(...) {
val entryPoint = EntryPointAccessors.fromApplication(
context,
PlaylistCPEntryPoint::class.java
)
val database = entryPoint.getMusicDatabase()
// ...
}
}
質問コーナー
内部的にSubComponentかdependenciesかどちらが使われるか?
(DaggerはComponentの依存関係を作る方法が2パターンある)
SubComponentのほうが技術的に楽なので、SubComponentが使われている。
Dynamic Feature Moduleでは依存関係が逆になるこれまでDaggerではGoogleはdependant moduleを推奨していた。
Hiltを使っている場合も、そのDFMの部分はDaggerで書く必要がある。
https://developer.android.com/training/dependency-injection/hilt-multi-module?hl=ja
上記の問題を解決する予定ある?
Dynamic Feature Moduleとアノテーションプロセッサーが相性が良くない。他のライブラリでも同じ問題が起こっている。例えばSafeArgsやNavigation。メタデータをexportするなどを考えているが現状はできない。
1つのモジュールではなくモジュールを分ける必要ある?
スケールしたり、他のチームと一緒に仕事したり、関心事の分離など。
AndroidX Extension
Dagger HiltはActivityやFragmentと一緒に動くが、他のComponentとも動くようになっている。例えばViewModelやWorkManager。ViewModelを例に見ていく。
ViewModelではコンストラクタにアクセスできるが、ViewModelでは直接インスタンスを作らず、Provider、Factoryなどを通じてインスタンスを作成する。なぜならConfiguration changeを通じて生き残るためである。
HiltはこのためのProviderなどのコードを自動生成する。そのため、ただ一つの@ViewModelInject
アノテーションが必要となる。プロセスが死んだり、Activityの再生成でsaved state handleを使いたいとき、通常フレームワークが作るので持っていない。しかしHiltでは @Assisted
アノテーションを付けることで何もする必要はなく必要なときに利用できる。
class PlayViewModel @ViewModelInject contructor(
@Assisted val savedStateHandle: SavedStateHandle
val db: MusicDatabase
) : ViewModel() {
...
}
ViewModelを使うときには @AndroidEntryPoint
がついたAndroidのコンポーネントでktx extensionを使っているとby viewModels()
を使うことができ、それを自由に使うことができる。AndroidX extensionと簡単に連携ができる。
@AndroidEntryPoint
class PlayActivity: AppCompatActivity() {
val viewModel: PlayViewModel by viewModels()
}
Testing
Hiltがどのようにテストをシンプルにするのか?
テストをするためにはPlayerを作る必要がある。そのためその依存関係のDatabaseを作る必要があり、そのときにはメモリに保持するDBがほしい。基本的にはそのインスタンスをただ作って、MusicPlayerに渡せばテストができる。なぜならこれでMusicPlayerのテストするためのインスタンスができるため。
@RunWith(AndroidJUnit4::class)
class MusicPlayerTest {
@Test
fun testPlay() {
val db = Room.inMemoryDatabaseBuilder(...)
val player = MusicPlayer(db)
player.play("...")
assertTrue(player.isPlaying())
}
依存関係が1つしかないため、今はこれで大丈夫だが、依存関係が20個ある場合はどうだろうか?そしてそれぞれの依存関係が推移的に依存を持っている場合(依存が他のものに依存している)、それをすべて作る必要がある。
それは問題となりえ、依存関係を切るためにmockやfakeを作り始める。しかしそれには良し悪しがある。
Hiltは基本的にすべてのコンストラクションロジックを持っていて、テストでも持っているため、基本的に行うことができる。
HiltAndroidRuleを使って、コンポーネントの作成とそれらのコンポーネントの管理を行う。そしてActivityや他の場所と同じようにinjectを使うことができる。MusicPlayerをInjectできる。
@HiltAndroidTest
@RunWith(AndroidJunit4::class)
class MusicPlayerTest {
@get:Rule
val rule = HiltAndroidRule(this)
@Inject
lateinit var player: MusicPlayer
@Before
fun setup() {
rule.inject(this)
}
@Test
fun testPlay() {
player.play("...")
assertTrue(player.isPlaying())
}
}
しかし、このままではInMemoryDatabaseに切り替えることができない。
そのため、HiltにDatabaseを作るレシピを忘れさせ、メモリのDBを使うようにする。
@UninstallModule
によってDataModule
の内容を忘れさせて、Databaseの作り方を忘れさせる。
@HiltAndroidTest
@UninstallModule(DataModule::class)
@RunWith(AndroidJunit4::class)
class MusicPlayerTest {
そしてここでMusicDatabaseの情報を教える必要があるので、テストの中でModuleを作る。このテストのための依存関係が追加できる。もしこのモジュールを外に持っていくとこれは、すべてのテストに適応される。
@HiltAndroidTest
@UninstallModule(DataModule::class)
@RunWith(AndroidJunit4::class)
class MusicPlayerTest {
@Module
@InstallIn(ApplicationComponent::class)
object TestMoudle {
@Provices
fun provideDB(app: Application) =
Room.inMemoryDatabaseBuilder(...).build()
}
テストランナーなどの設定が必要なので、ドキュメントを確認すること。
マイグレーション
Dagger HiltはDaggerやDagger Androidとともに動く。
HiltはマイグレーションAPIが存在する。
マイグレーションガイドもある
https://dagger.dev/hilt/migration-guide
将来の変更
- Android Gradle module以外でも動くように。
- Assisted Inject
view moduleにidを渡したいときなど - 他のAndroidXのコンポーネントとの連携 navigationなど
質問
stableになるには何が足りないのか?
いくつかがある。
Android Gradle Plugin外で動くようにしたり、Assited Injectionなどをstableにする前に入れたい。
プロダクションで準備はできている。なぜなら実際にGoogleのアプリでは使われているため。そしてオープンソースのアプリをhiltにマイグレーションしている。例えばsun flower、google io、tv from Chris Banesで使われている。
パフォーマンス、kaptを使わないといけないのでスピードが出ないのでは?
Hiltによる追加の時間は多くない。Hiltは基本的にレイヤーが薄く、Dagger上にあるため。
HiltはDaggerのためのコードを生成するが、生成するコードの量は多くはない。
kaptが遅いのは分かっていて、Daggerチームはannotation processorの代わりにksp(Kotlin Simbol Processing)を使う方法を探っている。しかし、それがいつできるかはわからない。
現在コードの生成にJavaPoetやそれに似たものを使っていて、同じ文法でkspとjavaコード両方を書くことができるようにしたいと考えている。ただJavaとKotlinを両方同時にサポートするため。これがHiltでkspを使う前にしたいことになる。
なぜHiltにGradle Pluginが必要なのか
Gradle Pluginは任意で、技術的ドキュメントを確認すればGradle Pluginなしで適応する方法が書いてある。@AndroidEntryPoint
アノテーションのためにある。
実際HiltはMusicPlayerActivityがあればHiltMusicPlayerActivityをバイトコードトランスフォーメーションで生成し、それをMusicPlayerActivityで継承するようにする。もし、Gradle Pluginを使わない場合はそのクラスを作る必要がある。
それってコンパイル時間かかる?
この処理ははやい
インクリメンタルビルドについては?
すでにincremental buildと一緒に動作する。
KoinやKodeinからマイグレートするべき?
KoinやKodeinは解決策で、Googleは何かを推薦するが、あなたが使いたいものを使うことができる。しかし、もし、KoinやKodeinでうまく言っていたときに他の人がプロジェクトに入ってきたときに、Hiltが標準の解決策なので、もしHiltであればプロジェクトを移動したときに同じ解決策でできるので新しい技術を学ぶ必要がない。しかしマイグレーションはあなたやチームによるので、これは必須ではない。
DaggerにHiltのための機能追加とかした?
していない。
なぜFragmentRetainedComponentがActivityRetainedComponentと同じようにないのか?
これは非常に複雑な質問。ダイヤモンド問題が起こる。
ActivityRetained <- Activity <- Fragment
のようになっていて、Fragment retained componentを追加したいが、Fragment retained componentはActivity retained componentやActivity comopnentを継承したいが、実際にはそこに循環が発生し、避けられない。
Fragment factoryを作るためにFragmentRetainedComponentを考えた。ダイヤモンドを作成し、リンクを切るというもの、しかし、現状ActivityComponentとActivityRetainedComponentなど複数の親を持てないのが問題になる。
square/anvilについて(同じようなことができるライブラリ)
Dagger Hiltは2つの部分からなる
-
@InstallIn
アノテーションによるモジュールのAuto discoverabillity
-
@AndroidEntryPoint
コンポーネントを自分で作る必要がなく、自動的にInjectされる
amvilでは1つ目のみを行う。
チームもこの2つのアーティファクトに分離することは考えている。
Hiltは大きなアプリでも使えるか?
実際Googleのアプリは大きいがHiltを使っている。DaggerとHiltはスケールする。Dagger Hiltで対応するコンポーネントがあるため対応もかんたん。
@ViewModelInject
みたいにViewModelのInjectはできるが、FragmentはFactory作れて、Construction Injectionできるけど、やらないの?
まだ実装されていない。ダイヤモンド問題がある。また他にも問題があり、Fragmentを作るときにFragment Factoryが必要になるが、これはonCreate前には使われない。そしてFragmentを作るときにFragmentのインスタンスを使うことができなくなる。
またActivityComponentにFragment Factoryを実装すると、Fragment FactoryはFragmentの情報を使えないが、Fragmentでは使えることになるので、混乱を生む。
Fragment Factoryとの連携のエッジケースが起こらない堅牢な方法を探している。
lateinitや@Inject
アノテーションだとNullPointerException起こる可能性あるけど消すプランはある?
Kotlinでlateinitをとても使っている。たぶん今そのようなFeature requestがないので、それをfileし、property delegateなどの方法を考えることはできる。
HiltでFileにアノテーションを付けてtop level functionで依存関係を配布とかできたりする?
いいアイデアだと思う。Feature reqeustにfileしたほうが良さそう。Daggerは長い間Javaのライブラリとして存在しており昔のメンテナは他のプロジェクトに移っており新しい人々が貢献している。Kotlinと共によく動くようにしていこうとしている。
Android以外でどのぐらいDaggerって使われているの?
むずかしい。わからない。
Dagger 3.0みたいなの考えている?
この年動き始めるかも。個人的にHiltかDIをComposeに入れるために。
Jetpack Composeに今使えるAmbientはとてもサービスロケーターっぽい。そのため、どのように適切にDIを行うのか。Hiltをその中で使いたいと考えている。
ViewModelにidとか渡せる?
今はサードパーティのAssitedInjectで行う。sunflowerでもAssisted Injectを使っている。
今後なおしたいと考えている。
(省略)
Multibindingを楽にできない?
難しいけど、エスケープハッチのように拡張性を作ってくれる。