背景
いつからかは正確にはわからないですが、 RecyclerView.Adapter.notifyDataSetChanged()
をコールしている箇所に
Android Studio で警告が表示されるようになりました。
警告の内容
adapter?.notifyDataSetChanged()
It will always be more efficient to use more specific change events if you can.
Rely on notifyDataSetChanged as a last resort.
データに変更があった場合はより軽量な処理(例: notifyItemChanged )を使ってくださいということです。
Workaround
以下のように notifyItemRangeRemoved
を使って修正すれば警告は回避できます。
val preCount = adapter?.itemCount ?: 0
// ここに全消し処理が入る
adapter?.notifyItemRangeRemoved(0, preCount)
ですが、本質的な対策ではありません。
最初に要素をロードし終わった時など、notifyDataSetChanged なしでどうやって動かしたらよいのかという疑問があったので調べたところ、
現在 RecyclerView で Adapter を実装する時は ListAdapter を継承するのが一般的だと知りました。
実装
新規で ListAdapter を使う Adapter を実装する場合から見ていきます。
個別の DiffUtil.ItemCallback を用意
ListAdapter では1つ実装しないといけないクラスが増えます。要素の型に合わせた DiffUtil.ItemCallback
を定義する必要があります。
internal class BookItemCallback : DiffUtil.ItemCallback<Book>() {
override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean {
return oldItem.id() == newItem.id()
}
override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean {
return oldItem.equals(newItem)
}
}
Adapter の実装
先ほど定義した DiffUtil.ItemCallback
の実装を用いて ListAdapter を継承します。
class BookListAdapter()
: ListAdapter<Book, BookViewHolder>(BookItemCallback()) {
ListAdapter は onCreateViewHolder() と onBindViewHolder() だけを override すればよくなります。
onCreateViewHolder() は従来と同じ実装で大丈夫です。onBindViewHolder() は要素を取得する際に getItem(position) を使うようにしてください。
getItemCount() の override は必須ではなくなります。RecyclerView に表示する要素数を制限したい時は引き続き必要です。
class BookListAdapter()
: ListAdapter<Book, BookViewHolder>(BookItemCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = DataBindingUtil.inflate<ItemBookBinding>(
inflater, R.layout.item_list_book, parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val book = getItem(position)
holder.bind(book)
//....
マイグレーション等で既存の getItemCount() が残っているとバグの原因になることもあるので注意しましょう。
要素の扱い
ListAdapter は不変のリストを扱うので、これまでのように List のインスタンスを持っておく必要はありません。
更新が発生する場合は更新したリストをそのまま submitList で渡します。
position ごとの要素が必要な場合は getItem(position)、現在のリスト全体が必要な場合は getCurrentList() で取得できます。
データをすべて削除する
すべてのデータを削除する場合は、DB等の全削除処理を呼び、Adapter には emptyList() を渡せば良いです。
CoroutineScope(Dispatchers.Main).launch {
withContext(Dispatchers.IO) { bookDataAccessObject.clear() }
submitList(emptyList())
}
データを1つだけ削除する
1つだけデータを削除したい場合は以下の手順を踏む必要がありました。
- currentList のコピーを Mutable なリストで取得
- コピーから削除対象の要素を削除
- コピーを submitList()
CoroutineScope(Dispatchers.Main) {
val copy = ArrayList<Bookmark>(currentList)
withContext(Dispatchers.IO) { bookDataAccessObject.delete(item) }
copy.remove(item)
submitList(copy)
}
既存コードの ListAdapter への書き換え
既存の RecyclerView.Adapter を使っているコードから ListAdapter を使ったコードに書き換えるには以下の手順を踏みます。
やることはそれほど多くはありません。
ListAdapter を継承
先ほどと同様、先に DiffUtil.ItemCallback
を実装したクラスを用意しておく必要があります。下記コードの BookItemCallback
です。
- ) : RecyclerView.Adapter<ViewHolder> () {
+ ) : ListAdapter<Book, ViewHolder>(BookItemCallback()) {
onBindViewHolder の一部書き換え
これまでリストで要素を保持していた場合、それは必要なくなります。要素を取り出す場合は getItem を使い、
Adapter に submit されているリストから取り出すように書き換える必要があります。
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
- val book = books[position]
+ val book = getItem(position)
getItemCount の削除
ほとんどのケースで override は不要になります。削除しておきましょう。
- override fun getItemCount(): Int = items.size
副産物
RecyclerView.Adapter からの書き換えは単純なリファクタリングにとどまらず、これまで DiffUtil を使っていなかった場合は
ListAdapter のコードに書き換えることで自動的に補間アニメーションを表示してくれるようになります。
ボイラープレートの削減
毎回要素の型ごとに同じようなコードを書くのは億劫で、テストカバレッジも下がりがちになるので控えたいと思ったので、
以下の共通で使えるクラスを実装しました。
import androidx.recyclerview.widget.DiffUtil
class CommonItemCallback<T> private constructor(
private val sameItemComparator: (T, T) -> Boolean,
private val equals: (T, T) -> Boolean
) : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean {
return sameItemComparator(oldItem, newItem)
}
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
return equals(oldItem, newItem)
}
companion object {
fun <T>with(
sameItemComparator: (T, T) -> Boolean,
equals: (T, T) -> Boolean
): DiffUtil.ItemCallback<T> =
CommonItemCallback(sameItemComparator, equals)
}
}
以下のように使えます。
Adapter : ListAdapter<Book, ViewHolder>(
CommonItemCallback.with({ a, b -> a.id() == b.id() }, { a, b -> a == b })
) {
まとめ
notifyDataSetChanged
は高コストなので極力使わないよう警告が表示されるようになりました。
個別の notify~~メソッドを使うか、あるいは ListAdapter に置き換えるかをしていくと良いでしょう。
ListAdapter で置き換える場合は DiffUtil.ItemCallback の実装が多少面倒でボイラープレートが生まれますが、
それ以外は特に問題なく置き換えが進められますし、補間アニメーションも自動で実装されますので、検討してみても良いでしょう。