Conscious Compose optimization. The tips and tricks | by Andrey Bogomolov

Image from Ozon Tech blog on Habr

Compose is a relatively young technology for writing declarative UI. Many developers don’t even realize that they are writing suboptimal code in such a critical part, and later it leads to unexpected poor performance and falling metrics.

Our Ozon Seller team also faced this problem. We decided to put together all the tips and tricks for writing optimized Compose code. Active application of these tips when optimizing existing screens and writing new ones has significantly improved our metrics: the lag duration in relation to the scroll duration (hitch rate; the less the better) of screens with lists has dropped from 15–19% to 5–7% on average (at the 90th percentile). We have described all these tips and tricks in this article. It will be useful for both beginners and experienced developers, it describes optimizations and Compose mechanisms in detail, and also tells you about poorly documented features and corrects mistakes that can be found in other articles. Let’s get started.

This is a translation of our article from Habr, written at the beginning of summer 23. Since then, we have accumulated even more tips, which will be described in the second part.

Table of content

First, let’s dive a little deeper into how Compose works and what it can do. This will help us understand why specific optimizations are needed and how they work.

Main idea

Building a UI tree is the main idea behind composable functions. Passing from the beginning to the end of the User() function, we will get a tree as in the picture:

@Composable
fun User()
Row
Image()
Column
Text()
Text()


Example tree of UI elements

To build such trees, you need more than just declarative code. The Compose compiler takes care of that for us.

The Compose compiler is a plugin for the Kotlin compiler. And this means that, unlike the kapt/ksp plugin, it can modify the current code, and not just generate a new one. At compile time, it replaces composable functions with new ones, to which it adds auxiliary constructs and parameters, among which $composer is especially important. It can be thought of as a calling context. And the process of converting a composable-function can be thought of as what Kotlin itself does with suspend functions.

The Compose compiler adds $composer method calls at the beginning and end of the generated composable function (see code below). These methods start and end a group, which can be thought of as a node in the tree that Compose builds. That is, the beginning and end of the function are the beginning and end of the node description. The word Restart refers to the type of the group. In this article we will not dive deep into group types, but if you are interested, you can read about it in the book “Jetpack Compose internals” (Chapter 2. “The Compose compiler”, paragraph “Control flow group generation”).

@Composable
fun User($composer: Composer)
$composer.startRestartGroup() // Start of group

// Function body

$composer.endRestartGroup() // End of group

Based on the data from the function body, $composer builds the tree step by step. This is the first phase of Compose — Composition.

Compose Phases

Like most other UI toolkits, Compose renders a frame through several distinct phases.

If we look at the Android View system, it has three main phases: measure, layout, and drawing. Compose has similar phases:

  1. Composition: What UI to show. Compose runs composable functions and creates a description of your UI.
  2. Layout: Where to place UI. This phase consists of two steps: measurement and placement. Layout elements measure and place themselves and any child elements in 2D coordinates, for each node in the layout tree.
  3. Drawing: How it renders. UI elements draw into a Canvas, usually a device screen.

These three phases are executed for almost every frame, but to improve performance, Compose can skip some phases if the data for them has not changed.

The conditions for calling phases, as well as examples of places for reading the state, can be seen in the picture below. We’ll talk about reading state later, for now just think of it as getting the value of muatbleStateOf(). You can read more about phases at Android Developers.

Jetpack Compose phases. Source

Parameters in composable functions

Compose encourages us to write pure functions. This makes them more deterministic and also allows Compose developers to make the first optimization — simply not to execute a composable function if the arguments haven’t changed.

Let’s immediately introduce such concepts as composition — building a tree of composable functions, and recomposition — updating this tree when the data changes.

We have come to another parameter that the Compose compiler adds to composable functions — $changed. It is just an Integer number, which is a bitmap where bits are responsible for information about the arguments of a composable function and their changes.

// Parameters of a composable function after running the Compose compiler 
@Composable
fun Header(text: String, $composer: Composer<*>, $changed: Int)

If some parameters have changed in the parent composable function, and some remain the same, then the information about the comparison is passed to the child functions as the $changed parameter so that they do not make unnecessary comparisons. The functions themselves compare only arguments that the parent is not sure about, or if the arguments are set by default.

Mutable arguments — objects that are able to change (modify their data) — can kill the whole point of comparison. To solve this problem, the Compose developers decided to separate all types into stable and unstable. If all the arguments of a function are stable and have not changed, the recomposition is skipped, otherwise you will have to restart the function again.

A function that supports skipping recomposition is called skippable. We should try to make almost all of our functions skippable. This will have a very good effect on optimization.

Classification of types by stability

The Compose compiler walks through all types and adds stability information to them: the @StabilityInferred annotation and the $stable static field with type stability information.

Type stability means that the Compose runtime can safely read and compare input of that type to skip recomposition if necessary. The ultimate goal of stability is to help the Compose runtime.

Stable types are:

  • All primitive types and String.
  • Functional types (lambdas) (that’s why the concept of “unstable lambdas” is not quite correct, but more on that below).
  • Classes with all fields of stable type and declared as val, including sealed classes. Stability of class fields is checked recursively until a type is found whose stability is already unambiguously known.
  • Enum (even if you specify a var field and change it).
  • Types marked @Immutable or @Stable.

All stable types must fulfill a certain contract, which we will discuss next.

Compose considers unstable:

  • Classes with at least one field of unstable type or declared as var.
  • All classes from external modules and libraries that do not have a Compose compiler (List, Set, Map and other collections, LocalDate, LocalTime, Flow…).

For generics (MyClass<T>), the check is based on the structure of the generic itself, and then on the specified type. If the generic’s structure is unstable (there are fields of unstable type or fields with var), it is immediately considered unstable. If we specify the generic’s type right away, Compose will determine it as stable or unstable at the compilation stage:

// Stable
class MyClassStable(
val counter: Pair<Int, Int>
)

// Unstable
class MyClassUnstable(
val counter: Pair<LocalDate, LocalDate>
)

// Unstable
class MyClassUnstable(
val counter: Pair<*, *>
)

If we make a composable generic function and pass the generic to it as argument (@Composable fun <T> Item(arg: Pair<T, T>)), then the behavior will be the same as for types with computable stability, which we will talk about further.

The Compose developers have also predefined external types that will be considered stable: Pair, Result, Comparator, ClosedRange, collections from the kotlinx.collections.immutable library, dagger.Lazy and others. Most of these types are generics, so this list only tells us about the stability of their structure. That is, if we pass a stable type to these generics, they will be stable, and if unstable, they will be unstable. It can be said that the principle that all classes from external modules and libraries that do not have a Compose compiler are unstable will simply not apply to these types.

There are also types with computable stability — about which Compose cannot say at compile time that they are unambiguously stable or unstable. Their stability is checked already at runtime, when specific objects are received. Such types include:

  • Types that are declared in other modules with Compose compiler enabled. If we use a type from module 2 where Compose is not enabled in module 1, then the Compose compiler simply cannot check this type for stability, so it will immediately consider it unstable. And if Compose is enabled in module 2, the Compose compiler assumes that it will check this type in module 2: it will annotate it with @StabilityInferred and add a static field $stable. And then it will read this field at runtime, not at compile time.
  • Interfaces (the check is based on the derived class, the object of which will be passed as argument).

About stability of interfaces:
Some other articles and sources write that interfaces are unstable, but according to
Compose source code, output metrics and test results we came to the conclusion that interface stability is calculated at runtime. In the tests they are marked as Uncertain (which correlates with the Unknown class). At the same time, the types that Compose is certain about are correlated with the Certain class (Stable or Unstable). The totality of all these arguments led me to this conclusion.

Additionally, you can find out the stability of types in tests or in composable metrics, which are described in the chapter on debugging.

@Immutable and @Stable

If you are sure that a class or interface and all its descendants are stable, you can annotate them with @Immutable if they are immutable, or @Stable if they can change but notify Compose of their change. For example, @Stable is suitable if the class has a field of type State<T> or MutableState<T> (mutableStateOf() creates such an object).

@Immutable
data class MyUiState1(val items: List<String>)

@Stable
data class MyUiState2(val timer: MutableState<Int>)

Stability from such annotations is inherited by child types.

@Immutable
interface Parent // Stable type

class Child1(val age: Int) : Parent // Stable type

class Child2(var list: List<String>) : Parent // Also the stable type

The @Immutable and @Stable annotations are useful to mark types that Compose thinks are unstable, but in fact are stable, or you are sure that they will be used as stable.

Both annotations currently just perform the logic of a stable type declaration and are the same for Compose, but it is still desirable to use them as intended, as Compose developers may change the behavior in the future.

By marking type with these annotations, you promise Compose that your type will fulfill the following contract:

  1. The result of equals will always return the same result for the same two instances.
  2. When a public property of the type changes, composition will be notified.
  3. All public property types are stable.

This contract is just your promise, and Compose will not check in any way if you break it. But then unexpected behavior of composable functions is possible. Let’s look at the contract in detail.

The first point is especially important and can shoot, even if you make all arguments stable. For example, in the code below we see that MyUiState doesn’t have an overridden equals like the data class, which means that the check will be done by reference. If MyComposable1 is recomposed, then MyUiState will be recreated. When checking by reference, Compose will consider it a completely different object and will not skip MyComposable2, although the field remains the same.

class MyUiState(val name: String)

@Composable
fun MyComposable1()
val myState = MyUiState("Name")
MyComposable2(myState)

@Composable
fun MyComposable2(uiState: MyUiState)
Text(uiState)

This situation is solved either by writing your own implementation of equals (or using data class), or by remembering this object with remember so that it won’t be recreated during recomposition (or similar actions in business logic if the object is recreated there).

The second point is implemented in State<T> and MutableState<T> (mutableStateOf), which under the hood notifies Compose when it changed.

The third point of the contract implies that you use public fields as stable fields. That is, if you have a field of formally unstable type List<T> and you don’t cast it somewhere to MutableList<T>, then feel free to mark your class as @Immutable or @Stable.

@Stable can also be applied to regular (non-composable) functions and properties. Then Compose will assume that they will return the same value if the arguments have not changed. The annotation does not affect composable functions. It is mainly needed to optimize the generated code for default arguments in composable functions.

Examples of functions and properties marked with @Stable: Modifier.padding(), Modifier.width(), Int.dp; transition functions in animations: fadeIn(), fadeOut(), slideIn().

About the influence of @Stable on functions and properties:

In composable metrics, you can see the impact of annotation by how the Compose compiler marks default arguments: @dynamic or @static. Briefly, with @static in the default arguments, composable functions are not called and states that can trigger recomposition are not read (more on this below). You can read more about @dynamic and @static at the link.

During our experiments we could not get a specific effect (e.g. skipping) in the function from marking @Stable, only the metrics changed from @dynamic to @static.

@Composable
fun MyWidget(param1: String, param2: String = testStable())
Text(param1 + param2)

@Stable
fun testStable() = "test"

// Composable metric for the function
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun MyWidget(
stable param1: String,
stable modifier: String? = @static testStable()
)

Skippability of the functions

Compose makes a composable function skippable only if all its parameters are of stable type and the function returns Unit. Unstable parameters are ignored if they are not used in the body.

For skipped functions, Compose specifically generates code to prevent them from being called again if the input data has not changed.

@Composable
fun Header(text: String, $composer: Composer<*>, $changed: Int)
if (/* Skip check logic */)
Text(text) // Function body is executed
else
$composer.skipToGroupEnd() // Letting Compose know that we skipped a function

Unstable types are also found among the frequently used types in Compose, for example, Painter, so you should be careful to use it to not lose skippability in the function.

If there are arguments with runtime-computed stability, the function remains skippable, but additionally code is generated that does not skip it if the argument turns out to be of an unstable type at runtime.

Based on the above, we in the team agreed to mark all UI models and states as @Immutable or @Stable, since we initially design them as such. We especially keep an eye on stability when developing a UI kit project, as the cost of an error becomes higher. To check type stability, you can use Compose metrics (we will return to them at the end of the article).

You can also simply pass as little unnecessary data as possible to the functions. It’s simple: less data — less probability that they will change.

What to do if you need to use standard collections or external classes and want functions to be skippable? So far, the space of possibilities is very limited: either make a wrapper class (value class can also be used as a wrapper) and mark it as @Immutable or @Stable, or simply avoid it. For standard collections there is an option to switch to collections from kotlinx.collections.immutable in UI-models. The ability to declare stability of external types is in the plans of Compose developers.

Lambdas

Let’s talk about how lambdas work in Compose and how to prepare them properly. This article gives an interesting example of invoking the ViewModel method inside a lambda, which leads to unnecessary recompositions.

Briefly, this situation can be summarized as follows:

@Composable
fun MyScreen()
val viewModel = remember MyViewModel()
val state by viewModel.state.collectAsState()

MyComposableItem(
name = state.name,
onButtonClick = viewModel.onAction()
)

To understand what and why, let’s take a look at how Compose handles lambdas. It divides them into non-composable, in which composable code is not executed, and composable respectively. Let’s consider the first type in detail.

Non-composable lambdas that are created in a composable function are wrapped in remember at compile time. All captured variables are put as a key for remember:

// Before compilation
val number: Int = 6
val lambda = Log.d(TAG, "number = $number"

// After compilation
val number: Int = 6
val lambda = remember(number) Log.d(TAG, "number = $number"

If a lambda captures a variable whose type is NOT stable (i.e. unstable or computable in runtime: the condition is stricter than for skippability) or the variable is declared as var, Compose does not wrap it in remember, which causes it to be recreated during recomposition. Further, when comparing the past and current lambda, Compose will find out that they are not equal, and because of this it will start recomposition of even the skippable function (it is assumed that MyViewModel is an unstable type).

How to solve this problem? Method reference (viewModel::onAction) used to work, but since Compose 1.4 it stopped working due to using reference comparison instead of the custom equals that Kotlin generates. You can read more in this thread, as well as in this video from 32:50.

Methods that work:

  • Remember the lambda yourself (in this case, the key itself must not change with each recomposition):
val onAction = remember   viewModel.onAction()  

You can do it this way (why remember the lambda and not the method reference, you can read here):

@Composable
inline fun <T : Any> MviViewModel.rememberOnAction(): ((T) -> Unit)
return remember this.onAction(it)

val onAction = viewModel.remberOnAction()

  • Use top-level (static) functions and variables. Here, the Kotlin compiler will call them directly since they are static, rather than passing them through the class constructor that will be created for the lambda at compile time.
  • Use only stable external variables inside the lambda.
  • Use arguments received from within the lambda. This may not help, only postpone or reduce the problem, but it will definitely help if you used to capture a list and now you won’t capture anything.
@Composable
fun MyComposableItem(items: List<MyClass>)
// Instead of this
ItemWidget items[5].doSomething()

// Do this
ItemWidget(item[5]) item -> item.doSomething()

A lambda can also implicitly capture an external variable if you in the lambda call a function from a fragment in a composable function. Then the lambda constructor will take the fragment as an argument, and remember around the lambda will not be generated.

class MyFragment : Fragment 
fun onButtonClick() ...

@Composable
fun Screen()
MyButton(onClick = onButtonClick() )

You might also have a question, how does remember handle the lambda itself if it accepts a lambda? The fact is that remember is an inline function, and its lambda turns into a regular code block. So, function:

val resultValue = remember(key1, key2) 
// Our calculations (e.g. creating a lambda)

will turn into the following code:

// Retrieving a remembered value
val rememberedValue = composer.rememberedValue()

val needUpdate = /* Checking if our keys key1 and key2 have changed,
or the value has not yet been initialized */

if (needUpdate)
// Our calculations. Inline lambda will turn into a code block
val value = calculation()

// Updating the remembered value
composer.updateRememberedValue(value)

return value // Returns the calculated and remembered value
else
return rememberedValue // Returns the remembered value

The code above only reflects the logic and has omissions.

In addition, you can see more about lambdas under the hood in the context of Compose in this video from 25 minutes.

Restartable functions

First, let’s understand what the restartable composable functions are. As mentioned above, $composer starts and ends a group — conventionally a node in the tree — at the beginning and end of the function. For restartable functions, the restartable group is called:

@Composable
fun MyComposable($composer: Composer)
$composer.startRestartGroup() // Group start

// Function body

$composer.endRestartGroup() // Group end
?.updateScope $composer ->
MyComposable($composer)

At the end of the code you can see the mechanism of restarting the function on change: if between the beginning and the end of the group there was read a state that can notify Compose about its change (State<T> or CompositionLocal), then $composer.endRestartGroup() will return not null and Compose will learn to restart our function. If there is a restartable group closer to the state reading location, then it is this group that will be restarted, and not the outer one.

Let’s look at this code:

@Composable
fun MyComposable1()
val counter: MutableState<Int> = remember mutableStateOf(0)
MyComposable2(counter)

@Composable
fun MyComposable2(counter: State<Int>)
Text(text = "My counter = $counter.value")

In it, when the counter changes, only MyComposable2 will be restarted, since the value is read in its scope. The same MutableState can be imagined as MutableStateFlow, which performs the necessary subscription and notification logic under the hood when reading and writing. This is a very important logic of how Compose works, since it is MyComposable2 that will restart without touching the other parent functions. This is what the recomposition mechanism is based on. Together with the skipping mechanism, it gives a wide range of optimization possibilities, especially for frequently changing parts of the UI.

To consolidate the chapter, here are more examples that will cause MyComposable2 to be a restart (recomposition) point and go through all its children, while MyComposable1 will not be affected. It may be added that animateColorAsState(), rememberScrollState(), etc. also contain State<T> inside, and can cause recomposition when changed.

val LocalContentAlpha = compositionLocalOf  1f 

@Composable
fun MyComposable1()
val counter1: MutableState<Int> = remember mutableStateOf(0)
var counter2: Int by remember mutableStateOf(0)
MyComposable2(counter1, counter2 )

@Composable
fun MyComposable2(counter1: State<Int>, counterProvider2: () -> Int)
Text("Counter = $counter1.value") // Reading the state
Text("Counter = $counterProvider2()") // Reading the state
Text("Counter = $LocalContentAlpha.current") // Reading the state

Note that if you are using State<T> as a delegate, then be careful about accidentally reading the state, especially if it changes frequently.

@Composable
fun MyComposable1()
var counter: Int by remember mutableStateOf(0)

// Reading the state will happen in MyComposable1, not in MyComposable2!!!
MyComposable2(counter)
}

The Compose developers advise passing not a State<T>, but a lambda, as there may be difficulties and unnecessary code if something needs to be hardcoded or during testing. But, in general, there are no cardinal differences. Why all this is necessary — we’ll tell you in the chapter about deferred state reading.

It should also be noted that composable lambdas, often used in Slot API, are also restartable and skippable.

Restartability and skippability

So that you don’t get confused by these two terms, I’ll summarize here:

  • A restartable function can be restarted, be a restart scope.
  • A skippable function can be skipped if its arguments haven’t changed.

This is how Compose refers to functions that are both restartable and skippable in its metrics:

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun MyWidget(
stable widget: WidgetUiModel,
stable modifier: Modifier? = @static Companion
)

If at least one argument is unstable, the function will remain only restartable.

If the function is only restartable, you should either make it skippable or get rid of restartability. The @NonRestartableComposable annotation removes restartability and (if present) skippability.

When restartability and skippability are not needed

All inline composable functions are non restartable (Box, Column and Row). This means that reading State<T> inside one of them will cause recomposition in the nearest external restartable function when the state changes.

@Composable
fun MyComposable()
val counter: MutableState<Int> = remember mutableStateOf(0)
Box
// The recomposition will affect the entire MyComposable()
// since the code will be inlined
Text(text = "My counter = $counter.value")

Functions that do not return Unit are also not skippable.

There are situations when skippability and restartability do not give real advantages but only lead to an excessive waste of resources:

  • composable function data rarely or never changes;
  • composable function simply calls other skippable composable functions:
    – function without complex logic and without State<T>, calling a minimum of other composable functions;
    – wrapper around another function — serves as a kind of parameter mapper or to hide unnecessary parameters.

In this case, you can mark the composable function with the @NonRestartableComposable annotation, which will remove restartability (and with it, skippability).

@Composable
@NonRestartableComposable
fun ColumnScope.SpacerHeight(height: Dp)
Spacer(modifier = Modifier.height(height))

If a function contains branching logic (if, when), then follow the above rules with respect to its branches. Whether to add annotation or not depends on how often the branches will change during use and how complex the code in each branch is.

As an example, with the @NonRestartableComposable annotation Comose developers marked Spacer (no logic and just a Layout call), some Image and Icon overloads (parameter mapping to its overload), Card (parameter mapping to Surface). The benefit of not restarting the function is minimal: no extra code is generated and no extra logic is executed, but if you are designing a UI kit, it is worth thinking about it, since your elements will be used in many places, often repeated, and in total it will give an effect .

Optimization of frequently changing elements

You should optimize reading of State<T> only where the state changes frequently and affects a lot of content. Otherwise the whole code will be overoptimized and become unusable for reading and development.

Derived state

derivedStateOf — derived (computed) state, which reflects the main use case.

val listState = rememberLazyListState() 
val showButton by remember
derivedStateOf listState.firstVisibleItemIndex > 0

Let’s say we have a list state where we read the index of the first visible element of the list. But we don’t need it by itself, we want to know whether to show us the button or not. To recompose only when the visibility of the button changes, instead of every time the first visible element of the list changes, we can read the state inside the derivedStateOf lambda. There, Derived state subscribes to the state changes that were read in the first pass and returns a final State<T>, which already causes recomposition only when the final state changes.

It is important that the Derived state responds to changes only to the State<T>, and not to ordinary variables, since the State<T> has the functionality to subscribe and notify changes to the Compose snapshot system that Derived state works with.

Let us emphasize that you should not use the value of a frequently changing state as the remember key, otherwise the whole meaning of Derived state will be lost and recomposition in this place will occur frequently, as well as recreation of Derived state:

// Don't do
val listState = rememberMyListState()
val showButton by remember(listState.value) // Reading the listState
derivedStateOf listState.value > 0

// Don't do
val listState by rememberMyListState()
val showButton by remember(listState) // Reading the listState
derivedStateOf listState > 0

Derived state should only be used when the derived state will change less frequently than the original states:

// Don't do
val derivedScrollOffset by remember
derivedStateOf scrollOffset - 10f

Derived state is useful for wrapping states of lazy list scrolling, swiping, and other frequently changing states. A few more examples:

  • Keeping track of whether scrolling crosses a threshold (scrollPosition > 0).
  • The number of items in the list is greater than the threshold (items > 0).
  • Form validation (username.isValid()).

For nested Derived states, it is periodically necessary to specify a mutation policy to avoid recalculating the expression when the first (nested) derivedStateOf is changed.

val showScrollToTop by remember 
// Mutation policy — structuralEqualityPolicy()
derivedStateOf(structuralEqualityPolicy()) scroll0ffset > 0f

var buttonHeight by remember
derivedStateOf
// By specifying a mutation policy in showScrollToTop,
// this calculation block will only be called when showScrollToTop changes
if (showScrollToTop) 100f else 0f

Read more about mutation policies in this article.

Deferred reading of states in composable functions

The previous paragraph described the principle that Derived state uses: it reads state internally and prevents it from recomposing the whole function. We can use the same principle, but to defer reading the state from the parent function to the child function. This should also be done only for frequently changing states. You can defer reading using a lambda or passing a State and reading it in the right place.

@Composable
fun MyComposable1()
val scrollState = rememberScrollState()
val counter = remember mutableStateOf(0)

MyList(scrollState)
MyComposable2(counter1, scrollState.value )

@Composable
fun MyComposable2(counter: State<Int>, scrollProvider: () -> Int)
// Reading the state in MyComposable2
Text(text = "My counter = $counter.value")
Text(text = "My scroll = $scrollProvider()")

In the code above, only the MyComposable2 function will be recomposed because of the fast counter or scrolling, not the entire MyComposable1.

Deferred reading of states in Compose phases

You can defer state reading not only between composable functions, but also between Compose phases (Composition → Layout → Drawing). For example, if we have frequent color changes, it’s better to use drawBehind instead of the background() modifier, which takes a lambda and will call code due the state changes only in the drawing phase, not the composition phase like background().

You can use a similar thing when scrolling: the offset modifier with lambda instead of simple offset(value). This way we defer reading the state to the Layout phase.

@Composable 
fun Example()
var state by remember mutableStateOf(0)

Text(
// Reading the state in Composition phase
"My state = $state",
Modifier
.layout measurable, constraints ->
// Reading the state in Layout phase
val size = IntSize(state, state)

.drawWithCache
// Reading the state in Drawing phase
val color = state

)

Reducing the recomposition area

You should break into small functions where you can keep parts of the code from recomposition. If you see that part of a function remains unchanged and part of it changes quite often, it is probably better to split this function into two. That way, one function will be skipped and the one that changes frequently will recompose a smaller area. But you should not get carried away and move Divider into a separate function.

Here is an example of moving the timer logic into a separate function and reducing the number of recompositions in Promo, since only Timer will be restarted (pay attention to the place where timer.value is called, which causes the restart on change):

@Composable
fun Promo(timer: State<Int>)
Text("Sample text")
Image()

// Old timer code right in the Promo function
// Text("$timer.value seconds left")

// New timer code
Timer(timer)

@Composable
fun Timer(timer: State<Int>)
// Timer code and reading the timer state (timer.value) inside

Using key and contentType in lists

In lazy lists, you need to pass a key to item() so that lists know how the data changes. Also pass contentType so that lists know which items can be reused.

LazyColumn 
items(
items = messages,
key = message -> message.id ,
contentType = it.type
) message ->
MessageRow(message)

If you make a list via forEach, you can use key() , then when the list changes, Compose will understand where the elements have moved to.

Column 
widgets.forEach widget ->
key(widget.id)
MyWidget(widget)


Modifiers

Custom modifiers

If you write your modifier then:

  • If it’s stateless, just use functions.
  • If it’s stateful, use Modifier.Node (ModifierNodeElement). Previously, it was recommended to use composed for this. In general, you can do it now, since there is no detailed guide on Modifier.Node yet, only samples (samples 1, samples 2).
  • If a composable function is called in the modifier, use Modifier.composed.

Learn more about modifiers in this video.

Reusing modifiers

If there is frequent recomposition in the area where modifiers are created, it is worth considering moving the creation of modifiers outside of this area. This, for example, is relevant for animations:

val reusableModifier = Modifier
.padding(12.dp)
.background(Color.Gray),

@Composable
fun LoadingWheelAnimation()
val animatedState = animateFloatAsState(...)

LoadingWheel(
modifier = reusableModifier,
// Reading a frequently changing state
animatedState = animatedState.value
)

It is also recommended to extract modifiers from lists so that all their elements reuse a single object.

val reusableItemModifier = Modifier
.padding(bottom = 12.dp)
.size(216.dp)
.clip(CircleShape)

@Composable
private fun AuthorList(authors: List)
LazyColumn
items(authors)
AsyncImage(modifier = reusableItemModifier)


You can extract modifiers not only from functions, but also simply to the parent composable function, in which recomposition occurs less often. Further modifiers can be safely appended.

reusableModifier.clickable  /*...*/ 
otherModifier.then(reusableModifier)

Long calculations only in ViewModel or in remember

Almost all calculations should be performed only in ViewModel. In this case, make sure that callbacks (onButtonClick, onIntent, onAction, onEvent, onMessage…) do not do heavy work in the main thread. If you have a single function executing in the main thread to process user actions, then you can attach a measurement of the duration of work to it and log critical values of execution time, so that the developer doesn’t forget to move complex and long calculations to background threads.

It is better to remove all logic from composable functions. In other cases, when it is inconvenient to put long or costly calculations into ViewModel, use remember.

Without long calculations in the UI State getters

data class MyUiState(
val list1: List<Int> = emptyList(),
val list2: List<Int> = emptyList(),
) list2.any it != 0

If you have something like this in UiState so as not to set the field every time, then the recalculation will happen at every recomposition, since it’s just executing the getIsTextVisible() method. So either remove the getter (by leaving the field in the class body or moving it to the primary constructor), or make sure that there is a minimum of recompositions at the place where the getter is called.

When to use remember

Use:

  • For any long or memory-consuming operations that can be executed more than once but should not be executed before changing the keys (if needed) passed to remember(), especially for frequent recomposition.
val brush = remember(key1 = avatarRes) 
ShaderBrush(
BitmapShader(
ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
Shader.TileMode.REPEAT,
Shader.TileMode.REPEAT
)
)
  • For wrapping lambdas with unstable external variables.
  • For classes without overridden equals.
  • For objects that need to be preserved between recompositions (e.g. State<T>).

If the remember keys are updated very often, it is worth considering whether remember is needed in this case. Also, you should not add to the keys anything that the calculation inside remember depends on: if you realize that these values will never change during the lifetime of the composable function and remember itself, then don’t add them to the keys.

Custom Layouts

Don’t be afraid to make custom layouts: they are much easier than in View, the main thing is to start. Useful videos and articles on this topic in Compose: link1, link2, link3, link4.

Unreasonable change in size and position

Avoid unreasonable resizing of Compose elements, especially in lists. Such a problem can occur if you don’t set a fixed size for the image and it changes its size after downloading it from the Internet. Unreasonable resizing or positioning can occur due to modifiers onGloballyPositioned(), onSizeChanged() and similar. A lot of unnecessary recomposition can occur because of this. If elements need to know about the position and size of other elements, more often than not it means you are either using the wrong layout or you need to make a custom one.

Layout pre-calculation

SubcomposeLayout

SubcomposeLayout defers composition until Measurement in the Layout phase so that we can use the available space to compose child elements. A second useful application is conditional composition. For example, depending on the size of the application window, we can arrange elements differently (for a tablet or for a phone, or in general for a window that can be resized). Or depending on the scrolling state, call composition of specific elements to implement a lazy list. SubcomposeLayout is quite expensive, so you shouldn’t use it to precompute the layout in any other case.

Subcomposition in the Jetpack Compose lifecycle. Source

The main implementations of SubcomposeLayout BoxWithConstraint, LazyRow and LazyColumn — cover most of the needs that Layout cannot.

Also, using SubcomposeLayout under the hood explains why LazyRow and LazyColumn lose out to Row and Column in performance with a small number of items. So if you have a small list, use Row and Column for it.

SubcomposeLayout is sometimes mistakenly used to implement the Slot API:

// Don't do!
@Composable
fun DontDoThis(
slot1: @Composable () -> Unit,
slot2: @Composable () -> Unit
)
SubcomposeLayout constraints ->
val slot1Measurables = subcompose("slot1", slot1)
val slot2Measurable = subcompose("slot2", slot2)

layout(width, height)
...


For Slot API there is a more correct option: via the layoutId() modifier and search among measurables by layoutId field or Layout with passing the list of composable and deconstructing the list of measurables in order.

@Composable
fun DoThis(
slot1: @Composable () -> Unit,
slot2: @Composable () -> Unit
)
Layout(
contents = listOf(slot1, slot2)
) (slot1Measurables, slot2Measurables), constraints ->
...
layout(width, height)
...


Intrinsic measurements

Intrinsic measurements are more efficient than SubcomposeLayout, and under the hood work very similarly to LookaheadLayout. Both approaches invoke the measurement lambda (which is passed in LayoutModifiers or MeasurePolicy) with different constraints in the same frame. But in the case of Intrinsics it is a pre-calculation in order to perform the actual measurement using the obtained values.

Imagine a Row with three children. In order for its height to match the height of the tallest child, the Row needs to get the intrinsic measurements of all its children and then measure itself using the maximum value. Read more at Android Developers.

Intrinsic measurements can have a negative effect on complex layouts in lazy lists, but not significantly.

LookaheadLayout

Used for precise pre-calculation of size and position of any (direct or indirect) child in order to power automatic animations (e.g. transition from one element to another). LookaheadLayout also does a more aggressive caching than intrinsics to avoid looking ahead unless the tree has changed. You can read more in the article by Jorge Castillo.

Avoid backwards writes

This mistake is more typical for beginners. We call a state change immediately after reading, which causes a recomposition.

@Composable
fun BadComposable()
var count by remember mutableStateOf(0)

// Causes recomposition on click
Button(onClick = count++ )
Text("Recompose")

Text("$count")
count++
// Backwards write, writing to state after it has been read

MovableContentOf

movableContentOf is useful when we want to move our elements to a different location without re-invoking the recomposition or losing the memoized state:

val myItems = remember   
movableContentOf
MyItem(1)
MyItem(2)

if (isHorizontal)
Row
myItems()

else
Column
myItems()

Read more in the article by Jorge Castillo.

An interesting fact: the key() construct is similar in logic to movableContentOf(), since both approaches use a movable group, which allows you to move Compose code without recomposition.

staticCompositionLocalOf and compositionLocalOf

staticCompositionLocalOf is usually needed when Composition Local is used by a huge number of composable functions and the value is unlikely to change. Example implementations: LocalContext, LocalLifecycleOwner, LocalDensity, LocalFocusManager, etc.

compositionLocalOf incurs an overhead in the initial building of the composition tree, since all composable functions that read the current value must be tracked. If the value will change frequently, compositionLocalOf may be a better choice. Example implementations: LocalConfiguration, LocalAlpha, etc.

@ReadOnlyComposable

If a composable function performs only read operations, you can mark it with the @ReadOnlyComposable annotation. As a result, it will not have a group generated. This will give a small performance boost. The main use case is a function that only needs the @Composable annotation to read CompositionLocal (e.g., reading a color from a theme) and not to call other composable functions. Read more on Android Developers.

Use less ComposeView

It’s simple: the less ComposeView you use to bridge with View, the faster Compose will work. Also, the earlier Compose code appears at application startup, the better the further code will work, because Compose will have time to “warm up”.

Use the latest version of Compose

Compose developers improve performance in almost every version, so don’t forget to update.

Baseline Profiles

Baseline Profiles are well documented on Android Developers. In general, Google already does this kind of work for us with Cloud Profiles. Baseline Profiles will help if we want to improve our metrics on older Android versions (7–8.1) and at the start of a new release on newer Androids (9+).

Optimization is good, but it’s always better to test your changes to make sure you’ve actually solved the problem, not added a new one.

You should check performance almost always in release mode and with R8. Debug mode has a lot of benefits in development, which slows down and distorts the final application code. The R8 compiler also optimizes the code significantly.

Also, Compose debugging is well written in this article and described in a video from Android Developers.

Stability and skippability testing

To check the types and composable functions in a project for stability, you need to run metrics generation. How to do it: Composable metrics and Interpreting Compose Compiler Metrics.

The metrics will be located in the path: your_module/build/compose_metrics. Among them, two files are important:

  • -classes.txt for type metrics;
unstable class WidgetUiState 
unstable val widgets: List<Widget>
stable val showLoader: Boolean
<runtime stability> = Unstable
  • -composables.txt and .csv for function metrics;
restartable scheme("[androidx.compose.ui.UiComposable]") fun MyWidgets(
unstable widgets: List<Widget>
stable modifier: Modifier? = @static Companion
)
  • -module.json for module statistics.

There is also a library for displaying metrics in HTML: Compose Compiler Report to HTML.

Debugging recomposition

To avoid repeating, I advise you watch the video about debugging recomposition. The point is that you turn on recomposition and skipping counting. After that, you do normal actions on your screen. Then look where there are unnecessary recompositions that could have been avoided, or look where recompositions occur, although the data has not changed and a skipping could occur.

Debugging recomposition. Source

You can also use Rebugger to debug recompositions. This library allows you to track changes in the given arguments and output the reasons for recomposition.

Rebugger. Source

Android Studio Hedgehog will add additional information to debuggers to view Compose state.

Compose state information in debugger. Source

Composition tracing

Use composition tracing to deeply analyze the problems of your UI elements. This is described in the article “Composition tracing” and also a bit in this video.

Composition tracing. Source

Benchmarking

Test your screens after optimizations with benchmarks (e.g., a list scrolling benchmark measuring the duration of frame rendering).

In this article we described the whole list of optimizations our team encountered and used in practice. Some tips can be converted into your team’s code style and used freely. Others are only useful if your element changes frequently. Check performance after each optimization to avoid degradation. And also it is not necessary to over-optimize your code beforehand, otherwise it will turn into an unreadable thing.

Next Post

TikTok integrates Salesforce CRM

By Luís Daniel in Social — Nov 7, 2023 TikTok last month announced a new integration with Salesforce CRM, a customer relationship management (CRM) platform. The integration allows businesses to automatically sync their TikTok leads and customer data with Salesforce, so they can better manage their sales and marketing efforts. […]
TikTok integrates Salesforce CRM

You May Like