v2.0.0 をリリースした28日後、Firebase Crashlytics のダッシュボードに赤いグラフが現れました。
IndexOutOfBoundsException: Inconsistency detected. Invalid view holder adapter positionViewHolder — 50ユーザー以上が同じクラッシュで落ちています。Beautiful HD Wallpapers のリニューアルに自信を持っていた分、このエラーの根深さが際立ちました。
notifyDataSetChanged() を入れてみたり、DiffUtil に切り替えてみたりしました。表面的な対処を繰り返しても、Crashlytics のグラフは下がりませんでした。最終的に Claude Code との対話でたどり着いたのが「防御的コピー」です。診断から修正までのプロセスを、同じ問題で詰まっている方のために書き残しておきます。
RecyclerView の IndexOutOfBoundsException はなぜ厄介か
RecyclerView のこのエラーは、スタックトレースだけ見ると原因がわかりません。
java.lang.IndexOutOfBoundsException: Inconsistency detected. Invalid view holder adapter positionViewHolder
at androidx.recyclerview.widget.RecyclerView$Recycler.validateViewHolderForOffscreenChange
RecyclerView はデータソース(List<Wallpaper>)が外部から変更されていないという前提で動きます。アダプターへのデータ渡しと、バックグラウンドでのリスト更新が競合すると、RecyclerView 内部の「想定サイズ」と「実際のサイズ」がずれてクラッシュします。
問題は、このズレが常に再現するわけではないことです。マルチコア環境での微妙なタイミングで発生するため、ローカルでの再現が難しく、Crashlytics で見る頻度に対してデバッグが追いつかない状況が続きました。個人開発を2014年から続けてきた経験上、このタイプのバグは「ちゃんと書いた気がする」コードに潜んでいることが多く、最も時間がかかるカテゴリのひとつです。
Claude Code に診断を依頼したときの対話
壁紙アプリ Beautiful HD Wallpapers のコアとなる WallpaperListAdapter と、データを流す WallpaperRepository のコードを貼り付けて、Claude Code に診断を依頼しました。
わたし: RecyclerViewのIndexOutOfBoundsExceptionが本番で多発しています。
スタックトレースと関連クラスを添付します。
どのコードパスで発生しているか、診断してください。
Claude Code: WallpaperRepository.getWallpapers() が返す List<Wallpaper> を
WallpaperListAdapter に直接渡している箇所を確認できます。
Repository 内で同じリストオブジェクトへの参照が外部に渡されているため、
バックグラウンドでのデータ更新時にアダプターが参照しているリストが
変更されています。これが Inconsistency の根本原因です。
「同じリストオブジェクトへの参照」という一言で、問題の構造が明確になりました。
自分でデバッグしているときは「どこを直すか」を探してしまいます。Claude Code への相談を「どこが問題の構造なのか」という問いに変えたとき、_wallpaperList への参照漏れというアーキテクチャレベルの問題が浮かび上がりました。個人開発者にとって、コードレビューを依頼できる同僚がいないぶん、この種の診断対話は特に有用だと感じています。
私のコードでは、Repository が内部で保持する _wallpaperList をそのままアダプターに渡していました。ページング処理でバックグラウンドスレッドが _wallpaperList に追加操作をする間、RecyclerView の描画スレッドは「変更前のサイズ」で動いている — それがクラッシュの正体でした。
防御的コピーによる修正
Claude Code が提案した修正はシンプルでした。Repository がリストを返すとき、内部リストそのものではなく、その瞬間のスナップショットを返すようにするというものです。
修正前のコードでは、Repository が直接 _wallpaperList への参照を返していました。外部のアダプターはその参照を受け取るため、Repository 側でリストが変更されると、アダプターが持っているリストも意図せず変わってしまいます。
// 修正前: 内部リストへの参照がそのまま渡っていた
fun getWallpapers(): List<Wallpaper> {
return _wallpaperList
}修正後は toList() を使って防御的コピーを返します。
// 修正後: イミュータブルなスナップショットを返す
fun getWallpapers(): List<Wallpaper> {
return _wallpaperList.toList()
}toList() は Kotlin の標準関数で、元のリストの内容をコピーした新しいイミュータブルリストを作ります。アダプターが受け取るリストは「渡した瞬間の状態」で固定されるため、その後 Repository 側でいくら変更しても RecyclerView の内部状態とズレが起きません。
あわせて、アダプター側でもデータ更新時に明示的にコピーを取るよう変更しました。
class WallpaperListAdapter : RecyclerView.Adapter<WallpaperViewHolder>() {
private var wallpapers: List<Wallpaper> = emptyList()
fun submitList(newList: List<Wallpaper>) {
// 防御的コピー+DiffUtilで差分更新
val defensiveCopy = newList.toList()
val diffResult = DiffUtil.calculateDiff(
WallpaperDiffCallback(wallpapers, defensiveCopy)
)
wallpapers = defensiveCopy
diffResult.dispatchUpdatesTo(this)
}
override fun getItemCount(): Int = wallpapers.size
}DiffUtil と組み合わせることで、差分更新のアニメーションも維持しつつ、スレッドセーフな更新ができるようになりました。
v2.1.0 リリース後の結果
修正を含む v2.1.0 を段階公開(5% → 25% → 50% → 100%)でリリースしました。Crashlytics のグラフで確認できた変化は明確でした。IndexOutOfBoundsException によるクラッシュはゼロになり、Crash-free users は 99.8% で安定を維持しています。段階公開中にクラッシュ増加が見られなかったため、安心して 100% ロールアウトの判断ができました。
28日間悩んでいた問題が、コードの変更としては数行で解決しました。修正の規模が問題の深刻さに比例しないのが、こういった並行処理バグの特徴だと思います。
この問題が起きやすい他のパターン
防御的コピーが必要になる場面は他にもあります。StateFlow や LiveData を使っている場合でも、可変リストを直接 .value に代入すると同じ問題が起きます。
// ❌ よくある危険なパターン(StateFlow でも同じ問題が潜む)
class CategoryRepository {
private val _categories = mutableListOf<Category>()
private val _categoriesFlow = MutableStateFlow<List<Category>>(emptyList())
fun update(newCategories: MutableList<Category>) {
_categoriesFlow.value = newCategories // 可変リストへの参照がそのまま流れる
}
}
// ✅ 安全なパターン
class CategoryRepository {
private val _categories = mutableListOf<Category>()
// 防御的コピーを返す
fun getCategories(): List<Category> = _categories.toList()
// StateFlow の場合もコピーしてから代入
private val _categoriesFlow = MutableStateFlow<List<Category>>(emptyList())
val categoriesFlow: StateFlow<List<Category>> = _categoriesFlow.asStateFlow()
fun update(newCategories: List<Category>) {
_categoriesFlow.value = newCategories.toList()
}
}私が今実践しているルールは「リストをコンポーネントや別スレッドの境界をまたいで渡す前に、必ず .toList() を呼ぶ」です。習慣化してからは、同様のクラッシュが再発していません。RecyclerView 以外でも、ViewPager2 やカスタム View でデータリストを保持する場合は同様の注意が必要です。
次に書く予定のこと
Beautiful HD Wallpapers の Android 版は引き続き更新を続けています。次に書こうと思っているのは、Glide 5.0.5 と AGP 9.x の組み合わせで発生した Java 8 の互換性クラッシュです。こちらは coreLibraryDesugaringEnabled true の1行追加で解決しましたが、その原因にたどり着くまでの経緯が今回と同じくらい面白かったので、また整理して書きます。
もし同じ IndexOutOfBoundsException で詰まっている方がいれば、まず Repository からアダプターへの「参照の渡し方」を確認してみてください。修正は小さいはずです。デバッグの時間より、実装の楽しさに集中できる時間が増えることを願っています。ViewPager2 やカスタム View でも同様の参照漏れは起こりえます。Android 開発で並行処理由来のクラッシュに直面したとき、「防御的コピー」という選択肢を思い出していただければ幸いです。
Claude Code を使ったデバッグのアプローチについて
今回改めて感じたのは、「コードを貼って質問する」だけでなく「診断を依頼する」というアプローチの有効性です。自分でデバッグしているときは「どこを直すか」を探してしまいがちです。Claude Code への問いを「どこが問題の構造なのか」に変えたとき、表面的な修正候補ではなく、アーキテクチャレベルの問題が見えてきました。
AdMob 広告を収益源に個人開発を2014年ごろから続けています。Beautiful HD Wallpapers をはじめとした壁紙アプリ群は累計5,000万DLを超えており、一人でバグ対応から収益管理まで担っています。こういった並行処理由来のバグに限らず、「自分では見えていなかった問題の構造」を発見するために他者の視点が必要になる場面は何度もありました。10年前なら信頼できる同僚にレビューを頼む場面です。今は Claude Code に「診断してください」と投げることで、同様の視点を得られます。
問いの立て方さえ変えれば、Claude Code はコードレビュアーとして機能します。「このコードを直して」ではなく「このコードの問題の構造を教えて」と問いかけることで、根本原因にたどり着く速度が変わりました。私自身、この方法に切り替えてから、同種の問題に費やす時間が大幅に減ったと感じています。
問いの立て方さえ変えれば、Claude Code はコードレビュアーとして機能します。「このコードを直して」ではなく「このコードの問題の構造を教えて」と問いかけることで、根本原因にたどり着く速度が大きく変わりました。