RecyclerViewの再利用バグ 〜見えないデータ汚染の恐怖〜

※ChatGPTを使用して記事を作成しています。

はじめに

RecyclerView。
それは、Android開発者なら誰もが一度は使うUIコンポーネントです。リスト表示・グリッド表示・ページングなど、多くの画面で欠かせない存在です。

しかし、このRecyclerView、便利な反面、恐ろしく厄介な一面 を持っています。
それが「再利用バグ」。

一見、何の問題もないように見えるのに、スクロールした瞬間、表示内容が入れ替わったり、チェックボックスが勝手にONになったり、削除したはずのデータが蘇ったり──。

この記事では、私が実際に遭遇した「RecyclerView再利用バグ」の地獄体験をケーススタディ形式で紹介し、原因と対処法を丁寧に解説していきます。

ケース1:チェックボックスが勝手にONになるバグ

発生状況

あるToDoアプリで、タスク一覧をRecyclerViewで表示していました。
各行にはタスク名とチェックボックスがあり、チェックを入れると完了扱いになります。

しかし、ユーザーから次のような報告が相次ぎました。

「チェックしてないのに、別のタスクに勝手にチェックが入ってる」
「スクロールするとチェック状態が変わる」

最初は「ユーザーの勘違いでは?」と思っていましたが、実際に確認してみると本当に起きていました。

失敗コード例

class TaskAdapter(
    private val tasks: List<Task>,
    private val onCheckedChanged: (Task, Boolean) -> Unit
) : RecyclerView.Adapter<TaskAdapter.TaskViewHolder>() {

    class TaskViewHolder(val binding: ItemTaskBinding) : RecyclerView.ViewHolder(binding.root)

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TaskViewHolder {
        val binding = ItemTaskBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return TaskViewHolder(binding)
    }

    override fun onBindViewHolder(holder: TaskViewHolder, position: Int) {
        val task = tasks[position]
        holder.binding.taskName.text = task.name
        holder.binding.checkBox.isChecked = task.isDone

        holder.binding.checkBox.setOnCheckedChangeListener { _, isChecked ->
            onCheckedChanged(task, isChecked)
        }
    }

    override fun getItemCount(): Int = tasks.size
}

一見、普通のコードです。
しかし、これが地獄の入り口でした。

原因

RecyclerViewは、画面外にスクロールしたアイテムを再利用 します。
つまり、スクロールによって「前に表示していたViewHolder」が新しいデータを表示するために再利用されます。

ところが、このコードでは setOnCheckedChangeListener が毎回新しく設定されており、古いリスナーが発火してしまう ことがありました。
さらに、isChecked を更新するたびにリスナーが呼ばれるため、意図せずタスクの完了状態が変更される 現象が起きていたのです。

修正版

override fun onBindViewHolder(holder: TaskViewHolder, position: Int) {
    val task = tasks[position]

    with(holder.binding) {
        taskName.text = task.name

        // 一度リスナーを解除してから状態をセット
        checkBox.setOnCheckedChangeListener(null)
        checkBox.isChecked = task.isDone

        // 再度リスナーを設定
        checkBox.setOnCheckedChangeListener { _, isChecked ->
            onCheckedChanged(task, isChecked)
        }
    }
}

setOnCheckedChangeListener(null) を挟むことで、以前設定されたリスナーが呼ばれなくなり、
再利用時の「チェックが勝手に動く」問題を解消できます。

ケース2:削除したアイテムが蘇るバグ

発生状況

別のアプリでは、RecyclerViewでお気に入りアイテムを一覧表示していました。
削除ボタンを押すとリストから消える──はずが、スクロールすると削除したはずのアイテムが戻ってくるという奇妙な現象が発生しました。

失敗コード例

class FavoriteAdapter(
    private val favorites: MutableList<FavoriteItem>
) : RecyclerView.Adapter<FavoriteAdapter.FavoriteViewHolder>() {

    class FavoriteViewHolder(val binding: ItemFavoriteBinding) : RecyclerView.ViewHolder(binding.root)

    override fun onBindViewHolder(holder: FavoriteViewHolder, position: Int) {
        val item = favorites[position]
        holder.binding.title.text = item.title

        holder.binding.deleteButton.setOnClickListener {
            favorites.remove(item)
            notifyItemRemoved(position)
        }
    }

    override fun getItemCount(): Int = favorites.size
}

削除処理は一見正しそうです。
しかし、実際には「別の位置のアイテムが消える」ことがあり、スクロール後に消したアイテムが復活していました。

原因

notifyItemRemoved(position) の呼び出し後、RecyclerView内部では positionの再計算 が発生します。
しかし、リストの削除とRecyclerViewの通知のタイミングが一致していないため、再利用されたViewHolderに古いデータが再バインドされていました。

また、favorites.remove(item) の直後に position を参照している点も不安定要因です。
ViewHolderの adapterPosition は削除操作後には無効化されるため、意図しない挙動に繋がります。

修正版

holder.binding.deleteButton.setOnClickListener {
    val currentPosition = holder.bindingAdapterPosition
    if (currentPosition != RecyclerView.NO_POSITION) {
        favorites.removeAt(currentPosition)
        notifyItemRemoved(currentPosition)
    }
}

bindingAdapterPosition を使用して常に最新の位置を取得し、
RecyclerView内部の状態と整合性を保つようにします。

教訓: position を直接使うのは危険。
adapterPositionbindingAdapterPosition を使うのが鉄則です。

ケース3:DiffUtilを使わない地獄

問題の本質

RecyclerViewの再利用バグの多くは、
「Adapterがデータの変更を正しく伝えられていない」 ことが原因です。

notifyDataSetChanged() で一括更新している場合、RecyclerViewは内部で「どのViewHolderを再利用すべきか」を判断できません。
結果として、別のデータが古いViewに再バインドされる現象が発生します。

修正版:DiffUtil導入例

class TaskDiffCallback : DiffUtil.ItemCallback<Task>() {
    override fun areItemsTheSame(oldItem: Task, newItem: Task): Boolean =
        oldItem.id == newItem.id

    override fun areContentsTheSame(oldItem: Task, newItem: Task): Boolean =
        oldItem == newItem
}

class TaskAdapter : ListAdapter<Task, TaskAdapter.TaskViewHolder>(TaskDiffCallback()) {
    class TaskViewHolder(val binding: ItemTaskBinding) : RecyclerView.ViewHolder(binding.root)

    override fun onBindViewHolder(holder: TaskViewHolder, position: Int) {
        val task = getItem(position)
        holder.binding.taskName.text = task.name
        holder.binding.checkBox.isChecked = task.isDone
    }
}

ListAdapterDiffUtil を使うことで、RecyclerViewが差分更新を自動的に処理してくれます。
これにより、再利用時の不整合 は劇的に減ります。

まとめ

RecyclerViewは強力で柔軟ですが、その分バグの温床にもなります。
特に「再利用」の仕組みを理解していないと、見えない不具合に長時間悩まされることになります。

再利用バグの本質は、“状態の持ち方” にある。
Viewではなくデータで状態を管理することが重要です。

最後に、再利用バグを防ぐ3つの鉄則

  1. position を直接参照しない(常に bindingAdapterPosition を使う)
  2. Viewの状態はリスナー設定前に初期化する
  3. DiffUtilで差分更新を行う
タイトルとURLをコピーしました