本文へスキップ
すべての記事

Kotlin、Room、Composeのための R8とProGuard:リリースビルドだけで起きるクラッシュ

Room + Compose の Android アプリがデバッグでは完璧に動き、本番でクラッシュする理由と、ユーザーより先にそれを捕まえる具体的な R8/ProGuard keep ルール。

MFKAPPS 1 分で読めます

自分の端末ですべてのテストを通過したビルドが、まったく同じバージョンでユーザーの端末だけクラッシュする——これは Android が抱える、最も落ち着かないバグの一つで、原因はたいてい一つに絞られる。minifyEnabled true だ。デバッグビルドはシュリンクと難読化を完全にスキップするため、R8 が触れる部分は、すでに Play ストアに公開されているリリース APK——つまり自分では一度も実行したことのないコード——に到達するまで一度も検証されない。

これは難読化に反対する話ではない。Kotlin + Compose アプリのメソッド数を減らし、未使用コードを取り除くことは、インストールサイズやコールドスタートに実際に効果があり、Google もそれをますます期待している。修正すべきことはもっと小さい——コードベースのどの部分を R8 が静的に推論できないのかを知り、そこには触れないよう伝えることだ。

「明らかに動くはず」のものを R8 が壊す理由

R8 は、アプリのエントリーポイント——アクティビティ、マニフェスト、直接呼び出されるあらゆるもの——からの到達可能性をたどって、何を残すかを決める。リフレクションだけを通じて、文字列として保存されたクラス名を通じて、あるいはライブラリが実行時に型を再構築することを通じてしか到達しないコードは、この追跡からは見えない。R8 はそれをリネームまたは削除し、アプリは問題なくコンパイルされ、そのリフレクション経路が実際に実行された瞬間にだけ失敗が現れる。

典型的なローカルファーストの Android アプリで、これが実際に噛みつく二つの場所を挙げる。

クラス名でインスタンス化される WorkManager のワーカー

バックグラウンド処理をスケジュールしているなら——Hydrame のリマインダースケジューリングの背後にある種類のものだ——ListenableWorker のサブクラスは直接呼び出されない。WorkManager はワーカーの完全修飾クラス名を独自のデータベースに保持し、実際に処理が実行される時——それをスケジュールしたアプリのプロセスがとっくに消えている、数時間後のこともある——Class.forName でインスタンスを再構築する。そのクラス名を難読化すると、クラッシュはスケジュール時には起きない。後になって、静かに、あなたのコードを指し示すスタックトレースが一切ない ClassNotFoundException として、WorkManager のディスパッチャーの奥深くで起きる。

# proguard-rules.pro
-keep public class * extends androidx.work.ListenableWorker {
    public <init>(android.content.Context, androidx.work.WorkerParameters);
}

最近の work-runtime はこれに近い consumer ルールをすでに含んでいるが、「おそらくライブラリ側がすでにカバーしている」は、リリースビルドで鵜呑みにしてよいことではない——想定するのではなく、確認すること。

バックアップとエクスポートのためのリフレクションベース JSON

本物のエクスポート/インポート機能を持つローカルファーストアプリはどれも——Stocky がパントリーデータのバックアップに使っている種類のものだ——エンティティの data class のフィールドを JSON へ、そしてまた戻すシリアライズを行っている。そのシリアライズがリフレクションベースのライブラリ(Gson、あるいは codegen を使わない Moshi)を経由している場合、JSON のキーはデフォルトで Kotlin のプロパティ名になる。R8 はリネームしても安全だと判断した private フィールドをリネームし、生成されるエクスポートファイルはそれでも正常に見える。失敗が現れるのはインポート時だけで、リフレクションベースのリーダーが、縮小されたクラスにはもう存在しないフィールド名を探すことになる。クラッシュもエラーもなく、ただデータが静かに戻ってこないだけだ。

-keepclassmembers class com.example.app.data.** {
    <fields>;
}
-keepattributes Signature

これはコードベース全体ではなく、実際の data パッケージに絞ること——包括的な -keep class ** は、そもそも難読化する意味を失わせてしまう。

実際に出荷されるビルドをテストする

これを確実に捕まえる唯一の方法は、デバッグビルドではなくリリースビルドを実行することだ。

buildTypes {
    release {
        isMinifyEnabled = true
        isShrinkResources = true
        proguardFiles(
            getDefaultProguardFile("proguard-android-optimize.txt"),
            "proguard-rules.pro"
        )
    }
}

./gradlew assembleRelease を実行し、実機に APK をインストールし、提出のたびに主要なフローを一つずつ手で確認する——リマインダーをスケジュールし、データをエクスポートし、それから強制終了して再度開き、インポートがまだそれを読み戻せることを確認する。これは五分でできるチェックだが、そうしなければ Play Console のクラッシュレポートが埋まり始めるまで見えないままのバグのクラスに対する備えになる。サードパーティのクラッシュレポーターを組み込んでいないアプリにとって、それが事後に得られる唯一の可視性だからだ。

もう一つ持っておく価値のある習慣がある。R8 がリリースごとに生成する mapping.txt を保管しておくこと(Play Console はこれを求めてくるし、App Bundle 経由でアップロードすれば自動的にも行われる)。これがないと、本番で実際に届くクラッシュは、一文字だけのクラス名とメソッド名で埋め尽くされたスタックトレースになる——R8 には読めても、あなたには読めない。

まとめ

難読化は派手に失敗しない。手で再テストしなかった、たった一つのコードパスで、それを出荷したビルドがあなたの確認したすべてを通過してから何週間も経った後に失敗する。名前でインスタンス化されるあらゆるクラス——ワーカー、リフレクションベースのシリアライザー、ライブラリが文字列から再構築するものすべて——は、ライブラリのデフォルトが自分の代わりにカバーしてくれることを期待するものではなく、意図して書く keep ルールとして扱うこと。