Optimizing RecyclerView with DiffUtil and Payload Updates in Kotlin for Android

In Android development, the RecyclerView is a crucial component for efficiently displaying large datasets. Optimizing its performance is essential, especially when dealing with frequent data changes. Two powerful tools for enhancing RecyclerView performance are DiffUtil and payload updates. This blog post delves into how to leverage these features effectively in Kotlin XML-based Android projects.

Understanding RecyclerView Performance Issues

When updating a RecyclerView‘s dataset, the naive approach of calling notifyDataSetChanged() can lead to performance bottlenecks. This method forces the RecyclerView to redraw all items, even if only a few have changed. This can result in janky scrolling, poor responsiveness, and a bad user experience.

Introducing DiffUtil

DiffUtil is a utility class provided by Android Jetpack that calculates the differences between two lists. It identifies insertions, deletions, moves, and changes within the dataset, allowing the RecyclerView to update only the necessary items. This significantly reduces the amount of work the RecyclerView needs to do, resulting in smoother animations and better performance.

How DiffUtil Works

DiffUtil compares two lists (old list and new list) and generates a DiffResult object. This result contains the information needed to perform optimized updates on the RecyclerView. By analyzing the differences, DiffUtil can intelligently dispatch update events (e.g., notifyItemInserted(), notifyItemRemoved(), notifyItemChanged(), notifyItemMoved()) to the RecyclerView‘s adapter.

Implementing DiffUtil in Kotlin

To implement DiffUtil, you need to create a custom ItemCallback class that defines how to compare items in your dataset.

Step 1: Create a Data Class

First, define a data class representing the items in your RecyclerView.

data class MyItem(val id: Int, val name: String, val description: String)

Step 2: Create a DiffUtil.ItemCallback

Create a class that extends DiffUtil.ItemCallback<MyItem>. This class requires you to implement two methods:

  • areItemsTheSame(oldItem: MyItem, newItem: MyItem): Boolean: Checks if two items are the same (usually by comparing unique IDs).
  • areContentsTheSame(oldItem: MyItem, newItem: MyItem): Boolean: Checks if the content of two items is the same (by comparing all relevant fields).
import androidx.recyclerview.widget.DiffUtil

class MyItemDiffCallback : DiffUtil.ItemCallback<MyItem>() {
    override fun areItemsTheSame(oldItem: MyItem, newItem: MyItem): Boolean {
        return oldItem.id == newItem.id
    }

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

Step 3: Update the RecyclerView Adapter

Modify your RecyclerView adapter to use DiffUtil. This typically involves using AsyncListDiffer for managing the list. AsyncListDiffer takes the DiffUtil.ItemCallback instance, publishes the computed updates on the main thread (to avoid threading issues in views), and allows background computations to occur (by default through a fixed thread pool):

import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.ListAdapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView

class MyAdapter : ListAdapter<MyItem, MyAdapter.MyViewHolder>(MyItemDiffCallback()) {

    class MyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val nameTextView: TextView = itemView.findViewById(R.id.nameTextView)
        val descriptionTextView: TextView = itemView.findViewById(R.id.descriptionTextView)
    }

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

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        val item = getItem(position)
        holder.nameTextView.text = item.name
        holder.descriptionTextView.text = item.description
    }
}

Step 4: Submit New Lists to the Adapter

Instead of directly modifying the list and calling notifyDataSetChanged(), use the submitList() method to update the data. AsyncListDiffer takes care of the DiffUtil logic in the background, handles background scheduling, computes list diffs, and publishes data updates through the main thread.:

val newList = listOf(
    MyItem(1, "New Item 1", "Description 1"),
    MyItem(2, "Item 2", "Updated Description 2")
)

myAdapter.submitList(newList)

XML Layout (item_layout.xml)

Define the layout for each item in the RecyclerView using XML.

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="16dp">

    <TextView
        android:id="@+id/nameTextView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textSize="18sp"
        android:textStyle="bold"/>

    <TextView
        android:id="@+id/descriptionTextView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textSize="14sp"/>

</LinearLayout>

Payload Updates

While DiffUtil is excellent for identifying changes, sometimes you only want to update a specific part of an item (e.g., update the view count without redrawing the entire item). This is where payload updates come into play.

Understanding Payload Updates

Payload updates allow you to pass a payload object with information about what has changed. The onBindViewHolder() method can then use this payload to update only the specific view elements that need to be updated.

Implementing Payload Updates

To implement payload updates, you need to override the getChangePayload() method in your DiffUtil.ItemCallback and the onBindViewHolder() method in your RecyclerView adapter.

Step 1: Override getChangePayload() in DiffUtil.ItemCallback

The getChangePayload() method allows you to specify a payload that represents the specific changes between two items. If no changes exist return `null` else specify all possible scenarios for any single type.

import androidx.core.os.bundleOf

class MyItemDiffCallback : DiffUtil.ItemCallback<MyItem>() {
    override fun areItemsTheSame(oldItem: MyItem, newItem: MyItem): Boolean {
        return oldItem.id == newItem.id
    }

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

    override fun getChangePayload(oldItem: MyItem, newItem: MyItem): Any? {
        // Check and return specific changes in a payload (Bundle).
        val diffBundle = bundleOf()

        if (oldItem.name != newItem.name) {
            diffBundle.putString("NAME", newItem.name)
        }
        if (oldItem.description != newItem.description) {
            diffBundle.putString("DESCRIPTION", newItem.description)
        }
        if (diffBundle.isEmpty) {
            return null // No changes detected
        }

        return diffBundle
    }
}
Step 2: Update onBindViewHolder() in the Adapter

Override the onBindViewHolder(holder: MyViewHolder, position: Int, payloads: List<Any>) method to handle the payload.

class MyAdapter : ListAdapter<MyItem, MyAdapter.MyViewHolder>(MyItemDiffCallback()) {

    class MyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val nameTextView: TextView = itemView.findViewById(R.id.nameTextView)
        val descriptionTextView: TextView = itemView.findViewById(R.id.descriptionTextView)
    }

   override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        onBindViewHolder(holder, position, emptyList()) // Call the overload method for the initial bind
    }
   override fun onBindViewHolder(holder: MyViewHolder, position: Int, payloads: List<Any>) {
        val item = getItem(position)

        if (payloads.isEmpty()) {
            // Perform a full bind. This happens on initial bind
            holder.nameTextView.text = item.name
            holder.descriptionTextView.text = item.description
        } else {
            // Partial bind to only updated fields if there are paylods passed in.
            val bundle = payloads.first() as? Bundle
            bundle?.let {
               it.getString("NAME")?.let { name -> holder.nameTextView.text = name }
               it.getString("DESCRIPTION")?.let { description -> holder.descriptionTextView.text = description }
            }
        }
    }
}

Real-World Example: Updating View Counts

Consider an application that displays a list of articles. You want to update the view count for an article without redrawing the entire item.

1. Update the Data Class

Add a viewCount property to your MyItem data class.

data class MyItem(val id: Int, val name: String, val description: String, val viewCount: Int)

2. Update the DiffUtil Callback

Override the getChangePayload() method to include the viewCount in the payload.

import androidx.core.os.bundleOf

class MyItemDiffCallback : DiffUtil.ItemCallback<MyItem>() {
    override fun areItemsTheSame(oldItem: MyItem, newItem: MyItem): Boolean {
        return oldItem.id == newItem.id
    }

    override fun areContentsTheSame(oldItem: MyItem, newItem: MyItem): Boolean {
        return oldItem.name == newItem.name && oldItem.description == newItem.description && oldItem.viewCount == newItem.viewCount
    }

    override fun getChangePayload(oldItem: MyItem, newItem: MyItem): Any? {
        // Check and return specific changes in a payload (Bundle).
        val diffBundle = bundleOf()

        if (oldItem.name != newItem.name) {
            diffBundle.putString("NAME", newItem.name)
        }
        if (oldItem.description != newItem.description) {
            diffBundle.putString("DESCRIPTION", newItem.description)
        }
        if (oldItem.viewCount != newItem.viewCount) {
            diffBundle.putInt("VIEW_COUNT", newItem.viewCount)
        }

        if (diffBundle.isEmpty) {
            return null // No changes detected
        }

        return diffBundle
    }
}

3. Update the onBindViewHolder() Method in the Adapter

Add a TextView in item_layout.xml for the view count and update the onBindViewHolder() method to handle the view count payload.

<TextView
    android:id="@+id/viewCountTextView"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:textSize="14sp"/>
import android.widget.TextView
import androidx.core.os.bundleOf

class MyAdapter : ListAdapter<MyItem, MyAdapter.MyViewHolder>(MyItemDiffCallback()) {

    class MyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val nameTextView: TextView = itemView.findViewById(R.id.nameTextView)
        val descriptionTextView: TextView = itemView.findViewById(R.id.descriptionTextView)
        val viewCountTextView: TextView = itemView.findViewById(R.id.viewCountTextView)
    }

   override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        onBindViewHolder(holder, position, emptyList()) // Call the overload method for the initial bind
    }

   override fun onBindViewHolder(holder: MyViewHolder, position: Int, payloads: List<Any>) {
        val item = getItem(position)

        if (payloads.isEmpty()) {
            // Perform a full bind. This happens on initial bind
            holder.nameTextView.text = item.name
            holder.descriptionTextView.text = item.description
            holder.viewCountTextView.text = "Views: ${item.viewCount}"
        } else {
            // Partial bind to only updated fields if there are paylods passed in.
            val bundle = payloads.first() as? Bundle
            bundle?.let {
               it.getString("NAME")?.let { name -> holder.nameTextView.text = name }
               it.getString("DESCRIPTION")?.let { description -> holder.descriptionTextView.text = description }
               it.getInt("VIEW_COUNT",-1).takeIf{it != -1}?.let{ count ->  holder.viewCountTextView.text = "Views: $count" }

            }
        }
    }
}

Benefits of Using DiffUtil and Payload Updates

  • Improved Performance: Updates only necessary items, reducing the load on the RecyclerView.
  • Smoother Animations: Provides a seamless user experience with optimized animations.
  • Efficient Data Management: Simplifies data updating logic with precise control over which parts of the UI are refreshed.

Conclusion

Optimizing RecyclerView performance using DiffUtil and payload updates is crucial for building responsive and efficient Android applications. By implementing these techniques, you can ensure that your RecyclerView handles frequent data changes smoothly, providing a better user experience and improved overall app performance.