java.lang.NullPointerException at a.a.a.b(SourceFile:0)。
この 1 行を渡したとき、モデルは「RecyclerView.Adapter の onBindViewHolder で、非同期に差し替えられたリストへ古い position でアクセスしています」と答えてきました。落ち着いた文体で、対処コードまで添えて。
私はしばらく、その答えを疑いませんでした。心当たりのある症状だったからです。ところが実際に該当箇所を追っていくと、a.a.a.b はアダプタではなく、画像のキャッシュキーを組み立てるユーティリティでした。RecyclerView はどこにも出てきません。
モデルは嘘をついたわけではありません。a.a.a.b という文字列には、もともと何の情報も残っていなかった。情報がない場所で「それらしいこと」を言わせれば、それらしい答えが返ってくる。当たり前のことを、私は 3 週間ほど見落としていました。
a.a.a.b(SourceFile:0) に、モデルは自信を持って答えました
個人開発で Android の壁紙アプリを運用していて、致命的でない例外——落ちはしないが握りつぶしている箇所——を自前のシンクに集めています。Crashlytics に上がるほどではないけれど、放っておくと表示が欠ける類のものです。
ここが盲点でした。Firebase のコンソールを開けば、スタックトレースは人間が読める名前で表示されます。Crashlytics の Gradle プラグインがビルド時に mapping ファイルを送っており、画面側で復元してくれているからです。私はその画面を毎日見ていたので、「Android のクラッシュログは読める形で手に入るもの」だと思い込んでいました。
自前で Thread.setDefaultUncaughtExceptionHandler や try-catch から拾った例外には、その復元が一切かかりません。
// 非致命的な例外を自前のシンクへ送る箇所。
// release ビルドでは e.stackTraceToString() の中身は R8 で名前が潰れています。
private fun reportNonFatal (e: Throwable , context: String ) {
val payload = NonFatalPayload (
versionCode = BuildConfig.VERSION_CODE, // ← 後でこれが効いてきます
versionName = BuildConfig.VERSION_NAME,
context = context,
stackTrace = e. stackTraceToString (), // release では "a.a.a.b(SourceFile:0)" 形式
)
sink. enqueue (payload)
}
debug ビルドで動かしている間は、この stackTrace は完全な形で出ます。R8 は release ビルドでしか走らないからです。手元では読める。本番では潰れている。そして本番のものだけが、私の分析パイプラインに流れ込んでいました。
Gemini に渡していたのは、後者です。
逆難読化は、確率で解く問題ではありません
ここが、この記事でいちばん伝えたいことです。
a.a.a.b から WallpaperCacheKeyBuilder.append を復元する作業は、答えが 1 つに決まる作業 です。mapping ファイルという対応表があり、そこを引けば正解が出る。曖昧さはありません。
一方、言語モデルがやっているのは、文脈から最もありそうな続きを選ぶことです。対応表がなければ、a.a.a.b から復元できるはずがない。それでも何かを答えさせれば、学習データの中で頻出する「Android でよくあるクラッシュ」を語り始めます。RecyclerView が出てきたのは、たぶんそういう理由です。
私は、決定的に解ける問題を確率的な仕組みに投げていました。これは Gemini の限界ではなく、私の設計の誤りです。
判断の軸として、こう置き直しました。対応表を引けば済むことは、対応表を引く。モデルには、表を引いた後にしか分からないことを聞く。
mapping.txt をプロンプトに同梱してはいけない
最初に思いついた対処は、素朴なものでした。「mapping ファイルもプロンプトに入れれば、モデルが照合してくれるのでは」。
手元の release ビルドの mapping ファイルを測って、この案は消えました。
項目 実測
mapping.txt のサイズ 6.8 MB
行数 約 91,000 行
countTokens での概算 約 200 万トークン相当
1 件のトレースが必要とする行数 6〜12 行
1 件のクラッシュを読むために必要なのは 10 行前後です。そのために 9 万行を毎回送る構成は、コスト以前に筋が通りません。仮に上限に収まったとしても、200 万トークンの表から正しい行を引き当てる作業を確率的な仕組みに任せることになります。前節の話がそのまま当てはまります。
モデルの入力上限は憶測せず、models.get の inputTokenLimit を引いて確かめる習慣をつけておくと、この種の判断が早くなります。この点は gemini-flash-latest が 3.5 Flash に切り替わっても深夜に事故らない「昇格ゲート」の設計 でも触れています。
retrace を前段に置く — 最小の実装
R8 には retrace という公式のツールが同梱されています。Android SDK の cmdline-tools を入れていれば、そのまま呼べます。
# mapping ファイルは release ビルドの出力に生成されます
ls app/build/outputs/mapping/release/mapping.txt
# トレースを渡すと、名前を復元して返します
$ANDROID_HOME/cmdline-tools/latest/bin/retrace \
app/build/outputs/mapping/release/mapping.txt \
crash.txt
# ファイルを渡さない場合は標準入力から読みます
cat crash.txt | $ANDROID_HOME /cmdline-tools/latest/bin/retrace mapping.txt
出力はこう変わります。
# Before(Gemini に渡していたもの)
java.lang.NullPointerException
at a.a.a.b(SourceFile:0)
at a.a.c.a(SourceFile:0)
# After(retrace を通したもの)
java.lang.NullPointerException
at net.dolice.wallpapers.cache.WallpaperCacheKeyBuilder.append(WallpaperCacheKeyBuilder.kt:47)
at net.dolice.wallpapers.cache.DiskCacheWriter.write(DiskCacheWriter.kt:112)
After の 3 行を渡したとき、モデルの答えは初めて RecyclerView から離れ、キャッシュキーの組み立てで null を握っている箇所を指しました。プロンプトは 1 文字も変えていません。変えたのは、渡す前の 1 工程だけです。
versionCode を取り違えた mapping は、静かに嘘をつきます
retrace を挟んだ直後、もう一段の落とし穴を踏みました。
R8 の名前の割り当ては、ビルドごとに変わります。v2.1.0 で a.a.a だったクラスが、v2.1.1 では別のクラスに割り当てられていても、何もおかしくありません。名前空間が再利用されるだけです。
つまり、バージョン違いの mapping で retrace を通しても、エラーにはなりません 。それらしいクラス名が、静かに出てきます。難読化されたまま渡すより質が悪い。人間もモデルも、復元された名前を疑わないからです。
だから mapping は versionCode でキーを付けて保管し、突合できたときだけ復元します。
状態 判定 Gemini への渡し方
versionCode 一致の mapping がある 復元する 復元済みトレース+根拠つきで診断させる
mapping が無い / 取得失敗 復元しない 難読化済みと明示し、断定を禁じて観点だけ返させる
versionCode が違う mapping しか無い 使わない 同上。「近いバージョンで代用」は絶対にしない
3 行目が要点です。手元にある mapping を「たぶん近いから」で使う誘惑は強いのですが、ここで妥協すると、以降の診断すべての土台が崩れます。この場合は、復元を諦めて 2 行目と同じ扱いに落とすことをお勧めします。私自身はここを気持ちではなくコードで塞ぎました。
行番号が消えていたら、まず R8 の設定を見ます
SourceFile:0 のように行番号が落ちていると、復元してもクラスとメソッドまでしか戻りません。原因の解像度が一段下がります。
AGP が用意している proguard-android-optimize.txt には、行番号を残すための属性が最初から含まれています。
// proguard-rules.pro — 既定ファイルに入っているものを、
// 自分で上書きしたり消したりしていないかをまず確認します
- keepattributes SourceFile , LineNumberTable
- renamesourcefileattribute SourceFile
ちなみに、Google Play Console の Android Vitals でも、mapping を上げてあれば復元済みの姿を見られます。読める画面がいくつもあることが、かえって「自分のパイプラインに流れている文字列」への注意を薄くしていました。
私の場合、ここは触っていませんでした。行番号が消えていた理由は別のところ——シンク側でトレースを保存する際、行数を切り詰めるつもりで書いた整形処理が、末尾の行番号ごと落としていた——にありました。犯人が R8 とは限らない、というのが教訓です。
build.gradle.kts の側では、Crashlytics に mapping を送る設定と、自分の保管先に mapping を残す設定は別物である点を意識しておくと迷いません。
android {
buildTypes {
release {
isMinifyEnabled = true
proguardFiles (
getDefaultProguardFile ( "proguard-android-optimize.txt" ),
"proguard-rules.pro"
)
// Crashlytics 画面での復元用。自前パイプラインはこれとは無関係に
// mapping.txt を versionCode つきで保管する必要があります
configure < com . google . firebase . crashlytics . buildtools . gradle . CrashlyticsExtension > {
mappingFileUploadEnabled = true
}
}
}
}
CI のリリースジョブで app/build/outputs/mapping/release/mapping.txt を mapping/{versionCode}.txt として保管する。この 1 行を足すだけで、後段が成立します。
それでも復元できないトレースを、どう扱うか
mapping が失われているバージョン、外部から共有されたログ、versionCode が取れないもの。復元できないトレースは必ず残ります。
これを捨てるのは惜しく、そのまま診断させるのは危険です。私が落ち着いたのは、断定を構造で禁じる という形でした。
復元済みと難読化済みで、指示を分ける
from google import genai
from google.genai import types
client = genai.Client() # 環境変数 GEMINI_API_KEY を参照します
DIAGNOSIS_SCHEMA = {
"type" : "object" ,
"properties" : {
# 復元済みトレースの、実在する行だけを引用させます。
# 難読化済みなら a.a.a.b がそのまま入り、後段で弾けます
"evidence_frames" : {
"type" : "array" ,
"items" : { "type" : "string" },
"minItems" : 1 ,
},
"root_cause" : { "type" : "string" },
# 難読化済みのときは low 以外を選べないよう、指示側で縛ります
"confidence" : { "type" : "string" , "enum" : [ "high" , "medium" , "low" ]},
# low のときは、原因ではなく「確認すべき箇所」を返させます
"next_checks" : { "type" : "array" , "items" : { "type" : "string" }},
},
"required" : [ "evidence_frames" , "root_cause" , "confidence" , "next_checks" ],
}
DEOBFUSCATED_INSTRUCTION = (
"あなたは Android の例外を診断します。"
"スタックトレースは R8 の mapping で復元済みで、クラス名とメソッド名は実名です。"
"evidence_frames には、渡されたトレースに実在する行だけをそのまま引用してください。"
"引用できない推測は書かないでください。"
)
OBFUSCATED_INSTRUCTION = (
"あなたは Android の例外を診断します。"
"以下のスタックトレースは R8 で難読化されており、クラス名とメソッド名は失われています。"
"実名を推測してはいけません。RecyclerView や Glide など、"
"具体的なライブラリ名を根拠なく挙げることを禁じます。"
"confidence は必ず low とし、root_cause には "
"『難読化のため特定不可』と書き、next_checks に "
"『どの mapping を探せば復元できるか』の観点のみを列挙してください。"
)
def diagnose (stack_trace: str , deobfuscated: bool ) -> dict :
resp = client.models.generate_content(
model = "gemini-3.5-flash" ,
contents = stack_trace,
config = types.GenerateContentConfig(
system_instruction = (
DEOBFUSCATED_INSTRUCTION if deobfuscated else OBFUSCATED_INSTRUCTION
),
response_mime_type = "application/json" ,
response_schema = DIAGNOSIS_SCHEMA ,
temperature = 0.0 ,
),
)
return resp.parsed
根拠を照合して、合わなければ落とす
evidence_frames を必須にしたのが効きました。引用させると、モデルは「トレースに無い行」を書けなくなります。書いてしまった場合も、後段の照合で機械的に弾けます。
def verify (result: dict , stack_trace: str ) -> tuple[ bool , str ]:
"""トレースに実在しない行を引用していたら、その診断は採用しません。"""
for frame in result[ "evidence_frames" ]:
if frame.strip() not in stack_trace:
return False , f "実在しない行を引用しています: { frame } "
if not result[ "evidence_frames" ]:
return False , "根拠が空です"
return True , "ok"
出力を機械的に検証する型そのものは、Gemini API の Structured Output でバリデーションエラーが返るときの原因と対処 と Gemini を使ったアプリで「間違った回答」を静かに検知する — 本番で回す自動評価ループの実装 で扱っている考え方の延長にあります。ここではその検証対象を「診断の根拠」に絞っています。
3 つを 1 本に畳む
パイプライン全体は、こう畳めます。
import subprocess, pathlib
MAPPING_DIR = pathlib.Path( "mapping" ) # mapping/{versionCode}.txt を置いた場所
RETRACE = pathlib.Path( "/opt/android-sdk/cmdline-tools/latest/bin/retrace" )
def resolve_mapping (version_code: int ) -> pathlib.Path | None :
"""近いバージョンでの代用はしません。完全一致だけを返します。"""
path = MAPPING_DIR / f " { version_code } .txt"
return path if path.exists() else None
def retrace (stack_trace: str , mapping: pathlib.Path) -> str :
proc = subprocess.run(
[ str ( RETRACE ), str (mapping)],
input = stack_trace, capture_output = True , text = True , timeout = 30 ,
)
if proc.returncode != 0 :
raise RuntimeError ( f "retrace failed: { proc.stderr.strip() } " )
return proc.stdout
def analyze (payload: dict ) -> dict :
mapping = resolve_mapping(payload[ "versionCode" ])
if mapping is None :
# 復元できない:断定を禁じたまま、観点だけ受け取ります
return diagnose(payload[ "stackTrace" ], deobfuscated = False )
restored = retrace(payload[ "stackTrace" ], mapping)
result = diagnose(restored, deobfuscated = True )
ok, reason = verify(result, restored)
if not ok:
result[ "confidence" ] = "low"
result[ "next_checks" ].insert( 0 , f "根拠の照合に失敗しました: { reason } " )
return result
resolve_mapping が完全一致しか返さないのが、前節の 3 行目をコードで塞いだ部分です。妥協したくなる場所を、実行時ではなく設計時に潰しておきます。
42件で数え直した — 前後で変わったこと
自前シンクに溜まっていた非致命的例外のうち、直近の 42 件で、retrace の有無だけを変えて診断を取り直しました。プロンプトもモデルも同じです。判定は私が該当コードを追って手で付けました。
診断の結果 retrace なし retrace あり
根本原因まで当たった 11 件 29 件
もっともらしいが的外れ 19 件 6 件
判断できないと返した 12 件 7 件
数字そのものより、私が重く受け止めたのは真ん中の行です。retrace なしでは 42 件中 19 件、およそ 45% が 放っておけばそのまま実装に手を付けていた 類の答えでした。retrace を挟むと、その割合は 14% まで下がります。「判断できない」と返してくれるほうが、まだ安全です。
その 19 件の内訳を眺めると、傾向がありました。挙げられたライブラリ名は RecyclerView、Glide、Room に偏っていた。どれも Android で頻出する、つまり学習データに多く出てくるものです。情報のない入力に対して、モデルは「よくある話」を返す。そう理解すると、責める気持ちは失せました。悪いのは、情報を渡さずに聞いた側です。
retrace あり側に残った 6 件は、そもそも例外の発生箇所と原因が離れているもの(別スレッドで壊した状態を後で踏む類)でした。これは復元では解けません。それはまた別の設計の話になります。
Crashlytics 側のログを起点にした分析パイプライン全体の組み方は、Firebase Crashlytics のクラッシュログを Gemini API で自動分析した記録 — 個人開発アプリ v2.1.0 での実証 にまとめています。あちらの記事では mapping について「プロンプトに含めると改善します」と書いていました。今の私の見立ては、この記事に書いたとおりです。前段で決定的に復元し、モデルには復元後にしか分からないことを聞く。書いた当時より、少しだけ分かったことがありました。
まず1件、release ビルドのトレースを retrace に通す
やることは 1 つだけです。手元の release ビルドで例外を 1 件起こし、そのトレースを retrace に通して、通す前と後の両方を同じプロンプトで Gemini に渡してみてください。
答えが変わらなければ、そのトレースはもともと十分な情報を持っていたということです。変わったなら、これまでの診断のうち何割かは、a.a.a.b に向かって投げた質問への「それらしい返事」だったことになります。
私はそこで少し胸が冷えました。同じ思いをする方が一人でも減れば、書いた甲斐があります。