Anatomy of RecyclerView: a Search for a ViewHolder (continued)
We continue our discussion of the way RecyclerView searches for a View at given position, which was started here: https://medium.com/@pavelshmakov/anatomy-of-recyclerview-part-1-a-search-for-a-viewholder-404ba3453714
For easier reference I copy the outline of RecyclerView’s view searching algorithm here.
- Search changed scrap
- Search attached scrap
- Search non-removed hidden views
- Search the view cache
- If Adapter has stable ids, search attached scrap and view cache again for given id.
- Search the ViewCacheExtension
- Search the RecycledViewPool
So far we’ve covered RecycledViewPool and the view cache.
The idea of ViewCacheExtension is attractive: it’s a cache that behaves the way you want. It’s not present unless you explicitly set it via
setViewCacheExtension() method of RecyclerView and implement a simple interface:
View getViewForPositionAndType(Recycler recycler, int position,
Looks nice and simple. But upon closer inspection it turned out to be neither simple nor nice.
First, you can’t just return any View you want. You need to return a View that is owned by some ViewHolder. When a ViewHolder is created, the reference to it is stored in the View’s LayoutParams, and this is where RecyclerView looks when you return a View from your ViewCacheExtension. If it doesn’t find a ViewHolder there, you get a crash. This is quite weird, isn’t it? Why require to return a View, when actually a ViewHolder is needed?
But there’s a bigger problem. Remember that ViewHolders are not stateless, they are mutable, having position, flags and other stuff managed by RecyclerView internally. If you return a View associated with a ViewHolder, which belongs to some position other than the one passed in the argument, RecyclerView and/or LayoutManager will get confused and produce bugs.
This problem with positions seems to be beyond your control. Let’s look a bit more closely at where it comes from. Say, you add or delete some items. At the beginning of the layout process, before the LayoutManager does any work, the AdapterHelper is asked to process the adapter changes that happened since last layout (we will cover the AdapterHelper in later parts of the series). It then calls back the RecyclerView to apply offsets to the positions of the ViewHolder. The RecyclerView loops through the ViewHolders of currently displayed Views and applies the offsets (see
offsetPositionsForInsert() and similar methods). But it doesn’t know about the ViewHolders you cached yourself somewhere, so it can’t offset their positions! And you can’t either, since the position is package-local.
Due to these constraints it seems that the usefulness of ViewCacheExtension is very limited. It wasn’t easy for me to think of an example where it works and gives some benefit, but here it is.
Imagine you have some “special” items with the following behavior:
- Their positions are fixed. For example, these are ads, which you always have to place at specific positions.
- They never change visually.
- They are in a reasonable quantity, so that it’s ok to keep all their Views in memory.
What you want to avoid is rebinding these items. Say, you have 3 of them far apart in the list. Then normally there will be one ViewHolder that will be rebound between those 3 items as you scroll back and forth. Rebinding might be costly at hurt the smoothness of scrolling, so you would rather just keep all 3 views in the memory. The pool can’t help you, because getting into the pool always means rebinding later on, and neither can the View Cache, which unfortunately doesn’t care about view types. ViewCacheExtension to the rescue!¹
You can write some code like this:
And it actually works: each “special” ViewHolder is created and bound to the data only once.
But as soon as you introduce any position changes of “special” items, e.g. due to deletion or addition of surrounding items, it all falls apart. You may think that recalculating the keys of the SparseArray would be enough, but it’s not: the ViewHolders themselves keep positions, which become stale, as we discussed earlier.
Our next stop is the mysterious “search non-removed hidden views”.
What are hidden views? They are the views that are currently in the process of going out of RecyclerView bounds. They are still being kept as children of RecyclerView, but only for the purpose of animating them out properly. From the LayoutManager’s perspective they are gone, and shouldn’t be included in any calculations. For example, if you call LayoutManager’s
getChildAt() method while some view is disappearing due to an item being removed, that view won’t count, which kind of makes sense.
All the calls to
addView() etc. coming from the LayoutManager are routed through the ChildHelper before being applied to actual children of the RecyclerView. ChildHelper is probably the simplest and nicest class in the RecyclerView’s family. Its responsibility is recalculating indices between the list of non-hidden children and the list of all children.²
Now, after what I’ve just said the fact that hidden views are used in a search for a ViewHolder, seems even more mysterious. Remember that we search for a view to give to LayoutManager, but LayoutManager shouldn’t know about hidden views!
Well, I it actually doesn’t know about them, but RecyclerView does, and in very specific cases can feed them back to LayoutManager. This a bit weird “bouncing from hidden views” mechanism is just necessary to deal with a following situation. Imagine we insert an item and then quickly, before the insertion animation finishes, remove that item:
What we want to see is b going up from exactly where is was at the moment of c’s removal. But at that moment b is a hidden view! If we just ignore it, a new b would be created below the existing b and they would overlap, as the new one goes up, and the old one continues to go down. To avoid such an epic bug, at one of the earlier steps in a search for a ViewHolder, RecyclerView asks ChildHelper whether it has a suitable hidden view. By suitable it means a view associated with the position we need, with correct view type and that the reason of it being hidden is not removal (obviously, we shouldn’t bring those back into life).
If there is such a view, RecyclerView returns it to LayoutManager and also adds it to pre-layout to mark the place it should be animated from (see
recordAnimationInfoIfBouncedHiddenView() method). Now, if you’ve been following me, you should have a strong “wtf” feeling after reading the previous sentence. Adding things to pre- or post-layout must be LayoutManager’s business, and now the RecyclerView itself is adding something to pre-layout. Yes, this mechanism looks quite hacky and shaky, and that’s a good enough reason to know about it.
Scrap lists are the first place where the RecyclerView looks when searching for a ViewHolder, and it’s quite different from Pool or View Cache.
Scrap is not empty only during layout. When LayoutManager starts a layout (either pre- or post-) , all the views currently added to layout are dumped into scrap (see a
detachAndScrapAttachedViews() call in
LinearLayoutManager#onLayoutChildren()). Then the LayoutManager retrieves the Views one by one, and unless something happened to a View, it comes right back from scrap:
In this example we delete b, which causes a re-layout. a, b and c are dumped to scrap, and then a and c are retrieved from scrap.
As a small digression, what happens to b? At the final stages of the layout process RecyclerView sees that b was never added to post-layout. It unscraps it, makes it hidden and starts the disappearance animation. When animation finishes, b goes to the pool.
Why couldn’t the LayoutManager just use unchanged children right away? I believe that the reason for scrap’s existence is separation of concerns between the LayoutManager and the RecyclerView.Recycler. The LayoutManager shouldn’t have to know whether a particular child is good to keep around or it should look into pool or somewhere else instead. That’s Recycler’s business to figure that out.
Besides being non-empty only during layout, there seems to be another invariant related to scrap: all scrapped views are detached from RecyclerView. The
detachView() methods of ViewGroup are similar to
removeView(), but don’t cause things like layout request or invalidation. They merely remove a child from ViewGroup’s list of children and set this child’s parent to null. Detached state must be temporary and followed by either attaching or removing. When calculating a new layout while you already have a bunch of added children, it is convenient to first detach them all, and that’s what RecyclerView does.
Attached vs Changed scrap
In the Recycler you can see two separate scrap containers:
mChangedScrap. Why do we need two?
Let’s look at how one of the two scrap containers is chosen. A ViewHolder goes to the changed scrap only if the associated item was changed (
notifyItemRangeChanged() was called), and the ItemAnimator says “no” when asked if it
canReuseUpdatedViewHolder(). Otherwise a ViewHolder goes to attached scrap. That “no” means that we want a change animation in which one View replaces another, e.g. a cross-fade animation. “Yes” would mean that change animation will happen within one View.
There is just one difference in the usage of changed scrap and attached scrap: attached scrap can be used in both pre- and post- layout, while changed scrap — only in pre-layout. That makes sense: in post-layout a new ViewHolder should replace a “changed” one, so the changed scrap is useless in post-layout. When change animation finishes the changed scrap is dumped into the pool, as expected.³
The default ItemAnimator can reuse updated ViewHolder in 3 cases:
- You called
- You called
- You provided a change payload like this:
The last case shows a nice way to avoid creating and/or binding a new ViewHolder when you just want to change some inner elements.
The most important thing to know about the stable ids feature is that it only affects RecyclerView’s behavior after a
When I was talking about scrap earlier I said that at the beginning of a new layout all the children are dumped into scrap containers. There is actually one exception to that. If you call
notifyDataSetChanged() and you don’t have stable ids, the RecyclerView has no idea about what’s going on, what exactly changed, so it assumes everything has changed, every ViewHolder is invalid and is thrown to pool instead of scrap. Thus we have a picture that we’ve already seen earlier:
If you do have stable ids, the picture is quite different:
So the ViewHolders go to scrap instead of pool, and then scrap is searched for a ViewHolder with a specific id (retrieved via
getItemId() method of your Adapter), rather than a specific position.
What are the benefits? The first one is that we don’t have the problem of overflowing pool, so no new ViewHolders will be created unless necessary. The ViewHolders will still be rebound, though, because the fact that the id didn’t change doesn’t mean that the content didn’t change.
The second benefit, and a much greater one, is that we get animations! On the pictures above I moved item 4 to position 6. Normally, you would have to call
notifyItemMoved(4, 6) to get a movement animation, but with stable ids,
notifyDataSetChanged() is enough. The RecyclerView can see where a view with a particular id was located in previous layout and where it appears in the new layout. Or it can see that a some id is no longer present, so the item must have been removed, and so on.
One thing to note, though, is that you only get simple (non-predictive) animations this way. Indeed, how would predictive animations even work here? If we see some id in new layout, which wasn’t there in previous layout, how do we know whether it’s an inserted item or a item that moved in from somewhere, and in the latter case where exactly did it come from? Normally the answers to that questions would be found in pre-layout which is extended beyond RecyclerView’s bounds according to the adapter changes, but now we don’t know what those changes are.
Overall, the usefulness of stable ids seems to be limited. I can think of one good reason to use it, though: if you are migrating from ListView to RecyclerView, it might be a pain to convert all the
notifyDataSetChanged() calls into notifications of specific changes. In that case, stable ids give you simple RecyclerView animations for free. As a next step you might want to try DiffUtil.
This concludes part 1 of our journey into RecyclerView’s inner workings.
^ ¹ Let’s also assume that the quantity of “special” items isn’t known at compile time, otherwise you can solve the problem by assigning a separate view type for each view and ignoring bind calls except for the first one. If the quantity is unknown, this approach is still feasible, but messy.
^ ² If you look at ChildHelper’s code, the Bucket inner class might seem scary at first sight. In fact it’s just an expandable bit string in which ones mark indices of hidden views.
^ ³ This is a result of ItemAnimator calling
dispatchAnimationFinished() with and old ViewHolder as an argument.