[MUSIC PLAYING] BEN TRENGROVE: Hi. I'm Ben from the Android
Developer Relations team. Today, we're going to
go over more performance tips in Jetpack Compose. This talk is a sequel to the I/O
2022 talk, Common Performance Mistakes in Jetpack Compose. We're going to go further
in depth in this talk. So if you haven't watched
the original talk, I would recommend
you start there. Make sure to wave to
Chuck while you're there. Of course, some points
deserve repeating. If you see performance
issues in your Compose app, always ensure you are testing
in release mode with R8 enabled. It really does make a
significant difference. And before we get started on
the tips, a small disclaimer. Compose is performant
right out of the box. You might not need to apply
any of the tips in this talk. But how do you know
if they apply to you? Should you just
blindly follow them? Of course not. You should follow
a defined process and not prematurely
optimize your app. Doing so will just lead
hard-to-maintain code. So the answer to all these
questions is, write a benchmark and find out. Performance optimization
is not one size fits all. There are just
too many variables to be able to give
any guarantees. The key to performance
optimization is just to inspect, improve,
monitor, and repeat. Once you have identified
a performance issue, write a benchmark for it. Diagnose and try to fix it. Run your benchmark again and
see if you have improved it. Continue monitoring
your benchmarks to catch any regressions and repeat. Use tools, not rules. We won't go into all
the details of writing a benchmark in this talk, but
they are just like UI tests. Here's a sample test that
measures the scroll performance of a lazy list. First, we get the
reference to the lazy list. Then we drag the
list three times to simulate a user
touching and dragging. If you run the test, you
will see results like this. Macrobenchmark runs
your test multiple times and outputs the results as
a statistical distribution. To understand them, you
can think of it like this. You can see how long
each frame took to render and if it overran its render
deadline, or in other words, janked. For overrun, negative
numbers are good and positive numbers mean your
app will show visible jank and start dropping frames. P50 means 50% of
cases rendered faster than this and 50% of
cases rendered slower. P90. 90% of cases rendered
faster than this, and conversely, 10% of cases
rendered slower, and so on. Why do we output a statistical
distribution rather than an average? Well, you might have an outlier
that only occurs occasionally. With just an average, you would
never get to find this out, but with a distribution you
can clearly see the outliers. It is these outlier frames
that often causing jank in your apps. For more details on how
to write benchmarks, watch the MAD Skills Inspecting
Performance video on YouTube. Another tool you can use
to help find performance issues in your app is tracing. We have new Compose tracing
tools just released, and to find out about
them, check out this link. Now that we know about how
to find out if we actually have a performance
issue, let's have a look at some common
problems you might face and tips to fix them. First up, defer reading state. While inspecting a
screen in Jetsnack in the Layout Inspector in
Android Studio Dolphin-- again, tools, not rules-- we noticed a large
amount of decomposition when inspecting our app. We know this could
lead to janky frames, but a quick look at the app's
code doesn't reveal any clues. What can we do? To understand, let's
go over some theory. Remember, Compose
has three phases-- composition, layout, and draw. Composition determines
what to show by building a tree of composables. Layout takes that tree and
works out where on the screen they will be shown. And draw-- well, pretty
self explanatory-- draws it all to the screen. Here's the cool part. Compose can skip
a phase entirely if nothing has changed in it. So if we can avoid changing
our composition tree, we can skip
composition altogether, and this can lead to
big performance gains. But how do we do that? Here is a simplified
version of Jetsnack's code, with a parent and
child composable. The child composable
needs to know some state from the parent, and
this is passed into it as the offset parameter. This means the offset
state is read in the parent and, as such, when the
offset state changes, parent will recompose. First off, we can defer
reading of this offset state. You should always try and read
state as late as possible. If we can move the
read of this state from the top level
composable into the child, we will limit how
many composables need to be evaluated
for recomposition and subsequently recomposed. Now, to do that
defer of the read, you might think that
this is a good idea-- passing the actual state object
into your child composable. While this does indeed
defer reading of state, it in turn makes your Compose
code much harder to work with. For example, you
would no longer be able to use the
byDelegate syntax, and you would have to add
dot value to every read. You would also be
tying your composable to need to use state,
and you wouldn't be able to pass in a
fixed value anymore. A much better way to handle
the deferring of reads is to use a lambda. By using a lambda,
you can control when the state is read without
affecting the rest of your code too much. You can still use the
by property Delegate inside the lambda
to read the value. So we switched the
read to the lambda, and now we can defer the read
inside the child composable. This is good, but
we can do better. If we can read this lambda
inside a modifier that isn't run during composition,
we can skip composition altogether. This is because we won't
be changing our composition tree at all. This is why our performance
documentation states prefer lambda modifiers
when using frequently changing state. But why does this work? How come just using a
lambda modifier means we can skip composition? Let's return to the
composition tree and see. The composition tree
is also built up of any modifiers that are
applied to the composables. Modifiers are effectively
immutable objects. When the offset changes and
the modifier is reconstructed, the old one is removed,
and the new one is added to the
composition tree. This happens every time
the offset changes. Because the composition tree has
changed, recomposition occurs. However, if we use
a lambda modifier, this modifier is not
actually changing. Compose is smart enough just
to rerun the lambda function when it needs to. And this is why our modifier
object does not change, which means the composition
tree does not change and composition can be skipped. So remember, you shouldn't
have to recompose just to relayout a screen, especially
on scroll, which will just lead to janky frames. So whenever you see
unnecessary recomposition, think about how you could move
the work to a later phase. So in this case, we can
move the read of the offset into the offset modifier. This offset modifier is run
during the layout phase, and doing this will
mean composition is skipped altogether. How do you know what phase of
Compose a modifier runs in? Well, if it's not a
lambda-based modifier, it will always be
run in composition. If it is a
lambda-based modifier, it is almost certainly not
running in composition. While this is not
guaranteed, you can almost always assume it
will be to start debugging. For more information
about this concept and a live demo of using it
to fix a problem in Jetsnack, check out the Debugging
Recomposition blog post. Next up, let's learn
about stability. Here is the problem. We've used the Layout Inspector
again to inspect our app, and we have noticed that some
composables are recomposing, even though none of
their state has changed. Jumping into the code, we
see something like this. We have a simple screen with a
checkbox and contact details. Now, when the selected
state changes to true, this home screen composable
starts to recompose. It goes back to the nearest
recomposition scope, which in this case is home screen. It then reruns this code. First, the checkbox
is called again because its selected
state changed. But then, as we step
through, we also see contact details being called
again, even though contact did not change. What could be going on here? To understand why this
is happening, first let's go back to the definition
of recomposition. Recomposition is the
process of calling your composable functions
again when inputs change. When Compose recomposes
based on the new inputs, it only calls the functions or
lambdas that might have changed and skips the rest. Hang on. Might Why might? In order to skip a
composable, Compose has to be sure it
hasn't changed. If Compose started
skipping composables that it shouldn't,
this would be very hard for you to diagnose and fix. Because of this, the rules
around what is skipped are strict. Compose determines the
restartability and skippability of each of your
composable functions. Restartable functions serve
as a point recomposition can begin at. Skippable functions
are able to be skipped if none of their
inputs have changed. Compose works out if a
function is skippable based on the stability
of its parameters. Immutable parameters
are types where the value of any of its
properties never change. We'll see an example
soon, but think of this as a data class
with all val parameters. Stable is a bit trickier. Stable types can have
mutable properties, but any mutations will
notify the Compose runtime of their changes. In practice, this
most likely means their mutable properties are
defined with Compose state objects. Unstable types are
just none of the above. Unstable types are what
lead to composables that can't be skipped. Let's see an example. Here's the definition of our
contact details composable. It takes one parameter, contact. Contact is a data class
with one property, name, but it's defined as var. And because it is defined
as var, it is not immutable, and therefore, contact
is an unstable type. This means that under
the hood, Compose determines the
ContactDetails function to be restartable but
not skippable as it has an unstable type parameter. To fix this, we just
have to make sure that all our parameters
are constant. Of course, that example
was easy to find. It would be pretty tedious
to have to do that manually with all your
classes, though, which is why the Compose Compiler
can output a report for you. When enabled, the
compiler will output reports about your
functions and classes in each module the Compose
Compiler is run on. This will allow you
to quickly look up what is being inferred
about your code. classes.txt is the
stability of the classes. composables.txt is the
restartability and skippability of each composable function. There's also a CSV
file output, which can be used in a script or CI. Opening up composables.txt,
you will see output like this. We can see ContactDetails is
both restartable and skippable. We can also see its contact
parameter is a stable type. But let's look at another one. This is the
ContactList composable. It's a composable that takes a
list of contacts and displays them. But there is something
strange happening. The list of contact is
being declared unstable, even though we know that
the contact class is stable because we just fixed it. This is why. Compose treats List,
Set, and Map as unstable. This might seem strange, but
there is a good reason why. It's because they are unstable. The Kotlin collections provide
no guarantee of immutability. This code here is
perfectly valid. Because of this, the Compose
Compiler cannot be sure they are immutable. So what do you do? There are two options. For collection
classes in particular, there are the KotlinX
immutable collections. You can also annotate
classes to override what is being inferred about them. Let's have a look at the
immutable collections first. First, add the immutable
collections dependency. Then, instead of list,
we can define our type as ImmutableList. Immutable lists
are easy to create. You can convert a regular
list into one just by using two immutable lists. Once you've done that,
if you rebuild your app, you should see that your list
is now declared as stable. Importantly, this was the fix
needed to make our ContactList composable skippable. Are we saying that
you should always use immutable lists in Compose? No. You should first ensure
this is actually causing you a performance issue. Then, this is just
one possible fix you can use if it
suits your use case. But of course, you aren't
always dealing with collections. Let's look at another case. Here is a small data
class for keeping logs. It has a timestamp
and a log string. Another common gotcha
is that any classes from external modules the
Compose Compiler is not run on will be treated as unstable. We are currently working on
a better solution to this. But in the meantime,
let's see how you can use
annotations to override the inferred stability. Running the Compose
Compiler on our data class, we can see the timestamp
gets declared unstable. As we have no control
over this class, our only option here is
to annotate the data class with immutable. You can also use
stable, but keep in mind the stable contract
mentioned previously. This will be enough
to force our LogEntry class to be stable,
even though it still has that unstable parameter. Be careful. Incorrectly annotating a
class as stable when it is not could cause composables
not to recompose. This brings us to the
obvious question-- should every composable
be skippable? No. You should only do this if you
have a verified performance issue. Chasing complete skippability
is a premature optimization. For example, if you
have a composable that never recomposes or
recomposes very infrequently, it probably doesn't matter
if it's skippable or not. This topic is nuanced
and quite detailed. For more information
about stability, including how to enable the
Compose Compiler reports, see the Jetpack Compose
Stability Explained blogpost. Let's now have a look
at derivedStateOf. A really common question
we see is, where and when is the correct place
to use this API? derivedStateOf is used when
your state or key is changing more than you want to update
your UI, or in other words, derivedStateOf is like
distinctUntilChanged from Kotlin Flows. Remember that composables
recompose when the state they read changes. derivedStateOf allows you to
create a new state that only changes as much as you need. Let's have a look at an example. Here we have a username field
and a button that enables when the username is valid. It starts off as empty,
and so our state is false. Now, when the user
starts typing, our state correctly updates,
and our button becomes enabled. But here's the problem. As our user keeps typing, we are
sending the state to our button over and over again needlessly. This is where
derivedStateOf comes in. Our state is changing more
than we need to update our UI, and so derivedStateOf can be
used for this to optimize. Let's rerun this and
see how the change goes. Our button starts
off as enabled. But as our user keeps
typing, this time we are only updating
our username state. And of course, if our
username becomes invalid, derivedStateOf
correctly updates again. Now, this example
was oversimplified. In reality, our button
would most likely be skipped, as we learned
in the stability section. But if your downstream
recomposition is expensive, derivedStateOf can
be very useful. derivedStateOf is just
another tool in your belt to help with managing state. Let's have a look
at another case. In this example,
we have two states that we need to
combine into one. Because we need to update our
UI just as much as this state changes, well, in this case,
derivedStateOf is pointless. If, though, we were
doing something a bit fancy or expensive
that we wanted to cache across
compositions, well, this is where remember
with keys comes in. We can remember the result
of our expensive function and make sure it still updates
whenever one of its keys changes. Because we need to
update our full name just as much as our inputs are
changing, we use remember. That is the difference
between derivedStateOf and remember with keys. derivedStateOf,
used when your state is changing more than you
want to update your UI. remember with keys, used when
we need to change our state as much as our key changes. The last tip of
the day-- we will look at upcoming
changes to help you call reportFullyDrawn
from Compose. reportFullyDrawn is
an API on activity that signals to Android that
your app is ready to use. This allows Android to optimize
your app startup in the future by preloading I/O
calls ahead of launch. Previously, this API
was difficult to call at the right time from Compose. But in Activity
Compose 1.7, we have new APIs coming to fix this. The new reportDrawnWhen
composable function takes a Boolean condition and
will report to your activity when that condition is true. This allows you to easily
wait for your first list composition to happen or any
other condition you need. Don't worry about it being
called multiple times. We handle that for you. There's also a
suspending version of this API, reportDrawnAfter. This will call reportFullyDrawn
when the suspending function completes. This allows you to easily wait
for an animation to finish or for data to load. We hope these new
APIs make it easy for you to call
reportFullyDrawn, and we recommend you do so. Macrobenchmark will
also detect these calls and display the information
in your startup benchmarks. And we're done. This talk had a
lot of information. Here's a quick summary
of what was covered. Macrobenchmark, the answer to if
your performance optimizations are working. Defer reading state-- reading
state as late as possible can help avoid recomposition. Stability determines which
composables can be skipped. derivedStateOf,
used when your state changes more than you
need to update your UI. And reportFullyDrawn
allows Android to optimize your app's startup. There are lots of great
talks at this year's ADS. In particular,
related to performance is Making Apps Blazing Fast
with Baseline Profiles, which goes over how to create and tune
a baseline profile which can lead to big performance gains. And that's it for me. Thanks for watching. [MUSIC PLAYING]