Beautiful HD Wallpapers の Android 版を v2.0.0 にアップデートしたあと、28 日間で 50 件を超える RecyclerView クラッシュが Google Play Console に積み上がっていきました。ほぼ同じスタックトレース、ほぼ同じ条件。Antigravity に原因を相談したとき、最初の提案は的外れでした。そこから正解にたどり着くまでの記録を残しておきます。
クラッシュの全体像
Play Console の Android Vitals に表示されたエラーは、ほぼ次のひとつに集中していました。
java.lang.IndexOutOfBoundsException: Inconsistency detected.
Invalid item position 12(offset:12).state:13
「state:13」「offset:12」のような数字は毎回微妙に変わりますが、パターンは一致していました。RecyclerView が描画しようとした時点で、アダプターが認識しているアイテム数とリストの実際のサイズがずれている、というものです。
累計 5,000 万 DL のアプリ群を個人で開発・運営していると、Play Console を毎日確認する習慣がついています。個人開発を始めた 2013 年頃は自分でクラッシュログを目で追う時間がありましたが、アプリ数が増えてからは Android Vitals の「Crash-free users」という指標を毎朝チェックするルーティンになりました。この数値が 99.7% を下回ったら即対応、という基準で運用しています。
v2.0.0 のリリース直前は 99.8% 前後で安定していました。リリース後 3 日ほどは変化がなく、安心していたところで徐々に下がり始めました。4 日目あたりから 1 日 5〜7 件ペースでクラッシュが増加し、28 日後には 50 件を超えていました。段階公開(5% → 25% → 50% → 100%)の途中だったので、全ユーザーへの影響が顕在化する前に気づけたのは幸いでしたが、それでも放置できない水準でした。
Antigravity に相談したら、最初の提案は同期処理の追加だった
スタックトレースをそのまま Antigravity に貼り付けて「このエラーの原因は何か」と問いかけました。返ってきた提案は、synchronized ブロックを使ってリスト操作をスレッドセーフにする、というものでした。
// Antigravity の初期提案(結論: これでは解決しない)
fun updateList(newItems: List<WallpaperItem>) {
synchronized(this) {
items.clear()
items.addAll(newItems)
notifyDataSetChanged()
}
}提案の意図はわかります。IndexOutOfBoundsException の多くはマルチスレッドによるデータ競合が原因なので、synchronized で保護するのは理にかなった初手です。ただし、私のケースではこの修正は効果がありませんでした。問題の本質は「スレッド間の競合」ではなく、「アダプターが内部状態としてリストを持っていない」ことだったからです。
Antigravity がスタックトレースだけを見て判断したのは、ある意味仕方のないことだと思います。スタックトレースからわかるのは「何が起きたか」であり、「なぜ起きたか」はコードの構造を見なければわかりません。最初の問いかけが症状の記述にとどまっていたことが、的外れな提案につながりました。
根本原因: 外部リストへの直接参照
v2.0.0 のリファクタリング時に、WallpaperAdapter のコンストラクタを変更していました。それ以前は内部でリストを生成していたのですが、外部から柔軟にデータを渡せるようにしようと、コンストラクタ引数で受け取る方式に変更したのです。
問題はその受け取り方にありました。
// 問題のあったコード(v2.0.0)
class WallpaperAdapter(
private val items: MutableList<WallpaperItem> // 外部リストの参照をそのまま保持
) : RecyclerView.Adapter<WallpaperAdapter.ViewHolder>() {
override fun getItemCount() = items.size
// ...
}Activity 側では次のように使っていました。
// Activity 側
val wallpapers = viewModel.getWallpapers().toMutableList()
val adapter = WallpaperAdapter(wallpapers)
recyclerView.adapter = adapter
// 別の処理の中で wallpapers を直接操作
wallpapers.removeAt(0) // ← ここがクラッシュの引き金アダプターは wallpapers という外部リストへの参照を保持しているため、Activity 側で wallpapers.removeAt(0) が呼ばれると、アダプターが知らないままリストのサイズが変わります。RecyclerView は「13 件ある」と思っているのに実際は 12 件、という状態が生まれます。
ここで notifyDataSetChanged() や notifyItemRemoved(0) が呼ばれていれば RecyclerView は状態を同期できますが、外部からの直接操作では通知が走りません。描画タイミングで不一致が露見して IndexOutOfBoundsException が発生する、という仕組みです。
この問題は「リストを別スレッドから変更した」ケースでも起きますが、私のアプリではメインスレッドからの操作でも再現していました。synchronized では防げません。
解決策: 防御的コピー(Defensive Copy)
修正の方向性は明確でした。コンストラクタでリストのコピーを取り、アダプターが自分の状態を自分で管理できるようにする、ということです。
// 修正後のコード(v2.1.0)
class WallpaperAdapter(
items: List<WallpaperItem> // イミュータブルに受け取り、内部でコピー
) : RecyclerView.Adapter<WallpaperAdapter.ViewHolder>() {
private val items: MutableList<WallpaperItem> = items.toMutableList() // 防御的コピー
override fun getItemCount() = items.size
fun submitList(newItems: List<WallpaperItem>) {
items.clear()
items.addAll(newItems.toMutableList()) // 更新時もコピー
notifyDataSetChanged()
}
// ...
}items.toMutableList() でコピーを取ることで、外部リストへの参照を断ち切ります。Activity 側で元のリストをどれだけ書き換えても、アダプターの内部状態には影響しません。アダプターが状態の所有者になる、という設計への転換です。
この修正を v2.1.0 として段階公開したところ、Crash-free users が 99.7% を超えて安定しました。28 日間で積み上がったクラッシュが、ほぼゼロになりました。
Antigravity に正しく問いかける方法
最初の提案が的外れだった件について、Antigravity を責めるつもりはまったくありません。問いかけの設計が悪かったのは私の側でした。
「このエラーの原因は何か」という問いに対して、Antigravity はスタックトレースから読み取れる情報を使って答えました。それは正しい行動です。問題は、スタックトレースだけでは構造的な原因まではわからないことでした。
次のように問いかけを変えることで、適切な回答が返ってきました。
アダプターのコンストラクタとデータ更新メソッドのコードを全文貼り付けたうえで、「このコードで IndexOutOfBoundsException が発生する可能性はどこか」と尋ねました。Antigravity は数秒で答えました。「コンストラクタで外部リストの参照を保持しているため、外部からリストが変更されるとアダプターの状態と乖離します。防御的コピーを取ることで解決できます」と。
個人開発を 12 年以上続けてきた中で、AI ツールとのデバッグ対話から学んだことのひとつは「症状ではなく構造を問う」ということです。「何が起きているか」ではなく「どこに問題がある可能性があるか」を、コードを添えて問いかける。この習慣が、デバッグにかかる時間を大幅に短縮してくれます。
同じクラッシュを防ぐためのチェックリスト
RecyclerView の IndexOutOfBoundsException で悩んでいる方は、まず以下を確認してみてください。
アダプターのコンストラクタで MutableList の参照をそのまま受け取っていないか確認します。受け取っている場合は、コンストラクタ内で toMutableList() してコピーを取ります。次に、notifyDataSetChanged() や notifyItemRemoved() の呼び出し前に、アダプターが知らないかたちでリストが変更されていないかを追います。特に、RecyclerView のスクロールや描画が走るタイミングとリスト操作のタイミングが重なると、このクラッシュが起きやすくなります。
また、バックグラウンドスレッドからアダプターのデータを直接変更しているケースも、同様のクラッシュを引き起こします。UI 操作はメインスレッドからのみ行うという Android の基本原則を守ることで、この種のバグの多くは防げます。
より堅牢な実装へ: ListAdapter への移行
防御的コピーで当面のクラッシュは解消できますが、新しいプロジェクトや次の大規模リファクタリングのタイミングでは、ListAdapter と DiffUtil を使った実装への移行をお勧めします。
// より堅牢な実装: ListAdapter + DiffUtil
class WallpaperAdapter : ListAdapter<WallpaperItem, WallpaperAdapter.ViewHolder>(
object : DiffUtil.ItemCallback<WallpaperItem>() {
override fun areItemsTheSame(oldItem: WallpaperItem, newItem: WallpaperItem) =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: WallpaperItem, newItem: WallpaperItem) =
oldItem == newItem
}
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
// ...省略
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position)) // ListAdapter が内部リストを管理するため安全
}
}ListAdapter は内部でリストを管理するため、防御的コピーが不要になります。差分計算も DiffUtil が担うので、notifyDataSetChanged() による全体再描画を避けられます。パフォーマンスと安全性の両方が改善します。
アダプターが自分の状態を自分で管理できる、という設計原則は、ListAdapter を使っても防御的コピーで対処しても同じです。この原則を守ることが、IndexOutOfBoundsException を根本的に防ぐ考え方になります。
Beautiful HD Wallpapers の v2.1.0 ではまず防御的コピーで対処し、次のメジャーアップデートで ListAdapter 移行を計画しています。段階的に改善していくことが、運用中のアプリを安定させながら品質を高める現実的な進め方だと感じています。
同じ問題で時間を溶かしている方の参考になれば幸いです。