Jetpack Compose Best Practice
🔈 This text is a summary and combination of the links mentioned in the references.
💡Use remember
for changing values:
When developing applications with Compose, there are some essential points to consider. Paying attention to these points can help you build more efficient and performant applications. Here, I will share key insights by summarizing documentation and various articles.
Composable functions offer a declarative approach, meaning any state changes automatically trigger a recomposition. However, if we fail to manage these updates efficiently, the component will be redrawn every time, which can negatively impact the application’s performance.
To better illustrate this, let’s look at the following example:
@Composable
fun ExpensiveCalculationScreen() {
val result = calculateSomethingExpensive()
Text(text = "Result: $result")
}
In the example above, result
is recalculated each time, leading to unnecessary recompositions. However, we can solve this issue by ensuring that the recomposition only happens when the state is updated. This can be achieved using remember
:
@Composable
fun ExpensiveCalculationScreen() {
val result = remember { calculateSomethingExpensive() }
Text(text = "Result: $result")
}
With this change, the calculateSomethingExpensive()
function is only executed once, and its result is stored using remember
. If recalculation is required only under certain conditions, you can specify dependencies for remember
:
@Composable
fun ExpensiveCalculationScreen(someInput: Int) {
val result = remember(someInput) { calculateSomethingExpensive(someInput) }
Text(text = "Result: $result")
}
In this way, recalculation happens only when the someInput
value changes, significantly improving performance.
The best approach is to perform expensive calculations outside of the composable function whenever possible. For example, perform the calculations in a ViewModel and pass the result to the composable function as a parameter.
💡 Use key
for LazyLayout
:
When displaying a list, you often use LazyColumn
or LazyRow
. However, recomposition is triggered whenever data changes, which may result in unnecessary performance costs in some cases, such as:
- When an item is removed from the list,
- When a new item is added to the list,
- When the order of items in the list changes.
When adding or removing an item from the list, we expect the list to update and reflect the change. However, this doesn't mean the entire list needs to be redrawn. Only the modified item should be redrawn.
To achieve this, you should assign a key
value to LazyLayout
. This key value should be unique for each item (e.g., an ID). This way, only the relevant items are redrawn, improving performance.
@Composable
fun NotesList(notes: List<Note>) {
LazyColumn {
items(
items = notes,
key = { note ->
// Return a unique and constant key for the note
note.id
}
) { note ->
NoteRow(note)
}
}
}
However, there's a potential issue: if a change is made within an item (e.g., editing text), the id
value remains unchanged, so the item won't update. To solve this, you can adopt the following approach: when the item's content changes, such as editing text, you can also update the item's ID in some way. For instance, you can modify a dynamic value like a timestamp during each update. This ensures that the item receives a new key
value and only the changed item is redrawn. This approach ensures that updated items are rendered correctly.
💡 Avoid backward writes:
A "backward write" occurs when a state is read and then updated within the same composition. This can cause the component to recompose repeatedly, leading to an infinite loop.
Example:
@Composable
fun BadComposable() {
var count by remember { mutableStateOf(0) }
// Triggers recomposition on click
Button(onClick = { count++ }, Modifier.wrapContentSize()) {
Text("Recompose")
}
Text("$count")
count++ // Backward write: Writing to state after reading
}
To avoid this issue, you should never update states during composition. Instead, update states only in response to events (e.g., a button click) and within a lambda function, as shown below:
Button(onClick = { count++ }, Modifier.wrapContentSize()) {
Text("Recompose")
}
💡 Use derivedStateOf
to limit recompositions:
derivedStateOf()
in Jetpack Compose is a mechanism that allows a state to be derived from other states. Simply put, it defines a new state based on one or more existing states. This ensures that recomposition only occurs when there is an actual change in the derived state.
derivedStateOf()
is used with a remember
block and observes changes only in its dependencies.
@Composable
fun DerivedStateExample() {
var number by remember { mutableStateOf(0) }
// Derived state
val isEven by remember {
derivedStateOf { number % 2 == 0 }
}
Column {
Button(onClick = { number++ }) {
Text("Increment")
}
Text("Is even: $isEven")
}
}
In the above code, recomposition happens every time the number
variable is incremented. This is because the isEven
state is recalculated each time the number
changes.
However, if you were incrementing number
by 2 instead, no recomposition would occur for the isEven
value as it wouldn't change. I mention this to clarify any confusion when running the code and noticing "Why is there no recomposition?"
💡 Delay state reads as much as possible:
By deferring state reads until they are truly needed, you can prevent Compose from unnecessarily recreating all composables. Using lambda-based modifiers ensures that state reads occur only when required, improving performance.
Before optimization:
@Composable
fun SnackDetail() {
// ...
Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
val scroll = rememberScrollState(0)
// ...
Title(snack, scroll.value)
// ...
} // Recomposition Scope End
}
@Composable
private fun Title(snack: Snack, scroll: Int) {
// ...
val offset = with(LocalDensity.current) { scroll.toDp() }
Column(
modifier = Modifier
.offset(y = offset)
) {
// ...
}
}
After optimization:
@Composable
fun SnackDetail() {
// ...
Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
val scroll = rememberScrollState(0)
// ...
Title(snack) { scroll.value }
// ...
} // Recomposition Scope End
}
@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
// ...
val offset = with(LocalDensity.current) { scrollProvider().toDp() }
Column(
modifier = Modifier
.offset(y = offset)
) {
// ...
}
}
Initially, the scroll
state was being read directly in the SnackDetail
composable, causing the entire composable to recompose. By optimizing it to pass the scroll
state to the Title
composable via a lambda, the state is read only when Title
is recomposed. This prevents the entire SnackDetail
composable from being recomposed.