この記事はAndroid Advent Calendar 2017 の2日目の記事です。

はじめに

RecyclerViewのデータ更新をする際どう実装していますか?
notifyDataSetChanged()などnotify系のメソッドを使って更新することば多い思います。しかしこのnotify系のメソッドは、画面に表示されているViewと上下にあるバッファのViewすべてを更新します。これでは、1行更新したいとなったときに無駄が生じてしまいます。
SupportLibrary25.1.0で追加されたDiffUtilをつかうことでパフォーマンスの良いコードを簡潔に書くことが出来るようになります。

DiffUtilとは

DiffUtilは2つのListを比較して差分を計算するユーティリティクラスです。
EUGENE W. MYERSの差分アルゴリズムを使って計算していて、計算時間はO(N)(移動検出をする場合はO(N ^ 2))になります。
DiffUtilをRecyclerViewで使うことで、変更があった際に差分だけ更新され、更新された部分にアニメーションがかかるようになります。最高ですね:smile:
DiffUtilを使うときにはCallbackとDiffResultを使います。以下でこの二つについて説明していきます。

DiffUtil.Callback

Callbackは、2つのList間の差分を計算するために使用されるCallbackクラスです。
5つのメソッドが生えていて、うち4つがabstractとなっています。DiffUtilはこれらのメソッドを使って差分を計算します。

戻り値 メソッド名 意味
abstract int getOldListSize() 古いリストのサイズを返します。
abstract int getNewListSize() 新しいリストのサイズを返します。
abstract boolean areItemsTheSame(int oldItemPosition, int newItemPosition) 2つのアイテム自体が同じものであるかを判定します。
abstract boolean areContentsTheSame(int oldItemPosition, int newItemPosition) 2つのアイテムのデータ内容が同じであるかを判定します。
Object getChangePayload(int oldItemPosition, int newItemPosition) areItemsTheSametrueを返し、areContentsTheSamefalseの時に呼び出され、どんな変更があったかを知ることが出来ます。
RecyclerDiffCallback.kt
class RecyclerDiffCallback(private val old: List<Any>, private val new: List<Any>) : DiffUtil.Callback() {

    override fun getOldListSize(): Int = old.size

    override fun getNewListSize(): Int = new.size

    override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean
            = old[oldItemPosition] == new[newItemPosition]

    override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean
            = old[oldItemPosition].id == new[newItemPosition].id
}

DiffUtil.DiffResult

Adapter.kt
fun update(new: List<Any>) {
    val diffResult = DiffUtil.calculateDiff(RecyclerDiffCallback(old, new))
    diffResult.dispatchUpdatesTo(this)
}

calculateDiff()から帰ってきたDiffResultのdispatchUpdatesTo(adapter)を呼び出すことでAdapterに変更が通知されます。

非同期で計算を行う

上のコードを使うことでDiffUtilを用いてRecyclerViewを更新できるようになります。
しかし、DiffUtilはListのサイズが大きい場合や複雑な場合には、計算に時間がかかるためバックグラウンドスレッドが推奨されています。そこで、RxJavaを用いて非同期でDiffUtilをあつかうようにします。

subject.kt
private val subject: Subject<List<Any>> = PublishSubject.create<List<Any>>().toSerialized()

subject.observeOn(Schedulers.computation())
        .map { Pair<List<Any>, List<Any>>(it, mutableListOf()) }
        .scan { old, new -> Pair(old.first, new.first) }
        .map { (oldList, newList) -> RecyclerDiffCallback(oldList, newList) }
        .map(DiffUtil::calculateDiff)
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe({
            it.dispatchUpdatesTo(this)
        }, {
            it.printStackTrace()
        })

ここでポイントとなるのは、以下の3点です。

  • SerializedSubject
  • Pair
  • .scan()

SerializedSubject

PublishSubjectを使うことでListの更新を監視することが出来ます。
しかし、RxJavaはスレッドセーフではなく、このままでは別スレッドから更新があった時にクラッシュする可能性があります。そこで、.toSerialized()してSerializedSubjectにすることで解決します。

Pair

RxJavaでDiffUtilを使うためには、新しいListと、calculateDiff()を呼び出すためのDiffResultをStreamに流す必要があります。そこで、 PairでMutableListとDiffResultをラップしてStreamに流しています。

PublishSubject.create<Pair<MutableList<Any>, DiffUtil.DiffResult?>>().toSerialized()

.scan()

DiffUtilで差分を計算するためには古いListと新しいリストが必要です。
そこで、RxJavaの.scan()を使います。

スクリーンショット 2017-11-28 19.23.01.png
http://reactivex.io/documentation/operators/scan.html

RxJavaのscan()は前回の値を引数にとるOperatorです。このscanを使うことで前回の値と今回の値を扱うことが出来るようになります。

さいごに

これで、RecyclerViewをパフォーマンスよく更新することが出来るようになります。

また、これらの処理をまとめて行うAbstractのBaseAdapterをつくると便利です。機能に応じた各AdapterがこのBaseAdapter継承するようにすることで使う側は何も考えずにAdapterを実装することが出来るようになります。

BaseAdapter.kt
/**
 * RecyclerViewを使う場合に継承必須のAdapter
 *
 * @author nakker1218
 */
abstract class RecyclerBaseAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    protected var items: MutableList<Any> = mutableListOf()
    private val subject: Subject<Pair<MutableList<Any>, DiffUtil.DiffResult?>>
            = PublishSubject.create<Pair<MutableList<Any>, DiffUtil.DiffResult?>>().toSerialized()
    private val compositeDisposable: CompositeDisposable = CompositeDisposable()

    init {
        compositeDisposable.add(
            subject.observeOn(Schedulers.computation())
                    .map { Pair<List<Any>, List<Any>>(it, mutableListOf()) }
                    .scan { old, new -> Pair(old.first, new.first) }
                    .map { (oldList, newList) ->
                        object : DiffUtil.Callback() {
                            override fun getOldListSize(): Int = oldList.size

                            override fun getNewListSize(): Int = newList.size

                            override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean
                                    = oldList[oldItemPosition] == newList[oldItemPosition]

                            override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean
                                    = areContentsTheSame(oldList, newList, oldItemPosition, newItemPosition)
                        }
                    }
                    .map(DiffUtil::calculateDiff)
                    .observeOn(AndroidSchedulers.mainThread())
                    .subscribe({
                        it.dispatchUpdatesTo(this)
                    }, {
                        it.printStackTrace()
                    })
        )
    }

    abstract fun areContentsTheSame(oldList: List<Any>, newList: List<Any>, oldItemPosition: Int, newItemPosition: Int): Boolean

    abstract fun getItemViewType(): Int

    override fun getItemCount(): Int = items.size

    override fun getItemViewType(position: Int): Int = getItemViewType()

    fun update(items: List<Any>) {
        subject.onNext(Pair(items.toMutableList(), null))
    }

    fun destroy() {
        compositeDisposable.clear()
    }
}

参考

https://developer.android.com/reference/android/support/v7/util/DiffUtil.html
https://medium.com/@iammert/using-diffutil-in-android-recyclerview-bdca8e4fbb00
https://android.jlelse.eu/smart-way-to-update-recyclerview-using-diffutil-345941a160e0
https://hellsoft.se/a-nice-combination-of-rxjava-and-diffutil-fe3807186012
http://blog.takuji31.jp/entry/kanmoba17
https://qiita.com/ralph/items/f7205c8171826cc2153b
https://qiita.com/izumin5210/items/952a23bf065648feabb7