More performance tips 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. 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]
Info
Channel: Android Developers
Views: 36,481
Rating: undefined out of 5
Keywords: Jetpack Compose, Compse state, stability, API, reportFullyDrawn, Android Dev Summit, Android Developers Summit, Android Dev Summit 2022, ADS, ADS 22, ADS ‘22, ADS 2022, Developers Summit, Dev Summit, Android developer, android developers, android dev, android devs, android announcements, android announcement, app developer, developer, application developer
Id: ahXLwg2JYpc
Channel Id: undefined
Length: 20min 46sec (1246 seconds)
Published: Mon Oct 24 2022
Related Videos
Note
Please note that this website is currently a work in progress! Lots of interesting data and statistics to come.