No More notifyDataSetChanged()
Let's take a look at the magic of DiffUtil in this month's post.
What is DiffUtil? It is a simple class that can help you calculate exactly what items have changed whenever you need to update a RecyclerView adapter. Why would you want to do this? So that your RecyclerView will smoothly animate the changes that have happened without having to tediously calculate the difference between our data set changes ourselves. If you've ever used notifyDataSetChanged()
on your RecyclerView adapter as a developer or noticed an app who's list of items blinks in and out of existence whenever a change happens then you've noticed a situation that could use DiffUtil.
I've created a very simple application to help guide us through using DiffUtil that you can find here
The app will display a list of Person
objects that updates everytime you pull to refresh. The app will randomly decide whether a person is online or offline as well as what their "status" is whenever you perform a pull to refresh. This will give us a reason to update the adapter and make use of DiffUtil. Let's have a look at our Person
class to start things off.
data class Person (val id : Int,
val name : String,
val isOnline : Boolean,
val status : String)
We're using Kotlin so we barely had to write any code to make this happen.
We'll have a PersonService
object that will be in charge of generating a list of Person
objects for us. It will do this with a small simulated delay to mimic what we might expect from a web service call in a real app.
@Singleton
class PersonService @Inject constructor(private val resources: Resources) {
private val random: Random = Random(System.currentTimeMillis())
private val fakeNameList: MutableList<String> = mutableListOf()
private val fakeStatusList: List<String> = listOf("Busy",
"AFK",
"In Chat",
"In Game",
"BRB")
fun getPersonList(): Single<List<Person>> {
if (fakeNameList.isEmpty()) {
initializeFakeNameList()
}
val currentPersonList = mutableListOf<Person>()
fakeNameList.forEachIndexed { index, name ->
currentPersonList.add(createFakePerson(index, name))
}
return Single.just(currentPersonList.toList())
.delay(random.nextInt(500).toLong(), TimeUnit.MILLISECONDS)
}
private fun createFakePerson(personId: Int, name: String): Person {
return Person(personId,
name,
random.nextBoolean(),
fakeStatusList[random.nextInt(fakeStatusList.size)]
)
}
private fun initializeFakeNameList() {
val rawInputStream = resources.openRawResource(R.raw.fake_name_list)
val reader = InputStreamReader(rawInputStream)
val bufferedReader = BufferedReader(reader)
fakeNameList.addAll(bufferedReader.readLines())
}
}
We'll create a simple PersonListViewModel
object in order to expose the Person
lists as well as handle the swipe to refresh events that will generate a new list.
@Singleton
class PersonListViewModel @Inject constructor(private val personService: PersonService,
@ApplicationResourcesModule.UI private val uiScheduler : Scheduler,
@ApplicationResourcesModule.IO private val ioScheduler : Scheduler) : ViewModel() {
private val personList : MutableLiveData<List<Person>> = MutableLiveData()
init {
personList.value = listOf()
}
fun loadLatestData() {
personService.getPersonList()
.subscribeOn(ioScheduler)
.observeOn(uiScheduler)
.subscribe(object : SingleObserver<List<Person>> {
override fun onSubscribe(d: Disposable) {}
override fun onError(e: Throwable) {}
override fun onSuccess(updatedPersonList: List<Person>) {
personList.value = updatedPersonList
}
})
}
fun getPersonList() : LiveData<List<Person>> {
return personList
}
override fun onCleared() {
personList.value = listOf()
}
}
Our PersonListViewModel
will be attached to the lifecycle of our PersonListActivity
. This activity will simply watch for changes on our view model and pass the updated lists to our adapter.
class PersonListActivity : AppCompatActivity() {
@Inject
lateinit var viewModelFactory: ApplicationViewModelFactory
@Inject
lateinit var personListAdapter: PersonListAdapter
lateinit var viewModel: PersonListViewModel
override fun onCreate(savedInstanceState: Bundle?) {
AndroidInjection.inject(this)
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_person_list)
personListRecyclerView.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
personListRecyclerView.adapter = personListAdapter
swipeToRefreshLayout.setOnRefreshListener {
viewModel.loadLatestData()
}
viewModel = ViewModelProviders.of(this, viewModelFactory).get(PersonListViewModel::class.java)
if (savedInstanceState == null) {
viewModel.loadLatestData()
}
viewModel.getPersonList().observe(this, Observer<List<Person>> { personList: List<Person>? ->
if (personList == null) {
return@Observer
}
personListAdapter.updateAdapter(personList)
swipeToRefreshLayout.isRefreshing = false
})
}
}
The next step in our data's journey to the screen will be our PersonListAdapter
.
class PersonListAdapter @Inject constructor() : RecyclerView.Adapter<PersonViewHolder>() {
private var personList : MutableList<Person> = mutableListOf()
override fun getItemCount(): Int {
return personList.size
}
override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): PersonViewHolder {
val layoutInflater = LayoutInflater.from(parent?.context)
val itemView = layoutInflater.inflate(R.layout.person_item_view, parent, false)
return PersonViewHolder(itemView)
}
override fun onBindViewHolder(holder: PersonViewHolder?, position: Int) {
holder?.bind(personList[position])
}
override fun onBindViewHolder(holder: PersonViewHolder?, position: Int, payloads: MutableList<Any>?) {
if(payloads == null || payloads.isEmpty()){
super.onBindViewHolder(holder, position, payloads)
} else {
holder?.update(payloads[0] as Map<String, Any>)
}
}
fun updateAdapter(updatedList: List<Person>) {
val result = DiffUtil.calculateDiff(PersonDiffUtilCallback(personList, updatedList))
personList = updatedList.toMutableList()
result.dispatchUpdatesTo(this)
}
}
Our adapter is be pretty simple. The important thing to note is that in our updateAdapter
method we are not simply assigning the new list to our adapter and then calling notifyDataSetChanged()
.
We also aren't going through some tedious and error prone process to determine when to call notifyItemChanged
or notifyItemInserted
or notifyItemRemoved
.
Instead we invoke DiffUtil.calculateDiff()
to which we pass an instance of PersonDiffUtilCallback
. This class takes the adapter's old data list and our new "updated" data list as constructor arguments.
DiffUtil.calculateDiff()
returns a result to us which we use by calling dispatchUpdatesTo()
passing our adapter as the argument.
It's important to note that we update our adapter's private list field after the result is calculated but BEFORE we dispatch our changes. Once we've done this our adapter will magically update itself in just the right ways to handle our updated data.
How does this magic happen? Well the details are in our PersonDiffUtilCallback
object which we can take a look at next.
class PersonDiffUtilCallback constructor(private val oldList : List<Person>,
private val updatedList : List<Person>) : DiffUtil.Callback() {
companion object {
const val NAME_KEY = "name"
const val ONLINE_STATUS_KEY = "online"
const val STATUS_KEY = "status"
}
override fun getOldListSize(): Int {
return oldList.size
}
override fun getNewListSize(): Int {
return updatedList.size
}
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldPerson = oldList[oldItemPosition]
val updatedPerson = updatedList[newItemPosition]
return oldPerson.id == updatedPerson.id
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldPerson = oldList[oldItemPosition]
val updatedPerson = updatedList[newItemPosition]
val isNameTheSame = oldPerson.name == updatedPerson.name
val isOnlineStatusTheSame = oldPerson.isOnline == updatedPerson.isOnline
val isStatusTheSame = oldPerson.status == updatedPerson.status
return isNameTheSame && isOnlineStatusTheSame && isStatusTheSame
}
override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
val oldPerson = oldList[oldItemPosition]
val updatedPerson = updatedList[newItemPosition]
val changeMap = mutableMapOf<String, Any>()
val isNameTheSame = oldPerson.name == updatedPerson.name
val isOnlineStatusTheSame = oldPerson.isOnline == updatedPerson.isOnline
val isStatusTheSame = oldPerson.status == updatedPerson.status
if(!isNameTheSame){
changeMap[NAME_KEY] = updatedPerson.name
}
if(!isOnlineStatusTheSame){
changeMap[ONLINE_STATUS_KEY] = updatedPerson.isOnline
}
if(!isStatusTheSame){
changeMap[STATUS_KEY] = updatedPerson.status
}
return changeMap
}
}
The first thing to note about our PersonDiffUtilCallback
class is that it declares the list of data currently being displayed by our adapter and the list we want to update our adapter with as its constructor parameters.
It then makes use of these two lists in the first two methods that we override:
getOldListSize()
and getNewListSize()
.
We simply return the lengths of our lists in this case, nothing crazy here.
Then we come to the areItemsTheSame
method which is given a position from our old list and a position from our new list. This method wants us to compare the object from our old list to some object in our new list and determine if they are "equal". Luckily for us our Person
class has an id field which we can use to determine equality.
This id will decide if two different Person
objects are the same even if their status
, name
or isOnline
field's values are different.
If we had a value object instead we would just need to make sure we compared each field and determined equality that way.
This method is the first thing that is called when the DiffUtil wants to compare two sets of data. If we returned false from this method then the job is done and the DiffUtil knows that they are not the same object and there's no need to probe any further about this particular position.
If, however, we return true from this method then we are letting the DiffUtil know that the objects are the "same", but this doesnt mean that they haven't changed in some manner that might be important to how our adapter displays them.
The next method in our class will determine if two objects that are the "same" have some difference that we need to mention in order for our adapter to update correctly.
In the case of our Person object we only return true from this method if the object's name
, isOnline
, and status
fields are the same.
This is important because even though the first person in each list is the same person as far as id
field is concerned if they have a new status
or have changed their isOnline
boolean then we need to make sure our adapter rebinds to that object so that the changes can be displayed in the view.
If we didnt' implement this method correctly then the DiffUtil would never notify our adapter that our person had changed and so we wouldn't see any change in our view until we scrolled away and came back.
At this point you've implemented everything you need to implement in order to use the DiffUtil mechanism to update your adapter. The DiffUtil will use the information its gathered from your callback about the changes in the two lists and proceed to call the various notify methods on your adapter which will then generate new calls to onBind so that your displayed data gets updated.
You may notice though that we have one additional method we haven't covered yet. The getChangePayload
method. This method can be used to conjunction with the lesser known onBindViewHolder(holder: PersonViewHolder?, position: Int, payloads: MutableList<Any>?)
of your RecyclerView.Adapter to deliver smaller more effcient updates to your UI.
This method will be called when we have returned true from areItemsTheSame
but false from areContentsTheSame
.
If we implement it then we have a chance to package up just the differences between our two objects and return them as a payload that will be used in our adapter.
In our example app we create a map that has some key-value pairs related to the different fields in our Person
object. Back in our adapter we make use of that map to save us from going through the trouble of a full rebind of our PersonViewHolder
, we simply examine the map and only update the relevent views.
In our trivial example app we aren't saving much, but in a more real world example this could give you a little performance boost when updating your data lists.
Helpful Links
- https://developer.android.com/reference/android/support/v7/util/DiffUtil.html
- https://developer.android.com/reference/android/support/v7/util/DiffUtil.Callback.html
- https://developer.android.com/reference/android/support/v7/widget/RecyclerView.Adapter.html
- https://martinfowler.com/bliki/ValueObject.html