Scientist cat with text

Performance in Jetpack Compose

Denis Rudenko
8 min readOct 3, 2022

Article tells about my research on how to write efficient Compose code. It consists of 6 sections and a TL;DR/Summary in the end. We will cover:

  • Optimising recompositions
  • When should you use @Immutable and @Stable annotations;
  • Unstable classes, variables, lambdas;
  • Non-restartable & skippable composables;
  • Lambda modifiers;
  • Passing lambdas providing required fields instead of fields in composables;
  • Inlined composables;
  • When you should use remember { }.

We’ll be using Compose Compiler Metrics and layout inspector tools to know:
- If a class is stable or not;
- If the composable function is skippable/restartable;
- Amount of skipped recompositions.

1. Unstable objects on UI layer.

To understand why we should care about stability, let us peek into a very important metric called skippability. It allows compose runtime to skip recomposition of a composable when all the parameters it uses are considered stable.
What is considered stable by the compiler?
-
All primitive value types: Boolean, Int, Long, Float, Char, etc.
- Strings
- Lambdas (not always, we will get to it later)
We want composable functions to use stable params to become skippable.

1) Don’t use var when seeking stability. Fields declared as var are considered unstable:

example of unstable class
compiler stability report

UserDetails composable will be recomposed even if the user never gets modified. Using val instead of var in User class will fix this issue.

2) Not all lambdas are considered stable. Let’s look at the following examples:

unstable lambda from viewModel
unstable lambda example 2

Since the lambdas capture outside scopes, they won’t be automatically inferred as stable and reused as expected. If the lambda requires access to external variables, the compiler will add those variables as fields, which are passed into the constructor of the lambda. We go a 2 ways of dealing with this situation.

A) Use references, it will prevent the creation of a new class that references the outside variable:

referenced lambda will be considered stable

Approach above works great for simple scenarios, but sometimes you have to place a few function calls inside. In such cases we can use remembered lambdas.

B) Explicitly remembering lambdas will ensure that we reuse the same lambda instance across recompositions.

Stable lambdas — skippable composables

Double braces in clearFocus are not needed unless you need to pass it as a function. It helps with compilers false positive warning described here.

3) Beware of unstable classes from libraries & other modules. When a class is used from a module or a library that doesn’t use compose, it won’t be considered as stable even if all requirements are met.

A very important note to take:

There is no need to explicitly mark any of your UI state classes with @Immutable or @Stable annotations unless you have a multi-module project or are creating a library for someone to use.

Stable & Immutable annotations are used to create certain promises for the compose compiler, so it can produce optimised code. This work is handled by the compiler, and marking already stability inferred classes with annotations won’t have any impact.

In case you do need to use an object from a library or multi-module project on UI layer, you need to double check if it’s marked as stable. Most of the time it won’t be, but we have following options:

  • Create your own classes and map from & to them;
  • Pass stable fields directly to the composable which needs them, so instead of passing user class you pass it’s primitives: user.id, user.name
  • Use a wrapper class marked with @Immutable annotation
deconstruction approach
wrapper approach

Using any of these solutions is inconvenient, and the topic should be addressed further.

4) Unstable collections. Kotlin lists are read-only, but not immutable, so in this snippet:

WrappedLazyColumn is not skippable since List<> is not considered stable

Since List is not considered stable,WrappedLazyColumn will be treated as non-skippable, so it will recompose even if the list is not changed. What can we do:

A) Create a wrapper for list, same as we did when treating entities from another modules, and use it on the UI layer:

WrappedLazyColumnApproach1 is considered skippable now

B) immutable collections from kotlinx.collections.immutable. It’s available as a dependency. Beware of this issue though, duplicated here as well.

ImmutableList will be treated as stable

Update: I went with this approach.

2. Inlined composables

It’s important to be aware that Column, Row & Box are inlined, meaning that they don’t produce their own state read scope, only use their parent scope. When state is read, it triggers recomposition in the nearest scope. This fact might be an issue when using inlined composables:

How Column affects the recompositions
StateReadsInlinedComposablesScreen is recomposed for no good reason

We can see that StateReadsInlinedComposablesScreen is being recomposed when we press the button, since it’s considered to be the nearest recomposition scope. This can be fixed by the following:

Difference — WrappedColumn composable

All we did here was create a WrappedColumn composable, which hosts the Column. This allows to skip StateReadsInlinedComposablesScreen composable recompositions since WrappedColumn is now considered the nearest scope:

We got recomposition only where it’s needed now

There is an alternative approach to fix this in the next point.

3. State reads from too high scope

Example above illustrated what happens if state read is done in a wrong scope. We can provide a smaller read scope when using lambdas which return values, instead of passing values directly into composables. Let’s try this on the previous example:

notice the lambda in TextWrapper

Passing lambda into the TextWrapper to retrieve the value when needed, creates a state read scope which allows us to get the same results in this exact scenario. This approach might come handy in a lot of places. Lets take a look at another example:

This code has 2 issues we need to attend.
Issue 1. When button is pressed, we get redundant composition of InputsWrapper, since count is read in its scope.

InputWrapper is not skipped

Issue 2. When name or credit card number is entered, we have redundant composition of StateReadsInputLambdasScreen because name & credit card number are read from its scope, and InputsWrapper since it’s passing values into NameTextField & CreditCardNumberTextField.

StateReadsInputLambdasScreen is not skipped

To fix first problem we can use the lambdas approach:

We did 2 things here. First created a TextWrapper composable which itself didn’t do anything useful. TextWrapper exposes a lambda which will be used to provide a value for the Text, and this allows to change the state read scope in a way, that InputsWrapper won’t be recomposed anymore:

Recompositions happen only where needed now

To fix second problem we apply the same approach:

This results in both StateReadsLambdasOptimizedScreen & InputsWrapper being omitted from recomposition, meaning that we don’t even need to use ColumnWrapper as we did before.

What happens when we enter text into NameTextField

4. NonRestartableComposable annotation

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:

Make the function skippable by ensuring all of its parameters are stable

Make the function not restartable by marking it as a @NonRestartableComposable

You can get more details about it here & here. We can mark InputsWrapper and NameTextField with this annotation, and add ColumnWrapper as parent to decrease recompositions:

Notice the @NonRestartableComposable annotations

While getting similar outcome in terms of optimisation, this approach has a side effect to be aware of:

Compose compiler report on composables. None are restartable & skippable

None of composables marked with annotation will be marked as skippable.

Also it’s possible to optimise the same code with a different approach — by passing states directly to the composables which use it. Note, this approach is just an example, not a recommendation:

StateFlows passed as arguments for lower level composables

While this results in text input related recompositions being optimised, it won’t fix recompositions when counter is changed, and also renders each composable taking StateFlow as param as non-skippable, since StateFlow is not considered stable.

5. Use lambda modifiers whenever possible

When you are passing frequently changing state variables into modifiers, you should use the lambda versions of the modifiers whenever possible.

By switching to the lambda version of the modifier, you can make sure the function reads the scroll state in the layout phase. As a result, when the scroll state changes, Compose can skip the composition phase entirely, and go straight to the layout phase.

More details can be found here. Let’s take a look at the following:

When you swipe — button moves
Redundant recompositions happening

We can optimise code above by doing 2 changes.
1) Change graphicsLayer modifier to graphicsLayer lambda modifier;
2) Use lambda to provide scrollOffset for the HorizontallyMovingButton;

Notice the scrollProvider lambda & graphicsLayer modifier lambda

Applying these changes to HorizontallyMovingButton removes the redundant recompositions.

6. When to use remember { }?

  1. When to use remember?
  • For any operation which might be executed more than once, but shouldn’t be until the key passed in the remember changes.
  • When using objects that are not treated by the compiler as stable, including unstable lambdas.

Few examples below:

Use with:

  • KeyboardActions();
  • LocalClipboardManager.current.setText (unstable lambdas);
  • LocalFocusManager.current.clearFocus/moveFocus (unstable lambdas);
  • FocusRequester;
  • MutableInteractionSource;
  • derivedStateOf();

Don’t use with:

  • TextStyle();
  • KeyboardOptions();
  • LocalSoftwareKeyboardController.current.hide/show;
  • FontFamily/Font;
  • .dp;
  • Arrangement;
  • Alignment;
  • RoundedCornerShape;
  • BorderStroke;

TL;DR/Summary:

  • @Immutable & @Stable annotations are usually not needed.
  • Don’t expose unstable params on UI layer;
  • Beware of unstable lambdas and classes from modules;
  • Use the recomposition tracking feature in Android Studios layout inspector to see just how much your composables are recomposing & compose compiler reports to see potential optimisation. In case you didn’t these tools — I’d recommend you start using the layout inspector tool asap. Doing so will help you to always know what’s going on with your composable trees, apply tips from this article and validate the positive impact. Official documentation is good at explaining how to use layout inspector. This will go in details about compose compiler reports.
  • Use immutable collections.
  • Use lambda modifiers wherever possible.
  • Use lambdas providing values to optimise recomposition counts;
  • Keep in mind the outcomes of using inlined composables;
  • Don’t over & under use remember {};
  • Use @NonRestartableComposable annotations;
  • Consider using JankStats library & even adding it to your CI process;

Not covered in the article but also important:

  • Consider using Baseline profiles;
  • Always use fastForEach, fastMap, fastFirstOrNull and other API’s for optimised iteration through collection, since it doesn’t allocate an iterator like forEach. Beware: never use it with LinkedList, 3d party libraries or other non-common collections without ensuring it works faster and isn't bugged out;
  • Also not covered in the article, but consider reusing modifiers.

You can find & use the repository created to find these nuances here:

https://github.com/Skyyo/compose-performance

Article was written with compose version 1.3.0-beta02 in mind.

I’d also like to bring attention to ongoing Russo-Ukrainian war and encourage everyone to donate to Ukrainian charities.

--

--

Denis Rudenko
Denis Rudenko

Written by Denis Rudenko

🇺🇦 Staff Android Engineer @Zenzap, Android Tech Lead @Group107

Responses (7)