DiffUtil（＋ListAdapter）実装時の注意点

この記事は[Android \#2 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/android-2)の10日目の記事になります。

## はじめに

DiffUtilとListAdapterでリスト（RecyclerView）を実装すると、 `ListAdapter.submitList()` でアイテム配列を送るだけで、データの差分をとって必要なアイテムの追加や更新、削除をDiffUtil側で勝手に行ってくれます。

そんな便利なDiffUtilですが、自分が実装する上でその動作を勘違いしていたことがあったため、同じ轍を踏む人が生まれないように、その注意点を共有したいと思います。

ちなみに今回対象となるDiffUtilは、以下のようなシンプルな作りのものとしています。

```kotlin:SampleDiffUtilListAdapter
class SampleDiffUtilListAdapter :
    ListAdapter<SampleDiffUtilListAdapter.SampleDillUtilItem, RecyclerView.ViewHolder>(DIFF_CALLBACK) {

    companion object {
        private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<SampleDillUtilItem>() {
            override fun areItemsTheSame(
                oldItem: SampleDillUtilItem,
                newItem: SampleDillUtilItem
            ): Boolean {
                return oldItem.id == newItem.id
            }

            override fun areContentsTheSame(
                oldItem: SampleDillUtilItem,
                newItem: SampleDillUtilItem
            ): Boolean {
                return oldItem.count == newItem.count
            }
        }
    }

    // 中略

    data class SampleDillUtilItem(
        val id: Int
    )
}
```

なお、全体のサンプルコードはGitHubで公開しています。
[SampRa\-android/app/src/main/java/io/github/yamacraft/app/sampra/ui/diffutil](https://github.com/yamacraft/SampRa-android/tree/master/app/src/main/java/io/github/yamacraft/app/sampra/ui/diffutil)

## areContentsTheSame()がデータの更新を検出しない

リストに送るアイテム配列を更新して`submitList()`で送ることで、DiffUtilが差分をチェックし、変更があった項目の表示を更新してくれます。
この時、データの差分を行うのが `areContentsTheSame()` です。

例として、SampleDillUtilItemにcountという変数を追加し、リストの該当項目がタップされた回数を記録してリストに反映するように実装します。

```kotlin:SampleDiffUtilViewModel
private val _items = MutableLiveData<List<SampleDiffUtilListAdapter.SampleDillUtilItem>>()
val items: LiveData<List<SampleDiffUtilListAdapter.SampleDillUtilItem>> = _items

fun itemCountUp(id: Int) {
    _items.value = _items.value?.map {
        if (it.id == id) {
            it.count = it.count + 1
        }
        it
    }
}
```

```kotlin:SampleDiffUtilActivity
class SampleDiffUtilActivity : AppCompatActivity() {

    private lateinit var viewModel: SampleDiffUtilViewModel
    private lateinit var listAdapter: SampleDiffUtilListAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_list_adapter)

        viewModel = ViewModelProviders.of(this)[SampleDiffUtilViewModel::class.java]

        viewModel.items.observe(this, Observer {
                listAdapter.submitList(it)
            })
        }

        listAdapter = SampleDiffUtilListAdapter().apply {
            // クリックリスナーの実装部分の解説は省略します
            onItemClickListener = {
                viewModel.itemCountUp(it.id)
            }
        }

        recycler_view.apply {
            adapter = listAdapter
            layoutManager = LinearLayoutManager(context)
        }
    }
}
```

この実装で、リストの項目がタップされるたびにitemが更新されて、`submitList()`で送られていくことまでは確認できますが、リストの表示が更新されません。

問題は`itemCountUp()`内の処理です。
このやり方では、`areContentsTheSame()`のoldItemとnewItemが同一のオブジェクトを参照することになってしまうので、比較結果がtrue（同一）と判断されてリストの更新処理が走りません。

```kotlin:SampleDiffUtilViewModel
fun itemCountUp(id: Int) {
    _items.value = _items.value?.map {
        if (it.id == id) {
            it.copy(count = it.count + 1)
        } else {
            it
        }
    }
}
```

このように`copy()`を使って、該当アイテムがオブジェクトごと変わるようにしましょう。
そうなると、countの変数自体もmutableにする必要がありません。valで宣言しましょう。
そもそもこのようなミスを無くすためにも、アイテム配列に使うdata class内の変数は、全てImmutableで宣言するようにすべきでした。

## DiffUtilのデータ追加の仕様について

DiffUtilはユーザーのUXを損なわないようにするため、現在表示している項目を基準に、データの追加や更新が行われます。

例えばSwipeToRefreshを実装し、更新のたびに固定のデータを毎回ランダムに並び替えて取得するようにしてみます。

```kotlin:SampleDiffUtilViewModel
fun refresh() {
   _items.value = createItems()
}

private fun createItems(): List<SampleDiffUtilListAdapter.SampleDillUtilItem> {
   val items = mutableListOf<SampleDiffUtilListAdapter.SampleDillUtilItem>()
   for (i in 0..100) {
      items.add(SampleDiffUtilListAdapter.SampleDillUtilItem(i, 0))
   }
   return items.toList().shuffled()
}
```

動作させると、このように動きます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4560/9b5d867c-7d33-7fe8-86de-c234cd2c0aed.gif" width="320" />

現時点で一番上に表示されている項目が視覚的に動くことなく、リストのデータが追加されたり移動されていることが分かります。

この動作はDiffUtilの仕様です。
そのため、更新後にリストのスクロールを一番上にもっていきたい場合は、主に2つの方法があります。
更新データを送る前に、先にnullを`submitList()`に送ることでリストを空にする方法、listAdapterのAdapterDataObserverを使って、データ更新のたびに`scrollToPosition()`する方法があります。

【参考】[android \- Recycler view not scrolling to the top after adding new item at the top, as changes to the list adapter has not yet occurred \- Stack Overflow](https://stackoverflow.com/questions/51889154/recycler-view-not-scrolling-to-the-top-after-adding-new-item-at-the-top-as-chan)

一度リストを空にする方法の場合、一瞬リストがまっさらになる瞬間がユーザーに見えてしまう欠点がある代わりに簡単に実装ができます。
AdapterDataObserverで制御する方法は、ちらつきがない代わりに、実装の難易度が高めです（適当に実装すると、意図しない更新でもスクロールが移動するため）。

## まとめ

- リストに使うアイテム（data class）は、全てImmutableで宣言しましょう
- DiffUtilのデータ追加時の挙動は把握した上で実装しましょう