As teams start using Jetpack Compose, most of them eventually find that there is a missing piece of the puzzle: measuring the performance of your composables.
In Jetpack Compose 1.2.0, a new feature has been added to the Compose Compiler which can output various performance related metrics at build time, allowing us to peek behind the curtains and see where any potential performance issues are. In this blog post we’ll explore the new metrics and see what we can find out.
Some things to know before you begin reading:
- This turned out to be a long blog post, covering lots of how Compose works under the hood. Take your time reading this.
- Just to set some expectations, we don’t really achieve anything by the end of this post 😅. However, hopefully you will get a better understanding of how your design choices can affect how Compose works.
- Don’t feel bad if you don’t understand everything here immediately, it’s an advanced topic! I’ve tried to signpost resources for further reading if you’re unsure on anything.
- Some of the stuff we go through here could be thought of as ‘micro optimizations’. As with any task involving optimization: profile and test first! The new JankStats library is a great entry point for this. If you don’t have an issue with performance on real devices, you might not have much to do here.
With that out of the way, let’s begin… 🏞
Enabling the metrics#
Our first step is to enable the new compiler metrics through some compiler flags. For most apps, the easiest way to enable it on all modules is with a global on/off switch.
In your root build.gradle
you can paste in the following:
subprojects {
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {
if (project.findProperty("myapp.enableComposeCompilerReports") == "true") {
freeCompilerArgs += [
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
project.buildDir.absolutePath + "/compose_metrics"
]
freeCompilerArgs += [
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
project.buildDir.absolutePath + "/compose_metrics"
]
}
}
}
}
This enables the necessary Kotlin compiler flags whenever you run a Gradle build with the myapp.enableComposeCompilerReports
property enabled, like so:
./gradlew assembleRelease -Pmyapp.enableComposeCompilerReports=true
Some notes:
- It’s important that you run this on a release build. We’ll see why a bit later.
- You can rename the
myapp.enableComposeCompilerReports
property however you wish. - You may find that you need to run the command above with
--rerun-tasks
, to ensure that the Compose Compiler runs, even when cached.
The metrics and report will be written to a compose_metrics
folder within each module’s build directory. For a typical setup, that will be in <module_dir>/build/compose_metrics
. If you open one of those folders, you’ll see something like so:
Note: technically the report (module.json
) and metrics (the other 3 files) are enabled separately. I’ve merged them into one flag and set them to output to the same directory for ease. You could split them if desired.
Interpreting the reports#
As you can see above, there are 4 files which are output for each module:
module-module.json
, which contains some overall stats.module-composables.txt
, which contains a detailed output of each function declaration.module-composables.csv
, which is a tabular version of the text filemodule-classes.txt
, which contains stability information for classes referenced from composables.
This blog post will not go into depth into the contents of all of the files. For that I recommend reading through the ‘Interpreting Compose Compiler Metrics’ doc, which we’ll reference throughout this blog post:
Instead, I’m going to step through the gold nuggets 👑 of information listed in the Things To Look Out For section from the doc above, and see what comes up in a module from my Tivi app.
The module I’m going to investigate is the ui-showdetails
module, which contains all of the UI for the ‘Show Details’ screen. This was one of the first modules which I converted to Jetpack Compose back in April 2020, so I’m sure there are things to improve!
Ok, so the first thing to look out for is…
Functions that are restartable
but not skippable
#
First up let’s define what the terms ‘restartable’ and ‘skippable’ mean for composable functions.
When learning Compose, you will have learnt about recomposition as it’s the basis of how Compose works:
Recomposition is the process of calling your composable functions again when inputs change. This happens when the function’s inputs change. When Compose recomposes based on new inputs, it only calls the functions or lambdas that might have changed, and skips the rest.
Restartable#
A ‘restartable’ function is the basis of recomposition. When Compose detects that the function inputs change, Compose restarts (re-invokes) the function with the new inputs.
Going a bit deeper into how Compose works, a restartable function marks the boundary of a composition ‘scope’. The ‘scope’ in which a Snapshot
(i.e MutableState
) is read in is important, as it defines what block of code is restarted when the snapshot changes. Ideally a snapshot change would trigger a restart in the closest function/lambda possible, allowing the smallest amount of code to be re-run. If the host code block is not restartable, Compose then needs to traverse up the tree to find the nearest ancestor restartable ‘scope’. This could mean then that a lot of functions would need to be re-run. In practice, nearly all @Composable
functions are restartable.
Skippable#
A composable function is ‘skippable’ if Compose determines that it can completely skip calling a function if the parameters haven’t changed since the last call. This is especially important for the performance of ’top-level’ composables, as they tend to be at the top of a large tree of function calls. If Compose can skip the ’top-level’ call, none of the functions below need to be called either.
In practice, our goal is to make as many of our composables as possible to be skippable, to allow Compose to ‘recompose intelligently’.
The definition of whether parameter values have changed is where things get a bit more tricky in Compose, and where we bring in two more terms: stability and immutability.
Immutability & stability#
Restartable and skippable are Compose attributes for functions, whereas immutability & stability are attributes of object instances, specifically the objects which are passed to composable functions.
An object that is immutable means that ‘all publicly accessible properties and fields will not change after the instance is constructed’. This characteristic means that Compose can detect ‘changes’ between two instances very easily.
An object that is stable on the other hand is not necessarily immutable. A stable class can hold mutable data, but all mutable data needs to notify Compose when they change, so that recomposition can happen as necessary.
Compose can enable a number of optimizations at runtime when it detects that all function parameters are stable or immutable, and is the key to functions being able to skip. Compose will try and infer whether a class is immutable or stable automatically, but sometimes it fails to infer correctly. When that happens, we can use the @Immutable
and @Stable
annotations on the class
With those terms briefly explained, let’s begin exploring the metrics.
Exploring the metrics#
We will begin with the module.json
file to get a sense of the overall stats:
{
"skippableComposables": 64,
"restartableComposables": 76,
"readonlyComposables": 0,
"totalComposables": 76
}
We can see there the module contains 76 composables: all of them are restartable, and 64 of them are skippable, leaving 12 functions which are restartable but not skippable.
Now we need to find out which functions they are. We have 2 ways to do this: either we look through the composables.txt
file, or we can import the composables.csv
file and view it as a spreadsheet. We’ll look at the text file later, so for now let’s take a look at the spreadsheet.
After importing the CSV into your spreadsheet tool of choice, you’ll end up with something like this:
After filtering the list of composables (there’s a ‘Not skippable’ filter view available on the sheet), we can easily find the non-skippable functions:
ShowDetails()
ShowDetailsScrollingContent()
PosterInfoRow()
BackdropImage()
AirsInfoPanel()
Genres()
RelatedShows()
NextEpisodeToWatch()
InfoPanels()
SeasonRow()
Making the functions skip#
Now our job is to go through each in turn, and determine why they’re not skippable. If we go back to the documentation, it says the following:
If you see a function that is restartable but not skippable, it’s not always a bad sign, but it sometimes is an opportunity to do one of two things:
1. Make the function skippable by ensuring all of its parameters are stable
2. Make the function not restartable by marking it as a@NonRestartableComposable
For now, we’re going to concentrate on the first thing. So let’s go ahead and look through the composables.txt
file and find one of the non-skippable composables, AirsInfoPanel()
:
restartable scheme("[androidx.compose.ui.UiComposable]") fun AirsInfoPanel(
unstable show: TiviShow
stable modifier: Modifier? = @static Companion
)
We can see that the function has 2 parameters: the modifier
parameter is ‘stable’ (👍), but the show
parameter is ‘unstable’ (👎), which is likely to be causing Compose to determine that the function is not skippable. But now the question becomes: why is the Compose compiler thinking that TiviShow
is unstable? TiviShow
is just a data class which contains only immutable data. 🤔
classes.txt#
Ideally we would reference the module-classes.txt
file here to drill into why the class is inferred as unstable. Unfortunately the output of that file seems a bit sporadic. In some modules I can see the necessary output, but for others it would be completely empty (which I had for this module).
We can see an example for a different module though, which looks really useful:
unstable class WatchedViewState {
unstable val user: TraktUser?
stable val authState: TraktAuthState
stable val isLoading: Boolean
stable val isEmpty: Boolean
stable val selectionOpen: Boolean
unstable val selectedShowIds: Set<Long>
stable val filterActive: Boolean
stable val filter: String?
unstable val availableSorts: List<SortOption>
stable val sort: SortOption
unstable val message: UiMessage?
<runtime stability> = Unstable
}
From judging the classes.txt
that were output, it seems that the Compose Compiler can only infer immutability and stability on classes which are compiled with the Compose compiler enabled. Most of the model classes in Tivi are built in a standard Kotlin module (i.e. no Android or Compose), and then used throughout the app. We have a similar situation for classes which are used from external libraries (i.e ViewModel
).
Unfortunately it seems that there’s not a lot we can do to workaround this right now without additional work. Ideally the annotations Compose uses (i.e. @Stable
) would be separated into a plain Kotlin library, allowing us to use them in more places (even Java libraries if necessary).
Wrapper class#
If you find that your composables are in the hot path for performance, and enabling skippability is key to achieving jank free performance, you can wrap incorrectly inferred stable objects in a wrapper class such as:
@Stable
class StableHolder<T>(val item: T) {
operator fun component1(): T = item
}
@Immutable
class ImmutableHolder<T>(val item: T) {
operator fun component1(): T = item
}
The downside is that you then need to use those in your composable declarations:
@Composable
private fun AirsInfoPanel(
show: StableHolder<ShowUiModel>,
modifier: Modifier = Modifier,
)
We can take it one step further though and explore a pattern which is recommended by a lot of teams: UI specific model classes.
UI model classes#
These model classes are built specific to each ‘screen’, containing the bare minimum needed for the UI to be displayed. Typically your ViewModel
would map your data-layer models into these UI models for easy consumption by your UI. More importantly here, they can be built right next to your composables, meaning that the Compose Compiler can infer everything it needs, or if all else fails we can add the @Immutable
or @Stable
as necessary.
That’s exactly what I implemented in the following pull request:
Instead of directly using TiviShow
which is used as a model in my data-layers (database, etc), we now map the show data into a ShowUiModel
which contains only the necessary information necessary for the UI.
Unfortunately this wasn’t enough for the Compose Compiler to infer ShowUiModel
as skippable 😔:
restartable scheme("[androidx.compose.ui.UiComposable]") fun AirsInfoPanel(
unstable show: ShowUiModel
stable modifier: Modifier? = @static Companion
)
Unfortunately there’s nothing immediately obvious in the metrics to work out why the class would be inferred as unstable. After reviewing the rest of the composables.txt
file I noticed that another function was also deemed as unstable:
restartable scheme("[androidx.compose.ui.UiComposable]") fun Genres(
unstable genres: List<Genre>
)
My new ShowUiModel
class is a data class which contains a number of primitives and enum classes, but one property is slightly different as it contains a list of enums: genre: List<Genre>
. It seems that the Compose Compiler does not infer stability for lists of stable types (
public issue).
The only way I found to force Compose to determine that ShowUiModel
was stable was to use one of the @Immutable
or @Stable
annotations. I used @Immutable
as none of the properties are mutable:
@Immutable
internal data class ShowUiModel(
// ...
)
After that, AirsInfoPanel()
was finally determined to be skippable 😅:
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun AirsInfoPanel(
stable show: ShowUiModel
stable modifier: Modifier? = @static Companion
)
Repeat#
After doing all of this work, you might think that we made a big difference in the overall stats for the module. Unfortunately, that’s not quite what happened:
{
"skippableComposables": 66,
"restartableComposables": 76,
"readonlyComposables": 0,
"totalComposables": 76,
"knownStableArguments": 890,
"knownUnstableArguments": 30,
"unknownStableArguments": 1
}
To remind you, we started with 64 skippable composables, which means that we increased that number by… 2 to 66 🙃.
Large apps can contain hundreds of UI modules which makes creating local UI models in every module unsustainable.
There’s also some other stats in there which are interesting. Compose has determined that there are 890 stable composable function arguments (which is great), but there’s still 30 more arguments which Compose has determined as unstable.
Upon inspection of those ‘unstable’ arguments, I found that nearly all of them can be safely used as immutable state. The issue seems to be the same as before, but with extra difficulty: most of the types were from external libraries.
For simple data classes from external libraries, we could do the same task as before and map them to local UI model classes (although it is laborious). However most apps will eventually find that there are some classes which can’t be easily mapped locally. In the ui-showdetails
module I had exactly that with some time/date classes from ThreeTen-bp: OffsetDateTime
and LocalDate
. I don’t particularly want to re-write a date/time library locally!
It is worth remembering at this point that we’re talking about a snapshot of just one module. Tivi is a fairly small app, but it still contains 12 UI modules. Large apps can contain hundreds of UI modules which makes creating local UI models in every module unsustainable. As we mentioned at the top of this blog post though, you only need to think about this for places where you’ve verified that performance is an issue.
The skipping rope has snapped#
It’s at this point that I went back to the suggestions from the documentation, and started looking at the second suggestion:
Make the function not restartable by marking it as a
@NonRestartableComposable
At first glance, this suggestion feels much more like a workaround (or escape hatch) versus the first suggestion of fixing class stability. Lets see what the documentation for the annotation says:
This annotation [prevents] code from being generated which allow this function’s execution to be skipped or restarted. This may be desirable for small functions which just directly call another composable function and have very little machinery in them directly, and are unlikely to be invalidated themselves.
If we think back to earlier, our goal is to make our composables restartable and skippable, so this annotation doesn’t immediately help us here from my reading. The Compose Metrics guidance has some more info though:
It is a good idea to [use the annotation] if the composable function doesn’t directly read any state variables, [as] it is unlikely that this restart scope is ever being used.
So does this annotation help us? Yes and no. The annotation seems to make the Compose compiler completely omit all of the automatic restarting of the composable function, therefore negating the original intention of making our functions restartable and skippable. I believe this then means that any state changes would require the Compose runtime to find an ancestor restart scope, which is why the doc above says to avoid them for functions which read state.
So what next? Local UI model classes are a lot of work to retrospectively add so will be a non-starter for a lot of teams. I did however find a suggestion on the Compose issue tracker which I really like as a solution: marking function parameters as @Stable
. This would enable developers to force stability/immutability on the composable function parameters themselves, even for external parameter types:
@Composable
fun AirsInfoPanel(
@Stable show: TiviShow,
modifier: Modifier = Modifier,
)
Default parameter expressions that are @dynamic
#
The second thing to look out for from the Metrics doc is to @dynamic
default parameter expressions. Composables make heavy usage of default parameter values to provide flexible APIs. I wrote about the Slot API pattern recently, which relies on default parameter values:
Default parameter values can come from both composable and non-composable code. Using values from composable code means that the code you’re calling will likely be restartable and that the return value can change. This is what we’re referring to by @dynamic
default parameter values. If a default parameter value is @dynamic
the calling function will likely need to be restarted too, which is why unintended dynamic parameter values should be avoided.
The metrics refer to the non-@dynamic
parameter values as @static
, which will likely make up the vast majority of what you find in your composables.txt
files. There are a few exceptions though to when @dynamic
are necessary:
You are explicitly reading an observable dynamic variable
The most common example you’ll see of this is using MaterialTheme.blah
as default values on your composables. Here we have a composable which has 3 parameters which are marked as dynamic.
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun TopAppBarWithBottomContent(
stable backgroundColor: Color = @dynamic MaterialTheme.colors.primarySurface
stable contentColor: Color = @dynamic contentColorFor(backgroundColor, $composer, 0b1110 and $dirty shr 0b1111)
stable elevation: Dp = @dynamic AppBarDefaults.TopAppBarElevation
)
The first two parameters, backgroundColor
and contentColor
, are dynamic because we are indirectly reading composition locals hosted in MaterialTheme
. Since theming is relatively static (in the dictionary sense of the word), the return value shouldn’t actually change all that often, so it being dynamic isn’t really an issue.
For the elevation
parameter though, I’m not sure why it is marked as dynamic. It uses the value from the Material-provided AppBarDefaults.TopAppBarElevation
property, which is defined as:
object AppBarDefaults {
val TopAppBarElevation = 4.dp
}
The dp
property is marked as @Stable
, and the Dp
class is marked as @Immutable
so this looks like a bug from my reading?
I found similar instance of this issue on another function:
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun SearchTextField(
stable keyboardOptions: KeyboardOptions? = @dynamic Companion.Default
keyboardActions: KeyboardActions? = @dynamic KeyboardActions()
)
keyboardOptions
is referring to the companion object of KeyboardOptions
(== a singleton), and keyboardActions
is creating a new empty instance of KeyboardActions
, both of which should have been inferred as @static
from my reading.
Similar to the first section of this blog post, I’m not sure that there’s not a lot we can do here to influence the Compose compiler. We can add @Stable
and @Immutable
to our own classes, but judging from the dp
example above, it seems that may not always work.
Release build?#
At the start of this blog post we mentioned that you need to enable the Compose Compiler metrics on a release build. When you build your app in debug mode, the Compose compiler enables a number of things to enable faster iteration of development. One of those things is Live Literals, which enables Android Studio to ‘inject’ updated values for certain parameter values without having to recompile your composables.
To do that, the Compose compiler replaces certain default parameter values with some generated code. Android Studio can then call that code to set new values. The end effect is that generated Live Literal code will cause your default parameters to be @dynamic
, when they’re not actually dynamic.
You can see an example below. The red is debug
mode output, and green is from the release
build. The expanded
parameter changes to @static
in release mode:
--- debug.txt 2022-04-06 14:43:16.000000000 +0100
+++ release.txt 2022-04-06 14:43:24.000000000 +0100
@@ -1,11 +1,11 @@
restartable skippable scheme("[androidx.compose.ui.UiComposable, [androidx.compose.ui.UiComposable], [androidx.compose.ui.UiComposable], [androidx.compose.ui.UiComposable]]") fun ExpandableFloatingActionButton(
stable text: Function2<Composer, Int, Unit>
stable onClick: Function0<Unit>
stable modifier: Modifier? = @static Companion
stable icon: Function2<Composer, Int, Unit>
- stable shape: Shape? = @dynamic MaterialTheme.shapes.small.copy(CornerSize(LiveLiterals$ExpandingFloatingActionButtonKt.Int$arg-0$call-CornerSize$arg-0$call-copy$param-shape$fun-ExpandableFloatingActionButton()))
+ stable shape: Shape? = @dynamic MaterialTheme.shapes.small.copy(CornerSize(50))
stable backgroundColor: Color = @dynamic MaterialTheme.colors.secondary
stable contentColor: Color = @dynamic contentColorFor(backgroundColor, $composer, 0b1110 and $dirty shr 0b1111)
stable elevation: FloatingActionButtonElevation? = @dynamic FloatingActionButtonDefaults.elevation(<unsafe-coerce>(0.0f), <unsafe-coerce>(0.0f), <unsafe-coerce>(0.0f), <unsafe-coerce>(0.0f), $composer, 0b1000000000000000, 0b1111)
- stable expanded: Boolean = @dynamic LiveLiterals$ExpandingFloatingActionButtonKt.Boolean$param-expanded$fun-ExpandableFloatingActionButton()
+ stable expanded: Boolean = @static true
)
What have I just learnt?#
At this point you might be thinking that you’ve just spent roughly 30 mins reading me pointing out a load of probable issues… and you’d be mostly right 😅. However I still think there are some action items for teams here:
- Starting profiling and tracking performance stats. Without this, any performance related work is a shot in the dark.
- Update to new versions of Compose as early as possible! This will allow you to try out and gain performance updates (and report possible regressions).
- Look out for small utility functions/lambdas which are marked as
@Composable
. These tend to return a value (rather than emit UI), and are usually composable just so that they can reference composition locals (LocalContext
is a common culprit from my experience). You can easily un-composable them by passing in the dependency.
Final thoughts#
As I alluded to above, I think these new metrics are an ✨ amazing ✨ step forward in seeing what is actually inferred from our composables.
The issues I pointed out are actually a good thing, and show that these metrics and outputs work. Without these, we would have no idea what is inferred and be able to see when the inferred result isn’t quite as expected. With this info, it becomes much easier to create an issue on the Compose issue tracker with debuggable information.
The metrics are obviously very raw right now, but knowing how good the Compose + Android Studio tools team are, I’m sure an Android Studio GUI isn’t too far away. I’m looking forward to seeing where the teams take this!