Software Engineer
Jetpack Compose makes it easy to design high-quality user interfaces, but it can be difficult to track down issues that affect UI performance. These kinds of issues can lead to problems that your users notice, like startup latency, and dropped frames that appear as scroll jank or choppy animations.
In this talk, we’ll introduce tools to uncover what’s impacting your UI’s performance, focusing on Perfetto and (re-)composition tracing. We’ll then explore specific anti-patterns you may be using that are secretly costing you, how you can find them in your code, and what positive patterns you can replace them with. By the end of this talk, you’ll understand how to think in Compose with a performance mindset, and how to profile, analyze, and verify performance optimizations in your app.
Transcript
Hello.
Hello, everyone.
I hope you had a nice break.
I hope you're caffeinated, moisturized, and all that.
We're going to have a bit of a deeper dive today.
Well, yes, here we go.
A bit of a deeper dive today.
My name is Andrei Shikov, and with me, I'm sharing the stage today with Andrew Bailey.
We both work on a Compose compiler runtime team, but also other a lot of other things, including performance.
And, the theme of the talk today is answering your question, questions about performance, because Jetpack Compose established as a default UI toolkit for Android development.
And, now a lot of people asking how can we make sure that our Compose UI as performant as possible.
So today, we're doing the deep dive in-depth dive, as I already said.
And, we also going to introduce some tools for you to outperform yourself in Compose.
First thing I want to start with is, talking about why performance matters to us.
When you think about quality and building quality apps, you usually think about animations, we thinking about rich feature set, and performance sometimes slips out, but user perception really depends on the performance of your app.
So if animations stutter, the perception immediately drops.
So all of the metrics, numbers, that we're going to mention today, actually proxy metrics that we selected to represent how users feel about the app, how they feel about all those animations, all the things happening on the screen.
So, fundamentally, optimizing your app is optimizing for better experience.
So this is just like a mindset thing that I want you to have, as we dive deep in a lot of topics today.
Now, as you start looking at the performance of your app, I want you to do three fast things, three quick quick things.
First thing, if you measure performance, you measure it in the release mode, so we don't have a lot of debuggable information and all this slowing down our app.
Second thing, you enable R8 or other minifiers.
Just this year, we introduced an optimization that improves coroutine performance twofold, and we also do a lot of other things.
We lean a lot on R8 when optimizing Compose.
And the last thing, especially for the startup metrics, you want to make sure you use baseline profile.
So the classes that you load in the startup are pre-compiled and pre-loaded when that app starts.
So as you can see, this is like 6x difference from your usual debuggable build of the app.
And, as you measure all those things, they will compound with the other wins that you're doing inside of your code.
Now, you did all of that, app still lags a little bit, or there are still other issues with performance, and this is where you want to go into this performance loop.
You want to start looking at some metrics on some specific scenarios.
For example, let's say your app takes too long to load, so maybe you want to reset your app, start it from just like usual launcher, and check how long the app takes to the first data screen.
Or maybe the app stutters a bit, so you scroll it, and you make sure that this scroll is repeatable and stable.
So the stable environment here in this slide is really important, because as you making optimizations later, you want to make sure that you see improvements to your metrics, and you can confirm that those optimizations actually work as intended.
Benchmarks are really good for this, because benchmarks already capture a lot of data, they do it a lot of iterations to make sure there's not that much noise in your device, and they also do a lot of device setup that kind of helps you.
And, benchmarks are also repeatable scenarios by default, and this is what we're striving for here.
But you can also do it with a bunch of scripts if you want.
And the last thing, you profile, you improve something in your app, look at the data, and then repeat again, and maybe repeat again with like different scenario metrics where your app struggles.
As you look at the data, the first tool that we're introducing today and you want to really familiar you want to be really familiar with is the Perfetto traces.
Perfetto traces record everything that happens in on device while you recording that trace, so, for example, during macrobenchmark run, or maybe some script that you're recording.
And it's a huge help for every engineer who's working on Android performance.
For me, I spend like most of my day work looking at the Perfetto traces these days.
They look something like this.
It's a lot of data.
As I said, they capture everything that happens on a device.
So, you might struggle at first to figure out like where exactly your app is, so I recommend using this handy search bar.
Search for Compose, search for your app package, and things like that.
Just ground yourself in a familiar things.
And as you ground yourself and find your app process, you will see the frame time lines and how long a lot of things on your app take.
If if we isolate one of them, you would see something like this.
The first is the main thread work.
This is where Compose does most of its stuff.
Then there's RenderThread.
This is where we process the canvas command, encode them to GPU.
Then GPU does its work, and then system composites that.
We're going to be looking at the main thread only today.
Because this is where Compose does its work.
We're talking about Compose, so it's reasonable to only focus on that.
And as we isolate that one frame the on the main thread, we actually start seeing Compose slices now.
I selected a few of them for you.
It's like composition, measure, layout, draw.
If you've heard about Compose phases, you looking at performance before, you'll probably see them.
So this is they have individual slices in a Perfetto trace, and it's really easy to find out how long each of the phases take, and which phases run in each particular frame.
So, we've talked a lot and at length about optimizing some of this work, and you want to make sure you do the right work in the right phase.
For example, if you have animation updating the color of your box or something like that, you only want to invalidate draw phase, and not a composition, and layout, things like that.
This is performance 101, and today we're talking bit deeper, or we're diving a bit deeper into some things, and we're focusing on the composition, in particular.
Now over to Andrew, to talk us through that.
Thanks, Andrei.
Like Andrei mentioned, we spend a lot of time previously talking in the context of performance about optimizations like avoiding backward reads, making sure that you defer state reads to the right phase in the composition life cycle, and optimizing, and making sure that your composables are invalidating as you expect them to, so that you're not doing too much work on every single frame.
This is a really good starting point, but we have a lot of material about this already, and we modern we want to move forward that conversation a little bit.
So, today we're going to be tackling what's left in composition after you do those optimizations, and tackle what your composables themselves are doing.
The unfortunate reality of everything inside of your Compose code, and really everything that you ship with your app as far as performance goes, is that it all has a cost associated with it.
None of the code that you ship in your app is free, and even things as trivial as reading state, and composing things like a side effect inside of your composition, have some tangible costs associated with them, and it's important to be mindful of them, especially when you start dissecting what your composables are actually doing, and start squeezing out more performance inside of your app itself.
To show what that actually looks like at the most microscopic scale, here are some of the smallest operations that you can do anywhere in your code, and especially inside of your composables, and at this resolution, we're talking about the nanosecond scale in terms of how much these things roughly cost as a mindset.
If you want to make a dent in your performance, you have two options.
You can either remove a really large number of inexpensive operations, or a smaller number of more expensive operations.
On this chart specifically, coroutines are m-- on the more expensive side as far as these operations go, and it makes for a good example of where to start looking, because if we remove a few of these instances, we can start to see more tangible benefits in our application faster.
One other thing to bear in mind about coroutines specifically is that as I've got on the slide, not only does launching a coroutine have a cost associated with it, but so does canceling a coroutine.
The cancellation cost is especially noteworthy because this takes place predominantly when you're disposing content and possibly moving to other areas inside of your application.
Navigating away is an extremely expensive case in many applications where you need to squeeze the most out of the frame time, and make sure that you're using the most of what you have.
So it's important to be mindful of both of these costs.
All of these, no matter what we're looking at, is going to add up at this scale of about 16 million nanoseconds to work with.
So we have some budget here that we need to chip away with, and coroutines are going to be a good example to start with.
In Compose, coroutines appear most often with the effects APIs.
There's a spectrum of APIs here that you can use with SideEffects from high functionality to low latency.
At the most functional side of this spectrum, we have LaunchedEffect, which includes everything for asynchronous work especially like animations, anything that you need a coroutine scope for.
If you don't need that though, and you can accomplish your task with a less expensive API, it might be worth considering using instead if it fits the needs of what you have for your use case.
To show what that looks like in practice, here, I've got a media player composable to demonstrate all of the different SideEffects and when they might appear.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Asynchronous work and animations
LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
}We can start with a LaunchedEffect.
This In this example, is being used to asynchronously load data inside of the media player.
If you need a coroutine scope, or if you need coroutines in general or to make a suspending call, use LaunchedEffect.
Even though it's still on the more expensive side and there is a cost associated with the coroutines, as long as you're leveraging it and justifying it, use the right API for the job.
But if you find yourself in a situation where you're using a LaunchedEffect, but not leveraging the coroutine, it may be a good place to consider other APIs that can do the same thing, but for less cost.
For that, moving down the spectrum, we can see other examples of side effects like DisposableEffect.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val activity = LocalActivity.current
// Setup and teardown
DisposableEffect(Unit) {
activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
onDispose {
activity.requestedOrientation = SCREEN_ORIENTATION_UNSPECIFIED
}
}
}DisposableEffect is great when you have a setup and teardown process associated with your effect.
Here, in the same media player example, I've got an example that locks them the activity's orientation to landscape when the composable is present, and then releases that orientation lock when it goes away sometime later.
If you want the one-time effect of LaunchedEffect and DisposableEffect, but you don't need coroutines, and you don't have a dispose effect, you can also use the new SideEffect with keys API that we introduced in Compose 1.12.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// One-shot events
SideEffect(Unit) { mediaPlayer.play() }
}This is great as an alternative for the other two APIs if you've reached for them, either LaunchedEffect out of convenience, or DisposableEffect out of necessity for a one-shot effect, navigating or migrating to the SideEffect with keys APIs can accomplish the same thing with lower overhead and improved performance, especially if migrated multiple times over the span of an entire app.
And then finally, the least expensive API is just the vanilla SideEffect API.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Synchronize the outside world to the state of composition
SideEffect { mediaPlayer.playbackRate = playbackRate }
}This is most appropriate when you want to synchronize the outside state of the world with what's going on in composition.
So here, again in the media player example, I've got another example that shows synchronizing some playback state with some parameter that's being received by composition.
One thing to bear in mind, if you do decide to audit your side effect usages and migrate to a less expensive one, bear in mind that in addition to the other behaviors and use cases that we've talked about, there is also a difference in the ordering guarantees with side effects.
All of the disposable effects execute first, followed by the regular side effects, keyed or not, and then after the frame completes is when all the launched effects run because there's a dispatch involved.
If you depend on this timing for your launched effects, bear that in mind if you're considering migrating.
And for any migration, make sure to test your code and make sure that you're not depending on any of these timing guarantees and that your code still works afterwards.
If you can't reduce the complexity of the side effect itself, you can also still reduce the number of side effects that you have by merging them together.
@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) { viewModel.loadCart() }
LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }
CartContent(...)
}This is especially a good thing to consider if you have multiple effects that use the same key.
It's a good candidate to consider merging together because they can execute at the same time.
Here, in this example, I've got several launched effects that are each making an asynchronous call into some view model.
If I replace them with a single LaunchedEffect and then other launch calls inside of that, even though I still have the same number of coroutines that are running here, this still has less overhead because it gets work out of composition and into coroutines instead.
@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) {
launch { viewModel.loadCart() }
launch { viewModel.loadPaymentMethods() }
launch { viewModel.loadDeliveryAddresses() }
}
CartContent(...)
}So, it's a good idea to consider migrating in at places in situations like this to reduce the number of effects when possible.
Effects and coroutines are just one example of one place you might audit inside of your application, but every composable and every application is going to be different and have different opportunities inside of them.
To understand what's relevant for your application, we need to talk about the next tool that Andrei is going to introduce, which is composition tracing.
All right.
So, composition tracing, as if you look at this faithful recreation of the Perfetto frame that we look before in a different slide, you would see that it gives us timing information with different phases, but doesn't really give us a lot of details about what is happening.
What?
Yes, what is happening inside composition of which composable functions are run.
And composition tracing help us with that.
So, we introduce some markers in a in the Compose compiler that basically annotate every function and make it visible on the Perfetto trace, so you can check how long each function takes.
You can use that to figure out which exact functions are recomposing.
There's a bit of the overhead to that though, so if you want to capture the most accurate performance data, you want to use regular Perfetto or just like run without Perfetto captures and use other metrics.
But composition tracing really useful if you want to figure out how long each individual composable takes.
Now, sometimes you also want to figure out why things are recomposing, and that's what we trying to solve in the next release of the Compose Tracing runtime tracing, rather, 1.13.
We're introducing recomposition tracing now, which annotates every state read and state write that resulted in recomposition.
So, you will get something like this on your in your Perfetto trace, where you would see each of the state writes and the call stacks where the write happens and read happens, so you can correspond them to actual lines in your code where those states were accessed or modified.
Now, if you want to learn more, look at the link here.
It has all of the documentation about both composition tracing and recomposition tracing.
Now, over to Andrew to talk how we can use this more efficiently to avoid state writes.
Thanks, Andrei.
Based on the results that you find from this investigation, you may discover that there are invalidations in your composition that aren't useful for your application and could have been avoided in the first place.
Here, for example, the most common example that I've got is some scroll to top button that is dependent on the visibility or the scroll progression for its visibility.
@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton = scrollState.value > 0
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}Here, because I'm reading the value of a scrolling state inside of composition, this is a very frequently changing state, it could change as much as once per frame, and this is going to cause a lot of recompositions that could be avoided because this condition, even though the scroll state itself is changing, the actual condition that we care about here doesn't change all that often.
This is an example of where we would recommend using something like a computed state, which can avoid invalidations for state changes that don't matter for your application.
So in this example, what I've done here is I've wrapped the calculation itself inside of a computed state, and when we read this state, instead of invalidating based on the scroll position itself, it'll conditionally invalidate this composable based on the entire condition and derive it from the other state itself.
@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton by remember { computedStateOf { scrollState.value > 0 } }
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}If you've seen this pattern before, chances are you've seen this use it with derived state instead.
ComputedState is new in Compose 1.13 and although ComputedState and DerivedState both have a similar goal of conditionally invalidating when their calculation changes, they have a very subtle difference in how they do it.
DerivedState, which we've shipped all the way back since the beginning of Compose, caches the value that it computes and reuses it until its dependencies change.
With ComputedState, it calculates the value every time you read it.
The caching sounds like it can be useful, but actually in many situations, the caching is more expensive than just reading the result.
To understand how that happens, we can look at an example of how these states might be used inside of your application and dig into what each of them are actually doing.
Here, I've got the same state that mirrors roughly what we were doing in the scroll to top button, where it's got some condition that is based on some other state, and I've got it both as a DerivedState and a ComputedState.
var a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
// First read
derivedState.value
computedState.value
// Second read
derivedState.value
computedState.valueI'm also then reading the state twice and we'll get the same value both times, but what goes on under the hood is different between the two of them.
To simplify a trace of what would actually show up here in something like Perfetto, we can examine what actually happens when you read both of these states.
Here, we start by calling into the computedState.value.
It will execute the calculation lambda, which then reads the value of the state, and there's also a little bit of upkeep to just notify that the read happened.
This is going to be true of all snapshot states in Compose.
The second read behaves exactly the same as the first read and has the same performance characteristics throughout.
By comparison, the very first read of the DerivedState does a similar thing, where it still has to call the calculation lambda and still does the other notification, but it's got a little bit of cache management overhead, which is a bit short-circuited on the first time around because there is no cache to check.
So it does a little bit of upkeep for the caching itself and then accomplishes the same thing in only slightly more time.
The second read, though, for DerivedState does actually win against the second read for ComputedState, even though these are both fairly trivial states.
The Like I said on the previous slides, the cost of a snapshot read is non-trivial in some cases, and actually reading from the cache is faster than doing the read itself, which is why we're seeing this very slight win for the second read itself for the DerivedState.
But the actual interesting scenario is what happens when the state needs to be invalidated.
This is what what we call the read-after-write scenario, where you update one of the states that's being used in the calculation and the calculation has to execute again.
What that looks like in code is we do something like modify a, maybe increment it, and then read the value again or perhaps recompose.
var a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
...
// Read-after-write
a++
derivedState.value
computedState.valueWhat that looks like in the actual diagram is going to be, again, exactly the same for ComputedState.
It doesn't have a cache to manage, this looks exactly the same, first, second, third, whatever read this actually is.
DerivedState, though, has its work cut out for it.
It goes through a very expensive path to see that the cache is invalid before it even updates and calls the calculation itself, and then it has to do dependency tracking and manage a lot of dependent a lot of the observation to write back the cache value and understand that all of these values actually changed and manage the upkeep itself.
All of this adds up and it makes the cost of invalidating invalidating a DerivedState very expensive compared to a ComputedState.
To put numbers to all of these claims, ComputedState is typically faster on the first read by about 200 ns.
ComputedState loses by about 350 ns on a Pixel 6, specifically, under specific benchmarking conditions.
So, on the second read, ComputedState will lose ever so slightly compared to DerivedState.
But then in the case that we actually care about, the one where we're invalidating and probably the one where you actually have work to do on your frame, ComputedState is faster by almost and in fact, more than double compared to the DerivedState.
So for what you depending on what you're using your states for, ComputedState can be a very good first pick to go to.
In general, there is still a decision to be made when using DerivedState and ComputedState.
We're not deprecating ComputedState, there are still usages for both.
Generally, if you're unsure which one to pick, computed state is a good default.
Derived state is most useful when you actually take advantage of the caching, meaning that you do something non-trivial like a complicated algorithm, some large allocation, a lot of state reads inside that actually benefit from the caching here, or anything non-trivial or that depends on things like object equality and object identity equality, where you make up the difference and gain the benefits of the caching that derived state offers.
The other elephant in the room that I want to talk about is that derived state and computed state are both trying to avoid invalidating the composable itself.
This is different from skipping a composable, which is what we normally talk about when we think about conditionally invalidating, or not performing work inside of a composable.
The truth that I want to expose here is that even though there is a lot of talk about stability and skipping inside of Compose, this isn't the end-all be-all, and it's not always the right thing to be optimizing for.
Like we said previously, everything in composition has some costs associated with it, and that includes skipping.
// What you write
@Composable fun ContactListItem(
contact: Contact
) { ... }
// What you get
@Composable fun ContactListItem(
contact: Contact,
$composer: Composer?
$changed: Int
) {
val $dirty = $changed or
$composer.changed(contact)
if ($dirty) { /* Your function body */ }
else $composer.skipToGroupEnd()
}Skipping isn't necessarily trivial.
There's a lot of upkeep that we have to do to enable it in the first place.
We have to do things like storing all of your parameters and finding space for them on the initial composition and then updating that record when you recompose.
And we also have to compare your values against what they were previously to understand whether your composable should skip or not.
And then afterwards, all of this is just the power of an if check that skips over the code or not.
This can be possibly expensive depending on what's going on here.
There is an optimization under the hood for the compiler, where if you pass the same parameter, we can reuse equality checks, but this isn't the end-all be-all, and it's pretty easy to defeat this.
To see an example of skipping betraying us a little bit, we can look at a really classical example of just a scrolling list that has, importantly, some massive object that contains all of the data to be composed on-screen.
data class ContactsModel(
val header: String,
val contacts: List<Contact>
)
data class Contact(...)
@Composable
fun ContactsScreen(model: ContactsModel) {
Column {
TopAppBar(title = { Text(model.header) })
ContactsList(model.contacts)
}
}
@Composable
fun ContactsList(contacts: List<Contact>) {
LazyColumn {
items(contacts) { ContactListItem(it) }
}
}
@Composable
fun ContactListItem(contact: Contact) { ... }So here, the screen model contains something like a header screen and then all of the list of contacts that it then passes through a couple of composables that have been-- eventually call a lazy list and then throw them on-screen with some item cells.
The composition hierarchy for this code looks something like this, where we again start with the contact screen, we fan out a little bit, and then have some other composables that call in and present our items.
If we look at how our data gets passed, we start with the model on top.
We destructure it into a string and then a list, that list then gets converted into an items lambda for the lazy list, and then lazy list will compose all of the items that appear on-screen.
Let's look at what happens when we modify the very last contact in this list.
What we want to happen is just invalidate the last item in the list, but that's not what's going to happen here.
If we look at a composition trace for this recomposition, it'll look something like this, where we start at the contact screen and then eventually work our way down to this list item.
And this is a fairly expensive way of getting down here.
We can also see that there's all of these sections where it looks like nothing is happening inside of the trace.
And although it's not clear here, this is actually where the composables are deciding whether they are about to skip or not.
For this example, only two composables get skipped because of the way that lazy list recomposes.
When you give it a new content lambda, it'll invalidate all of the items that it has.
So it'll invalidate the two contacts that haven't changed in addition to the one that does, and those two are the only items here that get to skip.
Everything else is doing this skipping equality comparison only to realize that it actually has to execute.
If your objects are stable, then this comparison can actually be fairly expensive.
If you force us to go through all of your data structures, this can actually add up and be a non-trivial cost just to tell if two objects are equals.
And worse, because we've destructured, we actually have to do the same comparison on the list twice.
If you instead let the compiler behave with the default behavior and use strong skipping, you can still get skipping and you can still get checks to conditionally skip over your composables, but they go the much faster route of using object identity equality instead of structural equality.
This is much faster for us to compute, and even though we recompose the same items on this frame, where it actually matters that we get in there fast, we don't waste too much time figuring out that we couldn't actually skip these composables.
This is also why we emphasize using tools like benchmarks and traces to understand what's going on inside of your application rather than recomposition counts directly.
They can be a useful proxy, but they're not something to necessarily hyper-fixate on or over-index on throughout the optimization of your applications.
Remember, instead of trying to skip your composable, avoid invalidating it at all to begin with.
We saw in this example that composition traces can give a lot of information about what's happening, but they also don't necessarily show the entire picture.
For that, Andrei is going to tell us about method traces.
All right.
As Andrew already said, composition tracing doesn't always tell you everything that's going on, just because it only annotates composable functions, and it must be some cases where you see just blank space in the composition trace that already augments Perfetto trace, but it still doesn't show everything that's happening in there.
And sometimes, it can be something that Compose does, sometimes it may be something in your code, it's really hard to figure out by just looking at the composition trace here.
What you want to do now is to grab the method trace.
There is like a thing in the profiler, like an item in the profiler in Android Studio, or you can also use ADB commands to grab the trace, and what will give you is the every function call that happens in Java world.
It it's everything that Compose Compiler encodes in there, everything that Compose Runtime does, and everything also that your code does, and it's really useful for tracking, investigating what exactly is executing, which functions were called in that space.
One thing, I want to-- see this item in the middle.
I want- want to note is that the method tracing has really high overhead.
You have 10 to 20 times more overhead depending on like how complex a fun- function tree is, because every function call adds up a little bit of the thing.
And to illustrate that, we can look at the screenshot.
This is what method trace for the start of the app's look like usually, and you might ask, "How do I make sense of this?"
Well, you start of the composition trace usually, you look at the functions that you have here.
So, this is like simplified composition trace, where they have like ScreenContent, some Button in there, and some other functions.
Let's say, we want to figure out what's happening in the ScreenContent.
Well, Perfetto has a search bar.
You load your method trace in the Perfetto viewer, you search for the same function as you have here, ScreenContent, it will get to something like that, which is the method trace for the screen.
So, you can see here there's like some Composer.startGroup and group calls around.
This is what runtimes and compiler do for you in the background to make sure your composition actually does it magic, but the interesting call here is the Composer.changed.
This is how we check if the parameters changed between previous invocations and current invocation of the composable.
And majority of these Composer.changed is dominated by this ScreenModel.equals.
So, maybe the ScreenModel is some kind of big data class, and maybe we made it stable to help with the recomposition count, so maybe it's something that showed up in compiler report.
And maybe removing this stable annotation will help with the performance in this particular case.
So, you want to be looking for that, and composition method traces are the best tool to find this.
All right.
We looked at many things today, so now over to Andrew to wrap us off.
To wrap up and give a high-level summary of everything that we've talked about today, there's a couple of things to keep in mind when you're designing composables and then optimizing your apps that you already have.
First of all, remember that cheaper composables and composables that you don't invalidate are better than composables that you skip.
Remember that when you're designing composables, it's better to consider arguments that are smaller and easier to compare and possibly reusing comparisons against other function calls to take advantage of the optimization.
When you're using APIs inside of your composables, be mindful that the performance is going to or the frame time is going to be a product of the cost of the APIs themselves and how often you use them.
Be mindful of how these costs add up, and also be mindful that there may be teardown costs associated with them that you may hit at the least opportune time for your application.
Also, remember that it's important to be a good citizen when making optimizations inside of your app itself.
Start by taking benchmarks to make informed decisions, profile your app to make sure that you're targeting the right areas of your code, and validate that you are making the changes and seeing the results that you expect from the modifications that you're making as a result of benchmarks and profiling.
Also, remember that there can be a trade-off between maintainability and performance inside of your application.
So, do what's best for you and your team to make sure that you're not overoptimizing and backing yourself into a corner with maintainability.
And also, for more help, there's one more tool at your disposal, and it's the AI agent that you already have.
We have launched as part of the Android CLI and as part of our skills that we ship there a Jetpack Compose performance skill that has a lot of the information we've talked about today and can go through and audit your application and make suggestions for these kind of optimizations.
You can install it in your existing agent using Android CLI.
android skills add jetpack-compose-performanceTo find more information about Android CLI, you can learn more on developer.android.com.
You can also learn more about our skills themselves on our GitHub repository.
And with that, safe benchmarking, and happy composing.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Asynchronous work and animations
LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
}@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val activity = LocalActivity.current
// Setup and teardown
DisposableEffect(Unit) {
activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
onDispose {
activity.requestedOrientation = SCREEN_ORIENTATION_UNSPECIFIED
}
}
}@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// One-shot events
SideEffect(Unit) { mediaPlayer.play() }
}@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Synchronize the outside world to the state of composition
SideEffect { mediaPlayer.playbackRate = playbackRate }
}@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) { viewModel.loadCart() }
LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }
CartContent(...)
}@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) {
launch { viewModel.loadCart() }
launch { viewModel.loadPaymentMethods() }
launch { viewModel.loadDeliveryAddresses() }
}
CartContent(...)
}@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton = scrollState.value > 0
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton by remember { computedStateOf { scrollState.value > 0 } }
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}var a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
// First read
derivedState.value
computedState.value
// Second read
derivedState.value
computedState.valuevar a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
...
// Read-after-write
a++
derivedState.value
computedState.value// What you write
@Composable fun ContactListItem(
contact: Contact
) { ... }
// What you get
@Composable fun ContactListItem(
contact: Contact,
$composer: Composer?
$changed: Int
) {
val $dirty = $changed or
$composer.changed(contact)
if ($dirty) { /* Your function body */ }
else $composer.skipToGroupEnd()
}data class ContactsModel(
val header: String,
val contacts: List<Contact>
)
data class Contact(...)
@Composable
fun ContactsScreen(model: ContactsModel) {
Column {
TopAppBar(title = { Text(model.header) })
ContactsList(model.contacts)
}
}
@Composable
fun ContactsList(contacts: List<Contact>) {
LazyColumn {
items(contacts) { ContactListItem(it) }
}
}
@Composable
fun ContactListItem(contact: Contact) { ... }android skills add jetpack-compose-performance