ストアの審査も通り、配信も始まったアプリで、ある画面を開いた瞬間だけ落ちる、という報告が一件だけ届きました。手元の開発ビルドでは何度開いても再現しません。Expo Go でも、expo run:android のデバッグビルドでも素通りします。落ちるのは、Play からダウンロードした本番の AAB だけでした。
原因は、直前に「アプリサイズを少しでも削りたい」と思って有効化した R8 のコード圧縮でした。R8 が「どこからも参照されていない」と判断したクラスを取り除いた結果、実行時にそのクラスを探して見つからず落ちていたのです。同じ轍を踏まないよう、症状の見分け方と keep ルールの当て方を残しておきます。
デバッグでは出ず本番だけ落ちる、という症状
このクラッシュには、はっきりした手触りがあります。
開発中は一切再現しません。minifyEnabled が無効なデバッグビルドではコードがそのまま残るため、剥がされるクラスが存在しないからです。圧縮が走るのはリリースビルドだけなので、症状もリリースビルドだけに現れます。
クラッシュログを見ると、例外は ClassNotFoundException や NoSuchMethodError、あるいは Kotlin の NullPointerException として出ます。そしてスタックトレースのクラス名が a.a.b のような短い記号に置き換わっています。この難読化された名前こそ、R8 が手を入れた印です。
特定の画面・特定の機能だけで落ちるのも特徴です。アプリ全体ではなく、リフレクションや JSON のデシリアライズを使っている箇所に偏ります。
なぜ参照しているクラスが消えるのか
R8 は、コードを静的にたどって「使われているか」を判定します。通常のメソッド呼び出しは追跡できますが、文字列のクラス名から実体を取り出すリフレクションや、ネイティブ側から呼ばれるブリッジ、JSON ライブラリがフィールド名をもとに値を詰めるモデルクラスは、静的解析からは「誰も呼んでいない」ように見えます。
そのため、実際には実行時に必要なクラスやフィールドが、未使用とみなされて削除されたり、名前を短く付け替えられたりします。Gson のようなライブラリがフィールド名に依存している場合、名前が変われば対応が取れず、値が空になったり例外になったりします。
つまり「コードが間違っている」のではなく、「R8 に必要だと伝えられていない」状態です。直し方は、消してほしくない部分を keep ルールで明示することに尽きます。
剥がされたクラスを mapping.txt で突き止める
闇雲に keep を足す前に、何が剥がされたのかを特定します。R8 はビルドのたびに難読化の対応表を出力します。
android/app/build/outputs/mapping/release/mapping.txt
このファイルには「難読化後の名前 → 元の名前」の対応が入っています。EAS Build で作った場合は、ビルドの成果物(Artifacts)から同じ mapping.txt を取得できます。
クラッシュした端末のスタックトレースに出ている a.a.b のような記号を、mapping.txt で逆引きすると、元のクラス名が分かります。Google Play Console を使っている場合は、この mapping.txt をアプリのバージョンに紐づけてアップロードしておくと、Console 上のクラッシュレポートが自動で元の名前に復元されて読めるようになります。私はこれを忘れて、記号の羅列のまま数十分悩みました。
元のクラス名さえ分かれば、あとはそのクラスが「なぜ静的解析から見えなかったのか」を考えます。多くはリフレクション経由か、シリアライズ用のモデルです。
expo-build-properties で keep ルールを当てる
Expo の管理ワークフローでは、android/ を直接編集しても prebuild で上書きされます。R8 の設定や keep ルールは expo-build-properties プラグイン経由で渡します。
剥がされたモデルクラス群を残すには、パッケージ単位で keep します。
{
"expo": {
"plugins": [
[
"expo-build-properties",
{
"android": {
"enableProguardInReleaseBuilds": true,
"enableShrinkResourcesInReleaseBuilds": true,
"extraProguardRules": "-keepattributes Signature\n-keepattributes *Annotation*\n-keep class com.yourapp.models.** { *; }"
}
}
]
]
}
}
com.yourapp.models.** は、自分のアプリの実際のパッケージ名に置き換えてください。-keepattributes の2行は、Gson などがジェネリクスやアノテーションを参照する場合に必要になります。
enum を文字列から復元している箇所があるなら、values() と valueOf() を残しておくと安全です。
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
ネイティブ側やライブラリから名前で呼ばれるブリッジクラスがあれば、それも個別に keep します。
-keep class com.yourapp.NativeBridge { *; }
ルールを書いたら npx expo prebuild --clean で android/ を作り直し、リリースビルドを通します。私の場合は、モデルのパッケージを keep した時点で当該画面のクラッシュは止まりました。個人開発で複数のアプリを配信していると、こうした「本番だけ」の不具合に一人で向き合う場面が時々あります。圧縮そのものを諦めずに済んだのは収穫でした。
同じ事故を繰り返さないための備え
私自身、R8 を有効にした直後は、必ず実機でリリースビルドを一周触るようにしています。配信用と同じ AAB を --local でビルドして手元の端末に入れ、主要画面を順に開くだけで、剥がれ由来のクラッシュはほぼ事前に見つかります。
mapping.txt はビルドごとに変わるので、リリースしたバージョンの分を必ず保管するか、Play Console にアップロードしておきます。これがないと、本番のクラッシュを難読化されたまま読むことになり、原因の特定が一段重くなります。
keep ルールは、広く -keep class com.yourapp.** { *; } と一括で当てたくなりますが、それでは圧縮の効果が薄れます。剥がれた範囲だけを最小限で残すほうが、サイズ削減と安定の両立に近づきます。
アプリのサイズ削減そのものについては、Rork アプリのバンドルサイズを減らす実践ガイド に R8 以外の手も整理しています。あわせて読んでいただけると、削るところと残すところの線引きがしやすくなるはずです。
次の一手としては、いま配信中のアプリで enableProguardInReleaseBuilds を有効にしているなら、手元で --local のリリースビルドを一本作って主要画面を触ってみてください。剥がれが潜んでいれば、ユーザーより先に気づけます。同じ課題に取り組んでいる方の参考になれば幸いです。