Performance best practices for Jetpack Compose

Video Statistics and Information

Video
Captions Word Cloud
Reddit Comments
Captions
[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]
Info
Channel: Android Developers
Views: 78,290
Rating: undefined out of 5
Keywords: Jetpack Compose, Jetpack, Google Jetpack, Jetpack Compose Best Practices, JetPack Best Practices, Jetpack Compose Mistakes., Google I/O, Google IO, I/O, IO, Google I/O 2022, IO 2022
Id: EOQB8PTLkpY
Channel Id: undefined
Length: 21min 17sec (1277 seconds)
Published: Thu May 12 2022
Related Videos
Note
Please note that this website is currently a work in progress! Lots of interesting data and statistics to come.