はじめに
会社のアプリも個人的なアプリもマルチモジュールプロジェクトでDIフレームワークにはKoinを使っています。Googleさんお勧めのDaggerは難解でメンバーへの展開が難しかっため、直感的に理解しやすいKoinの方を採用していました。
しかし、Android Studio 4.1(初稿執筆の2020年8月時点ではBeta)ではDaggerに対応したソースコードのナビゲーション機能が追加されたらしく、Koinではこのようなことはできないので、Koinを採用したことを軽く後悔したできことがありました。
また先日のAndroid 11 Meetupsで、Jetpackの一部としてDaggerベースのHiltがリリースされてDaggerよりも簡単に使えるという内容のセッションがありました。
それらを受けて、Koinを使っている個人的なアプリについて、Hiltに移行してみました。
2021年6月13日追記
新しく別の個人開発Androidアプリの開発を開始しましたが、Dagger Hiltのバージョンが2.36になり、2020年8月に書いたこの記事の内容と差異が発生していることが分かったので、現在のバージョンで変更がある部分は補足説明を加えました。
KoinよりHiltを使うメリット
Koin採用済みのある程度規模のある実プロダクトで、わざわざHiltに移行しなくても良いと思います。私の今回の移行作業は学習のためというモチベーションが大きいです。しかし新規プロダクトやこれからDIを採用するプロダクトにおいてはKoinではなくHiltを使うと以下のメリットがあると思います。
- 依存性注入のModuleを探しやすくなる(HiltはAndroid Studio 4.2からの対応)
- 依存性を解決できないことで発生する実行時エラーは起こらない
- KotlinとJavaで依存するインスタンスの取得方法が同じになる
Hiltに移行するアプリの解説
今回、KoinからHiltに移行するアプリはシンプルな音声の録音再生アプリです。
画面回転や画面分割に対応しています。
クラスの紹介
今回解説する範囲における、クラスの依存関係はこのようになっています。
MainFragment
Fragmentです。画面回転や画面分割で再生成されます。
RecordViewHelper
録音のために録音デバイス制御などを行います。ViewModelを継承しているので画面回転や画面分割が起きても同じインスタンスが保持されます。
PlayViewHelper
再生のために音声デバイス制御などを行います。RecordViewHelperと同様にViewModelを継承していて、画面回転や画面分割が起きても同じインスタンスが保持されます。
SoundRepository
録音音声の保持と取り出しを担当します。シングルインスタンスです。音声がメモリー、ローカルファイル、クラウド等どこにあるか呼び出し元は関与しません。
SoundMemoryLocalDataStore
録音音声をメモリーに保持する担当です。シングルインスタンスです。
SoundFileLocalDataStore
録音音声をAACにエンコードしてローカルファイルファイルに保持します。シングルインスタンスです。プロセスキルからの復帰ではこちらが使われます。
モジュールの紹介
レイヤードアーキテクチャへの強制力を持たせるために、レイヤー別マルチモジュールプロジェクトになっています。下図のように役割に応じてクラスをモジュールに配置しています。
移行の手順
ライブラリ追加
ほぼ公式情報の手順です。
プロジェクトルートの build.gradle
ファイルに hilt-android-gradle-plugin
プラグインを追加します。
buildscript {
// 略
dependencies {
// 略
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.28-alpha'
}
}
私のプロジェクトでは Minimum supported Gradle version is 5.6.4. Current version is 5.4.1.
エラーが表示されたので、Gradleバージョンを上げました。
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip
マルチモジュールプロジェクトなので、各プロジェクトへのライブラリの追加のために共通のgradleファイルの方を修正しました。
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'
// 略
dependencies {
// 略
implementation "com.google.dagger:hilt-android:2.28-alpha"
kapt "com.google.dagger:hilt-android-compiler:2.28-alpha"
}
gradle/common.gradle
ファイルは以前からこのように取り込んでいます。
apply from: rootProject.file('gradle/common.gradle')
ViewModelも注入したいため、公式のこちらの文献に沿って、さらにライブラリを追加します。
apply from: rootProject.file('gradle/common.gradle')
// 略
dependencies {
// 略
implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha02'
kapt 'androidx.hilt:hilt-compiler:1.0.0-alpha02'
}
ViewModelを含むmainモジュールではこのように取り込んでいます。
apply from: rootProject.file('gradle/view-common.gradle')
2021年6月13日追記
Daggerバージョン2.31.1から androidx.hilt:hilt-lifecycle-viewmodel
ライブラリは不要になりました。
Daggerバージョン2.34のリリースノートのMigration stepsも参考になります。
モジュール依存関係変更
Hiltはアプリケーションモジュールから直接参照しているモジュールにあるクラスしか注入できないので、このようにモジュールの依存関係を変更しました。
dependencies {
// 略
implementation project(':repository')
implementation project(':localDataStore')
}
Koinでは直接参照していないモジュールにあるクラスを注入できるのですが、その件は補足で説明します。
2021年6月13日追記
Daggerバージョン2.31からアプリケーションモジュールから直接参照されてないモジュールにあるクラスも注入できるようになりました。
アプリケーションモジュールのbuild.gradleにこちらを追加します。
hilt {
enableExperimentalClasspathAggregation = true
}
参考にした情報
- takahiromさんの2021年1月22日のツイート
- DroidKaigi/conference-app-2021
- Dagger HiltのenableExperimentalClasspathAggregationオプションについて
Daggerバージョン2.37について
Daggerバージョン2.37からは enableExperimentalClasspathAggregation
フラグの代わりに enableAggregatingTask
フラグを使うようです。しかし単体テストでビルドエラーが発生してしまい、執筆時点ではリリースから3日しか経ってなかったので、今回は見送りました。
Applicationクラスにアノテーション追加
すでにKoinのためにApplicationを継承したクラスを定義していました。
そのクラスに @HiltAndroidApp
アノテーションを追加します。
@HiltAndroidApp
class QuickEchoApplication : Application() {
override fun onCreate() {
super.onCreate()
// 略
}
}
モジュールを作成する
SoundMemoryLocalDataStore、SoundFileLocalDataStore、SoundRepositoryを注入できるように、Hiltのモジュールを作成します。
MockKでモックを作って単体テストできるように、インターフェースと実装を分けています。
// appモジュールまたはlocalDataStoreモジュールに置く
@Module
@InstallIn(ApplicationComponent::class)
abstract class HiltLocalDataStoreModule {
@Binds
@Singleton
abstract fun bindSoundMemoryLocalDataStore(
soundMemoryLocalDataStore: SoundMemoryLocalDataStoreImpl
): SoundMemoryLocalDataStore
@Binds
@Singleton
abstract fun bindSoundFileLocalDataStore(
soundFileLocalDataStore: SoundFileLocalDataStoreImpl
): SoundFileLocalDataStore
}
// appモジュールまたはrepositoryモジュールに置く
@Module
@InstallIn(ApplicationComponent::class)
abstract class HiltRepositoryModule {
@Binds
@Singleton
abstract fun bindSoundRepository(
soundRepository: SoundRepositoryImpl
): SoundRepository
}
注入されるクラスのコンストラクタには @Inject
アノテーションを付けます。
アプリケーションコンテキストは @ApplicationContext
アノテーションで注入できます。
class SoundMemoryLocalDataStoreImpl
@Inject constructor(@ApplicationContext private val context: Context) : SoundMemoryLocalDataStore {
// 略
}
class SoundFileLocalDataStoreImpl
@Inject constructor(@ApplicationContext private val context: Context) : SoundFileLocalDataStore {
// 略
}
class SoundRepositoryImpl
@Inject constructor(private val soundMemoryLocalDataStore: SoundMemoryLocalDataStore,
private val soundFileLocalDataStore: SoundFileLocalDataStore) : SoundRepository {
}
2021年6月13日追記
Daggerバージョン2.28.2からApplicationComponentクラスはSingletonComponentクラスになりました。
ViewModelを注入する
ViewModelのコンストラクタに @ViewModelInject
アノテーションを付けます。
class RecordViewHelper @ViewModelInject constructor(
private val repository: SoundRepository) : ViewModel() {
注入されるActivityとFragmentに @AndroidEntryPoint
アノテーションを付けます。Activityでは注入を行ってなくても、Activityが持つFragmentに対して注入を行っている場合はActivityにもアノテーションを付けないと実行時エラーになります。
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
// 略
}
@AndroidEntryPoint
class MainFragment : Fragment() {
// 略
}
ここまで定義すると、このようにするだけでViewModelが依存するインスタンスとともにViewModelが生成されます。
@AndroidEntryPoint
class MainFragment : Fragment() {
private val recordViewHelper: RecordViewHelper by viewModels()
private val playViewHelper: PlayViewHelper by viewModels()
// 略
}
2021年6月13日追記
Daggerバージョン2.31.1からViewModelクラスには @HiltViewModel
アノテーションを付けて、そのコンストラクタには @Inject
アノテーションを付けるようになりました。
Daggerバージョン2.34のリリースノートのMigration stepsも参考になります。
@HiltViewModel
class RecordViewHelper @Inject constructor(
private val repository: SoundRepository) : ViewModel() {
補足
Koinでアプリケーションモジュールから直接参照していないモジュールにあるクラスを注入できるようにする
一番下のレイヤーのモジュールです。
val localDataStoreModule = module {
single { SoundMemoryLocalDataStoreImpl(androidContext()) as SoundMemoryLocalDataStore }
single { SoundFileLocalDataStoreImpl(androidContext()) as SoundFileLocalDataStore }
}
その上のレイヤーのモジュールでは、さらに上のレイヤーのモジュールから下のレイヤーのモジュールを参照できるようにします。
val localDataStoreModuleInRepositoryModule = localDataStoreModule
val repositoryModule = module {
factory { SoundRepositoryImpl(get(), get()) as SoundRepository }
}
その上のレイヤーでも同様にします。
val localDataStoreModuleInMainModule = localDataStoreModuleInRepositoryModule
val repositoryModuleInMainModule = repositoryModule
val mainModule = module {
viewModel { RecordViewHelper(get()) }
viewModel { PlayViewHelper(get()) }
}
アプリケーションモジュールにある、startKoinブロックではすべてのモジュールを指定します。
object KoinSetting {
fun start(application: Application) {
startKoin {
androidContext(application.applicationContext)
modules(listOf(localDataStoreModuleInMainModule,
repositoryModuleInMainModule,
mainModule))
}
}
}
全体ソースコード
プルリクはこちらです。
この記事では @Binds
を使用したインターフェースからのインスタンス注入しか説明してないですが、 @Provides
を使用したインスタンス注入もこちらにあります。