Jetpack Compose Interop: Using Compose in a RecyclerView
8 min read
·
Jul 22, 2022
TL;DR
RecyclerView 1.3.0-alpha02
and
Compose UI 1.2.0-beta02
bring out-of-the-box performant usage of composables from RecyclerView ? no extra code required!
- If you had previously implemented our guidance for Compose in RecyclerView, you should now remove this code.
Introducing Compose incrementally in your codebase means that you can end up in the situation when you’re using composables as items in a
RecyclerView
. Before Compose UI version
1.2.0-beta02
, the underlying composition of a
ComposeView
disposes when the view detaches from the window. However, in the context of a
RecyclerView
, items continually detach/attach from the window as they move off/on screen. Having to dispose and recreate compositions repeatedly is expensive and has performance implications, especially when quickly flinging through the list.
Starting with Compose UI version 1.2.0-beta02 and RecyclerView version 1.3.0-alpha02, the view composition strategy used by the libraries has changed: the
composition is now disposed of automatically
when the view is detached from a window,
unless
it is part of a pooling container,
such as RecyclerView. So when a ComposeView is used as an item in a RecyclerView, composables are no longer disposed, but rather re-used. This behavior change means that there is no work required from you to properly handle this. If you have implemented the previous guidance, you should remove it after updating to the latest libraries, as it will override the improved default behavior.
Here’s how your implementation should look like:
In this post, I will cover the background on why the previous guidance was recommended, and why we recommend that you update your implementation if you are on the aforementioned versions (or later) of RecyclerView and Compose to see better scrolling performance and simplify your code.
Understanding the previous default composition strategy: DisposeOnDetachedFromWindow
The
ViewCompositionStrategy
of a
ComposeView
determines when the underlying composition should be disposed of. Before version
1.2.0-beta02
of Compose UI, this value was configured to
DisposeOnDetachedFromWindow
, which will dispose of the composition whenever the view detaches from the window. There are different scenarios when a view might detach from the window depending on the context, though generally this happens when the underlying container is going off screen or is about to be destroyed. While this is the behavior you’d want in most cases, this strategy is suboptimal in situations where views are frequently being detached and reattached to the window, such as in a
RecyclerView
. Frequently disposing and recreating compositions can hurt scrolling performance, especially when quickly flinging through the list.
To mitigate this,
ComposeView
composition disposal can be improved by disposing when the underlying view is recycled, not when it is detached from the window (note that this is still not the ideal case, as we will see later). We can listen to this event by overriding the
onViewRecycled(ViewHolder)
method in the
RecyclerView.Adapter
class. According to the
documentation
of that method, an underlying view will be recycled when:
“…a RecyclerView.LayoutManager decides that it no longer needs to be attached to its parent RecyclerView. This can be because it has fallen out of visibility or a set of cached views represented by views still attached to the parent RecyclerView. If an item view has large or expensive data bound to it such as large bitmaps, this may be a good place to release those resources.”
In
onViewRecycled(ViewHolder)
, we can call the
disposeComposition()
method on the
ComposeView
. This translates to the first part of the previous guidance (which is no longer recommended if you are on the aforementioned versions of Compose and
RecyclerView
):
Additionally, to prevent the
ComposeView
from being disposed when the view gets detached, we must set a different
ViewCompositionStrategy
. Specifically, we must set it to
DisposeOnViewTreeLifecycleDestroyed
so that the composition will be disposed of when the underlying lifecycle owner gets destroyed.
With these changes, the
ComposeView’s
composition will no longer be automatically disposed when an item view detaches. So, we should be able to see the
ComposeView
items dispose less as you scroll through the list. To validate this, let’s assume each item in a
RecyclerView
is represented by an
ItemRow
composable:
When an
ItemRow
is composed, a
DisposableEffect
also enters the composition which is used as a mechanism to print when it enters, followed by leaving the composition. With this setup, scrolling produces the following log statements:
16:06:12.840 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 0 composed
16:06:12.970 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 1 composed
16:06:13.047 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 2 composed
16:06:13.119 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 3 composed
16:06:13.196 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 4 composed
16:06:17.922 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 5 composed
16:06:19.033 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 6 composed
16:06:20.781 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 7 composed
16:06:20.909 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 0
DISPOSED
16:06:23.482 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 8 composed
16:06:23.527 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 1
DISPOSED
16:06:23.678 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 9 composed
16:06:23.752 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 2
DISPOSED
Here we can see that
ItemRow’s
compositions with index 0, 1 and then 2 are disposed as a result of the containing
ComposeView
being recycled.
While these changes are certainly an improvement, a preferable behavior would be this:
Compositions should undergo recomposition when new data is rebound to the adapter. Additionally, compositions should only be disposed when we can be certain that the `ComposeView` will not be used again.
With this current solution, it is also possible for compositions to not be disposed when they should. Specifically, if the
RecyclerView
gets detached from the window, but the contained Activity/Fragment lifecycle is still active, the compositions will not be disposed, resulting in compositions still being active despite no longer being needed.
There was no way around these limitations with the previously available APIs, which necessitated changes in both Compose and
RecyclerView
to improve these behaviors.
Understanding the new default composition strategy: DisposeOnDetachedFromWindowOrReleasedFromPool
To support disposing compositions at the right time, the new strategy,
ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool
was introduced and set as the new
default
ViewCompositionStrategy
of a
ComposeView
since version
1.2.0-beta02
of Compose UI. According to the documentation:
“The composition will be disposed automatically when the view is detached from a window, unless it is part of a
pooling container
, such as RecyclerView. When not within a pooling container, this behaves exactly the same as
DisposeOnDetachedFromWindow
.”
This new strategy behaves exactly as the previous default; however, it prevents disposing detached views in the context of a
RecyclerView
, which is precisely what we want. This introduces the concept of a pooling container, which enables types that recycle through items communicate when an item should dispose of any resources it holds. The concept of a pooling container is implemented in a new artifact (
androidx.customview.poolingcontainer
), which both Compose UI and
RecyclerView
depend on. Through interfaces provided in this artifact,
RecyclerView
can communicate to Compose when compositions should be optimally disposed, making it no longer necessary to manually dispose compositions in
onViewRecycled(ViewHolder)
. Instead, compositions will be disposed when either the item view is discarded (e.g., when the
RecycledViewPool
is already full) or when the
RecyclerView
is detached from the window. This implementation is more efficient than disposing compositions in
onViewRecycled(ViewHolder)
, as it results in fewer unnecessary disposals due to having more information about the
RecyclerView’s
item lifecycle.
With this new view composition strategy, using the same
ItemRow
composables as items in a
RecyclerView
, we can see that compositions are no longer disposed when scrolling through the list:
16:17:27.699 6406-6406/com.google.samples.app.rv D/ItemRow: ItemRow 0 composed
16:17:27.796 6406-6406/com.google.samples.app.rv D/ItemRow: ItemRow 1 composed
16:17:27.850 6406-6406/com.google.samples.app.rv D/ItemRow: ItemRow 2 composed
16:17:27.909 6406-6406/com.google.samples.app.rv D/ItemRow: ItemRow 3 composed
16:17:27.961 6406-6406/com.google.samples.app.rv D/ItemRow: ItemRow 4 composed
16:17:31.747 6406-6406/com.google.samples.app.rv D/ItemRow: ItemRow 5 composed
16:17:31.897 6406-6406/com.google.samples.app.rv D/ItemRow: ItemRow 6 composed
16:17:32.313 6406-6406/com.google.samples.app.rv D/ItemRow: ItemRow 7 composed
16:17:33.061 6406-6406/com.google.samples.app.rv D/ItemRow: ItemRow 8 composed
Remembering state
Compositions remain active while items are being recycled. This means that any internally remembered state will also be remembered even when binding the new data. For example, in the scenario of having a LazyRow within a RecyclerView like in ItemRow, the scroll position is remembered when the view is recycled. In the screenshot below, notice how the scroll position for row #1 also affects the scroll position of row #10.
To prevent this behavior, you have two options.
Option 1: If possible, you should
hoist any item-specific state into the adapter
. For example, the
RecyclerView
of
LazyRows
could have an adapter like:
The
onBindViewHolder
method of this adapter creates the
LazyListState
and sets it on the
AbstractComposeView
subclass. It is stored in a delegated property using
mutableStateOf
to ensure that the
LazyRow
always has the correct state.
If you aren’t able to hoist the state, then there’s a simpler approach.
Option 2:
wrap anything that has
remembered
state in a
key
, passing a value (or values) that uniquely identifies the item (if your list order never changes, the position will work). When the value changes, this will cause everything within the key to be fully recreated without any of the existing
remember
ed state. You should only do this for parts of your UI that have
remember
ed state, as this incurs a performance penalty to recreate the relevant parts of the composition. Additionally, it means that any state that you want to be restored when the item is scrolled into view again (such as the scroll position of the
LazyRow
s) will be lost when the item is recycled.
For this approach, the ItemRow component would look something like:
Recompositions on detached items
Compositions will remain active despite being detached. This means that recompositions in response to state changes, such as animations, will continue to run. This can affect scrolling performance, so make sure to stop active animations when an item is going off screen.
For example, say you are using the
Animatable
API to animate the background color of your
RecyclerView
item. You can state hoist the
Animatable
object to the item view and invoke
stop()
in the adapter
onViewDetachedFromWindow(ViewHolder)
method, like so:
Summary
To take advantage of the improvements of using Compose in
RecyclerView
, update your dependencies to at least
RecyclerView
version
1.3.0-alpha02
and
Compose UI version
1.2.0-beta02
. If you previously followed our guidance,
make sure to also remove explicit
disposeComposition()
calls when views are recycled, as well as code setting the
ViewCompositionStrategy
to
DisposeOnViewTreeLifecycleDestroyed
. If you encounter any issues in the process, you can file an issue in our public
issue tracker
.
Still haven’t migrated your existing View-based app to Compose and want to learn more? Check out the
Migrating to Jetpack Compose
Codelab and the
Sunflower
sample app, which shows Views and Compose being used side-by-side.
If you have any questions, feel free to leave a comment on this post. In the meantime, happy composing!
The following post was written in collaboration with
Ryan Mentley
. Thanks to
Florina Muntenescu
,
Rebecca Franks
, and
Simona Stojanovic
for their reviews.