The Fantastic RecyclerView.ViewHolder and Where Are They Created

Let’s imagine that you have already optimized your recycler inside and out:

  • setHasFixedSize (true)

  • DiffUtil

  • views are flatter than the earth

  • the fastest binding of viewholders in the Wild West

But this is not enough for you and you keep looking for ways to optimize. Congratulations, you’ve come to the right article!

Background

Once, while painting and moving the buttons, I noticed that the recycler view on one of our screens sagged a little in drawing when scrolling was active due to the wide variety viewType (and, as a consequence, the frequent creation of new viewholders), and looking at this, I remembered about old article by Chet Haase about how it became possible to load the recycler with work on prefetching elements during the downtime of the main trade. But it seemed to me that this was not enough, and I wanted to create elements at the moments of downtime … not the main thread.

So the idea came up to tweak the work. RecyclerView.RecycledViewPool in order for it to fill itself with views created off the main thread and give them to the recycler upon request, i.e. engaged in prefetching.

In addition to the performance benefit when scrolling through lists with a large variety of elements, this mechanism will be able to get elements when, for example, the first page of content is loaded and we can create views for the content that is just loading, thereby speeding up its display to our user.

I decided to break this idea into two components: one will be engaged in creating views in the background (Supplier), and the other – their receipt and subsequent transfer to the recycler for use (Consumer).

RecyclerView.RecycledViewPool

To begin with, you should figure out what it is RecyclerView.RecycledViewPoolto build our Consumer

RecycledViewPool Is a repository of recycled views, from which we can get them for what we need viewType, thereby not creating them again. Thus, it is RecycledViewPool underlies godlike performance RecyclerView… Beyond just storing recycled views RecycledViewPool also stores information about how much time the views are created and bound – so that in the future, upon request GapWorker, it is plausible enough to “predict” whether it will be possible to efficiently utilize the remaining time in the frame in order to proactively create a view of one or another viewType

Consumer

Consumer Code
class PrefetchViewPool(
    private val defaultMaxRecycledViews: Int,
    private val viewHolderSupplier: ViewHolderSupplier
) : RecyclerView.RecycledViewPool() {

    private val recycledViewsBounds = mutableMapOf<Int, Int>()

    init {
        attachToPreventFromClearing()
        viewHolderSupplier.viewHolderConsumer = ::putViewFromSupplier
        viewHolderSupplier.start()
    }

    fun setPrefetchBound(viewType: Int, count: Int) {
        recycledViewsBounds[viewType] = max(defaultMaxRecycledViews, count)
        viewHolderSupplier.setPrefetchBound(viewType, count)
    }

    override fun putRecycledView(scrap: RecyclerView.ViewHolder) {
        val viewType = scrap.itemViewType
        val maxRecycledViews = recycledViewsBounds.getOrPut(viewType) { defaultMaxRecycledViews }
        setMaxRecycledViews(viewType, maxRecycledViews)
        super.putRecycledView(scrap)
    }

    override fun getRecycledView(viewType: Int): RecyclerView.ViewHolder? {
        val holder = super.getRecycledView(viewType)
        if (holder == null) viewHolderSupplier.onItemCreatedOutside(viewType)
        return holder
    }

    override fun clear() {
        super.clear()
        viewHolderSupplier.stop()
    }

    private fun putViewFromSupplier(scrap: RecyclerView.ViewHolder, creationTimeNanos: Long) {
        factorInCreateTime(scrap.itemViewType, creationTimeNanos)
        putRecycledView(scrap)
    }
}

Now that we’ve figured out what kind of beast is RecyclerView.RecycledViewPool – you can start modifying its functionality to suit our needs (advance filling not only upon request from GapWorkerbut also from our Supplier)

First of all, we want to add to the API of our prefetcher the ability to configure the number of views of a particular type that should be … prefetched.

fun setPrefetchBound(viewType: Int, count: Int) {
    recycledViewsBounds[viewType] = max(defaultMaxRecycledViews, count)
    viewHolderSupplier.setPrefetchBound(viewType, count)
}

Here, in addition to storing the value of the maximum number of stored views, we also report ViewHolderSupplier data about how many views of a certain type we want from it by calling setPrefetchBound, thereby starting the prefetching process.

Further, when we try to recycle the view, we will inform the view pool the number of the maximum possible stored views, so as not to store unnecessary and update the knowledge about this number, since it can change over time.

override fun putRecycledView(scrap: RecyclerView.ViewHolder) {
    val viewType = scrap.itemViewType
    val maxRecycledViews = recycledViewsBounds.getOrPut(viewType) { defaultMaxRecycledViews }
    setMaxRecycledViews(viewType, maxRecycledViews)
    super.putRecycledView(scrap)
}

Then, when a request from the recycler comes to our view pool to receive a previously processed view, if there is no prepared / processed copy in our pool, we will notify our Supplier that the recycler itself will create another view of this type on the main trade.

override fun getRecycledView(viewType: Int): RecyclerView.ViewHolder? {
    val holder = super.getRecycledView(viewType)
    if (holder == null) viewHolderSupplier.onItemCreatedOutside(viewType)
    return holder
}

It remains only to link the life cycle of our view pool and Supplier, and determine that our view pool is Consumer‘om prefiled views:

init {
    attachToPreventFromClearing()
    viewHolderSupplier.viewHolderConsumer = ::putViewFromSupplier
    viewHolderSupplier.start()
}

private fun putViewFromSupplier(scrap: RecyclerView.ViewHolder, creationTimeNanos: Long) {
    factorInCreateTime(scrap.itemViewType, creationTimeNanos)
    putRecycledView(scrap)
}

override fun clear() {
    super.clear()
    viewHolderSupplier.stop()
}

It is worth noting here the method factorInCreateTime Is a method that saves the creation time of the viewholder, based on which GapWorker will draw conclusions about whether he will be able to prefetch something on his own during the downtime of the main trade or not.

Supplier

Supplier Code
typealias ViewHolderProducer = (parent: ViewGroup, viewType: Int) -> RecyclerView.ViewHolder
typealias ViewHolderConsumer = (viewHolder: RecyclerView.ViewHolder, creationTimeNanos: Long) -> Unit

abstract class ViewHolderSupplier(
    context: Context,
    private val viewHolderProducer: ViewHolderProducer
) {

    internal lateinit var viewHolderConsumer: ViewHolderConsumer

    private val fakeParent: ViewGroup by lazy { FrameLayout(context) }
    private val mainHandler: Handler = Handler(Looper.getMainLooper())
    private val itemsCreated: MutableMap<Int, Int> = ConcurrentHashMap<Int, Int>()
    private val itemsQueued: MutableMap<Int, Int> = ConcurrentHashMap<Int, Int>()
    private val nanoTime: Long get() = System.nanoTime()

    abstract fun start()

    abstract fun enqueueItemCreation(viewType: Int)

    abstract fun stop()

    protected fun createItem(viewType: Int) {
        val created = itemsCreated.getOrZero(viewType) + 1
        val queued = itemsQueued.getOrZero(viewType)
        if (created > queued) return

        val holder: RecyclerView.ViewHolder
        val start: Long
        val end: Long

        try {
            start = nanoTime
            holder = viewHolderProducer.invoke(fakeParent, viewType)
            end = nanoTime
        } catch (e: Exception) {
            return
        }
        holder.setItemViewType(viewType)
        itemsCreated[viewType] = itemsCreated.getOrZero(viewType) + 1

        mainHandler.postAtFrontOfQueue { viewHolderConsumer.invoke(holder, end - start) }
    }

    internal fun setPrefetchBound(viewType: Int, count: Int) {
        if (itemsQueued.getOrZero(viewType) >= count) return
        itemsQueued[viewType] = count

        val created = itemsCreated.getOrZero(viewType)
        if (created >= count) return

        repeat(count - created) { enqueueItemCreation(viewType) }
    }

    internal fun onItemCreatedOutside(viewType: Int) {
        itemsCreated[viewType] = itemsCreated.getOrZero(viewType) + 1
    }

    private fun Map<Int, Int>.getOrZero(key: Int) = getOrElse(key) { 0 }
}

Now that we have dealt with the first part of our mechanism, let’s deal with the implementation of the second – Supplier… Its main task is to start a queue to create views of the required viewType, create them somewhere outside the main trade and transfer Consumer… In addition, in order not to do unnecessary work, he should react to the fact that the view was created outside of it, reducing the size of that same creation queue.

Launching the queue

To start the creation queue, we check if we already have a sufficient number of views of this type in the queue. Next, let’s check if we have already created enough views of this type. And if both of these conditions pass, we add to the queue a request for that number of views, which is enough to bring the number of already created views to the target value count:

internal fun setPrefetchBound(viewType: Int, count: Int) {
    if (itemsQueued.getOrZero(viewType) >= count) return
    itemsQueued[viewType] = count

    val created = itemsCreated.getOrZero(viewType)
		if (created > count) return

		repeat(count - created) { enqueueItemCreation(viewType) }
}

Create views

As for the method enqueueItemCreation – it will be abstract and its implementation will depend on the approach to multithreading chosen by your team.

But what will definitely not be abstract is the method createItem, which is supposed to be called from somewhere outside the main trade just through the implementation enqueueItemCreation… In this method, first of all, we check, but, in general, do we need to do something or do we already have a sufficient number of cached views? Then we create our view, remembering the time spent on its creation, ignoring errors (let them fall there in our main thread). After that, we will inform the new viewholder about it. viewType, just to keep him informed, let’s make a note that we have created another element with the desired viewType and notify Consumer‘but about this:

protected fun createItem(viewType: Int) {
    val created = itemsCreated.getOrZero(viewType) + 1
    val queued = itemsQueued.getOrZero(viewType)
    if (created > queued) return

    val holder: RecyclerView.ViewHolder
    val start: Long
    val end: Long

    try {
        start = nanoTime
        holder = viewHolderProducer.invoke(fakeParent, viewType)
        end = nanoTime
    } catch (e: Exception) {
        return
    }
    holder.setItemViewType(viewType)
    itemsCreated[viewType] = itemsCreated.getOrZero(viewType) + 1

    mainHandler.postAtFrontOfQueue { viewHolderConsumer.invoke(holder, end - start) }
}

Here viewHolderProducer it’s simple typealias:

typealias ViewHolderProducer = (parent: ViewGroup, viewType: Int) -> RecyclerView.ViewHolder

(which is suitable for the role RecyclerView.Adapter.onCreateViewHolder, eg)

It is worth mentioning that the traditional creation of a view through LayoutInflater.inflate – not the most efficient way to utilize the capabilities of the mechanism presented in the article due to synchronization on the arguments of the constructor …

Reacting to View Creation Outside the Supplier

In the case of creating a view outside of our Supplier (method onItemCreatedOutside) – just update the current knowledge about the number of already created views of this type:

internal fun onItemCreatedOutside(viewType: Int) {
    itemsCreated[viewType] = itemsCreated.getOrZero(viewType) + 1
}

Profit!

Now you just need to define the behavior of the methods start, stop and enqueueItemCreation depending on the approach chosen by your team to work with multithreading, and you will get a wunderwaffe for creating viewholders outside the main stream, which can even be reused between screens and slipped to different recyclers using the same adapters / views, for example …

Well, in case you don’t want to think about how to write your own implementation ViewHolderSupplierthen, just today, especially for you, I did small library, which has an artifact with core functionality described in this article, as well as artifacts for all major approaches to multithreading today (Kotlin Coroutines, RxJava2, RxJava3, Executor).

Similar Posts

Leave a Reply

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