Edited at

GroupieのDiffUtilを正しく利用するコードを記述する

DroidKaigi 2019DroidKaigi 2018でも採用され、最近はプロダクトでもよく導入されるようになってきた Groupie ですが、今回はそのGroupieを使ううえで気をつける必要がある差分更新の実装について記述しました。

Groupieでは、RecyclewViewのDiffUtilを利用しItemの更新を検知し、その結果を使って追加や更新のアニメーションを行ってくれます。このとき、Itemの実装の仕方によりDiffUtilが意図したように動かず、不要なアニメーションが発生することがあります。

もし、そのような問題に直面している場合は、RecyclerViewによるレイアウトを楽にするGroupieを1年使ってみて という記事を参照して対策してみましょう。それらの問題についての原因と対策について記述されています。

今回の記事では、上記の記事とは別の方法での解決策を考えてみます。


問題はオブジェクトの再生成にある

上記の記事でも記述されている通り、以下のようなコードを記述した場合に clickListener が毎回別のオブジェクトとして再生成されるため、更新アニメーションがはいります。

        val handler = Handler()

handler.postDelayed(object : Runnable {
override fun run() {
val clickListener: (View) -> Unit = { // 毎回**新しい**クリックリスナを作る
Log.d("Groupie!", "click:$it")
}
val items = listOf(
HeaderItem("Header1"),
TextItem("test1", clickListener), // クリックリスナを渡す
TextItem("test2", clickListener),
TextItem("test3", clickListener),
HeaderItem("Header2"),
TextItem("test4", clickListener),
TextItem("test5", clickListener)
)
groupAdapter.update(items)
handler.postDelayed(this, 1000)
}
}, 1000)
}
}
...
data class TextItem(
val text: String,
// 毎回作られるクリックリスナがdata classのequals()で比較されてfalseが返る!!!
val clickListener: (View) -> Unit
) : BindableItem<ItemTextBinding>(
text.hashCode().toLong()
) {
override fun getLayout() = R.layout.item_text

override fun bind(viewBinding: ItemTextBinding, position: Int) {
viewBinding.textView.text = text
viewBinding.textView.setOnClickListener(clickListener)
}
}

ここで問題なのが、毎回リスナを生成しなおしていることにあります。

さきほど紹介した記事では、 オブジェクトの再生成を許容し equalshashCode を工夫することで対策していました。そのため自由度が高く、インターフェースの実装さえ忘れなければ、コードの変更コストがアイテム内だけで完結し、アイテムを生成する側のリファクタリングコストが発生しません。

では、オブジェクトの再生成を許容しない実装も考えてみます。


オブジェクトが再生成されないようにする

さきほどのコードを見てみると clickListener は引数で渡されるものしか使わないため (今回は説明のためにあえて毎回あたらしいリスナを作っていたが) 毎回生成する必要がない。 そのため、handler (アイテムが再生成されるスコープ) の外で clickListener を生成するようにしてみます。

        val clickListener: (View) -> Unit = { // 毎回新しいクリックリスナを作らない

Log.d("Groupie!", "click:$it")
}
val handler = Handler()
handler.postDelayed(object : Runnable {
override fun run() {
val items = listOf(
HeaderItem("Header1"),
TextItem("test1", clickListener), // クリックリスナを渡す
TextItem("test2", clickListener),
TextItem("test3", clickListener),
HeaderItem("Header2"),
TextItem("test4", clickListener),
TextItem("test5", clickListener)
)
groupAdapter.update(items)
handler.postDelayed(this, 1000)
}
}, 1000)
}
}

このようにすると、新しいアイテムに渡されるリスナが同じになるため問題が発生しなくなります。また、同じ挙動をするインスタンスを複数生成しなくなるためメモリの使用率も下がります。紹介した記事の対策では、アイテムを生成する側のリファクタリングが必要ありませんが、こちらの場合は、アイテム側のリファクタリングが必要なくなります。


リスナをどうしても再生成したい

リスナをどうしても毎回新しいものにしたい場合は、紹介した記事の対策方法があります。また、今回でいうとデータクラスのコンストラクタにリスナを渡してしまっていたため、リスナがデータとして扱われているというところにも問題があります。そのため、できることなら以下のようにアイテム自体にメソッドを生やすのが良さそうです。

data class TextItem(

val text: String
) : BindableItem<ItemTextBinding>(
text.hashCode().toLong()
) {
override fun getLayout() = R.layout.item_text

override fun bind(viewBinding: ItemTextBinding, position: Int) {
viewBinding.textView.text = text
viewBinding.textView.setOnClickListener(onClick)
}

// アイテム自体にメソッドを生やす
fun onClick(view: View) {
Log.d("Groupie!", "click:$view")
}
}

また、リスナをコンストラクタ以外で保持する方法をとれば、外からの注入もできます。以下のようにListenerインターフェースを定義し notifyChanged でListenerを実装したオブジェクトを渡します。

data class TextItem(

val text: String
) : BindableItem<ItemTextBinding>(
text.hashCode().toLong()
) {
// 再度bindされたときにも利用できるようListenerを保持しておく必要がある
private val listenerHolder = AtomicReference<Listener>()

override fun getLayout() = R.layout.item_text

override fun bind(viewBinding: ItemTextBinding, position: Int) {
viewBinding.textView.text = text
viewBinding.textView.setOnClickListener(listenerHolder.get())
}

// notifyDataChangedでpalyLoadsにListenerが渡される
override fun bind(viewBinding: ItemTextBinding, position: Int, payloads: MutableList<Any>) {
// palyLoadsからListenerを見つけて保持する
payloads.forEach { if (it is Listener) listenerHolder.set(it) }
super.bind(viewBinding, position, payloads)
}

// Listenerを用意する
interface Listener {
val clickListener: (View) -> Unit
}
}

ただし、この場合はListenerを渡さないとクリックリスナが設定されないので注意しましょう。

ちなみにこの方法でも、実装したListenerが再生成されないようにオブジェクト化することで、データクラスのコンストラクタに渡せます。すると listenerHolder は必要なくなるので安全です。


もう data class をやめる

紹介した記事では equalshashCode を上書きすることで挙動を変えていました。つまり、それらを自身で実装しているのとあまり変わらないため、そもそも data class である必要もないのではないだろうか。つまり data class をやめて equalshashCode を上書きするのだ(同じ)。ちなみにその場合は data class の他の恩恵を受けられなくなります (toStringcopyなど)。


結局どうすれば良いと思っているか

個人的には、リスナを外から注入する場合に再生成をしてしまってる場合は、そもそもコードの書き方や設計が間違っている可能性があると考えています。そのため、まずは そのリスナが本当に再生成する必要があるのか を確かめてみましょう。また、これからも正しくコードを書くために、GroupieのItemのこと、RecycerViewのDiffUtilのこと、RecyclerViewのこと、Kotlinとデータクラスのこと、などを詳しく調べておくと良いと思います。