5 月 19 日に Android の壁紙アプリ v2.1.1 のホットフィックスをほぼ書き終えたあと、Crashlytics を眺めながらコーヒーを淹れていたら、Play Vitals の「ユーザー認識クラッシュ発生率」が 1.08% に上がっていました。Google の「不正な動作」フラグが立つしきい値が 1.09% なので、あと 0.01%。
廣川政樹です。2014 年から個人開発を続けていて、累計 5,000 万 DL ほどの iOS/Android アプリを一人で保守しています。壁紙系の主力 2 本(綺麗な壁紙・浮世絵壁紙)はこの 5 月にメジャーアップデート(v2.0.0 / v1.7.0)を投げたばかりで、リリース直後の Crashlytics は新規バグの祭りでした。11 日間で 30 件以上のスタックトレースを掘り、そのうち 13 件を確定でコードに落とし、6 件を OS・SDK 起因として塩漬け判定にしました。この記事はその工程を、Claude Code の使い方を中心にして公開する実装ログです。
「個人開発で Crashlytics を毎日チェックする習慣はあるけれど、スタックトレースを読みきれずに『そのうち直す』が溜まっている」「Play Vitals のしきい値接触で焦って手当てしたら別の不具合を出した」あたりで困っている方を想定読者に置いています。
11 日間の地図 — 何を直して、何を直さなかったか
最初に全体像を出しておきます。期間は v2.1.0 リリース後の観察期から、v2.1.1 / v1.8.1 を Play Console に投げた 5 月 27 日まで。
5/12 (Day 1) : v2.0.0 リリース直後の Crashlytics 新規 issue 12 件を仕分け。RecyclerView$Recycler.tryGetViewHolderForPositionByDeadline の IOOBE が 28 日で 50+ ユーザーまで急増していたのを発見
5/13 (Day 2) : Android 6.0.1 ユーザー全員が起動 3 秒でクラッシュする j.u.function.Supplier NoClassDefFoundError を発見・即日修正。coreLibraryDesugaringEnabled を有効化するだけの 1 行修正
5/13 (Day 2 後半) : Gallery.onResume の入れ子構造で、戻る時のインタースティシャル広告がリワード誘導に勝てない論理を解体
5/14〜18 (Day 3〜7) : v2.1.0 をリリース、観察期。BW-015 (PageJumpSlider の NPE) が 1 user → 9 user に伸張。BW-009 (R8 strict mode + density split で drawable がストリップされる) が継続発生
5/19 (Day 8) : 上の 1.08% スパイクが見えた日。ViewPager2 内部 RecyclerView の fling 中 IOOBE (BW-017) を setItemAnimator(null) + setHasFixedSize(true) の 2 行で潰し、Glide プレースホルダーの同期デコード ANR (BW-027) を ColorDrawable 置換で潰す
5/22 (Day 11) : API 37 エミュレーターで Material You が overlay を再生成して CategoryActivity が真っ白になる無音 UX バグを再現・修正
5/25 (Day 14) : Play Console + Crashlytics を全件再精査。残存リスクの BW-015 / UE-007 / BW-009 を v2.1.1 / v1.8.1 で塩漬け撤回・先行リリース確定
5/27 (Today) : v2.1.1 / v1.8.1 を Play Console に審査提出
この期間、Claude Code のセッションは合計 27 回起動しました。1 回あたりだいたい 30 分〜2 時間。コードの実装と Crashlytics の確認、両方を 1 つのセッションに混ぜないのが安定運用のコツだったので、後述します。
スタックトレースを Claude Code に投げる時の前処理
Crashlytics や Play Console の「クラッシュの問題」ページからスタックトレースをコピーすると、ほぼ毎回 200〜400 行になります。これを生のまま貼ると Claude Code のコンテキストを浪費する上に、原因仮説が曖昧になりがちです。私は次の形に整形してから投げる癖をつけました。
## 環境
- アプリ: 綺麗な壁紙 v2.1.0 (vc=49)
- 端末: Sharp AQUOS wish4 (DOCOMO SH-52E)
- OS: Android 15 (SDK 35)
- 発生: 7日で 117 events / 99 users(74.4% 寄与)
## トップ 5 フレーム(関係ありそうな上位だけ抜粋)
RecyclerView$Recycler.tryGetViewHolderForPositionByDeadline (line 7160)
LinearLayoutManager.scrollBy
RecyclerView.scrollHorizontallyBy
RecyclerView.scrollStep
RecyclerView$ViewFlinger.run
## メッセージ
Inconsistency detected. Invalid item position 1(offset:1).state:1030
## 既知パターン早見表での当該行
- "ViewPager2 内部 RV の fling 中"
- 修正候補: setItemAnimator(null), setHasFixedSize(true), Predictive Animation を切る
## 実装側に予想がついている部分
ThumbnailActivity の ViewPager2 で setAdapter 直後に
mViewPager.getChildAt(0) で内部 RecyclerView を取って
チューニングを当てれば直りそう。Gallery 側にも予防的に当てたい。
Claude Code に投げる前に、自分なりの仮説と「どのファイルを触りたいか」を 3 行でも書いておくと、出力が一気にコードに寄ります。なにも書かずに丸投げすると、解説 8 割・コード 2 割の応答になりがちで、それは個人開発の保守フェーズでは欲しい形ではありません。
このスタックトレースを Claude Code にこのまま貼ると、ほぼ確実に次のような修正案を返してきます。
// ThumbnailActivity#ThumbnailPager.start() に追加
mViewPager. setAdapter (mAdapter);
View inner = mViewPager. getChildAt ( 0 );
if (inner instanceof RecyclerView) {
RecyclerView rv = (RecyclerView) inner;
rv. setItemAnimator ( null ); // Predictive Animation 由来の状態不整合を抑制
rv. setHasFixedSize ( true ); // ノートブック型再計測のスキップ
}
これは v2.1.0 → v2.1.1 で実際に入れた修正そのままです。修正前は 7 日で 117 events / 99 users、修正後は v2.1.1 リリース後の観察対象として「>99% 帯への改善見込み」と判定しました(クラッシュフリーユーザー 97.98% からの引き上げ)。
setItemAnimator(null) は副作用があります。私の場合、PageJumpSlider のドラッグで setCurrentItem(largePosition, false) を呼んだ時に LayoutManager の再 layout がスキップされて壁紙が変わらないバグ(BW-032)を別途踏みました。Claude Code に「setItemAnimator(null) で何が変わるか、ViewPager2 の挙動に副作用がないか教えて」と聞いておくと、こういう罠を事前に拾えます。
既知バグトラッカーを「Claude が読める形」で保持する
私は known-bugs-tracker.md というファイルを 1 つのアプリにつき 1 本持っています。中身は単純なマークダウンですが、Claude Code が読んだ時に最速で当該バグを引き当てられる構造に整えてあります。
### v2.1.0(公開中、vc=49)の既知バグ
| ID | バグ内容 | スタックトレース起点 | ステータス | 影響規模 | 修正先 |
|---|---|---|---|---|---|
| BW-017 | RecyclerView IOOBE(fling 中) | `RecyclerView$Recycler.tryGetViewHolderForPositionByDeadline` → `RecyclerView$ViewFlinger.run` | 緑(v2.1.1 で修正) | 7日で 117 events / 99 users、v2.1.0 のみ 74.4% | setItemAnimator(null) + setHasFixedSize(true) |
| BW-027 | Glide プレースホルダーが同期デコード ANR | `libhwui ImageDecoder_nDecodeBitmap` → `Glide.SingleRequest.getPlaceholderDrawable` | 緑(v2.1.1 で修正) | 7日で 1 event / 1 user / Sharp AQUOS SX4 | R.drawable.background_silver(220KB JPG)を XML ColorDrawable に置換 |
この表のあとに「既知パターン早見表」というセクションを持っています。Crashlytics のタイトル文字列を 1 行マッピングしただけのものです。
| 文字列(Crashlytics タイトル) | 該当 ID | 備考 |
|---|---|---|
| `RecyclerView$Recycler.tryGetViewHolderForPositionByDeadline` IOOBE | BW-002 / BW-007 / BW-017 / UE-004 / UE-009 | ViewPager2 内部 RV の fling 中。v2.1.1 / v1.8.1 で `setItemAnimator(null)` 適用済 |
| `Glide.SingleRequest.getPlaceholderDrawable` + ANR | BW-027 / UE-016 | JPG プレースホルダーの同期デコード。v2.1.1 / v1.8.1 で XML ColorDrawable に置換 |
| `[libart.so] art::DumpNativeStack` ANR | UE-013 | ART ランタイム自身のスタックダンプ中。低 DAU 統計ノイズ |
新しい Crashlytics 通知が来たら、まずこの早見表をテキスト検索します。ヒットすれば既知。ヒットしなければ Claude Code を起動して仮説生成 → 該当行を追記、というループです。11 日間で 30 件のうち、約半数はこの早見表に既知としてマッチしました。残り半分のうち、自分のコードで直せるのは更にその半分、残りは OS や SDK 内部の問題で塩漬け判定 — このざっくり 4 分割を見えるようにしておくと、毎朝の Crashlytics チェックが 5 分で終わるようになります。
「個人で直せないバグ」を堂々と塩漬けにするための区分
11 日間で Crashlytics に出てきたバグのうち、6 件は私の側のコードでは絶対に直せない種類のものでした。Play Console と Crashlytics の両方で「新規」フラグが立つので、放置すると毎朝の確認のたびにストレスが溜まります。これらを最初から「対応不可」として分類できる根拠を、Claude Code に整理してもらった結果が下記です。
LinkedHashMap$LinkedHashIterator.nextNode + zzbiv (play-services-ads 25.2.0) — Google Mobile Ads SDK 25.x 内部の並行アクセスバグ。私の側で並行性を気にしても何も変わらない。SDK 更新待ち
com.applovin.impl.p.onCreate NPE / com.applovin.impl.g0.a NPE — AppLovin SDK 内部の null 参照。難読化されていてアプリ側で防げない。SDK 更新を待つしかない
Parcel.readFloatArray "bad array lengths" — OS の WindowManager IPC バグ。アプリ側からの介入は不能
GoogleCertificatesRslt SecurityException — Google Play Services が apk 署名を検証できない端末(GMS なし/root/サードパーティストア配布等)。改造 APK の可能性も高い
[libc.so] __ioctl ANR + 「バインダー呼び出しが遅い」タグ — Sharp AQUOS の特定機種でディスプレイ HAL レイテンシが原因のシステム側 ANR。OS 側の問題
BaseDexClassLoader.findClass com.rini.xanrn.Wish — 改造 APK/マルウェアラッパー。正規ストア経由ユーザーへの影響なし
ここで重要なのは「直さない判断」を Claude Code に書いてもらって、known-bugs-tracker.md に証拠と一緒に残すことです。次に同じスタックトレースが出てきた時、過去の自分が「これは SDK 待ちです」と書いた行を読めば、3 秒で判定が終わります。known-bugs-tracker.md の「ステータス凡例」に「対応不可(外部要因)」というステータスを最初から用意しておくと、塩漬け判定が運用に乗ります。
両家の祖父がともに宮大工で、「直せるものと、見守るしかないものは分けるべし」というのを近くで見て育ったのですが、ソフトウェアのバグ対応にも同じ構造があると最近感じています。SDK 内部の null 参照に時間を投じても、自分の家のコードはきれいになりません。
Play Vitals のしきい値接触で何を入れ、何を見送ったか
5/19 に上述の 1.08% を見て、当初は塩漬け予定だった v2.1.1 を「7 月の targetSdk 37 移行と一緒にまとめてリリース」する方針を撤回しました。release-plan-v2.1.1-v1.8.1.md というファイルを Claude Code に作ってもらって、何を v2.1.1 に入れて何を入れないか、優先度順に並べました。
v2.1.1 に入れた修正は以下 7 件です。
BW-017 / UE-009 — ViewPager2 内部 RecyclerView の fling 中 IOOBE。setItemAnimator(null) + setHasFixedSize(true) を ThumbnailActivity と Gallery の 2 箇所に予防的に適用
BW-027 / UE-016 — Glide プレースホルダーの同期デコード ANR。R.drawable.background_silver(220KB JPG)を XML ColorDrawable R.drawable.background_silver_placeholder に差し替え。Glide listener / .into() を try-catch で包み、placeholder 起因の二次クラッシュも防御
BW-015 / UE-007 — PageJumpSlider.hide() のアニメーション完了時 NPE。onAnimationEnd で mContainer != null チェックを追加
BW-009 / UE-021 — R8 strict mode + density split で background_silver.jpg がストリップされる。res/raw/keep.xml に tools:keep="@drawable/background_silver,@drawable/background_silver_placeholder" を追加
BW-030 / UE-017 — API 37 / Android 14+ で Material You が overlay を再生成し、CategoryActivity が真っ白になる無音 UX バグ。imageList が empty かつ復元不能と判定したら finish() して ThumbnailActivity に戻す graceful finish
UE-015 — WallpaperPagerAdapter.onBindViewHolder で mCurrentHolder = holder を直接代入していたため、オフスクリーン先読み bind で隣ページに上書きされ、「壁紙に設定」で 1 つズレた壁紙が設定されるユーザー報告バグ。mCurrentHolder の更新を onPageSelected → setCurrentHolder() に一本化
Liftoff (Vungle) 早期 reward 不具合への防御線 — AdMobRewardedAd / AdMobRewardedIntersAd に MIN_WATCH_DURATION_MS = 3_000L ガードを追加。3 秒未満の reward callback を Suspicious reward callback after only NNms でログ出力 + 付与スキップ
逆に v2.1.1 で見送ったものを挙げると次の通りです。「Crashlytics に出ているが直すと別の事を壊しそうなもの」と「targetSdk 37 移行とセットでないと意味がないもの」が中心です。
BW-003 / BW-023 — 起動時の MessageQueue ANR。広告 SDK 初期化の非同期化が必要で、ホットフィックスでやると別の事を壊すリスクが高い。v2.2.0 に繰り越し
BW-005 — Mintegral SDK のロック競合 ANR。SDK 更新を待つほうが安全
BW-016 / BW-019 — 1 user / 1 event の散発。蓄積を待ってから判断
「ホットフィックスにつめこみすぎない」「正規リリースに繰り越せるものは繰り越す」という線引きを、Claude Code に「このリストを優先度別に分けて、入れる / 見送る理由を添えて」とお願いして整理しました。一人で書いていると優先度判断にバイアスが入りやすいので、第三者視点の整理は有効です。
ANR 系は ANR 系で別ループにする
Crashlytics で扱いが一番難しいのが ANR です。スタックトレースが native 寄りで、art_jni_trampoline や libhwui などのフレームが大量に出てきて、コードの当事者性が見えづらい。クラッシュ系と一緒に処理しようとすると判定基準がぶれます。
私は ANR 専用に「ループ A → 端末 / OS 情報を見る、ループ B → main thread が何で止まっているかを見る、ループ C → SDK 初期化の同期処理を疑う」の 3 段階に分けて、Claude Code に投げる時もこの 3 段階を明示するようにしています。例えば BW-027 の同期デコード ANR は次のような問い方をしました。
Sharp AQUOS SX4 / Android 15 で WallpaperPagerAdapter.onBindViewHolder:145 のあとに Glide.SingleRequest.getPlaceholderDrawable を経由して libhwui ImageDecoder_nDecodeBitmap+512 に降りていく ANR が 1 件出ています。main thread で 220KB の JPG を ImageDecoder で同期デコードしているのが原因の可能性が高いと考えています。Glide の placeholder に R.drawable.background_silver (220KB JPG) を渡しているのですが、これを軽量な XML ColorDrawable に置き換えるべきでしょうか。XML ColorDrawable は ImageDecoder を経由しないという理解で合っていますか。
Claude Code は「ColorDrawable は ImageDecoder を経由せず、内部で android.graphics.drawable.ColorDrawable として直接解決される」「JPG の場合は density に応じた resampling と decode が走るため、低 DAU 端末でも main thread に余計な負荷がかかる」と教えてくれました。これで XML ColorDrawable 置換に踏み切れました。
ANR を扱う時のコツは「main thread で何が起きているか」を 1 行に圧縮する作業を最初にやることです。art_jni_trampoline の下に IPackageManager$Stub$Proxy.getNameForUid+252 があれば「system_server とのバインダー IPC でブロック」「Display.getRefreshRate を経由していたらディスプレイ HAL レイテンシの可能性」のように、文章 1 行に落としてから Claude に投げると、SDK 起因か OS 起因かの切り分けが早いです。
Claude Code に「実装してもらう」のではなく「議事録を取ってもらう」
11 日間でセッションを 27 回起動して気づいたのは、「コードを書かせる」より「自分の判断を整理して書き残させる」用途で Claude Code を使うほうが、保守フェーズでは効きました。
具体的には、デバッグの最中に次のような節目があります。
「BW-017 を setItemAnimator(null) で潰すと決めた。副作用として PageJumpSlider のドラッグが効かなくなる可能性があるので、setHasFixedSize(true) も同時に当てる」
「BW-009 が v2.1.0 リリース後も残った。drawable-nodpi に置いただけでは density split で R8 が誤除去するパターンの可能性が高いので、res/raw/keep.xml を追加する」
「BW-030 は API 37 エミュレーターでだけ再現する。configChanges に assetsPaths を足すという選択肢もあったが、API 互換性リスクが高いので graceful finish で対処」
これを口頭で Claude Code に説明して、「いま話したことを、known-bugs-tracker.md の BW-XXX の 修正先 列に入れる形で短くまとめて」と頼みます。Claude Code は議事録の整理が得意なので、運用ノートが Crashlytics の証拠と一緒に残ります。半年後に同じスタックトレースを見た時に「過去の自分はこう判断した」が読めることが、個人開発の保守では何よりの資産になります。
離れて暮らす子どもたちに何を残せるかと考えるたびに、技術を分かりやすく書き残すことの意味を再確認しているのですが、自分自身の数ヶ月後に向けたメモも、似た構造で書いておくと使い物になるなと最近思っています。
運用に乗ったあとに見えてきた指標
11 日間のループを抜けて v2.1.1 / v1.8.1 を審査に投げたあと、Crashlytics と Play Console の指標は次のように動きました(2026-05-25 時点)。
BW v2.1.0 7日 : クラッシュなしユーザー 98.62%(+0.48%) 、クラッシュ 67 件 / ユーザー 57 名(前週比 -42.7% / -43.6%)
BW v2.1.0 Play Console 28日 : 15 件、全部既知。上位 4 件(BW-017 / BW-015 / BW-027 / RecyclerView IOOBE 変種)は v2.1.1 で修正済み
UE v1.8.0 7日 : 7 件、全部既知。新規未知バグはゼロ
クラッシュフリー率 +0.48% は地味な数字に見えますが、累計 5,000 万 DL の規模で考えると 7 日間で 24 万人弱がクラッシュに遭わなくなった計算になります。setItemAnimator(null) + setHasFixedSize(true) という 2 行の修正と、XML ColorDrawable への置き換え、それだけのことで母数が大きく動きます。個人開発のホットフィックスでも、Crashlytics の指標を一つひとつ抑えに行くと、ユーザー数ベースのインパクトは想像より大きい印象です。
次に同じ作業をするときに最初にやること
これから別アプリでも同じデバッグループを回すつもりで、最初にやることを並べておきます。
known-bugs-tracker.md を新規作成して、ステータス凡例(未対応 / 対応中 / 対応済み / 対応不可 / 観察中)を最初に書く
「既知パターン早見表」のヘッダだけ用意して、新規バグが出たら 1 行ずつ追記する
1 セッションでは「コード書く」「Crashlytics 確認する」を混ぜず、最低でも別タブで開く
スタックトレースは Claude Code に投げる前に 4 段(環境 / トップ 5 フレーム / メッセージ / 自分の仮説)に整形する
「直さない」と決めたバグも known-bugs-tracker.md に残し、根拠を Claude Code に書いてもらう
Play Vitals しきい値(クラッシュ 1.09% / ANR 0.47%)が見えてきたら、塩漬け予定を撤回するかどうかを優先順位リストで再判定する
Crashlytics は毎朝開くと一日が荒れるツールですが、こうやって地ならししておくと、新規 issue を見ても「これは既知の BW-017 の変種なので v2.1.1 で消える見込み」と即断できて、コーヒーを飲み終わる前に閉じられるようになります。
実装の参考になれば嬉しいです。お読みいただきありがとうございました。