Android: Groupieの内部でやっている差分更新周りの話

GroupieにはGroupAdapter#updateメソッドという便利なメソッドがあります。

この記事ではupdateメソッドをコールした時に、どのように差分更新されるかを、簡単な説明と実際に動かしてみて見ていきます。

そもそもRecyclerViewの差分更新って何よ

RecyclerViewでは、DiffUtil.Callbackクラスを実装することで、前のAdapterの状態と、新しいAdapterの状態の差分を計算することが出来ます。その計算結果をもとに、RecyclerViewでは効率的にViewを更新してくれます。またいい感じにアニメーションを実行してくれます。

Groupieでは、内部でDiffUtil.Callbackを実装したDiffCallbackクラスがあり、そのクラスをもとに差分更新が行われます。

DiffCallbackクラスの実装を見ていく

まずはareItemsTheSameメソッドから。areItemsTheSameメソッドは、Itemが同一かどうかを判定します。

@Override
public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
    Item oldItem = GroupUtils.getItem(oldGroups, oldItemPosition);
    Item newItem = GroupUtils.getItem(newGroups, newItemPosition);
    return newItem.isSameAs(oldItem);
}

まず、1つ前のItemと新しいItemを取ってきて、newItem.isSameAs(oldItem);をコールしています。

isSameAsメソッドは次の定義になっています。

public boolean isSameAs(Item other) {
    if (getViewType() != other.getViewType()) {
        return false;
    }
    return getId() == other.getId();
}

ItemのviewTypeが等しい かつ Idが等しい場合にtrueを返します。

Idは、Itemクラスのコンストラクタから与えることが出来ます。

protected Item(long id) {
    this.id = id;
}

なので、GroupAdapterがupdateされる可能性があるなら、適切なIdを渡すのが良いです。 例えば、Userクラス的なものがあって、運良くUserを一意に判定できるidが定義されていたら、それを渡すと良いと思います。


次に、areContentsTheSameメソッド。areContentsTheSameメソッドは、このItemの内容が同じかどうかを判定します。

@Override
public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
    Item oldItem = GroupUtils.getItem(oldGroups, oldItemPosition);
    Item newItem = GroupUtils.getItem(newGroups, newItemPosition);
    return newItem.hasSameContentAs(oldItem);
}

1つ前のItemと新しいItemを取ってきて、newItem.hasSameContentAs(oldItem);をコールしています。

public boolean hasSameContentAs(Item other) {
    return this.equals(other);
}

デフォルトでは、Object#equalsメソッドをコールしているので、同一のインスタンスかどうかで、Itemの内容が同じかどうかを判定しています。 Itemのインスタンスが変わったらfalseを返し、Itemの内容が変わっても、同一インスタンスならtrueを返します。

なので、基本的にはhasSameContentAsないし、equalsメソッドをoverrideしたほうが良いです。

Kotlinなら、data classで定義するのも手です。equalsを自動で実装してくれるからです。

data class HogeItem(model, id) : Item<...>(id) ...

最後に、getChangePayloadメソッド。getChangePayloadメソッドは、Itemの差分を計算して、payloadを求めます。

public Object getChangePayload(int oldItemPosition, int newItemPosition) {
    Item oldItem = GroupUtils.getItem(oldGroups, oldItemPosition);
    Item newItem = GroupUtils.getItem(newGroups, newItemPosition);
    return oldItem.getChangePayload(newItem);
}

1つ前のItemと新しいItemを取ってきて、newItem.getChangePayload(oldItem);をコールしています。

public Object getChangePayload(Item newItem) {
    return null;
}

デフォルトでは、nullを返しており、payloadの計算は行われません。ここでいい感じにpayloadを計算してあげると、効率よく差分更新が出来ます。

ケースごとに動作を見てみる

今までのを踏まえて、いろいろなケースでどのように動作するかを見ていきます。

違うid && インスタンスを毎回生成する

class Adapter : GroupAdapter<GroupieViewHolder>() {
  fun update() {
    update(listOf(BasicItem())) // 毎回新しく生成する && idは毎回違う
  }
}

class BasicItem : Item<GroupieViewHolder>()

Itemが点滅しているのが分かります。これは、Idが毎回異なるので、areItemsTheSameがfalseを返すためです。この場合、Viewは再利用されません。

同じid && インスタンスを毎回生成する && equalsなどを実装しない

class Adapter : GroupAdapter<GroupieViewHolder>() {
  fun update() {
    update(listOf(BasicItem(3))) // 毎回新しく生成する && idは固定
  }
}

class BasicItem(id: Long) : Item<GroupieViewHolder>(id)

この場合も、Itemが点滅しているのが分かります。これは、equalsメソッドなどを実装していないので、areContentsTheSameがfalseを返すためです。 areContentsTheSameがfalse かつ payloads周りの実装がない かつ RecyclerView.ItemAnimator周りの設定を変えていない場合は、1つ前のViewが再利用されません。

同じid && インスタンスを毎回生成する && equalsを実装して、trueを返す

class Adapter : GroupAdapter<GroupieViewHolder>() {
  fun update() {
    update(listOf(BasicItem(3))) // 毎回新しく生成する && idは固定
  }
}

// dataクラスなのでequalsが実装される
data class BasicItem(private val id: Long) : Item<GroupieViewHolder>(id)

この場合は、Itemが点滅していないのが分かります。これは、areItemsTheSameareContentsTheSameがtrueを返すので、Viewが再利用されるためです。またItemにはbindメソッドがあるんですが、bindメソッドもコールされません。なぜなら、areContentsTheSameがtrueなので、Viewの中身を更新する必要がないためです。

多くの処理をスキップ、再利用することが出来ます。

同じidを渡す && インスタンスを毎回生成する && getChangePayloadを実装する

class Adapter : GroupAdapter<GroupieViewHolder>() {
  fun update() {
    update(listOf(BasicItem(3))) // 毎回新しく生成する && idは固定
  }
}

class BasicItem(id: Long) : Item<GroupieViewHolder>(id) {
  ...
  override fun getChangePayload(newItem: Item<*>): Any? {
    return newItem.id // 今回はサンプルなので適当な実装
  }
}

この場合も、Itemが点滅していないのが分かります。これは、areContentsTheSameはfalseを返しているのですが、payload(以前との差分)を計算しているため、1つ前のViewが再利用されるためです。この場合、bindメソッドはコールされます。なぜなら、areContentsTheSameがfalseなので、Viewの中身を更新する必要があるためです。

まとめ

Written by
あんどろいどでぃべろっぱぁー🍎