Anatomy of RecyclerView: a Search for a ViewHolder

Pavel Shmakov
AndroidPub
Published in
15 min readMar 11, 2017

--

Intro

In this series of articles I’m going to share my knowledge of the inner workings of RecyclerView. Why? Just think about it: RecyclerView is needed almost in every modern Android app, so the way developers work with it affects the experience of millions and millions of users. And yet, what kind of educational material on RecyclerView do we have? You can surely find some basic tutorials on how to use RecyclerView, but what about how it works? The “black box” approach is definitely not good enough, especially if you are doing complex customizations or optimizing the performance.¹ The “deepest” material out there is probably the RecyclerView ins and outs talk at Google I/O 2016, which I recommend, but seriously, that’s not even close to “ins and outs”, that’s just the tip of the iceberg. My goal is to go deeper than that.

I assume that the reader has basic knowledge of RecyclerView, stuff like what LayoutManager is, how to notify Adapter of particular changes in data or how to use item view types.

In this first part we will be figuring out what’s going on in just one method inside RecyclerView: getViewByPosition(). This is one of the most crucial bits of source code, and by studying it, we will learn about many aspects of RecyclerView, such as ViewHolder recycling, hidden views, predictive animations and stable ids. You may be surprised to see predictive animations here. Well, the thing is, despite the best efforts of guys at Google to decouple responsibilities of different components of RecyclerView, a lot of “knowledge” is still shared between them, predictive animations being one of those things. There is no way to avoid talking about them at one point or another.

So, during laying items out the LayoutManager asks the RecyclerView “please give me a View at position 8”. And here is what RecycleView does in response:

  1. Searches changed scrap
  2. Searches attached scrap
  3. Searches non-removed hidden views
  4. Searches the view cache
  5. If Adapter has stable ids, searches attached scrap and view cache again for given id.
  6. Searches the ViewCacheExtension
  7. Searches the RecycledViewPool

If it fails to find a suitable View in all of those places, it creates one by calling adapter’s onCreateViewHolder() method. It then binds the View via onBindViewHolder() if necessary, and finally returns it.

As you see, there’s a lot going on here, much more than just one pool of reusable ViewHolders as one may have expected. Our goal is to figure out what all those caches are about, how they work and why they are needed. We’ll go through them one by one (in the order I think would be the best), and our first stop is the RecycledViewPool.

RecycledViewPool

We’d like to have an answer to a few questions about each kind of cache: what is its backing data structure, under which conditions the ViewHolders are stored there and retrieved from there, and, most importantly, what’s the purpose of it.

You probably know the purpose of the pool very well: while scrolling, say, downwards the views that disappear on top are recycled into the pool to be reused for the views that emerge from the bottom. We’ll talk about other scenarios where ViewHolders go to the pool a little bit later. But first let’s take a look at some of the RecycledViewPool’s code (which is an inner class of RecyclerView.Recycler):

public static class RecycledViewPool {    private SparseArray<ArrayList<ViewHolder>> mScrap =                   new SparseArray<>();    private SparseIntArray mMaxScrap = new SparseIntArray();    public ViewHolder getRecycledView(int viewType) {        ArrayList<ViewHolder> scrapHeap = mScrap.get(viewType);

First of all, don’t let the name mScrap confuse you — this has nothing to do with attached or changed scrap mentioned in the list above.

We see that each view type has its own pool of ViewHolders. When RecyclerView runs out of all other possibilities during a search for a ViewHolder, it asks the pool to give a ViewHolder with correct view type; at that point, view type is the only thing that matters.

Now, each view type has it’s own capacity. It is 5 by default, but you can change it like that:

recyclerView.getRecycledViewPool()
.setMaxRecycledViews(SOME_VIEW_TYPE, POOL_CAPACITY);

This is a very important point of flexibility. If you have dozens of same-type items on screen, which often change simultaneously, make the pool bigger for that view type. And if you know, that items of some view type are so rare, that they never appear on screen in quantity more than one, set the pool size 1 for that view type. Otherwise sooner or later the pool will be filled with 5 of those items, and 4 of them will just sit there unused, which is a waste of memory.

The methods getRecyclerView(), putRecycledView(), clear()are public, so you can manipulate the contents of the pool. Using putRecycledView()manually, e.g. in order to prepare some ViewHolders beforehand, is a bad idea, though. You should create ViewHolder only in onCreateViewHolder()method of your Adapter, otherwise the ViewHolders can appear in states that RecyclerView doesn’t expect.²

Another cool feature is that along with the getter getRecycledViewPool() there is a setter setRecycledViewPool(), so you can reuse a single pool for multiple RecycleViews.

Finally, I’ll note that the pool for each view type works as a stack (last in — first out). We’ll see why this is good later.

Ways to pool

Now let’s address the question of when the ViewHolders are thrown into the pool. There are 5 scenarios:

  1. A view went out of RecyclerView’s bounds during scrolling.
  2. Data has changed so that the view is no longer seen. Addition to the pool happens when the disappearance animation finishes.
  3. An item in the view cache has been updated or removed.
  4. During a search for a ViewHolder, one was found in scrap or cache with position we want, but turned out to be unsuitable because of the wrong view type or id (if adapter has stable ids).³
  5. The LayoutManager added a view at pre-layout, but didn’t add that view in post-layout.

The first two scenarios are pretty obvious. One thing to note, though, is that scenario 2 is triggered not only by removal of an item in question, but also, for example, by insertion of other items, which push the given item out of bounds.

The other scenarios require a bit of comment. We’ve not covered the view cache and scrap yet, but the idea behind scenarios 3 and 4 is simple. The pool is a place for the views we know to be “dirty” and requiring rebinding. ViewHolders in all caches, except for the pool, retain some of their state (most importantly, positions). All those caches are searched by position in hope that some ViewHolder can be reused as-is. By contrast, when a view goes to pool, it’s state (all the flags, position, etc.) is cleared. The only things that remain are associated view and the view type. The pool is searched by the view type, as we know, and when a ViewHolder is found there, it starts a new life.

Given that picture, scenarios 3 and 4 shouldn’t be a mystery: for example, if we see a item in the view cache being removed, there’s no point in holding it in that cache any more, since it won’t be reused as-is for the same position. It’s not nice to throw it away completely, though, so we throw it into the pool instead.

The last scenario requires us to know what pre-layout and post-layout are. Well, let’s just go ahead and figure that out! That scenario is definitely not the most crucial aspect of the pre/post-layout mechanism, but this mechanism is hugely important in general and is manifesting itself in every part of RecyclerView, so we’ll have to know it anyway.

Offtopic: pre-layout, post-layout and predictive animations

Consider a scenario where we have items a, b and c, of which a and b fit on the screen. We delete the b, which brings c into view:

What we’d like to see is c smoothly sliding from the bottom up to it’s new place. But how can that happen? We know the final location of c from the new layout, but how do we know where it should slide from? It would be wrong of RecyclerView or ItemAnimator to just assume by looking at the new layout that c should come from the bottom. We might have some custom LayoutManager where it should come from the side or something. So we need some more help from the LayoutManager. Can we use the previous layout? No, there’s no с there. At that point no-one new that b was going to be deleted, so laying out c was rightly considered by LayoutManager to be a waste of resources.

The solution guys at Google offered is as follows. After a change in the Adapter happens, the RecyclerView requests not one but two layouts from the LayoutManager. The first one — the pre-layout, lays out items in the previous adapter state, but uses the adapter changes as a hint that it might be a good idea to lay out some extra views. In our example, since we now know that b is being deleted, we additionally lay out c, despite the fact it’s out of bounds. The second one — the post-layout, is just a normal layout corresponding to the adapter state after the changes.

Now, by comparing the locations of c in pre-layout and post-layout, we can animate its appearance properly.

This kind of animation — when the animated view is not present either in previous layout or in the new one — is called predictive animation, which is one of the most important concepts in RecyclerView. We’ll discuss it in greater detail in later parts of this series. But now let’s quickly look at another example: what if b is changed instead of being deleted?

This might come as a surprise, but the LayoutManager still lays out c in the pre-layout phase. Why? Because maybe the change of b would make it become smaller in height, who knows? And if b becomes smaller, c might pop up from the bottom, so we better lay it out in pre-layout. But then later, in post-layout, it appeared to be not the case, say we just changed some TextView inside b. So c is not needed, and is thrown into the pool. That’s the scenario 5 of getting oneself into the pool. It is hopefully clear now and we can go back to the RecycledViewPool.

RecycledViewPool, continued

When we encounter one of the scenarios when a ViewHolder should go to the pool, there are still two more obstacles on its way there: it might not be recyclable and it’s View might be in transient state.

Recyclability

Recyclability is just a flag in ViewHolder, that you can manipulate by using setIsRecyclable() method of ViewHolder class. RecycleView makes use of it as well, making ViewHolders non-recyclable during animations.

Manipulating a single flag from different independent places is usually a bad idea. For example, RecyclerView calls setIsRecyclable(true) when animation ends, by you don’t want it to be recyclable for some reason specific to your application. But things don’t actually break in this case because calls to setIsRecyclable() are paired. That is, if you call setIsRecyclable(false) twice, then calling setIsRecyclable(true) only once doesn’t make the ViewHolder recyclable, you need to call it twice as well.

Transient state

The View’s transient state is a very similar thing. It’s a flag in the View, manipulated by setHasTransientState() method, and the calls are paired as well. The View class itself doesn’t use the flag, but just holds it. It serves as a hint for widgets like ListView and RecyclerView, that it’s better not to reuse this View for new content at this time.

You can set this flag yourself, but also the ViewPropertyAnimator (that is when you do someView.animate()…) automatically sets it to true at the start and to false at the end of animation. Note that if you use, for example, a ValueAnimator to animate your views, you would have to manage the transient state yourself.

One last thing to note about the transient state is that it is propagated from children to parents, all the way up to the root view. So if you animate some internal view of an item in the list, not only that view, but the item’s root view, which a ViewHolder holds reference to, goes to transient state.

OnFailedToRecycleView

If a ViewHolder about to be recycled fails either recyclability or transient state check, the onFailedToRecycleView() method of your Adapter is called. Now, this is a really important point: this method is not just a notification of an event, but a question to you on how this situation should be dealt with.

Returning true from onFailedToRecycledView() means “recycle it anyway”. One situation where this is appropriate is when you clear all the animations and other sources of this trouble when binding a new item. Alternatively, you can deal with these things right in the onFailedToRecycledView() method.

What you shouldn’t do is to ignore onFailedToRecycledView() completely. One scenario in which that might hurt you is the following. Imagine you are fading in images in the items when they come into view. If the user scrolls your list fast enough, the images won’t finish fading in when they go out of view, rendering the ViewHolders ineligible for recycling. So you’ll have laggy scroll, and on top of that, new and new ViewHolders will be created, cluttering the memory. For Russian-speaking readers I recommend this talk by Konstantin Zaikin, where, among other things, this scenario is shown in action: https://events.yandex.ru/lib/talks/3456/

Succeeding in recycling a ViewHolder leads to a call to onViewRecycled() method, which is a good place to release heavy resources, such as images. Remember that some ViewHolder instances can end up sitting in the pool for a long time without usage, which may be a big waste of memory.

We now move on to the next cache — the view cache.

View Cache

When I say “view cache” or just “cache” what I refer to is mCachedViews field found in RecyclerView.Recycler class. It’s also called “first level cache” in some comments in the code.

This is just an ArrayList of ViewHolders, no splitting by view types here. The default capacity is 2, and you can tweak it via setItemViewCacheSize() method of RecyclerView.

As I mentioned before, the most important difference between the pool and other caches (including the view cache), is that those other caches are searched for a ViewHolder associated with given position, while the pool is searched by view type. When a ViewHolder is in the view cache, we hope to to reuse it “as-is”, without rebinding, at the same position as the one it was at before it got into the cache. So let’s make this distinction perfectly clear:

  • If a ViewHolder was found nowhere, it is created and bound.
  • If a ViewHolder was found in pool, it is bound.
  • If a ViewHolder was found in cache, there is nothing to be done.

At this point one important thing becomes very clear: a ViewHolder being bound and recycled into pool (onViewRecycled()) is not the same thing as an item in list going in and out of visible bounds. When it goes out, its ViewHolder can go to view cache instead of pool, and when it comes in, the ViewHolder is sometimes retrieved from view cache and is not bound. If you need to track the presence of items on screen, use the onViewAttachedToWindow() and onViewDetachedFromWindow() callbacks of your Adapter.

Filling pool and cache

Now, to the next question: how do ViewHolders end up in the view cache? When I was talking about the scenarios, which lead to the pool, I actually deceived you a little bit. In those scenarios (except for the third one) the ViewHolder goes either to the cache or to the pool.

Let me illustrate the rules by which either the cache or the pool is selected. Say, we have empty pool and cache initially and recycle the items one by one. This is how the pool and the cache is filled (assuming default capacities and one view type):

So, as long as the cache isn’t full, ViewHolders go there. If it’s full, a new ViewHolder pushes a ViewHolder from the “other end” of the cache into the pool. If a pool is already full, that ViewHolder is pushed into oblivion, to the garbage collector that is.

Pool and Cache in Action

Now let’s look at the way pool and cache behave in a couple of actual RecyclerView usage scenarios.

Consider scrolling:

As we scroll downwards, there is a “tail” behind the currently seen items consisting of cached items and then a pooled item. When the item 8 appears on screen, no suitable ViewHolder is found in cache: no ViewHolder associated with position 8 there. So we use a pooled ViewHolder, which was previously at position 3. When item 6 disappears on top, it goes to the cache, pushing 4 into the pool.

The picture is different when we start scrolling in the opposite direction:

Here we find a ViewHolder for position 5 in view cache and reuse it right away, without rebinding. And that seems to be the main use-case of the cache — to make scrolling in opposite direction, to the items we’ve just seen, more efficient. So if you have a news feed, the cache might be useless, since users won’t go back too often. But if it’s a list of things to choose from, say a gallery of wallpapers, you might want to extend the capacity of the cache.

A couple of things to note here. First, what if we scroll up to view 3? Remember that the pool works like a stack, so if we didn’t do anything but scrolling since the last time we saw 3, then the ViewHolder 3 will be the last one to be put into the pool and thus is now chosen to be rebound at position 3. If the data didn’t change, we actually don’t need to do any rebinding. You should always check in your onBindViewHolder() if you actually need to change this TextView or that ImageView etc., and this is an example of a situation when you don’t.

Second, notice that there is always no more than one item (per view type) in the pool while scrolling! (Of course, if you have a multi-column grid with n columns, then you’ll have n items in the pool.) The other items that ended up in the pool via scenarios 2–5, just stay there uselessly during scrolling.

Now let’s look at a scenario, in which, by contrast, lots of items go into the pool: calling notifyDataSetChanged() (or notifyItemRangeChanged() with some wide range):

All the ViewHolders become invalid, cache is not a suitable place for them, and they all try to go to pool. There might be not enough space for them, so some unlucky ones will be garbage collected and then created again. In contrast to scrolling, you might want a bigger pool in this situation. Another case in which a big pool is useful is jumping from one position to another programmatically via scrollToPosition().

So how do we choose the optimal size of the pool? It seems that the optimal strategy is to extend the pool right before you’ll need it to be big, and shrink it right afterwards. One dirty way to implement this is the following:

recyclerView.getRecycledViewPool().setMaxRecycledViews(0, 20);adapter.notifyDataSetChanged();new Handler().post(new Runnable() {    @Override    public void run() {        recyclerView.getRecycledViewPool()
.setMaxRecycledViews(0, 1);
}});

Continued here:

https://medium.com/@pavelshmakov/anatomy-of-recyclerview-part-1-a-search-for-a-viewholder-continued-d81c631a2b91

^ ¹ In fact, even understanding public API of RecyclerView requires you to know some of the inner workings. For example, the javadoc to setHasStableIds() method tells you nothing about why would you want to use it.

^ ² E.g. the correct view type is set in createViewHolder() method right after the Adapter call, and the field is package local, so you can’t set it yourself.

^ ³An example when this happens: change the item, so that it’s view type changes, call notifyItemChanged(). Also, disable change animations in your ItemAnimator, otherwise scenario 2 will happen.

^ ⁴ One other example of View being in transient state is EditText with some text being selected or in the process of editing.

^ ⁵ The recyclability and transient state checks come before selection between cache and pool, which to be honest doesn’t make much sense to me, since views in cache are supposed to reappear exactly in the state they were when disappearing.

^ ⁶ In support version 23 this mechanism is broken by a simple off-by-one indexing mistake. The number of ViewHolders in the cache alternates between 1 and 2 as we recycle ViewHolders one by one.

--

--