[MUSIC PLAYING] BEN TRENGROVE: Hi, I'm Ben from
the Android Developer Relations team. I will also be joined by
Chuck from the Compose team. And together, we're here
to talk to you today about common performance
gotchas in Jetpack Compose. Ah, Chuck, Chuck, you
can stop waving now. Our goal with Compose is
to provide a performance UI system right out of the box. We can write code how it
comes naturally to you. And it will perform well,
as you would expect. There are a few steps
you can take, though, and some common gotchas
to avoid to ensure you maximize performance
in your Compose app. We'll start by taking a look
at how you can correctly configure your app with
regards to performance. Then we will dive straight
into those common gotchas and describe the best
practices for avoiding them. Let's get started. Configuration-- first,
let's take a look at how to configure your app
for testing and evaluating performance. When evaluating how your
Compose app is performing, it is very important to ensure
you are running in release mode with R8 optimization enabled. Why? An application is
slower when deployed as debug because the
Android runtime turns off optimizations to improve
the debugging experience. For example, not
having minification enabled is what allows you
to step through your code. Many of the optimizations
disabled when running in debug are critical for a
jank-free application. If you are noticing
performance issues in your app, you should first check if the
problem exists in Release Mode. It may be that you don't
have a problem at all. Gotchas-- now we've seen
how to configure the app. Let's take a look at some common
gotchas and how to fix them. Number 1, something
to remember-- for these first few
examples, we will be working on a simple
contacts app that shows a large list of names. Pause the video and see
if you can guess what the problem with this line is. Don't worry. I'll wait. If you guessed
that the list would be resorted with
every recomposition, then you are correct. Even though it might be easy
to just sort in line like this, you have to remember
that composables can be run very
frequently and must be written with that in mind. In this example, we
would have resorted the contacts list any time a
new row appeared on the screen. That's because, when
a [INAUDIBLE] appears, the LazyList composition
scope is invalidated. And Compose recomposes it. This means all our
code is re-executed. Because our sort is
inside this scope, the sort is called on
every recomposition. We can use the remember function
to catch expensive operations or allocations and ensure
they are only run when needed. Let's return to
our contacts list. We can move the sort
into a remember function. We key the remember function
with the contacts list and the sort comparator. This will ensure the
list gets resorted whenever one of these keys
changes, but won't get resorted on every recomposition. An even more optimal
improvement would be to move this sorting into
our view model or data source and out of Compose completely. By only changing Compose
state when needed, we will have the lowest
overhead possible. Number 2, a key piece-- now that we've taken care
of sorting the contacts, let's return to
the list composable and see if we can make
any further improvements. We can provide LazyColumn
with additional information here to help it know
which items have changed. Do you know what it is? A key. You can define a key for
your items in a LazyList. Without providing a key,
Compose will use the position of that item as the key. This can be very
bad for performance when an item moves in the
list, as every item after it will also be recomposed. Providing a key is easy. Adding the key lambda
to the items function allows us to provide the key. The only consideration is
that each key must be unique. Now when items move
in the list, Compose will know which
item moved and only have to recompose that item. A key is used for more than
just optimization in a LazyList. It also unlocks a
lot of features. For more information on this,
check out the Lazy Layouts in Compose talk. Number 3, deriving change-- next up, our designers have
asked us to add a button to scroll back to the top. The trick is they only want
it to show once the list has been scrolled down. Declarative programming in
Compose makes this easy. We can add a Boolean
variable called showButton that becomes true when
the first visible item index is greater than 0. We will use this
variable as a parameter for animated visibility, to
ensure a nice fade effect when the button
appears and disappears. There is a catch though. Because LazyList updates
the listState variable on every frame of
every scroll and we are reading the listState, we
are introducing a large amount of recomposition we don't need. For the showButton
variable, we only care about when the first
visible index changes from or to 0. There's another
Compose function, like remember, that
can help us here. Do you know what it is? derivedStateOf. Compose provides the
derivedStateOf function for situations just like this. derivedStateOf will take our
frequently changing list state and buffer those changes
to only the ones we need. In our case, that's when
the first visible index is greater than 0. We wrap our condition in
a remember derivedStateOf function. Now, we will only recompose
when this condition actually changes-- so once when the list
is scrolled down, and then again if the
list is scrolled back up. derivedStateOf really shines
in cases such as this one, converting a busy stream
into a Boolean condition. Any time you're converting
state into a Boolean, consider if
derivedStateOf could help. It's important to keep in mind
what derivedStateOf is not good for. You do not need to
use derivedStateOf every time you create a
variable out of some state. In this example, we
want to know the number of items in our contacts list. You might think that, because
we are deriving state, that you should
use derivedStateOf. But this is not true. Because this would not actually
buffer out any changes-- that is, our item count variable
needs to update just as much as the state of
the count changes-- derivedStateOf would actually
introduce a slight overhead here and is redundant. CHUCK JAZDZEWSKI: Number
4, procrastination-- the next example we'll
look at is not so much a gotcha than a potential
missed opportunity. Our designer has asked us
to animate the background of the user interface. Here, we want to
continuously animate the background color of box
between cyan and magenta. Not something I recommend,
but let's go with it. It's very easy to create
these animations in Compose. This does exactly what
the designers asked us for and seems to perform well. But there is a
potential optimization that can be done here that
might be difficult to spot. Here, we're asking Compose to do
much more work than necessary. The animation is
requiring this code to be recomposed in every frame. Well, to understand why
composition might not be necessary here,
we should first understand how Compose works. Compose has three
primary phases-- composition, layout, and draw. In the first phase, composition,
the composable functions are executed. This phase creates or updates
the content of your application and defines what the
next two phases will do. In the second phase, layout, the
content defined by composition is measured and placed where
it'll appear on the screen. This phase takes into account
all the modifiers and calls to other composable functions,
such as text, row, and column, and in a single pass, measures
and places all the content. This phase is discussed in much
more detail in the 2021 Android Studio Dev Summit Talk titled "A
Deep Dive into Jetpack Compose Layouts." You might want to check it out. In the final phase, draw, the
actual graphic instructions are issued to draw the
content to the canvas of the application. These instructions
are primitives, such as drawing lines, arcs,
rectangles, images, and text. And they are drawn in
the locations determined by the previous phase, layout. These three phases are
repeated in every frame that the data they read changes. If, however, the data does
not change, then one or more of the phases can be skipped. Since in our
application, the color is changing on every frame
because it's being animated, composition will also
occur for every frame. Since we're only drawing
a different color, it would be nice
if we could just redraw the box in the new
color, skipping the composition and layout phases altogether. Deferring the reading of
state until it is required is an important
concept in Compose. Deferring a read can
reduce the number of functions that need
to be re-executed, as it is in this case, and can
allow us to skip composition and even layout entirely. In this version, we
use drawBehind instead of background. drawBehind
takes a function instance that is called during
the draw phase of Compose. Since that's the only time
the color value is read, only the results
of the draw phase need to change when
the color changes. Draw then becomes the only phase
that needs to be re-executed, allowing Compose to skip
both composition and layout. The magic here is the read of
the color state in the function instance, not the
composition function. Since the function
instance doesn't change, the variable it
reads is the same. Nothing has changed from
composition's perspective, so it doesn't need to
re-execute this function. Reading the state in
function instances like this and passing it as
parameters is a useful tool that can be used to not only
allow phases to be skipped, as in this case,
but it could also be used to reduce the
amount of code that needs to be re-executed
when the state changes. One way to take
advantage of this is by nesting, as
nesting implicitly creates function instances. Here, for example, when
the contact's name changes, only the call to Text
will be re-executed. The calls to
ContactCard and MyCard are skipped, as they do not
read the contact's name. Just the call to
Text does, which is captured in a
function instance. Because recomposition
can restart at the beginning of any
compositional function instance, a function
instance can be used to reduce the
amount of code that needs to be re-executed when
the data it reads changes. Number 5, running backwards-- unlike the last
example, where it worked but could be
optimized, this next gotcha is code that should
always be avoided. Here, the designer has
asked us to display a list of banking transactions
and the corresponding balances, such as you would find
on a banking statement. To do this, we maintain a
running total of the balance, and updating it for
each transaction, and then displaying
the transaction and the new balance. However, this has a problem. Before we dig deeper,
can you spot it? We began to realize
that there was a problem when we took a system
trace of our application. After building a released
version of our app, ensuring it's configured,
as Ben recommended, we use Android Studio's
built-in profiler to take a system
trace in the CPU view. This trace is the trace
we're looking at now. We immediately noticed that the
main thread of our application is much busier than we expected. In fact, we expected
it to go idle, as the screen isn't
changing at all. As Android Studio enables
the application trace markers automatically for us, we can
see that the recompose marker is present in every frame. We can tell it's every frame,
as Android Studio highlights each frame in
contrasting color bars. Composition is occurring
all the time now and never seems to stop. Let's go back and try
and figure out why. The problem turns out to be
the line to update the balance. This code violates a core
assumption of Compose. Compose assumes that once
a value has been read, it will not change until
after composition completes. You should never write to
a value that has already been read in composition. Writing to data that
has already been read is what we call a
backwards write. And it violates a core
assumption of Compose and might cause recomposition
to occur on every frame, like it does here. To better understand when
backwards writes occur, let's go back to the code. The backwards right is on
the line updating balance. But the read appears to occur
after the write in the call to Text. Well, how is this
a backwards write? Well, the backwards write
is more obvious if we were to unroll the loop like this. The writes to balance
before it reads are fine. It is the write to
balance in the loop itself that causes
composition to always think it's out of date and
needs to be re-executed. It is updating the
value beginning with the second item
in the list that's the root cause of the problem. When composition thinks
it's out of date, it will schedule a new
composition for the next frame. If that composition
marks itself out of date, composition will schedule itself
for the next frame, endlessly. This is a better version
of the code which uses our friend remember
again, and also avoids writing to state entirely. This version only executes
the composable function once when it is first displayed. And it's only considered out of
date when transactions change. An even better
version of this would be one that calculates
balances in the view model, just like the sort
example before, allowing these calculations
to be performed even before composition starts. But that example wouldn't
fit on this slide. Now, after making these changes,
we take a system trace again. And we see what we
originally expected. The main thread is initially
busy, and then goes idle. Looking more closely
at the trace markers, we can see that the
composition is run once when transactions list
is shown, and is not scheduled to run again. So remember, to avoid
backwards writes, never write to state that
has already been read. BEN TRENGROVE: Number
6, covering your bases-- when running our app
from Android Studio, we noticed that it seems to
be janky for the first couple of seconds. But after that,
it appears smooth. We first checked
that it was correctly configured with release
mode and R8 optimization. But we are still
seeing it happen. Do you have any idea what
could be going on here? We are seeing the effects
of just-in-time compiling. When running from
Android Studio, there is often a
performance drop at startup as your code is interpreted. Most likely, your
users will never see this effect, thanks to our
next item, baseline profiles. Adding a baseline
profile to your app can help to speed up startup,
reduce jank, and improve performance. But what exactly is
a baseline profile? As Compose is an
unbundled library, this has allowed us to
support older Android versions and devices,
as well as easily update Compose with new
features and bug fixes. We don't have to wait
for an Android upgrade to bring those changes to you. However, this comes
with a small drawback. Android shares system resources,
including toolkit classes and drawables, between apps. This speeds up the startup time
and decreases memory usage. As an unbundled library,
Compose does not take part in this sharing and
is treated as just another part of your app. When a user installs an
app from the Play Store, the APK downloaded to the
device includes all your code, plus any libraries
bundled with your app. On startup, this code has to
be interpreted by the Android runtime and compiled
to machine code. This process takes time, so
it can slow down performance. The Play Store has
an existing feature to improve this situation-- cloud profiles. Over time, the Play
Store will aggregate data about classes and methods
used at startup by your app. The data is simply a list
of the code used by your app during startup. We call this list
a cloud profile. The Play Store will
then ship this profile with subsequent downloads
of your app by other users. At install time,
the Android runtime will use this data to precompile
the listed classes and methods. This means there will be
less to interpret on startup. If you frequently update your
app roughly more than every two weeks, your users may never
see this benefit in action. Every update to your app also
clears the existing cloud profile data. A baseline profile is a way to
provide this list to the Play Store yourself-- that is, to provide a baseline. When a user downloads
your app, the Play Store will include your
baseline profile to ensure at install time
there is always profile data available. So the runtime knows
what to precompile. It will also continue to
aggregate the Cloud Profile data over time to
improve it further. Compose also ships with its
own Baseline Profile, which will be included in
your APK by default. You may not have to do
any additional work at all to see the benefits
of Baseline Profiles. Just know that it is there. But when running
from Android Studio, baseline profiles
are not included. And so you won't see this
benefit in your local testing. If you notice that your app
is slow on first startup and then gets faster,
this will probably be fixed by the default
baseline profile. You can configure
your app to start up with the baseline profile
enabled using our testing library Macrobenchmark. This can be useful to test
the first-run experience users will get when installing
from the Play Store. Because we are already
including an optimized profile for the Compose libraries,
adding your own baseline profile is not a guaranteed
performance gain, as profiles need to be
tweaked and optimized. If you do add a
baseline profile, be sure to test it is actually
improving your metrics. Both the generation of a profile
and the testing of the benefits are done using the
Macrobenchmark library. When a profile is
properly optimized, it can have a massive effect
on your app's performance. By adding a baseline profile
to one of our Compose samples, jetsnack, we improve our
startup performance by 22%. Google Maps added a
profile to their app and improved their average
startup time by 30%. It's not just
startup performance that has improved though. Play Store added a
profile to their app and were able to improve
the initial rendering time of the search page by 40%. As you can see, adding
a baseline profile can be one of the
best things you can do for your app's performance. For details on how to
generate your own profile or how to configure
Macrobenchmark for the generation and
testing of profiles, check out our
documentation and codelab. And that's it. To sum up today,
we looked at how to configure an app for
the best performance. Remember, the first
thing to check if you see a performance issue
is if it persists in release mode and you have R8 enabled. We then saw some common
mistakes and how to fix them. Here are the key
takeaways to remember-- remember, LazyList keys,
derivedStateOf, defer reads, backwards writes, and
baseline profiles. For more details on anything
we talked about today, check out our new Compose
performance documentation, as well as the baseline profiles
documentation and codelab. And that's it. Thanks, everyone. [MUSIC PLAYING]