The Diffing Dilemma! All about diffing with LazyLists!

the-diffing-dilemma!-all-about-diffing-with-lazylists!
Photo by Dewang Gupta on Unsplash

If we were to talk about creating lists in Android View System, it’s no doubt that RecyclerView is just the right tool for it, and not just that, it’s quite mature and performant at diffing (we talked about what diffing is, in our previous article here). But one can not simply forget the boilerplate that comes with creating lists with RecyclerViews. Imagine your life creating a heterogenous list containing multiple view-types, some even having nested horizontally scrolling shelves. Good luck creating an infinite number of classes like DeathByAdapters, ViewHolders, DiffCallback, life_is_too_short_to_write.xmls. It is just a bit overwhelming, especially after you have had a taste of Compose’s conciseness, it’s just too hard to go back to 2015 with all the RecyclerView boilerplate craziness (sorry 🙈).

Not just conciseness, Compose‘s state system is a totally different mindset, which gives us more control over the state of composables. It is quite easy to misuse or not make use of the API called ListApater which gives RecyclerView its diffing capabilities (we went over this in the previous article). On the other hand, in Compose, diffing is quite straight-forward. There is comparatively much less to remember and understand wrt diffing in Compose. We will look into how Compose translates from the world of ListAdapter’s DiffUtil, i.e. areItemsTheSame() and areContentsTheSame mindset to Compose’s slot keys and stability.

ListAdapter with RecyclerViewas discussed previously gives us this power by basically just implementing these two functions:

  • areItemsTheSame()
  • areContentsTheSame()

Example

Let’s take an example of a simple screen with a list of multiple view types. We want to create a nested list of items as shown in Illustration 1. There are two content types within the vertically scrolling list:

  1. Titles: Text
  2. Horizontal Image List: LazyRow
Illustration 1

Creating the above is as simple as:

    @Composable
fun ShelvesWithTitlesLazy(items: List) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
) {
items(
count = items.size,
) { index ->
val item = items[index]
when (item.type) {
CONTENT_TYPE_TEXT -> {
ShelfTitle(text = (item as TextItem).text)
}
CONTENT_TYPE_IMAGE_SHELF -> {
ImageShelf(images = (item as ImageShelfItem).images, item.id)
}
...
}
}
}
}

where the ShelfTitle composable can be defined as:

    @Composable
fun LazyItemScope.ShelfTitle(
text: String
) {
Text(text = text, modifier = Modifier.fillMaxWidth())
}

and ImageShelf composable is defined as:

   @Composable
fun ImageShelf(images: List, id: Int) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
count = images.size,
key = { index ->
images[index].id
},
) { index ->
val image = images[index]
GlideImage(
modifier = Modifier
.width(50.dp)
.clip(RoundedCornerShape(30.dp)),
requestBuilder = {
Glide.with(LocalContext.current)
.asBitmap()
.load(image.url)
},
imageModel = { image.url },
imageOptions = ImageOptions(contentScale = ContentScale.FillBounds),
)
}
}
}

Let’s run this!

Illustration 2

All good, now let’s try to add an item at the top.

Illustration 3

Whenever we add an item at the top, the whole screen blinks. This is because when creating the ShelvesWithTitle composable, we did not send in a key lambda to the items() dsl.

By default, compose compiler takes the position of the items as keys to save nodes in the slot table to be reused. If an item is added at the top, compose cannot reuse the nodes since the data for the newly added node is different from that, at the first position and hence the whole list is recomposed. Similarly, if an item is added in the middle, compose cannot reuse the nodes below the newly added item.

Let’s add the key and contentType lambdas to our items() dsl in the ShelvesWithTitle snippet. This key should be unique for each list item. If it is not unique then it will crash at runtime.


@Composable
fun ShelvesWithTitlesLazy(items: List) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.scrollable(rememberScrollState(), Orientation.Vertical)
) {
items(
count = items.size,
key = { index -> //KEY
items[index].id
},
contentType = { index -> //CONTENT-TYPE
items[index].type
}
) { index ->
val item = items[index]
when (item.type) {
CONTENT_TYPE_TEXT -> {
ShelfTitle(text = (item as TextItem).text)
}

CONTENT_TYPE_IMAGE_SHELF -> {
ImageShelfComposable(images = (item as ImageShelfItem).images, item.id)
}
...
}
}
}
}

Now, let’s try to add items again!

Illustration 4

Similarity to areItemsTheSame() function

As you can see, that instead of the whole screen going bonkers, the items are reused and move to their new positions whenever a new item is added at the top. Providing an identifier to each item is similar to implementing the areItemsTheSame() function that we provide to the AsyncListDiffer implementation of ListAdapter. This identifier is then used to figure out if the item was moved from its place and whether it can be reused or not.

To learn more about how LazyList reuses its items internally, let us have a look at the LazyLayoutItemReusePolicy.

LazyLayoutItemReusePolicy

Item reuse is a different concept than recycling in Android. All words no code? Let us look at how LazyColumn internally reuses items. Looking at the source code of LazyLayout. We see the following call chain:

Illustration 5

LazyLayout creates a LazyLayoutItemReusePolicy and passes it to the SubcomposeLayout. SubcomposeLayout composes items lazily as and when needed. Let’s have a look at the LazyLayoutItemReusePolicy:

// Copyright 2021 The Android Open Source Project

@ExperimentalFoundationApi
private class LazyLayoutItemReusePolicy(
private val factory: LazyLayoutItemContentFactory
) : SubcomposeSlotReusePolicy {
private val countPerType = mutableMapOf()

override fun getSlotsToRetain(slotIds: SubcomposeSlotReusePolicy.SlotIdsSet) {
countPerType.clear()
with(slotIds.iterator()) {
while (hasNext()) {
val slotId = next()
val type = factory.getContentType(slotId)
val currentCount = countPerType[type] ?: 0
if (currentCount == MaxItemsToRetainForReuse) {
remove()
} else {
countPerType[type] = currentCount + 1
}
}
}
}

override fun areCompatible(slotId: Any?, reusableSlotId: Any?): Boolean =
factory.getContentType(slotId) == factory.getContentType(reusableSlotId)
}

/**
* We currently use the same number of items to reuse (recycle) items as RecyclerView does:
* 5 (RecycledViewPool.DEFAULT_MAX_SCRAP) + 2 (Recycler.DEFAULT_CACHE_SIZE)
*/
private const val MaxItemsToRetainForReuse = 7

If you look closely, there are multiple things to note here :

  • LazyLayoutItemReuse policy saves and reuses 7 items per contentType. If we do not provide the contentType lambda to our items() DSL, by default all items will have same contentType and thus end up using the 7 item pool pretty quickly.
override fun areCompatible(slotId: Any?, reusableSlotId: Any?): Boolean =
factory.getContentType(slotId) == factory.getContentType(reusableSlotId)
  • The areCompatabile function looks similar to our areItemsTheSame function. This function checks the compatibility of two nodes using the provided contentType associated to the slotId (key) of the node.

Now that we have added key and contentType lambda, and all seems good, let’s verify this using the recomposition highlighter modifier as shown here. Recomposition highlighter is going to draw a border around the elements that are recomposing. We can also look at recompositions using the recomposition counts within Android studio’s Layout Inspector, but for the purposes of making it a bit more visual, we use the recomposition highlighter modifier here.

Illustration 5

As shown in the Illustration 5 above, whenever a new text item is inserted, it is expected to see the new item getting composed. But, what’s strange is why the horizontal shelf of items is getting recomposed whenever a new item is added at the top. Let’s dig deeper:

Similarity to areContentsTheSame()

This is our ImageShelf composable:

   @Composable
fun ImageShelf(images: List, id: Int) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
count = images.size,
key = { index ->
images[index].id
},
) { index ->
val image = images[index]
GlideImage(
modifier = Modifier
.width(50.dp)
.clip(RoundedCornerShape(30.dp)),
requestBuilder = {
Glide.with(LocalContext.current)
.asBitmap()
.load(image.url)
},
imageModel = { image.url },
imageOptions = ImageOptions(contentScale = ContentScale.FillBounds),
)
}
}
}

If we look at the parameters passed to the ImageShelf composable:

  1. images: List
  2. id: Int

Out of these two, List parameter is an interface and is considered unstable by the compose compiler. When the compose compiler is not able to infer the stability of the parameters passed to a composable, it marks them as unstable and the corresponding composable then becomes non-skippable, i.e. it will always be recomposed when the parent composable recomposes, even if the parameter passed to it didn’t actually change.

If all the parameters passed to a composable are stable, the corresponding composable is marked as skippableand compose can then skip its recomposition. Compose can then evaluate if a particular composable needs to be recomposed or not by comparing data passed as the parameter to the composable with the corresponding data saved in the slot table.

This applies to each composable item in the list, while creating or updating a given item, if compose finds the item using the item’s slotId (key) previously saved in the slot table and if the item is compatible (same content-type) with the saved item, compose then tries to reuse the node saved in the slot table and can skip recomposing this composable.

If the parameters passed to a composable are stable and also the value for the parameters haven’t changed, the corresponding node in the slot table can then be used directly, if not, compose triggers recomposition for the node.

This highlights an important concept, i.e. Stability. Stability deserves another blog in itself, there are already quite a few blogs written on it already(Check the references), for now, let’s try to fix our unstable parameter.

Stability is also the way to specify areContentsTheSame() in Compose. If compose finds the item previously saved for a particular slotId (key) compatible to a saved node. Compose then tries to reuse the node. In case the parameters passed to the composable are stable and also the value for the parameters haven’t change, the node can be used directly, if not, compose triggers recomposition. Stability deserves another blog in itself, there are already quite a few blogs written on it (check the references), for now, let’s try to fix our unstable parameter.

In case it is difficult to find out which parameter passed to a composable is unstable, we can enable compose compiler metrics to dig deeper.

There are multiple ways to convert/ help compose compiler understand that the List parameter is actually stable:

There are multiple ways to convert/ help compose compiler understand that the List parameter is actually stable:

  1. @Immutableannotation: In case we intend to map our domain/data model into a UI model already, we can mark our UI state model as @Immutable and then pass it to the composable function. This annotation is a promise to the compose a compiler that .equals() method is implemented and it will return valid status of equality.
  2. ImmutableHolder: Since we do not intend to mutate this list, we can wrap the list within a generic immutable wrapper created using the @Immutable annotation :
@Immutable
data class ImmutableHolder(val item: T)

3. Since using lists is quite common, we can also create a list specific ImmutableHolder as suggested by Shreyas Patil here.

@Immutable
data class ComposeImmutableList private constructor(
private val baseList: List
) : List by baseList {
companion object {
/**
* Creates [ComposeImmutableList] from [baseList].
*/
fun from(baseList: List): ComposeImmutableList {
return ComposeImmutableList(Collections.unmodifiableList(ArrayList(baseList)))
}
}
}

4. ImmutableList: We can also use Immutable list from kotlinx.collections.immutable package.

Good News:

In future compose versions, strong skipping will be enabled that will avoid the need to use immutable collections etc. Very early days for this though. — Rebecca Franks

In the above snippet I decided to use ImmutableList and let’s look at the result then:

Illustration 6

As shown in Illustration 6 above, once we pass the list as a stable parameter, compose can perform an equality check on the passed in data which hasn’t changed and thus the nodes can be reused efficiently.

Stability is how Compose’s version of areItemsTheSame() is implemented. All we need to take care of is to keep the parameters that we pass to the composable function stable.

Conclusion

Jetpack Compose presents a more concise and developer-friendly alternative to RecyclerView for managing lists, reducing the boilerplate associated with RecyclerView’s adapters and diffing callbacks. Compose achieves efficient diffing through slot keys and stability, reminiscent of RecyclerView’s areItemsTheSame() and areContentsTheSame(). Thus, while using Compose’s lazy lists, keep in mind :

  • Use key and contentType for Lazy Lists
  • Try to keep your Composable inputs stable

References and further reading

  1. Composable metrics by Chris Banes
  2. Promise compose compiler and imply when you’ll change 🤞” by Shreyas Patil
  3. Strong Skipping in Compose

Special thanks to Ritesh Gupta, Shreyas Patil and Rebecca Franks for reviewing 🙂


The Diffing Dilemma! All about diffing with LazyLists! was originally published in Google Developer Experts on Medium, where people are continuing the conversation by highlighting and responding to this story.

Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post
the-psyche-of-today’s-b2c-buyer:-unraveling-the-secrets-of-their-purchasing-decisions

The psyche of today’s B2C buyer: Unraveling the secrets of their purchasing decisions

Next Post
for,-map-and-reduce-in-elixir

For, Map and Reduce in Elixir

Related Posts