Our use of DiffUtil may be biased

Posted by leapingfrog on Sun, 23 Jan 2022 15:32:45 +0100

Our use of DiffUtil may be biased

The front is my daily account. I think it looks boring. You can jump directly to the essence section

Advantages of DiffUtil

When I first came into contact with DiffUtil, I had a lot of good feelings for it, including:

  1. The algorithm sounds very nb and must be a good thing;
  2. The refresh logic of RecyclerView is simplified. It doesn't need to worry about whether to call notifyItemInserted or notifyItemChanged. All submittlists are completed (although notifyDataSetChanged can also do it, it has poor performance and no animation);
  3. When LiveData or Flow monitors a single List data source, it is often difficult to know which data items in the whole List have been updated. Only the notifyDataSetChanged method can be called, and DiffUtil can solve this problem. Mindless submitList is finished

DiffUtil code example

When using DiffUtil, the code is roughly as follows:

data class Item (
    var id: Long = 0,
    var data: String = ""
)

class DiffAdapter : RecyclerView.Adapter<DiffAdapter.MyViewHolder>() {
    // The asynclistdiff class is located in Android recyclerview. Widget package
    // Here, take the use of asynclistdiff as an example. Using ListAdapter or directly using DiffUtil also has the following problems
    private val differ = AsyncListDiffer<Item>(this, object : DiffUtil.ItemCallback<Item>() {
        override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
            // The = = operator in Kotlin is equivalent to calling equals in Java.
            return oldItem == newItem
        }

        private val payloadResult = Any()
        override fun getChangePayload(oldItem: Item, newItem: Item): Any {
            // payload is used when updating local fields of Item. You can search for the specific usage
            // This method is called when an update of the same Item is detected
            // This method returns null by default. At this time, the update animation of the Item will be triggered, which shows that the Item will flash
            // When the return value is not null, you can turn off the update animation of the Item
            return payloadResult
        }
    })

    class MyViewHolder(val view: View):RecyclerView.ViewHolder(view){
        private val dataTv:TextView by lazy{ view.findViewById(R.id.dataTv) }
        fun bind(item: Item){
            dataTv.text = item.data
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        return MyViewHolder(LayoutInflater.from(parent.context).inflate(
            R.layout.item_xxx,
            parent,
            false
        ))
    }

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        holder.bind(differ.currentList[position])
    }

    override fun getItemCount(): Int {
        return differ.currentList.size
    }
    
    public fun submitList(newList: List<Item>) {
        differ.submitList(newList)
    }
}

val dataList = mutableListOf<Item>(item1, item2)
differ.submitList(dataList)

The key of the above code lies in the implementation of the following two methods:

  • areItemsTheSame(oldItem: Item, newItem: Item): compare whether two items represent the same data;
  • Arecontents thesame (olditem: Item, newitem: Item): compare whether the data of two items are the same

Pit stepping process of DiffUtil

The above example code looks simple and easy to understand. When we try to add a piece of data:

val dataList = mutableListOf<Item>(item1, item2)
differ.submitList(dataList)

// Add data
dataList.add(item3)
differ.submitList(dataList)

It is found that item3 is not displayed on the interface. What's the matter? Let's look at the implementation of the key code of asynclistdiff:

public void submitList(@Nullable final List<T> newList) {
    submitList(newList, null);
}

public void submitList(@Nullable final List<T> newList, @Nullable final Runnable commitCallback) {
    // ... Omit irrelevant code
    // Note that here is the Java code, comparing whether newList and mList are the same reference
    if (newList == mList) {
        // nothing to do (Note - still had to inc generation, since may have ongoing work)
        if (commitCallback != null) {
            commitCallback.run();
        }
        return;
    }
    // ... Omit irrelevant code

    mConfig.getBackgroundThreadExecutor().execute(new Runnable() {
        @Override
        public void run() {
            final DiffUtil.DiffResult result = DiffUtil.calculateDiff(new DiffUtil.Callback() {
                // ... Omit the Diff algorithm comparison code of newList and mList
            });

            // ... Omit irrelevant code
            mMainThreadExecutor.execute(new Runnable() {
                @Override
                public void run() {
                    if (mMaxScheduledGeneration == runGeneration) {
                        latchList(newList, result, commitCallback);
                    }
                }
            });
        }
    });
}

void latchList(
        @NonNull List<T> newList,
        @NonNull DiffUtil.DiffResult diffResult,
        @Nullable Runnable commitCallback) {
    // ... Omit irrelevant code
    mList = newList;
    // Update results to specific
    diffResult.dispatchUpdatesTo(mUpdateCallback);
    // ... Omit irrelevant code
}

It can be seen that the submitList method with one parameter will call the submitList method with two parameters, focusing on the implementation of the submitList side with two parameters:

  • First, check whether the newly submitted newList and the internally held mList reference are the same. If they are the same, they will be returned directly;
  • If there are different references, compare the Diff algorithm between newList and mList, and generate the comparison result diffutil DiffResult;
  • Finally, the newList is assigned to mList through the latchList method, and the result of the Diff algorithm diffutil Diffresult is applied to mUpdateCallback

The last mUpdateCallback is actually the recyclerview. When creating the AsyncListDiffer object in the above example code Adapter object, no code will be posted here

Shallow copy

After analyzing the code, we can know that different List objects must be passed in each submitList. Otherwise, the method will not compare the Diff algorithm, but return directly, and the interface will not be refreshed You need a different List, don't you? It's not easy. I'll just create a new List?

So we modify the submitList method:

class DiffAdapter : RecyclerView.Adapter<DiffAdapter.MyViewHolder>() {
    private val differ = AsyncListDiffer<Item>(this, object : DiffUtil.ItemCallback<Item>() {
        override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
            return oldItem == newItem
        }
    })

    // ... Omit irrelevant code

    public fun submitList(newList: List<Item>) {
        // Create a new List and call submitList
        differ.submitList(newList.toList())
    }
}

The corresponding test code also becomes:

val diffAdapter = ...
val dataList = mutableListOf<Item>(item1, item2)
diffAdapter.submitList(dataList)

// Add data
dataList.add(item3)
diffAdapter.submitList(dataList)

// Delete data
dataList.removeAt(0)
diffAdapter.submitList(dataList)

// Update data
dataList[1].data = "Latest data"
diffAdapter.submitList(dataList)

After running the code, it is found that the performance of the test code of "add data" and "delete data" is normal, but when the "update data" is run alone, the interface has no response

In fact, you can understand when you think about it, although we call different When using the submitlist (NEWLIST. Tolist()) method, a copy of the List is indeed made, but it is a shallow copy. When running the Diff algorithm for comparison, in fact, the same Item object is compared with itself (the oldItem and newItem of the areContentsTheSame method parameter are referenced by the same object), and it is determined that there is no data update

copy of data class

Some students may have something to say: "when updating the data field, you should call the copy method to copy a new object, update the new value, and then replace the original Item!"

So you have the following code:

val diffAdapter = ...
val dataList = mutableListOf<Item>(item1, item2)
diffAdapter.submitList(dataList)

// Update data
dataList[0] = dataList[0].copy(data = "Latest data")
diffAdapter.submitList(dataList)

After running the code, "update data" becomes normal. When the business is relatively simple, it's over. There's no new pit to step on However, if the business is complex, the code for updating data may be as follows:

data class InnerItem(
    val innerData: String = ""
)
data class Item(
    val id: Long = 0,
    val data: String = "",
    val innerItem: InnerItem = InnerItem()
)

val diffAdapter = ...
val dataList = mutableListOf<Item>(item1, item2)
diffAdapter.submitList(dataList)

// Update data
val item = dataList[0]
// Internal data can not be assigned directly, but need to be copied. Otherwise, it is similar to the above situation
val innerNewItem = item.innerItem.copy(innerData = "Latest internal data")
dataList[0] = item.copy(innerItem = innerNewItem)
diffAdapter.submitList(dataList)

It seems a little complicated. What if we nest deeper? I also nested a List inside? It is necessary to recursively copy in turn, and the code seems to be more complex

At this point, we will recall the second advantage of DiffUtil mentioned at the beginning and have a question I thought it would simplify the code, but make the code more complex. I might as well assign values manually and call notifyItemXxx myself. I'm afraid the code should be simpler

Deep copy

Therefore, in order to avoid recursive copy, the code for updating data becomes too complex, so there is a deep copy scheme I set several layers for you. I first make a deep copy. I directly modify the deep copy data, and then set it back directly. The code is as follows:

data class InnerItem(
    var innerData: String = ""
)
data class Item(
    val id: Long = 0,
    var data: String = "",
    var innerItem: InnerItem = InnerItem()
)


val diffAdapter = ...
val dataList = mutableListOf<Item>(item1, item2)
diffAdapter.submitList(dataList)

// Update data
dataList[0] = dataList[0].deepCopy().apply { 
    innerItem.innerData = "Latest internal data"
}
diffAdapter.submitList(dataList)

The code looks much more concise, and our focus is on how to implement deep copy:

Using Serializable or Parcelable, you can realize deep copy of objects, and you can search by yourself

  • Using Serializable, the code looks simple, but the performance is slightly poor;
  • Using Parcelable has good performance, but it needs to generate more additional code, which seems not concise enough;

In fact, it doesn't matter whether you choose Serializable or Parcelable. It depends on your personal preferences The key is that after implementing the Serializable or cancellable interface, the data types in the Item will be limited. It is required that all direct or indirect fields in the Item must also implement the Serializable or cancellable interface, otherwise the serialization will fail

For example, Item cannot be declared as Android text. SpannableString field (used to display rich text), because SpannableString does not implement either the Serializable interface or the Parcelable interface

essence

Looking back, let's take a look at the two core methods of DiffUtil:

  • areItemsTheSame(oldItem: Item, newItem: Item): compare whether two items represent the same data;
  • Arecontents thesame (olditem: Item, newitem: Item): compare whether the data of two items are the same

First, what are the purposes of these two methods, or what is their role in the algorithm?

Simple. Even if you don't understand the implementation of DiffUtil algorithm (in fact, I don't understand o( ̄▽  ̄) o), you can guess that we can realize the following three operations only by using areItemsTheSame method:

  • itemRemove
  • itemInsert
  • itemMove

The last itemChange operation requires the areItemsTheSame method to return to true first, then the areContentsTheSame method to return to false to determine the itemChange operation, which corresponds to the annotation specification of this method.

This method is called only if {@link #areItemsTheSame(T, T)} returns {@code true} for these items.

Therefore, the role of areContentsTheSame method is only to determine whether the part of item used for interface display is updated, and it is not necessary to call the equals method to compare all fields of two items In fact, the code annotation of areContentsTheSame method also explains:

This method to check equality instead of {@link Object#equals(Object)} so that you can change its behavior depending on your UI.
For example, if you are using DiffUtil with a {@link RecyclerView.Adapter RecyclerView.Adapter}, you should return whether the items' visual representations are the same.

You will find that the code examples of many online tutorials use equals to determine whether the data has been modified, and then based on the comparison premise of equals, when updating the data, it is recursive copy and deep copy. In fact, it is biased and the idea is limited

Improvement method

Since the areItemsTheSame method is only used to determine whether the displayed part of the Item is updated, so as to determine the itemChange operation, we can create a new contentId field to identify the uniqueness of the content. The implementation of the areItemsTheSame method only compares the contentId. The code looks like this:

private val contentIdCreator = AtomicLong()
abstract class BaseItem(
    open val id: Long,
    val contentId: Long = contentIdCreator.incrementAndGet()
)
data class InnerItem(
    var innerData: String = ""
)
data class ItemImpl(
    override val id: Long,
    var data: String = "",
    var innerItem: InnerItem = InnerItem()
) : BaseItem(id)

class DiffAdapter : RecyclerView.Adapter<DiffAdapter.MyViewHolder>() {
    private val differ = AsyncListDiffer<Item>(this, object : DiffUtil.ItemCallback<Item>() {
        override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
            // When the contentids are inconsistent, it is considered that the Item data has been updated
            return oldItem.contentId == newItem.contentId
        }
        
        private val payloadResult = Any()
        override fun getChangePayload(oldItem: Item, newItem: Item): Any {
            // payload is used when updating local fields of Item. You can search for the specific usage
            // This method is called when an update of the same Item is detected 
            // This method returns null by default. At this time, the update animation of the Item will be triggered, which shows that the Item will flash
            // When the return value is not null, you can turn off the update animation of the Item
            return payloadResult
        }
    })

    // ...  Omit irrelevant code

    public fun submitList(newList: List<Item>) {
        // Create a shallow copy of the new List, and then call submitList
        differ.submitList(newList.toList())
    }
}


val diffAdapter: DiffAdapter = ...
val dataList = mutableListOf<Item>(item1, item2)
diffAdapter.submitList(dataList)

// Update data
// You only need to copy the data in the outer layer to ensure that the contentId is inconsistent, and you only need to assign a value directly to the data nested inside
// Because ItemImpl inherits from BaseItem, when executing ItemImpl When using the copy method, the constructor of the parent BaseItem will be called to generate a new contentId
dataList[0] = dataList[0].copy().apply { 
    data = "Latest data"
    innerItem.innerData = "Latest internal data"
}
diffAdapter.submitList(dataList)

When the areContentsTheSame method is executed, two different objects need to be compared. Therefore, when a field is updated, a new object needs to be generated through the copy method

There is a possibility of misjudgment in this way, because the update of some fields in ItemImpl may not affect the display of the interface. At this time, the areContentsTheSame method should return false However, I think this situation is rare and misjudgment is acceptable. The price is only one additional update of the interface item

In fact, after understanding the essence, we can customize according to our own business needs For example, what should I do with Java? We might be able to do this:

class Item{
    int id;
    boolean isUpdate; // This field is used to mark whether this Item has been updated
    String data;
}

class JavaDiffAdapter extends RecyclerView.Adapter<JavaDiffAdapter.MyViewHolder>{
    public void submitList(List<Item> dataList){
        differ.submitList(new ArrayList<>(dataList));
    }
    @NonNull
    @Override
    public MyViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        return new MyViewHolder(LayoutInflater.from(parent.getContext()).inflate(
                R.layout.item_xxx, 
                parent,
                false
        ));
    }
    @Override
    public void onBindViewHolder(@NonNull MyViewHolder holder, int position) {
        // After binding data once, assign the identification to be updated to false
        differ.getCurrentList().get(position).isUpdate = false;
        holder.bind(differ.getCurrentList().get(position));
    }
    @Override
    public int getItemCount() {
        return differ.getCurrentList().size();
    }
    class MyViewHolder extends RecyclerView.ViewHolder{
        private TextView dataTv;
        public MyViewHolder(View itemView) {
            super(itemView);
            dataTv = itemView.findViewById(R.id.dataTv);
        }
        private void bind(Item item){
            dataTv.setText(item.data);
        }
    }
    private AsyncListDiffer<Item> differ = new AsyncListDiffer<Item>(this, new DiffUtil.ItemCallback<Item>() {
        @Override
        public boolean areItemsTheSame(@NonNull Item oldItem, @NonNull Item newItem) {
            return oldItem.id == newItem.id;
        }
        @Override
        public boolean areContentsTheSame(@NonNull Item oldItem, @NonNull Item newItem) {
            // Determine whether the data is updated by reading isUpdate
            return !newItem.isUpdate;
        }
        private final Object payloadResult = new Object();
        @Nullable
        @Override
        public Object getChangePayload(@NonNull Item oldItem, @NonNull Item newItem) {
            // payload is used when updating local fields of Item. You can search for the specific usage
            // This method is called when an update of the same Item is detected
            // This method returns null by default. At this time, the update animation of the Item will be triggered, which shows that the Item will flash
            // When the return value is not null, you can turn off the update animation of the Item
            return payloadResult;
        }
    });
}


// Update data
List<Item> dataList = ...;
Item target = dataList.get(0);
// Identification data updated
target.isUpdate = true;
target.data = "New data";
adapter.submitList(dataList);

last

In fact, if our list < Item > comes from the Room, there is not so much trouble. Just call submitList directly without considering the problems mentioned here, because when the Room data is updated, a new list < Item > will be automatically generated, and each Item in it is also new. For specific code examples, please refer to the notes at the top of asynclistdiff or ListAdapter class It should be noted that if the data is updated too frequently, too many temporary objects will be generated

Topics: Android kotlin RecyclerView