Beautiful HD Wallpapers の Android 版 v2.0.0 をリリースしてから 28 日間で、Firebase Crashlytics に 50 件以上の IndexOutOfBoundsException レポートが積み上がっていきました。スタックトレースはいつも同じ場所を指していました。RecyclerView の onBindViewHolder です。
最初に Antigravity に「この RecyclerView のクラッシュを直してほしい」と頼んだとき、返ってきた提案は try-catch で例外を握りつぶすコードでした。エラーの赤い波線は消えましたが、クラッシュは翌日も届き続けました。
Antigravity に並行処理のバグを正しく直してもらうには、「クラッシュしている」という事実だけでなく、「なぜクラッシュするのか」を伝える必要があります。v2.1.0 で実際にクラッシュを根絶した手順を、同じ問題に直面している方へ向けて共有します。
Antigravity が提案する「間違った修正」のパターン
RecyclerView の IndexOutOfBoundsException を「直してほしい」と伝えると、Antigravity はよく次のような修正を提案します。
// Antigravity が最初に提案しがちなコード(根本解決にならない)
override fun onBindViewHolder(holder: WallpaperViewHolder, position: Int) {
try {
holder.bind(items[position])
} catch (e: IndexOutOfBoundsException) {
// クラッシュを握りつぶす
e.printStackTrace()
}
}あるいは if (position < items.size) のガード節を追加するだけの提案が来ることもあります。この修正でもクラッシュの頻度は下がりますが、根本原因は残ったままです。
なぜこうなるかというと、Antigravity はコードのスナップショットしか見えないため、スレッド間の状態変化を自力で推測するのが難しいからです。クラッシュが「position が不正な値だから」ではなく、「getItemCount() が返す値と onBindViewHolder() が実行される時点でのリストサイズが一致していないから」発生していることを、Antigravity に教える必要があります。
ここが個人開発者として痛感するポイントです。AIツールは「何が起きているか」には強いのですが、「なぜ起きているか」という設計上の文脈については、開発者側から補足してあげることで真価を発揮します。
IndexOutOfBoundsException の本当の原因
多くのケースで、このクラッシュはアダプターが外部リストへの直接参照を保持していることが原因です。
// 問題のあるコード
class WallpaperAdapter(
private val items: List<Wallpaper> // 外部から渡されたリストへの参照を直接保持
) : RecyclerView.Adapter<WallpaperViewHolder>() {
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: WallpaperViewHolder, position: Int) {
holder.bind(items[position]) // ← ここでクラッシュ
}
}UI スレッドが getItemCount() を呼んで 100 件と把握した直後、バックグラウンドスレッドがリストから 2 件削除したとします。次に onBindViewHolder(position=99) が呼ばれたとき、リストはすでに 98 件しかありません。これが IndexOutOfBoundsException の典型的な発生パターンです。
Beautiful HD Wallpapers では、カテゴリ選択時にバックグラウンドで壁紙リストをフィルタリングしており、その更新が UI スレッドの描画と競合していました。2014 年のアプリ開発開始当時は並行処理に対する意識が今ほど整理されておらず、この種の設計のコードが何年もそのまま残っていたのが実情です。累計 5,000 万 DL を超えるまでに成長したアプリでも、初期の設計負債は静かに蓄積されています。
Antigravity に根本原因を伝えるプロンプトの書き方
Antigravity に正しい修正を出してもらうには、プロンプトに「原因」と「修正方針」を明記するのが最も効果的です。
# Antigravity への修正依頼プロンプト(効果的な書き方)
WallpaperAdapter の IndexOutOfBoundsException を修正してください。
【根本原因】
アダプターが外部リストへの直接参照を保持しているため、
バックグラウンドスレッドがリストを更新すると、UIスレッドの
getItemCount() が返す値と onBindViewHolder() 実行時の
リストサイズが一致せず IndexOutOfBoundsException が発生する。
【修正方針】
- コンストラクタで防御的コピーを作成する(ArrayList(source))
- submitList() 呼び出し時も新しいコピーを作成する
- DiffUtil を使ってアニメーション付きで差分更新する
- try-catch による握りつぶしは行わない
このプロンプトを渡すと、Antigravity は表面的な try-catch ではなく、リストの所有権を明確にした設計を提案してくれます。「どう直すか」に加えて「なぜそう直すか」を理解した上で修正されるため、別の場所で同じパターンが出てきたときにも一貫した対応をしてくれるようになります。
防御的コピーの実装
Antigravity が提案するコードをベースに、v2.1.0 で採用した実装を紹介します。
// 修正後:防御的コピーを使った RecyclerView アダプター
class WallpaperAdapter : RecyclerView.Adapter<WallpaperViewHolder>() {
// 外部リストへの参照を持たず、必ず内部コピーを保持する
private val items = mutableListOf<Wallpaper>()
fun submitList(newItems: List<Wallpaper>) {
// 防御的コピー:呼び出し元のリストへの参照を断ち切る
val copy = ArrayList(newItems)
// DiffUtil で差分計算(アニメーション付き更新)
val diff = DiffUtil.calculateDiff(
WallpaperDiffCallback(items, copy)
)
items.clear()
items.addAll(copy)
diff.dispatchUpdatesTo(this)
}
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: WallpaperViewHolder, position: Int) {
// 万が一の二重安全装置として position ガードを残す
if (position >= items.size) return
holder.bind(items[position])
}
}ArrayList(newItems) の 1 行がポイントです。この行がなければ、items フィールドは呼び出し元と同じリストオブジェクトを指し続け、スレッド間の競合が解消されません。
この修正を v2.1.0 に含めた結果、Crashlytics の Crash-free users が 98.2% から 99.8% に改善しました。28 日間で積み上がっていたクラッシュレポートが消えたときは、静かに安堵しました。アプリのクラッシュ率改善は、直接ユーザーの体験に響くため、どんな機能追加よりも優先して取り組む価値があります。
DiffUtil のコールバック実装
DiffUtil.Callback の実装も Antigravity に任せてしまいましょう。上記プロンプトに続けて依頼すると、以下のような実装を生成してくれます。
// DiffUtil.Callback の実装例
class WallpaperDiffCallback(
private val oldList: List<Wallpaper>,
private val newList: List<Wallpaper>
) : DiffUtil.Callback() {
override fun getOldListSize() = oldList.size
override fun getNewListSize() = newList.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
// 同一アイテムかどうかは ID で判定(コンテンツではなく)
return oldList[oldItemPosition].id == newList[newItemPosition].id
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
// 表示内容が変わったかどうかで差分アニメーションを制御
return oldList[oldItemPosition] == newList[newItemPosition]
}
}areItemsTheSame と areContentsTheSame の区別を Antigravity に説明する必要はありません。上記プロンプトの「DiffUtil を使ってアニメーション付きで差分更新する」という一文を読んで、適切な実装を生成してくれます。
AGENTS.md に規約として書いておく方法
毎回プロンプトで説明するより、AGENTS.md にプロジェクト規約として書いておくほうが持続可能です。一度書けば、Antigravity が RecyclerView 関連のコードを生成・修正するたびに、防御的コピーパターンを自動的に適用してくれます。
# AGENTS.md(Android プロジェクト向け)
## RecyclerView とリスト操作の規約
- アダプターは外部リストへの直接参照を保持してはいけない
- submitList() 系のメソッドでは必ず防御的コピーを作成する
- 正: `val copy = ArrayList(newItems)` → `items.addAll(copy)`
- 誤: `this.items = newItems`(参照の直接代入)
- getItemCount() と onBindViewHolder() の間でリストが変更される可能性がある場合、
position のガード節(`if (position >= items.size) return`)を必ず入れる
- リスト更新には DiffUtil を使い、notifyDataSetChanged() の多用を避ける12 年以上の個人アプリ開発を通じて感じるのは、こうしたコーディング規約を AGENTS.md に書き溜めていくことが、AI ツールと長く付き合うための最も地道で確実な投資だということです。宮大工だった祖父が「道具を整えることが仕事の始まり」と言っていましたが、AGENTS.md はまさにそういう存在です。
他に起きやすい関連クラッシュと対策
同じ「共有リスト参照」の問題は、RecyclerView 以外でも起きます。Antigravity に修正を頼む前に、以下のパターンも確認してみてください。
ViewPager2 の FragmentStateAdapter: アダプターが持つ List<Fragment> が外部から変更されると同じ問題が発生します。submitList() の防御的コピーパターンは ViewPager2 でも同様に有効です。
スピナーや ArrayAdapter(レガシーコード): notifyDataSetChanged() を呼ぶ前にリストのコピーを保持しているかを確認してください。古いコードベースでは特に注意が必要です。Beautiful HD Wallpapers のような長期運用アプリには、こうしたレガシーパターンが点在していることがあります。
DiffUtil を使わない一括更新: notifyDataSetChanged() は差分計算をしないため、大量データの更新でパフォーマンスが落ちます。防御的コピー導入と同時に DiffUtil への移行も検討してみてください。
これらのパターンも AGENTS.md に追記しておくと、Antigravity が他の画面のアダプターを修正するときにも同じ配慮が反映されます。
まず grep -r "private val.*: List<" app/src/ を実行して、プロジェクト内にある「外部リストへの直接参照を持つアダプター」の候補を洗い出してみてください。意外と多くのクラスが候補として見つかることがあります。同じ問題を抱えているアダプターをまとめて修正するなら、Antigravity へのプロンプトに対象ファイルを全て含めると、一貫したリファクタリングをしてくれます。
クラッシュ修正は地味な作業ですが、アプリの信頼性を積み上げる最も確実な方法です。Beautiful HD Wallpapers の Crash-free users 改善は、レビューの評価にも静かに反映されていました。ユーザーが「落ちなくなった」と気づく前に直しておくことが、長期運用の本質だと考えています。