Koinを使っているマルチモジュールプロジェクトをHiltに移行してみた

# はじめに

[会社のアプリ](https://play.google.com/store/apps/details?id=jp.r_n_i.code.point.app)も[個人的なアプリ](https://github.com/tfandkusu/quickecho)もマルチモジュールプロジェクトでDIフレームワークには[Koin](https://insert-koin.io/)を使っています。Googleさんお勧めの[Dagger](https://github.com/google/dagger)は難解でメンバーへの展開が難しかっため、直感的に理解しやすいKoinの方を採用していました。

しかし、[Android Studio 4.1(初稿執筆の2020年8月時点ではBeta)ではDaggerに対応したソースコードのナビゲーション機能が追加された](https://medium.com/androiddevelopers/dagger-navigation-support-in-android-studio-49aa5d149ec9)らしく、Koinではこのようなことはできないので、Koinを採用したことを軽く後悔したできことがありました。

また先日の[Android 11 Meetups](https://developersonair.withgoogle.com/events/a11meetups-jp)で、[Jetpackの一部](https://developer.android.com/jetpack/androidx/releases/hilt?hl=ja)としてDaggerベースの[Hilt](https://developer.android.com/training/dependency-injection/hilt-android?hl=ja)がリリースされて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に移行するアプリはシンプルな音声の録音再生アプリです。
画面回転や画面分割に対応しています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/8e1bc2da-67ff-15c4-83f0-4bb60ddaf2c3.gif" width="240px"> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/a06151eb-d615-425c-7bf4-d10e1f69c79c.gif" width="240px">

## クラスの紹介

今回解説する範囲における、クラスの依存関係はこのようになっています。

![スクリーンショット 2020-08-08 18.12.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/e1409136-829c-1d45-de50-1e12c0b45059.png)

### MainFragment

[Fragment](https://developer.android.com/reference/androidx/fragment/app/Fragment)です。画面回転や画面分割で再生成されます。

### RecordViewHelper

録音のために録音デバイス制御などを行います。[ViewModel](https://developer.android.com/reference/androidx/lifecycle/ViewModel)を継承しているので画面回転や画面分割が起きても同じインスタンスが保持されます。

### PlayViewHelper

再生のために音声デバイス制御などを行います。RecordViewHelperと同様にViewModelを継承していて、画面回転や画面分割が起きても同じインスタンスが保持されます。

### SoundRepository

録音音声の保持と取り出しを担当します。シングルインスタンスです。音声がメモリー、ローカルファイル、クラウド等どこにあるか呼び出し元は関与しません。

### SoundMemoryLocalDataStore

録音音声をメモリーに保持する担当です。シングルインスタンスです。

### SoundFileLocalDataStore

録音音声を[AAC](https://ja.wikipedia.org/wiki/AAC)にエンコードしてローカルファイルファイルに保持します。シングルインスタンスです。プロセスキルからの復帰ではこちらが使われます。

## モジュールの紹介

レイヤードアーキテクチャへの強制力を持たせるために、レイヤー別マルチモジュールプロジェクトになっています。下図のように役割に応じてクラスをモジュールに配置しています。

![QiitaのHiltの記事.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/38c636a7-2fd8-e28d-88fb-0dfca89f1b0f.png)


# 移行の手順

## ライブラリ追加

ほぼ[公式情報の手順](https://developer.android.com/training/dependency-injection/hilt-android?hl=ja#setup)です。


プロジェクトルートの `build.gradle` ファイルに `hilt-android-gradle-plugin` プラグインを追加します。

```build.gradle
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バージョンを上げました。

```gradle/wrapper/gradle-wrapper.properties 
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ファイルの方を修正しました。

```gradle/common.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` ファイルは以前からこのように取り込んでいます。

```localDataStore/build.gradle
apply from: rootProject.file('gradle/common.gradle')
```

ViewModelも注入したいため、[公式のこちらの文献](https://developer.android.com/training/dependency-injection/hilt-jetpack#viewmodels)に沿って、さらにライブラリを追加します。


```gradle/view-common.gradle
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モジュールではこのように取り込んでいます。

```main/build.gradle
apply from: rootProject.file('gradle/view-common.gradle')
```

### 2021年6月13日追記

[Daggerバージョン2.31.1](https://github.com/google/dagger/releases?after=dagger-2.31.1)から　`androidx.hilt:hilt-lifecycle-viewmodel` ライブラリは不要になりました。
[Daggerバージョン2.34のリリースノート](https://github.com/google/dagger/releases/tag/dagger-2.34)のMigration stepsも参考になります。


## モジュール依存関係変更

Hiltはアプリケーションモジュールから直接参照しているモジュールにあるクラスしか注入できないので、このようにモジュールの依存関係を変更しました。

![スクリーンショット 2020-08-08 20.17.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/5ddf06ab-5576-fb87-d429-6765e9f9e55f.png)


```app/build.gradle
dependencies {
    // 略
    implementation project(':repository')
    implementation project(':localDataStore')
}
```

Koinでは直接参照していないモジュールにあるクラスを注入できるのですが、その件は補足で説明します。


### 2021年6月13日追記


[Daggerバージョン2.31](https://github.com/google/dagger/releases/tag/dagger-2.31)からアプリケーションモジュールから直接参照されてないモジュールにあるクラスも注入できるようになりました。

アプリケーションモジュールのbuild.gradleにこちらを追加します。

```app/build.gradle
hilt {
    enableExperimentalClasspathAggregation = true
}
```

#### 参考にした情報

- [takahiromさんの2021年1月22日のツイート](https://twitter.com/new_runnable/status/1352423305416896512)
- [DroidKaigi/conference-app-2021](https://github.com/DroidKaigi/conference-app-2021/blob/4f1f481f51646a780f2aec9613555417b1abb2c0/android/build.gradle#L96)
- [Dagger HiltのenableExperimentalClasspathAggregationオプションについて](https://yuiki.hatenablog.jp/entry/2021/05/29/122151)

#### Daggerバージョン2.37について

[Daggerバージョン2.37](https://github.com/google/dagger/releases/tag/dagger-2.37)からは `enableExperimentalClasspathAggregation` フラグの代わりに　`enableAggregatingTask` フラグを使うようです。しかし単体テストでビルドエラーが発生してしまい、執筆時点ではリリースから3日しか経ってなかったので、今回は見送りました。

## Applicationクラスにアノテーション追加

[すでにKoinのためにApplicationを継承したクラスを定義していました。](https://start.insert-koin.io/#/getting-started/starting-koin?id=starting-for-android)
そのクラスに `@HiltAndroidApp` アノテーションを追加します。

```QuickEchoApplication.kt
@HiltAndroidApp
class QuickEchoApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        // 略
    }
}
```

## モジュールを作成する

SoundMemoryLocalDataStore、SoundFileLocalDataStore、SoundRepositoryを注入できるように、[Hiltのモジュールを作成](https://developer.android.com/training/dependency-injection/hilt-android#hilt-modules)します。
[MockK](https://github.com/mockk/mockk)でモックを作って単体テストできるように、インターフェースと実装を分けています。


```HiltLocalDataStoreModule.kt
// 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
}
```

```HiltRepositoryModuke.kt
// appモジュールまたはrepositoryモジュールに置く
@Module
@InstallIn(ApplicationComponent::class)
abstract class HiltRepositoryModule {
    @Binds
    @Singleton
    abstract fun bindSoundRepository(
            soundRepository: SoundRepositoryImpl
    ): SoundRepository
}
```

注入されるクラスのコンストラクタには `@Inject` アノテーションを付けます。
アプリケーションコンテキストは `@ApplicationContext` アノテーションで注入できます。

```SoundMemoryLocalDataStore.kt
class SoundMemoryLocalDataStoreImpl
@Inject constructor(@ApplicationContext private val context: Context) : SoundMemoryLocalDataStore {
    // 略
}
```

```SoundFileLocalDataStore.kt
class SoundFileLocalDataStoreImpl
@Inject constructor(@ApplicationContext private val context: Context) : SoundFileLocalDataStore {
    // 略
}
```

```SoundRepository.kt
class SoundRepositoryImpl
@Inject constructor(private val soundMemoryLocalDataStore: SoundMemoryLocalDataStore,
private val soundFileLocalDataStore: SoundFileLocalDataStore) : SoundRepository {
}
```

### 2021年6月13日追記

[Daggerバージョン2.28.2](https://github.com/google/dagger/releases/tag/dagger-2.28.2)からApplicationComponentクラスはSingletonComponentクラスになりました。



## ViewModelを注入する

ViewModelのコンストラクタに `@ViewModelInject` アノテーションを付けます。

```RecordViewHelper.kt
class RecordViewHelper @ViewModelInject constructor(
        private val repository: SoundRepository) : ViewModel() {
```

注入されるActivityとFragmentに `@AndroidEntryPoint` アノテーションを付けます。Activityでは注入を行ってなくても、Activityが持つFragmentに対して注入を行っている場合はActivityにもアノテーションを付けないと実行時エラーになります。

```MainActivity.kt
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    // 略
}
```

```MainFragment.kt
@AndroidEntryPoint
class MainFragment : Fragment() {
    // 略
}
```

ここまで定義すると、このようにするだけでViewModelが依存するインスタンスとともにViewModelが生成されます。

```MainFragment.kt
@AndroidEntryPoint
class MainFragment : Fragment() {

    private val recordViewHelper: RecordViewHelper by viewModels()

    private val playViewHelper: PlayViewHelper by viewModels()

    // 略
}
```

## 2021年6月13日追記

[Daggerバージョン2.31.1](https://github.com/google/dagger/releases?after=dagger-2.31.1)からViewModelクラスには `@HiltViewModel` アノテーションを付けて、そのコンストラクタには `@Inject` アノテーションを付けるようになりました。
[Daggerバージョン2.34のリリースノート](https://github.com/google/dagger/releases/tag/dagger-2.34)のMigration stepsも参考になります。

```RecordViewHelper.kt
@HiltViewModel
class RecordViewHelper @Inject constructor(
        private val repository: SoundRepository) : ViewModel() {
```

# 補足

## Koinでアプリケーションモジュールから直接参照していないモジュールにあるクラスを注入できるようにする

一番下のレイヤーのモジュールです。

```LocalDataStoreModule.kt
val localDataStoreModule = module {
    single { SoundMemoryLocalDataStoreImpl(androidContext()) as SoundMemoryLocalDataStore }
    single { SoundFileLocalDataStoreImpl(androidContext()) as SoundFileLocalDataStore }
}
```

その上のレイヤーのモジュールでは、さらに上のレイヤーのモジュールから下のレイヤーのモジュールを参照できるようにします。

```RepositoryModule.kt
val localDataStoreModuleInRepositoryModule = localDataStoreModule
val repositoryModule = module {
    factory { SoundRepositoryImpl(get(), get()) as SoundRepository }
}
```

その上のレイヤーでも同様にします。

```MainModule.kt
val localDataStoreModuleInMainModule = localDataStoreModuleInRepositoryModule
val repositoryModuleInMainModule = repositoryModule
val mainModule = module {
    viewModel { RecordViewHelper(get()) }
    viewModel { PlayViewHelper(get()) }
}
```

アプリケーションモジュールにある、startKoinブロックではすべてのモジュールを指定します。

```KoinSetting.kt
object KoinSetting {
    fun start(application: Application) {
        startKoin {
            androidContext(application.applicationContext)
            modules(listOf(localDataStoreModuleInMainModule,
                    repositoryModuleInMainModule,
                    mainModule))
        }
    }
}
```

## 全体ソースコード

プルリクは[こちら](https://github.com/tfandkusu/quickecho/pull/63)です。
この記事では `@Binds`を使用したインターフェースからのインスタンス注入しか説明してないですが、 `@Provides` を使用したインスタンス注入も[こちら](https://github.com/tfandkusu/quickecho/blob/67b2d04de7342762087fcb46675fc72f6a0ad546/localDataStore/src/main/java/jp/bellware/echo/di/HiltLocalDataStoreModule.kt#L40)にあります。