ある朝、Crashlytics に見覚えのない NullPointerException が並んでいました。スタックトレースの一番上は a.b.c.d のような難読化された記号だけで、自分が書いた覚えのないクラス名です。手元の端末でデバッグビルドを動かしても、まったく再現しません。リリースして数時間後、特定の画面を開いたユーザーだけが落ちている——個人開発で広告付きの壁紙アプリを長く運用してきた中で、この「リリースビルドだけで落ちる」種類のクラッシュには何度か足をすくわれてきました。
原因はたいてい同じ場所にあります。R8 のフルモード難読化が、Gson がリフレクションで読み書きしているデータクラスのフィールド名を書き換えてしまい、JSON との対応が取れなくなっているのです。デバッグビルドは難読化が無効なので何事もなく動き、リリースでだけ壊れる。だから再現が難しく、原因にたどり着くまでに時間を溶かします。
私自身が実際にこの問題を踏んだときの調べ方と、最終的にどう直したかを書き残しておきます。「とりあえず全部 keep すれば消える」というのは事実ですが、それは設計判断を放棄しているだけで、難読化の恩恵もアプリサイズの縮小も同時に捨てています。壊れたクラスだけを最小限に守る、というところまで一緒に詰めていきます。
なぜデバッグでは起きずリリースでだけ落ちるのか
Android のリリースビルドでは、minifyEnabled true のときに R8 がコードの圧縮(未使用コードの削除)と難読化(クラス名・メソッド名・フィールド名の短縮)を行います。AGP 8.0 以降は R8 が標準のコードシュリンカーで、さらに「フルモード」が既定で有効になりました。フルモードは旧 ProGuard 互換モードより踏み込んだ最適化を行い、keep ルールで明示的に守られていないものは、より積極的に削除・改名します。
問題は、Gson がフィールドを「名前」で突き合わせている点です。{"display_name": "夕焼け"} という JSON を data class Category(val displayName: String) にマッピングするとき、Gson は実行時にリフレクションでフィールド名を読み、そこにアノテーションがなければフィールド名そのものを JSON キーとして使おうとします。ところが R8 がそのフィールドを a という名前に書き換えてしまうと、Gson から見えるのは a であって displayName ではありません。結果、JSON のキーと一致せず、その値は埋まらないまま null になります。
デバッグビルドでは minifyEnabled false なのでフィールド名は元のまま残り、何も壊れません。この非対称性が、再現の難しさの正体です。コードを書いた本人が手元で動かす限り、まず再現しないのです。
手元でリリースの挙動を再現する
原因を推測で潰すと時間がかかります。まず、ローカルでリリースビルドの挙動を確実に再現させてしまうのが近道です。
# リリースビルドを生成して実機/エミュレータに入れる
./gradlew assembleRelease
# 署名済みリリース APK をインストール(debuggable でなくても adb install は通る)
adb install -r app/build/outputs/apk/release/app-release.apk
これで手元でも本番と同じ難読化済みコードが動きます。「リリースだけで落ちる」と聞くと本番でしか調べられない気がしますが、assembleRelease を一度回すだけで、自分の端末で何度でも再現できる状態に持ち込めます。私はここを飛ばして本番ログとにらめっこしていた時期があり、それが一番の時間の無駄でした。
落ちる画面を開いてクラッシュさせ、adb logcat でスタックトレースを取ります。ただしこの時点のスタックトレースはまだ難読化された記号のままなので、次に元の名前へ戻す作業が必要になります。
mapping.txt と retrace で難読化を元に戻す
R8 は難読化のたびに、元の名前と短縮後の名前の対応表 mapping.txt を出力します。これがないと難読化済みのスタックトレースは読めません。
app/build/outputs/mapping/release/mapping.txt
このファイルはビルドごとに変わるため、ストアに出したビルドの mapping.txt を必ず保管しておく必要があります(Crashlytics や Play Console にアップロードしておくと、難読化済みのレポートを自動で復元してくれます)。手元の logcat を復元するには、AGP に同梱の retrace を使います。
# logcat のクラッシュ部分を crash.txt に保存しておき、元の名前へ復元する
retrace app/build/outputs/mapping/release/mapping.txt crash.txt
復元すると、a.b.c.d だった行が、たとえば com.example.wallpaper.data.CategoryRepository.load のように元の名前に戻ります。ここで初めて「ああ、カテゴリ設定の JSON をパースしているところだ」と分かる。難読化済みのまま眺めていても永遠に見えてこなかった景色が、一気に開けます。
Antigravity のようなエージェント型 IDE に復元後のスタックトレースとマッピング前後の差分を渡すと、「このクラスは Gson でデシリアライズされており、フィールドが難読化されると @SerializedName がない限りマッピングが外れます」といった当たりを早く付けてくれます。ただし最終的にどの keep ルールを採用するかは、アプリのサイズと安全性のトレードオフを自分で引き受ける判断なので、エージェントの提案は出発点として扱うのが安全だと感じています。
何を keep すべきか — 4つの選択肢
原因が「Gson が見るデータクラスのフィールドが難読化された」だと分かったら、直し方は一つではありません。よく使う4つを、副作用と一緒に並べます。
| 方法 | 守る範囲 | 長所 | 注意点 |
| @Keep アノテーション | 付けたクラス/メンバーのみ | ルールがコード側に残り、移動・リネームに強い | クラス単位だと丸ごと残るためサイズ削減効果は薄い |
| -keep class ルール | パッケージ/クラス指定 | モデルをまとめて一括指定できる | 広く書くと未使用フィールドまで残る。範囲が肥大しやすい |
| @SerializedName | JSON キーとの対応のみ | 難読化されても JSON キーが固定。最も堅牢 | 全フィールドへの付与が必要。keep と併用が前提 |
| -keepclassmembers | メンバー(フィールド)のみ | クラスは縮小しつつフィールド名だけ保持 | ルールが少し複雑。対象の絞り込みが必要 |
私が最終的に落ち着いたのは、@SerializedName でフィールド名と JSON キーを切り離したうえで、データクラスのメンバーを -keepclassmembers で守る組み合わせです。理由は次の節で詳しく書きますが、ひとことで言えば「難読化は効かせたまま、Gson が依存している名前だけを固定する」のが、サイズと安全性のバランスが一番良かったからです。
@SerializedName でキーを固定し、最小の keep で守る
まず、データクラスのフィールド名に依存しない形へ書き換えます。@SerializedName を付けると、フィールド名が a に難読化されても、Gson は常にアノテーションで指定したキーを見ます。
import com.google.gson.annotations.SerializedName
data class Category(
// フィールド名が難読化されても、JSON キーは "display_name" に固定される
@SerializedName("display_name") val displayName: String,
@SerializedName("wallpaper_count") val wallpaperCount: Int,
@SerializedName("is_premium") val isPremium: Boolean = false,
)
ただし @SerializedName だけでは不十分な場合があります。R8 フルモードは、未使用と判断したフィールドそのものを削除したり、Gson が Unsafe 経由で呼ぶ引数なしコンストラクタを消したりすることがあるためです。そこで、モデルのメンバーとコンストラクタを keep します。
# proguard-rules.pro
# data クラスのメンバーを保持(フィールドの削除・改名を防ぐ)
-keepclassmembers class com.example.wallpaper.data.** {
<fields>;
<init>(...);
}
# ジェネリクスを使うモデル(List<Category> など)は型情報も必要
-keepattributes Signature
# Gson が内部で参照するアノテーションを保持
-keepattributes *Annotation*
-keepattributes Signature は地味ですが重要です。List<Category> のようにジェネリック型でデシリアライズする場合、R8 が型シグネチャを落とすと Gson が要素の型を解決できず、LinkedTreeMap のような汎用型に化けて ClassCastException を投げます。これは @SerializedName を付けても直らないので、見落としやすい二段目の罠です。
<init>(...) を keep しているのは、フルモードがデータクラスの引数なしコンストラクタを未使用とみなして削ることがあるためです。Gson はこのコンストラクタをリフレクションで呼ぶので、消えるとインスタンス生成の段階で落ちます。
直ったことをリリースビルドで検証する
keep ルールを足したら、修正版でもう一度リリースビルドを作り、手元で再現手順を踏みます。「ビルドが通った=直った」ではありません。難読化が絡むバグは、難読化済みのバイナリで実際に該当画面を開くまで直ったと言い切れない、というのが私の実感です。
./gradlew clean assembleRelease
adb install -r app/build/outputs/apk/release/app-release.apk
# 落ちていた画面を開き、JSON 由来の値(カテゴリ名など)が正しく表示されるか目視確認
合わせて、mapping.txt の差分も確認しておくと安心です。守りたいフィールドが元の名前のまま残り、それ以外はちゃんと難読化されていれば、keep の範囲が広すぎていない証拠になります。
# 守ったフィールドが元名で残っているか(= keep が効いている)
grep "displayName" app/build/outputs/mapping/release/mapping.txt
私の壁紙アプリの場合、この修正を入れた版を段階公開で 5% から流し、Crashlytics の該当クラッシュが新しいビルドで一件も出ないことを確認してから 100% へ広げました。リリースだけで落ちる種類のバグは、デバッグでの「動いた」を信用しすぎないことが、結局いちばんの近道になります。
リリース固有のクラッシュをもう少し広く扱った話としては、古い Android 端末だけで落ちる coreLibraryDesugaring の盲点や、難読化済みレポートの解析を自動化するFirebase Crashlytics の解析自動化も、同じ「本番でしか出ない不具合」の系列として役に立つはずです。
再発させないための運用メモ
最後に、同じ穴に二度落ちないための運用を3つだけ。第一に、JSON にマッピングするモデルには最初から @SerializedName を付ける習慣にしておくこと。難読化が原因の不具合は、後から踏むと再現に時間がかかるので、書く時点で予防するのが一番安いです。第二に、ストアに出したビルドの mapping.txt を必ず保管し、Crashlytics と Play Console にアップロードしておくこと。これがないと、本番のクラッシュレポートが難読化済みのまま届いて読めません。第三に、リリースのたびに「最低限、JSON を読む主要画面をリリースビルドで一度開く」というチェックを CI かリリース手順に組み込むこと。
R8 のフルモードは、放っておけば勝手にアプリを軽く・速くしてくれる優秀な仕組みです。ただ、リフレクションに依存する箇所だけは、こちらが意図を明示しないと静かに壊れます。「全部 keep」で蓋をするのではなく、壊れた一点だけを最小限に守る——その線引きを自分のアプリで一度引いておくと、次のリリースからはずいぶん楽になります。