前回、「AndroidアプリをKotlin Multiplatform(KMP)でiOSに移植する前に、コードを全数調査したら地雷が3つ出てきた」という記事を書きました。
今日は、その移植の中の、ひとつの山場だけを取り出します。DI(依存性注入)を、HiltからKoinへ乗り換えた話です。
(前回同様、白状しておきます。私はコードを一行も書けません。実装はぜんぶ生成AI(Claude)。この記事も作業ログをもとにAIに草稿を書かせ、私が事実を確かめて直しました。ただ、数字もコードも、出どころは全部、自分のアプリの実際の中身です。)
なぜ、わざわざ乗り換えるのか
理由は、ひとつです。
Hiltは、iOS(Kotlin/Native)では動きません。
Hiltは、Androidのために、ビルド時にコードを自動生成して配線してくれる仕組みです。これがよくできている。よくできているのですが、Androidの世界の住人でして、海を渡れない。KMPで「AndroidとiOSでコードを共通化する」となった瞬間、共通の土台(commonMain)に載せられないものが出てきます。DIは、その筆頭でした。
だから、多くのプラットフォームで動くDIライブラリ——今回はKoin——に乗り換える。移植のためというより、「移植できる体」にするための、土台の入れ替えです。
幸運:うちのDIは、素直だった
乗り換えると決めて、まず全数調査です。出てきた数字がこちら。
-
@Inject72箇所 /@Module10 /@Provides14 /@Binds13 - スコープはシングルトン一色。複雑な multibinding、ゼロ。
@Namedでの names 出し分け、ゼロ。
これは、大当たりでした。DIの移行がつらくなるのは、たいていスコープが入り組んでいたり、同じ型を名前で出し分けていたりするからです。うちには、それが無かった。
つまり、やることの大半は「Hiltが裏で自動でやっていた配線を、Koinの流儀で手で書き起こす」だけ。約60本の定義を明示する、ほぼ機械作業です。
// Hilt:コンストラクタに @Inject。あとは自動で繋がる
class GetTodayTotalUseCase @Inject constructor(
private val repo: TimeEntryRepository,
)
// Koin:繋がりを、手で一行、明示する
single { GetTodayTotalUseCase(get()) }
見た目のとおり、退屈です。退屈なやつは、AIの得意分野。ここは危なげなく進みました。
賢い進め方は「いきなり替えない」
一点だけ、進め方のコツを。Hiltを引っこ抜いてからKoinを入れる、をやってはいけません。 その間、アプリは配線が全部切れて、動かなくなります。
やったのは、3段構えです。
- 並走:Hiltを生かしたまま、Koinの定義を"純増"で足す。この時点ではKoinはまだ誰も使っていない=リスクゼロ。
-
一括切替:アプリの起動点で
startKoinを呼び、hiltViewModel()をkoinViewModel()に置換。ここだけは一気にやる。 - 掃除:Hiltを、葉のモジュールから順に撤去。
「足す→切り替える→消す」。この順番なら、どの段階でもアプリは動いたままです。
本題:残りの2割で、事故は待っていた
機械作業が8割。では残りの2割は、というと、ここが戦場でした。
① Lazy の循環
Hiltには、お互いを参照し合う「循環」を、dagger.Lazy(遅延読み込み)でそっと許す仕組みがありました。うちも2箇所、これに頼っていた。ところがKoinは、循環を見つけるとその場でランタイム例外。容赦なし。
// 片方を「遅延」にして、輪を断ち切る(挙動はHilt時代と同じ)
single { CalendarExportService(lazy { get<GoogleAuthManager>() }) }
② Activityに紐づいたViewModel
検索画面の条件を、画面をまたいで保持する仕掛けがありました(過去に苦労して直した挙動です)。素直に koinViewModel() と書くと、別インスタンスが生まれて、その保持が壊れる。
// これだと別物になり、またいだ検索条件が消える
val vm = koinViewModel<SearchViewModel>()
// Activityに紐づけて保持する(Hilt時代の hiltViewModel(activity) と等価)
val vm = koinViewModel<SearchViewModel>(viewModelStoreOwner = activity)
③ WorkerとWidget
バックグラウンド処理(@HiltWorker 3件)とホーム画面ウィジェット(Glanceの @EntryPoint)は、特別扱いが要ります。Workerは koin-androidx-workmanager の workerOf + workManagerFactory() へ。ここは自動では繋がりません。
いちばん怖かったのは、コードの話ではなかった
正直に言うと、上の3つより、ぞっとしたことがあります。
Hiltは、コンパイル時にDIグラフを検証してくれていた。
配線が一本でも抜けていたら、ビルドが通らない。つまり、出荷する前に、コンパイラが必ず教えてくれる。ありがたい番犬でした。
Koinは、違います。ランタイム解決です。定義を一本書き忘れても、ビルドは通る。そして、その画面を開いた瞬間に「依存を解決できません」で、初めて落ちる。しかも意地の悪いことに、リリースビルド(難読化あり)で初めて剥がれるやつまでいる。
「ビルドが通った=安全」が、通用しなくなる。これが、Hilt→Koinの、本当のコストでした。
なので、失った番犬の代わりを、自分で立てました。
-
koin-testのverify()を、アプリのテストに一本。全定義が解決できるかを、CIで静的に検査する。
@Test
fun koinModulesAreValid() {
appModules.verify(
// extraTypes = 「Koin が自分では作れない、外から来る型」を教えておく欄
extraTypes = listOf(Context::class, /* WorkerParameters 等 */),
)
}
extraTypes に並べているのは、Android の Context や Worker の引数のような、**Koin が自分では生成できない"外来の型"**です。これを教えておかないと、verify() が「その型は解決できません」と誤検知して落ちます(教え忘れると、ここで足をすくわれます)。
- 難読化ありのリリースAABで、起動スモークを必須にする(ランタイム解決の剥がれは、これでしか見つからない)。
- R8 の Keep ルールを、Hilt から Koin へ差し替える。難読化は、ランタイム解決に要る型情報まで削ってしまうことがあります。Hilt 用の keep ルールを外し、Koin 用に置き換えておかないと、リリースビルドだけ起動時に落ちる——という、いちばん見つけにくい壊れ方をします。
-
Lazyの循環はverify()ですり抜けることがあるので、その実行パスを必ず一度は通す。
コンパイラが黙って守ってくれていた安全網を、テストと手順で、編み直す。移行そのものより、この「番犬の引き継ぎ」のほうが、よほど神経を使いました。
おわりに
これで、うちのDIは、海を渡れる体になりました。同じ定義が、AndroidでもiOSでも動く。土台の入れ替えは、地味ですが、効きます。
……もっとも、番犬を一匹手放した心細さは、まだ少し残っていますが(笑)。
この記事のアプリについて
いじっているのは、ActLog という時間管理アプリです。ポモドーロで測った時間が、そのまま作業記録として残る。案件ごとに集計して、CSV・PDFで出せる。いまはAndroidで公開中、この連載のとおり、iOS版を作っている最中です。
「コードを一行も書けないおっさんが、AIと組んでどこまでやれるか」の実況です。よろしければ、のぞいてやってください。