0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Hiltを捨ててKoinに乗り換えた——移行より怖かったのは「コンパイラの保証」を失うことだった

0
Posted at

前回、「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は、素直だった

乗り換えると決めて、まず全数調査です。出てきた数字がこちら。

  • @Inject 72箇所 / @Module 10 / @Provides 14 / @Binds 13
  • スコープはシングルトン一色。複雑な 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段構えです。

  1. 並走:Hiltを生かしたまま、Koinの定義を"純増"で足す。この時点ではKoinはまだ誰も使っていない=リスクゼロ
  2. 一括切替:アプリの起動点で startKoin を呼び、hiltViewModel()koinViewModel() に置換。ここだけは一気にやる。
  3. 掃除: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-workmanagerworkerOfworkManagerFactory() へ。ここは自動では繋がりません。

いちばん怖かったのは、コードの話ではなかった

正直に言うと、上の3つより、ぞっとしたことがあります。

Hiltは、コンパイル時にDIグラフを検証してくれていた。

配線が一本でも抜けていたら、ビルドが通らない。つまり、出荷する前に、コンパイラが必ず教えてくれる。ありがたい番犬でした。

Koinは、違います。ランタイム解決です。定義を一本書き忘れても、ビルドは通る。そして、その画面を開いた瞬間に「依存を解決できません」で、初めて落ちる。しかも意地の悪いことに、リリースビルド(難読化あり)で初めて剥がれるやつまでいる。

「ビルドが通った=安全」が、通用しなくなる。これが、Hilt→Koinの、本当のコストでした。

なので、失った番犬の代わりを、自分で立てました。

  • koin-testverify() を、アプリのテストに一本。全定義が解決できるかを、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と組んでどこまでやれるか」の実況です。よろしければ、のぞいてやってください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?