- Jetpack Compose performance
- Stability in Compose
- Jetpack Compose Stability Explained
- Layout Inspector
- Performance best practices for Jetpack Compose
- More performance tips for Jetpack Compose
- Jetpack Compose: Debugging recomposition
In order to understand different optimisation techniques in Composer, it's important to understand how the framework works under the hood.
Compose tracks places when we read the state, not where we declare it. When the state changes, Compose restarts the closest composable function from the point where state was read. This restarting process is called recomposition.
@Composable
fun Info(title: String, body: String) {
// Declaration of the state is not tracked by Compose
var isExpanded by remember { mutableStateOf(false) }
// When the isExpanded changes the Card function is restarted as this is
// the closest composable function from the point where isExpanded was read
Card {
Text(title)
// Read of the state is tracked by Compose
if (isExpanded) {
Text(body)
}
Button(onClick = { isExpanded = !isExpanded }) {
Text("Toggle")
}
}
}
Inline functions are removed from the code during compilation and their content is pasted in the place where they are called. It means that they are not treated as the closest composable function which can be restarted.
@Composable
fun Info(title: String, body: String) {
var isExpanded by remember { mutableStateOf(false) }
// Column is inline so now the Info function is the closest composable function
Column {
Text(title)
if (isExpanded) {
Text(body)
}
Button(onClick = { isExpanded = !isExpanded }) {
Text("Toggle")
}
}
}
Normally, restarting a function should result in restarting all of its children. However, it would be inefficient to always restart all of them. That's why Compose skips the composable function when its parameters don't change.
@Composable
fun TopAppBar(title: String) {
// The TopAppBar function is skipped during recomposition
// when the title doesn't change
TopAppBar(title = title) {
Text(title)
}
}
It is a tool which allows you to see how many times each composable function was recomposed in running application.
Unfortunately not every composable function can be skipped during recomposition. Compose requires that all parameters of a composable function be stable.
It means basic types like String
, Int
, Double
, Float
, Boolean
.
@Composable
fun Example(
string: String,
int: Int,
double: Double,
float: Float,
boolean: Boolean,
) {
// ...
}
Objects where all the properties are basic types and are defined as val
.
data class ExampleData(
val string: String,
val int: Int,
val double: Double,
val float: Float,
val boolean: Boolean,
)
@Composable
fun Example(data: ExampleData) {
// ...
}
Collections are interfaces so the compiler cannot guarantee that they have read-only implementation.
data class ExampleData(
val string: String,
val int: Int,
val double: Double,
val float: Float,
val boolean: Boolean,
// Whole ExampleData object is unstable because of the list
val list: List<String>,
)
@Composable
fun Example(data: ExampleData) {
// ...
}
If our class contains a collection, we can annotate it with @Immutable
to tell the Compose that we don't expect it to change.
@Immutable
data class ExampleData(
val string: String,
val int: Int,
val double: Double,
val float: Float,
val boolean: Boolean,
val list: List<String>,
)
@Composable
fun Example(data: ExampleData) {
// ...
}
The @Immutable
annotation can be used only with classes.
When we pass a list as a parameter of a composable function, we cannot use it.
@Composable
fun Example(
// This code doesn't compile
@Immutable list: List<String>
) {
// ...
}
As and alternative to the @Immutable
annotation, we can use a dedicated library which adds immutable collections.
@Composable
fun Example(
// Now compiler knows that the list will not change
list: ImmutableList<String>
) {
// ...
}
Even if our class contains only basic types, it's still unstable when it comes from a module which does not have a Compose compiler applied. These are typical some domain or data modules.
// :data:articles
data class Article(
val id: String,
val title: String,
val content: String,
)
// :features:article-list
@Composable
fun ArticleItem(article: Article) {
// ...
}
It's better to pass only the necessary data, as separate parameters with basic types.
// :data:articles
data class Article(
val id: String,
val title: String,
val content: String,
)
// :features:article-list
@Composable
fun ArticleItem(
// Only the necessary data is passed
title: String,
content: String,
) {
// ...
}
When we have a complex business object, it's better to introduce a separate UI model which will contain only the necessary data and guarantee stability.
// :data:articles
data class Article(
val id: String,
val title: String,
val content: String,
val image: URL,
val author: String,
val date: LocalDate
)
// :features:article-list
data class ArticleUiState(
val title: String,
val content: String,
val imageUrl: String,
)
@Composable
fun ArticleItem(article: ArticleUiState) {
// ...
}
Plain class state holders usually contain some var
properties, so we can't annotate them as @Immutable
.
However, we can use @Stable
annotation to tell Compose that all the var
properties are implemented as MutableState
so the Compose will be notified about changes.
@Stable
class EmailFieldState {
var email: String by mutableMapOf("")
}
@Composable
fun EmailField(state: EmailFieldState) {
// ...
}
You don't have to always apply all the optimisation techniques. If performance is not an issue, it's better to keep the code simple and readable.