Leverage the essentials: Jetpack Compose best practices

Leverage the essentials: Jetpack Compose best practices

Recently, I started a new project where I decided to use Jetpack Compose for the UI layer. Alongside this, I aimed to explore best practices for using Compose effectively, with a particular focus on performance optimizations techniques and options. While Compose is occasionally overlooked due to perceived performance issues, I wanted to deep dive into the exact issues and what we could do to improve.

Here are some of my findings ...

  • Change the way of using remember block

As a newcomer to compose, we generally use remember block to store the state of the app/screen, to ensure a re-composition upon its change. But we can pick up the advantage of this re-composition and extend the use of remember block to get rid of expensive calculations.

As we know, composable's run very frequently, maybe on every frame. Due to this, we should avoid doing complex calculations inside these functions, instead we can use remember block to perform the expensive operation and store the value there itself.

//Before
...
LazyColumn{
  items(list.filter{it.isAvailable}){
      ...
  }
}

//After
 val filteredList = remember(list) {
         list.filter{it.isAvailable}
 }
LazyColumn{
  items(filteredList){
      ...
  }
}

In the above example, the list is filtered inside the remember block only for the first time when the composable composed for the first time. Now, only if the list changes, the filtering will happen again else it will not be performed.

  • Start using Lazy layout keys

The key seems to be an optional parameter, but has a great significance. Let's assume a situation, where we show the list of products sorted by their price. Now if the price of the last element changes, compose will recompose the last element and bring it to the top, resulting in recomposition of all the other element as compose thinks now the item3 is item2 and so on.

With the help of stable keys assigned to every element, compose knows exactly which value changed, and this drastically reduces unnecessary recompositions.

//Using keys with Lazy layouts

LazyColumn{
items(items = list,
      key = { note ->list.id}
      ) { ele ->
            MyView(ele)
        }
}
  • Using derivedStateOf to limit unnecessary recomposition

As we know recomposition happens when a state gets changed, now considering a situation where the state is rapidly changing, then frequent re-composition happens at every frame change, making the app more and more laggy.

Let's consider a situation where we have to store the listState, let's say to observe the first visible item index, as the user is scrolling the list continuously, we see recomposition at every scroll gesture of the user. Here we can use derivedStateOf to avoid the too much recomposition. With derivedStateOf we can specify composable that exactly when to recompose.

// Before using derivedStateOf

...
val listState = rememberLazyListState()

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

val isScrollingStarted = listState.firstVisibleItemIndex > 0
...


// After using derivedStateOf
...
val listState = rememberLazyListState()

LazyColumn(state = listState) {
    ... 
}

val isScrollingStarted by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}
...
  • Avoiding backward write

Compose have been developed with an assumption of not writing a state after it has been read, if done so, it will get into an endless recomposition, making the overall performance worse.

...
Text("$progress")
progress++ //writing after reading (backward writing)
...

We should avoid updating the same state which just have been read. As it will always set the state on the Text() and then again change itself resulting in recomposition and this becomes an infinite loop.

  • Optimize conditional modifiers

Generally when we have some UI related conditions based on some state, for example, if user crosses the limit of 10, make the background of the TextField red, we do something like this:

modifier = Modifier.background(
              if(isValidInput) Color.Black else Color.RED
           )

But wait, isValidInput this may frequently make the whole composable tree recompose, resulting in the bad performance. Instead we should use Modifier.then()

modifier = Modifier.then(
            if (isValidInput) {
                Modifier.background(Color.Black)
            } else {
                Modifier.background(Color.RED)
            }
        )

The above code only changes the background Modifier, as it's a separate modifier and not the part of the root modifier.

  • Be careful while reusing composable components

One of the best feature that compose provides is to make reusable Views, but we generally make some mistakes while making the component.

We should avoid using fillMaxWidth() or fillMaxHeight() within the Composable, as it may lead to unexpected layout results when used in various places, hence making it non-usable.

Instead, we should pass the modifier itself as the parameter and let the source decide how the composable should look (defined by modifier).

  • Prefer making stateless composable

State hoisting should always be done in the closest possible place where it's been used, as it will avoid the state and its update to pass through the whole composable tree unnecessarily.

// Bad code

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

    Column {
        Text("Count: $count")
        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

The composable here is stateful and may lead to unnecessary recomposition

// Improved code

@Composable
fun Counter(count: Int, onIncrement: () -> Unit) {
    Column {
        Text("Count: $count")
        Button(onClick = onIncrement) {
            Text("Increment")
        }
    }
}

@Composable
fun CounterScreen() {
    var count by remember { mutableStateOf(0) }
    Counter(count = count, onIncrement = { count++ })
}

This composable Counter() does not hold any state and has now become stateless composable.

  • Make use of the power of ViewModels to store the state.

As we already know ViewModels are very efficient in handling states and save them in case of any configuration changes, we can use it to store the state on ViewModels and use derivedStateOf to read the incoming state value from the viewmodel. This will indirectly help to minimize recomposition as we are only reading the state inside a composable, hence following the "Avoid Backward writing" point.

//Bad Code

...
var user by remember { mutableStateOf(User())}
TextField(value = user.name, onValueChange = { user.name = it }) 
...
//Good code

...
val userName by remember { derivedStateOf { viewModel.userName.value } }
TextField(value = userName, onValueChange = { viewModel.setUserName(it) }) 
...

We can also consider using LiveData / Flow to live observe the data to handle the states inside the composable, which is altogether our target.

  • Use @Stable annotation

To be honest, it has been seen that there's aren't a drastic performance improvement with this point but we can consider using it in complex UI system .

@Stable is an annotation which tells a composable function that the variable or that data class is immutable and will not change itself.

It's better to use this in data classes which will be used in the composable in the future.

Let's understand this with an example:

// without using @Stable 

data class User(
  val name: String,
  val email: String, 
  val profilePic: String
)

@Composable
fun ProfileView(user: User){
   Text(text=user.name, ...)
   Text(text=user.email, ...)
   Image(
     painter = rememberImagePainter(user.profilePic), 
     contentDescription = null
   )
}

In this above practice, whenever ProfileView() recomposes for any reason, it will go to the whole data class structure to match if they changed or not, hence making this process inefficient.

If we have used @Stable annotation with the data class after, we are sure it will never change in the future, it will save that equality matching process and improving little bit of the performance. Again, if we have many such cases, we can see a good amount of improvement.

//Improved code

@Stable
data class User(
  val name: String,
  val email: String, 
  val profilePic: String
)

@Composable
fun ProfileView(user: User){
   Text(text=user.name, ...)
   Text(text=user.email, ...)
   Image(
     painter = rememberImagePainter(user.profilePic), 
     contentDescription = null
   )
}

In conclusion, minimizing unnecessary recompositions is crucial for improving performance in Jetpack Compose. Many performance issues arise from frequent recompositions, which can occur at every frame, causing noticeable lags. By focusing on efficient state management and reducing redundant recompositions, we can significantly enhance the performance of Compose UI's.

That was it, would appreciate any feedback and additional insights in the comments.

Thanks for reading ๐Ÿš€

Did you find this article valuable?

Support LearnDroid by becoming a sponsor. Any amount is appreciated!

ย