0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RecyclerViewはViewを使い回す。onBindViewHolder()で状態をリセットしないと起こる問題

0
Posted at

はじめに

Android開発でよく利用するRecyclerViewですが、表示するデータ数が多い場合でも高速にスクロールできるように、ViewHolderを再利用する仕組みになっています。

この仕組みにより大量のView生成を避けられる一方で、onBindViewHolder()で状態のリセットを忘れると、以前表示していたアイテムの状態が別のアイテムに残るという問題が発生します。

今回は、ClickListenerを例にこの問題を再現します。

動作確認環境

  • Android Studio
  • Kotlin
  • RecyclerView
  • ViewBinding

RecyclerViewはなぜViewを使い回すのか

RecyclerViewは、大量のデータを効率的に表示するために作られています。

例えば1000件のデータを表示する場合、1000個のViewを生成してしまうとメモリ使用量が増え、スクロール性能にも影響します。

そのためRecyclerViewでは、現在画面に表示するために必要な数だけViewHolderを生成し、表示領域外へ移動したViewHolderを別のアイテム表示に再利用します。

イメージとしては以下のような動きになります。

ViewHolder #1
    ↓
Item0を表示
    ↓
スクロール
    ↓
Item 20を表示

つまりRecyclerViewでは、Viewは「アイテム専用のもの」ではありません。

以前はItem 0を表示していたViewHolderが、次はItem20の表示に使われる可能性があります。

そのためonBindViewHolder()では、

  • 現在のアイテムの値を設定する
  • 不要になった以前の状態をリセットする

という2つの責任があります。

「必要な設定だけ行う」のではなく、「このアイテムを表示するための状態を100%作り直す場所」 と考えることが重要です。

今回再現する問題

今回は以下のような仕様を想定します。

  • 削除可能なアイテムはDeleteボタンを通常表示
  • 削除できないアイテムはDeleteボタンを半透明表示

※今回は ClickListener の残り方を分かりやすく確認するため、Deleteボタンは常に表示したままにしています。実際のアプリでは isEnabled や isClickable、visibility を変更する設計もあります。

データは以下のように作成しています。

List(40) { index ->
    Item(
        id = index,
        canDelete = index < 10
    )
}

つまり、

Item canDelete
0〜9 true
10〜39 false

になります。

問題のある実装

削除可能な場合だけClickListenerを設定しています。

if (item.canDelete) {
    binding.tvDelete.setOnClickListener {
        Toast.makeText(
            holder.itemView.context,
            "Delete Item ${item.id}",
            Toast.LENGTH_SHORT
        ).show()
    }
}

一見問題なさそうに見えます。

しかし、canDelete == falseの場合は何もしていません。

つまり、

以前設定したClickListenerが残ったまま

になります。

また、このClickListenerは設定時点のitemをキャプチャしています。

そのためViewHolderが別のアイテム表示に再利用されても、
以前のアイテムを参照したClickListenerが残り続けます。

サンプルアプリで再利用状態を可視化する

今回のサンプルでは、ViewHolderが再利用されたことを分かりやすくするため、前回の状態と今回の状態を表示しています。

また、状態変化が発生したセルは背景色を変更しています。

背景色 条件
赤 前回true → 今回false
白 その他
val backgroundColor = if (previousCanDelete == true && !item.canDelete) {
    R.color.background_reused_true_to_false
} else {
    R.color.background_default
}

binding.root.setBackgroundColor(
    ContextCompat.getColor(
        holder.itemView.context,
        backgroundColor
    )
)

previousCanDeleteには、そのViewHolderが直前に表示していたアイテムの状態を保持しています。

※今回のpreviousCanDeleteは、ViewHolderの再利用を可視化するための検証用です。
実際のアプリでは、このような状態をViewHolderに保持することは少なく、表示内容はデータ(Model)をもとに毎回onBindViewHolder()で設定するのが一般的です。

その値と現在のアイテムの状態を比較することで、「このViewHolderが別の状態から使い回されたか」を確認できます。

実際に動かしてみる

初期状態では、Item 0〜9だけ削除可能です。

initial.png

スクロールすると、ViewHolderが再利用されます。

例えば、

Item 1
ViewHolder #2

で使用されていたViewHolderが、

Item 10
ViewHolder #2

として再利用されます。

reused.png

ボタンは半透明表示になっています。

しかし、このViewHolderには以前Item 1で設定されたClickListenerが残っています。

発生する問題

Item 10のDeleteボタンをタップします。

bug_mode.png

表示されたのは、

Delete Item 1

です。

Item 10を操作したつもりなのに、以前表示されていたItem 1のClickListenerが実行されています。

今回は分かりやすくするためToastを表示していますが、実際のアプリで削除処理などを行っていた場合、

  • 間違ったデータを削除する
  • 本来とは別のアイテムに対して更新処理が実行される
  • 意図しない画面へ遷移する

といった重大な問題につながる可能性があります。

原因

原因は、RecyclerViewがViewを使い回しているためです。

今回の場合、

Item 1
 ↓
ViewHolder #2
 ↓
ClickListener設定
 ↓
スクロール
 ↓
Item 10
 ↓
同じViewHolderを再利用

という流れになっています。

しかし、Item 10では削除不可なので、

if (item.canDelete) {
    setOnClickListener(...)
}

の中には入りません。

結果、

Item 1用のClickListener
        ↓
そのまま残る

という状態になります。

今回のコードでは、「削除可能な場合だけClickListenerを設定する」という実装になっていました。

しかし、onBindViewHolder()は「必要な設定だけ追加する場所」ではありません。

ViewHolderが以前どのような状態だったかに関係なく、そのアイテムを表示するための状態を毎回作り直す必要があります。

修正方法

ClickListenerもViewの状態の一つです。

そのため、削除可能な場合だけ設定するのではなく、不要になった場合は明示的に解除する必要があります。

if (item.canDelete) {
    binding.tvDelete.setOnClickListener {
        Toast.makeText(
            holder.itemView.context,
            "Delete Item ${item.id}",
            Toast.LENGTH_SHORT
        ).show()
    }
} else {
    binding.tvDelete.setOnClickListener(null)
}

ポイントは、

「必要な時だけ設定する」

ではなく、

「不要な時は必ず解除する」

ことです。

修正後の動作

修正後は、削除不可のItem10ではClickListenerが解除されるため、Deleteボタンをタップしても何も起こりません。

その結果、以前表示していたItem1のClickListenerが実行されることもなくなります。

fixed_mode.png

ClickListener以外にも注意が必要

今回の問題はClickListenerだけではありません。

RecyclerViewで条件付き設定を行う場合、同じような問題が発生します。

1. CheckBox

問題例

if (item.checked) {
    holder.checkBox.isChecked = true
}

この場合、

チェックあり
↓
ViewHolder再利用
↓
本来チェックなしのアイテムにもチェックが残る

という問題になります。

修正版

holder.checkBox.isChecked = item.checked

毎回現在の状態を設定します。

2. 背景色

問題例

if (item.selected) {
    holder.itemView.setBackgroundColor(Color.BLUE)
}

元の背景色へ戻す処理を書かない場合、

選択中アイテム
↓
ViewHolder再利用
↓
別アイテムも青い

となります。

修正版

holder.itemView.setBackgroundColor(
    if (item.selected) {
        Color.BLUE
    } else {
        Color.WHITE
    }
)

3. Visibility

問題例

if (item.isAdmin) {
    holder.button.visibility = View.VISIBLE
}

Visibilityを元に戻す処理がないため、

管理者アイテム
↓
ViewHolder再利用
↓
一般ユーザーにもボタン表示

となります。

修正版

holder.button.isVisible = item.isAdmin

isVisibleは内部的に以下のような処理を行っています。

if (item.isAdmin) {
    holder.button.visibility = View.VISIBLE
} else {
    holder.button.visibility = View.GONE
}

そのため、条件がtrueの場合だけ設定するのではなく、毎回現在の状態を指定できます。

RecyclerViewではViewHolderが再利用されるため、表示されない場合の状態(GONE)も明示的に設定することが重要です。

まとめ

RecyclerViewではViewHolderが再利用されるため、

前回の状態が残る可能性がある

ことを意識する必要があります。

onBindViewHolder()は、「必要な設定を追加する場所」ではなく、「そのアイテムを表示するための状態を100%作り直す場所」です。

その意識で実装すると、

  • ClickListener
  • CheckBox
  • 背景色
  • Visibility
  • Enabled状態

などのリセット漏れによるバグを防ぎやすくなります。

参考

サンプルコード

今回使用したサンプルコードはGitHubで公開しています。

Bug Mode と Fixed Mode を切り替えながら動作を確認できるので、実際にスクロールしてViewHolderの再利用を試してみてください。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?