Jetpack Compose & best practices you must always remember

Kaaveh Mohamedi
ProAndroidDev
Published in
5 min readDec 10, 2022

--

⚠️ This is my summary according to the official doc and all developer relations talks I watched.

Image from Ben Trengrove article

If you must do some calculations, do it in rememberSavable

Let’s see an example:

@Composable
fun ContactList(
contacts: List<Contact>,
comparator: Comparator<Contact>,
modifier: Modifier = Modifier
) {
LazyColumn(modifier) {
// DON’T DO THIS
items(contacts.sortedWith(comparator)) { contact ->
// ...
}
}
}

In this scenario, I want to sort contacts and show them. For any reason if ContactList need to be recomposed, contacts.sortWith() will execute again even contacts not changed. This unnecessary execution can be prevented like this:

@Composable
fun ContactList(
contacts: List<Contact>,
comparator: Comparator<Contact>,
modifier: Modifier = Modifier
) {
val sortedContacts = remember(contacts, sortComparator) {
contacts.sortedWith(sortComparator)
}

LazyColumn(modifier) {
items(sortedContacts) {
// ...
}
}
}

Using remember API prevents execution sorting contacts in every recomposition. Also, we can tell remember to re-execute lambda body when either contacts or sort comparator changes. But, wait! Still, there is a problem: Configuration change!

For example, if the user rotates their device, turns the dark mode on/off, fold/unfold their device, and … the remember value will be lost! To prevent these situations, remeberSavable API comes in handy!

Use key as much as you can in Lazy Layouts

When we want to show a list of items, we simply use LazyColumn:

@Composable
fun NotesList(notes: List<Note>) {
LazyColumn {
items(
items = notes
) { note ->
NoteRow(note)
}
}
}

This is a basic approach that when you google, you find. But, in some situations cause some unnecessary recompositions:

  1. When items order changed
  2. When an item is added to the list
  3. When an item is deleted from the list

For the above three scenarios, recomposition accrued for all items even if they are the same items before changes.

Solution: Use Key DSL in items API. It tells compose compiler to recompose more smartly:

@Composable
fun NotesList(notes: List<Note>) {
LazyColumn {
items(
items = notes,
key = { note ->
// Return a stable, unique key for the note
note.id
}
) { note ->
NoteRow(note)
}
}
}

Actually, it says: Hey Compose, recompose any item that changed its id.

But, still there is a problem: What if the content of an item changed? For example, we edit the text of a note. Since the item id has not been updated, the item has not been recomposed.

For solving this issue, I think we can use timestamp as id. When any item updates, we update its id too. This way recomposition can trigger.

When the state changes frequently, be cautioned!

In some situations, the state may change frequently and cause a bunch of recompositions. For example, in showing a list, we want to know which item is the first visible item. If the first item is not visible, visible go to the top button:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
// ...
}

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}

When the user makes any drag, listState changes rapidly. This has caused the entire list to be recomposed.

Solution: drivedStateOf API

drivedStateOf prevent unwanted recompositions. The code is as followed:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
// ...
}

val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}

AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}

Here, the value of the showButton changed only if the expression in drivedStateOf was updated not based on every listState update.

Procrastinating reading of states

Move the reading of a state down of the composition tree as far as possible. See this example:

@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)
) {
// ...
}
}

This is a sample from the JetSnack google project. Pay attention to the scroll.value on the SnackDetail. The parent Box is recomposed on every user scroll because it is the nearest composable that can see (nearest composition scope). But, as you see the scroll.value is needed just for constructing the Title!

Solution: pass the scroll.value as lambda to the down:

@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)
) {
// ...
}
}

For now, Every time the scroll.value is updated, just Title be recomposed.

PS: The above snippet code can be optimized. The Modifier.offset(x: Dp, y: Dp) has a lambda version that guarantees reading the state moves to the layout phase. This is how it is:

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
// ...
Column(
modifier = Modifier
.offset { IntOffset(x = 0, y = scrollProvider()) }
) {
// ...
}
}

Whenever the scroll state changes, the composition phase bypasses and jumps to the layout phase directly.

For this topic, there is another example: Imagine you want to change the background color of a Box with animation between two colors. This a naive solution:

// Here, assume animateColorBetween() is a function that swaps between
// two colors
val color by animateColorBetween(Color.Cyan, Color.Magenta)

Box(Modifier.fillMaxSize().background(color))

In every frame, the color changes so the Box recomposes. But if using drawBehind API, the reading state of color moves to the draw phase, and the composition phase and the layout phase bypass completely:

val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
Modifier
.fillMaxSize()
.drawBehind {
drawRect(color)
}
)

Avoid backward writes

Compose assumes that whenever you read a state, you don’t update its value in composable immediately. If you do this, you do a backward write. Let’s see this code:

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

// Causes recomposition on click
Button( = { count++ }, modifier = Modifier.wrapContentSize()) {
Text(&quotRecompose&quot)
}

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

Whenever you increase the count value, immediately triggers recomposition and so on. You slip into an infinite loop. Always updating a state, must occur by an event!

--

--