大規模AndroidアプリのDIをKoinからHiltへ移行する

## はじめに

with で Android エンジニアをしている 石田 です。with の Android 版アプリ (以下、with-android) では長らく DI ライブラリとして [Koin](https://insert-koin.io/) を採用してきましたが、先日 1~2か月かけて [Hilt](https://developer.android.com/training/dependency-injection/hilt-android?hl=ja) に完全移行しました(頑張りました)。本記事では移行方法について紹介します。

:::note warn
**本記事での約束**

* Hilt + Dagger 2 を1つのライブラリと見なして単に Hilt と呼びます。
* Android/Fragment/Service といったクラスを Android クラス と呼びます。
:::



## Koin と Hilt

[Koin](https://insert-koin.io/) は オープンソースの DI ライブラリで、手軽に導入できることが特徴です。GitHub のスター数は記事執筆時点で7400ある人気のライブラリで、with-android でも採用してきたことから安心して利用できます。現在も活発に開発されていて、最近では KSP や Compose にも対応しているようです。

https://github.com/InsertKoinIO/koin

一方 [Hilt](https://developer.android.com/training/dependency-injection/hilt-android?hl=ja)は 2020 年に発表され、2021年に正式版がリリースされた Google 公式の Android用 DI ライブラリです。

https://developer.android.com/training/dependency-injection/hilt-android


KoinとHiltの比較はこちらの記事が詳しいです。

https://medium.com/mobile-app-development-publication/dagger-2-and-koin-comparison-4223dae9725d


## Hilt 移行のモチベーション

2020 年に Hilt が登場してきた時点で、Hilt 移行について GitHub Discussion 上でゆるやかに議論をしてきました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/56635/4a88ad42-da94-3d68-28fa-f083ccf378cd.png)

当時は Hiltが誕生して間もなく、公式のライブラリである点は大変魅力的である一方で Koin に満足していたため移行するほどでもないかなと考えていました。 しかしながら、 Koin はバージョンが上がるたびにAPIが変更になったり、ビルドが通らなかったりと 信頼性の面でやや不安もありました。

Hilt も安定し、導入事例も増えてきたことから思い切って移行することを決断しました。


## Hilt 移行の計画

DI ライブラリを入れ替えるのは、結構大変な作業になります。with-android は 約15万行 のプロジェクトです。手作業で行っていては膨大な作業量になりますし、ミスの原因にもなります。また、Hilt 移行版のリリースタイミングをコントロールするのも難しくなると感じました。

そこで Koin から Hilt への移行スクリプトを作成する方針を採ることにしました。そうすれば手作業から解放されミスも起きづらく、好きなタイミングで Hilt を導入できると考えたからです。


## 前準備

Koin と Hilt のどちらにもある機能は移行スクリプトで対応するとして、Koin にあって Hilt にない、もしくは実現が手間なものはノイズになるので Hilt 移行前に事前準備として対応することにしました。

### by inject と KoinComponent の削除

Koin の `by inject()` は Android クラス で注入したい時に使っていましたが、ViewModel 側で処理できそうなものはよい機会と思い移動させました。移動できないものは Hilt 移行時に `@Inject lateinit var`を使って対応することにしました。

また、`KoinComponent` は 非 Android クラス で注入したい時に使っていましたが、できるだけ使わないように修正しました。どうしようもないものは、Hilt 移行時に `EntryPointAccessor` を使って対応することにしました。

### SavedStateHandle 経由で引数を渡す

Hilt では ViewModel での AssistedInject の使用が非推奨になっています。よって Koin の `parametersOf` のように引数を渡すことができません。そこで [代替案の SavedStateHandle 経由で引数を渡す](https://github.com/google/dagger/issues/2287#issuecomment-771671159) ようにコードを変更しました。

type-safeに引数を渡すために `ScreenArgsContract<T>` インターフェースを導入し ViewModel ごとに必要な引数を定義することにしました。(ここはもう少し上手いやり方があるかもしれないです。)


```kotlin
object Contract : ScreenArgsContract<Long> {
    override val key1 = "userId"
}

interface ScreenArgsContract<T : Any> {
    val key1: String
}

fun <T : Any> SavedStateHandle.getArgs(contract: ScreenArgsContract<T>): T {
    return checkNotNull(get(contract.key1))
}
```

### 表記の統一

スムーズに移行できるよう、事前に表記を統一しました。例えば ViewModel は 以下のように統一しました。

```kotlin
// Before
private val viewModel by viewModel<FootPrintsViewModel>()

// After
private val viewModel: FootPrintsViewModel by viewModel()
```

## Hilt 移行

いよいよHilt 移行です。Hilt 移行は「コードの生成」と「コードの置換」の大きく2つに分けて説明しますが、その前にそもそもどうやってコードを処理していくかを説明します。

### コードの処理方法

コードを高度に処理するには正規表現だけでは不十分です。正規表現ではプログラミング言語で当たり前のように使用されている再帰的な構文を処理することができません。そこでコードを構文解析することを考えます。

はじめは公式のパーサーの [kotlinx/ast](https://github.com/kotlinx/ast) を使おうかと考えたのですが、このパーサーを使いこなすまでにややコストがかかりそうだったのと、今回のケースだと正確にパースする必要がなかったので、自前で簡易的にパースする方針を採ることにしました。

例えば import 文 のパーサーは以下の通りです。結構適当です。同様に Repository/UseCase/ViewModel 等のパーサーも作成しました。パースしてしまえばコードを自由に組み立てたり、書き換えたりすることができます。

```kotlin
data class ImportNode(
    val element: String
) : Codable {
    companion object : Parser<ImportNode> {
        override fun parse(input: String): ParseResult<ImportNode> {
            val regex = Regex("""^import (.+)""")
            val result = regex.find(input)!!
            val element = result.groupValues[1]
            return ParseResult(
                node = ImportNode(element),
                rest = input.substring(result.range.last + 1),
            )
        }
    }

    override fun build(): String {
        return "import $element"
    }
}
```

ちなみに今回は使用しませんでしたが、 Android Studio では Structural search/replace という機能があり、正規表現では不可能な構造を踏まえた検索と置換を行うことができます。

https://www.jetbrains.com/help/idea/structural-search-and-replace.html


### コードの生成

Koin のモジュールを元に Hilt のモジュールを生成することを考えます。Koin と Hiltでモジュールの見た目は結構異なりますが、共通項に注目するとどの部分が同じでどの部分が違うか一目瞭然です。あとは先程と同様にモジュールのパーサーを作成し、Hiltのモジュールを組み立ててやればよいです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/56635/ea94992f-c786-8946-bf85-e5b7d5cab0be.png)


### コードの置換

Hilt のモジュールを作ったら、次に細かいコードの書き換えを行っていきます。以下についてこれまでと同様にパーサーを作って対応していけばよいです。

* Application クラスに `@HiltAndroidApp` を付ける
* Android クラスに`@AndroidEntrypoint`を付ける
* UseCase や Repository の実装クラス(末尾に `~Impl` が付いているもの)に `@Inject constructor` を付ける (引数を取らないものであっても付ける必要があることに注意する)
* ViewModel に `@HiltViewModel` と　`@Inject constructor` を付ける
* `by inject` を `@Inject lateinit var` に置き換える
* `by viewModel()` 等を置き換える (対応表参照)

| Koin | Hilt |
| - | - |
|  `by viewModel()` <br /> `by stateViewModel()` | `by viewModels()` |
| `by sharedViewModel()` <br /> `by sharedStateViewModel()` | `by activityViewModels()` |


## 手作業で対応したもの

WorkManager と `EntryPointAccessor` を使うものは該当コードが少なく、移行スクリプトで対応するまでもないと考え手動で対応しました。


## Hilt 移行版リリース

リリース前には Android チーム全員で協力してアプリ全体の動作を確認しました。加えてQAでもアプリ全体の動作を見てもらいました。比較的大きな変更になるので、重要機能のリリースとは混ぜないように気を配りました。

QA完了後は「10%」で段階リリースして1日様子を見ました。`@AndroidEntrypoint` の付け忘れによるクラッシュが少し発生しましたが、その他の問題は発生しませんでした。

## まとめ

* Koin から Hilt への移行はアプリ全体に影響する上、作業量もそこそこあり大変でしたが、無事に移行を完了することができました。
* Hilt になったことでビルド時に依存の解決のチェックが行われるため、開発中の実行時エラーを減らすことができました。また [Android Studio のサポート](https://medium.com/androiddevelopers/dagger-navigation-support-in-android-studio-49aa5d149ec9)の恩恵も受けられるようになりました。
* Hilt は Google 公式のライブラリということもあり信頼性が高く、今後も安心して使えると感じています。