Understanding performance of states and using inlining & lambdas in Jetpack Compose

Furkan Yurdakul
Appcent
10 min readDec 15, 2023

--

In this article I’ll be talking about how to optimize apps in Jetpack Compose and share my experience with how using the “correct” approach can be wrong sometimes. There will be GIFs, explanations on how things work under the hood in surface level, and more. You can find some of the examples in this GitHub link: https://github.com/appcentfurkanyurdakul/states-example

Jetpack Compose is one of Google’s platforms on developing cross-platform applications. In its current state, it is possible to develop apps for Android, iOS, Desktop and Web. While this article will cover my experiences with Compose in Android, the issues & suggestions written can affect all platforms.

It is easier to create components and screens once you get used to the flow in Compose, however there are some key differences on how to improve & stabilize your performance while using the library.

In my experience with Compose with Android (especially on multi-module projects), I’ve stumbled upon usages such as having a feature, with a Screen and ViewModel attached to it, creating its screen state within a data class and passing it to its root composable, generating the screen from this state alone. An example structure can be something like this:

data class HomeScreenState(
val firstName: String = "",
val lastName: String = "",
val profileUrl: String = "",
val isSubscribed: Boolean = false,
val posts: List<Post> = emptyList(),
val isLoading: Boolean = true
)

class HomeViewModel: ViewModel() {
var state: HomeScreenState by mutableStateOf(HomeScreenState())
private set

init {
// fetch the state from API and update UI
state = apiState
}
}

@Composable
fun HomeScreen() {
val viewModel = viewModel() // compose view model, can be Hilt as well
HomeContent(viewModel.state)
}

@Composable
private fun HomeContent(state: HomeScreenState) {
if (state.isLoading) {
// show loading
} else {
// draw the full screen content here
}
}

Let’s breakdown what happens in this case:

  • In first creation of the composable, a loading state will be drawn from HomeContent
  • Afterwards, when the view model fetches the API result, it will update the UI by writing the new state to the state variable.
  • The HomeContent will draw the new state, where isLoading is false, which will be the full screen content the user will see.

Sounds fine, right? Well, not exactly. Let’s find out why.

Importance of scopes

In Jetpack Compose, every function is a scope. This is the key to optimize the app: Make functions and divide every small portion that can be updated to its own function, or divide everything in general. The more scopes you have, the less you need to worry about updating a section of a screen. These are called composition scopes.

Compose is closer to a functional language rather than the natural object-oriented approaches when it comes to its UI side.

An example with a LazyColumn

For example, a screen that has a header, a scrollable container with its children, and its footer, should be divided like this:

data class Item(
val title: String,
val description: String
)

@Composable
fun HomeScreen() {
// Imagine these are properly aligned & placed
val screenItems = remember {
mutableStateListOf(
Item("title1", "description1"),
Item("title2", "description2"),
)
}
HomeHeader()
HomeScrollableContent(screenItems)
HomeFooter()
}

@Composable
private fun HomeHeader() {
// draw your header here
}

@Composable
private fun HomeScrollableContent(screenItems: List<Item>) {
// A basic component that has vertically-scrollable
// elements. The list is assumed to be a "mutableStateListOf()"
// variant which is important for a lazy column.
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(screenItems) { item ->
HomeItem(item)
}
}
}

@Composable
private fun HomeFooter() {
// draw your footer here
}

@Composable
private fun HomeItem(item: Item) {
// draw your item here
Text(text = item.title)
Text(text = item.description)
}

When different functions are used for different portions of the screen, it becomes possible to update only that part of the screen when that update is required due to the nature of Compose having different scopes for each function. Otherwise, when everything is thrown into one function, since one scope is used, the whole function gets called again, causing a bigger overhead than it should.

Using a LazyColumn with mutableStateListOf combination allows us to update only the necessary elements when they are changed. In the example above, if the 2nd item gets updated, the LazyColumn will only update its 2nd item, skipping the 1st one. If this was a normal MutableList, since LazyColumn wouldn’t know the list’s item was changed, it would not update any of its elements, or if the update was triggered from outside (a.k.a the whole list was changed and the new list is sent), the LazyColumn would update its all elements regardless if they were changed or not.

If the HomeItem function was inside the LazyColumn and not separated, the texts could’ve been updated more rapidly if LazyColumn were to perform animations, or needed to draw the item once again on its own for any reason. Moving the function outside of its scope helps whenever the function needs to be triggered, and only when it’s necessary.

An example with mutableStateOf()

Mutable states are the general way of updating the UI when it comes to compose. Without them, there is no efficient way to do so, as the mutable states work in such a way its composition scope (a.k.a the function) will redraw when a state variable is read again with a different value.

There are key differences on defining and using these mutable states, and for compose, it is important where the state is read. and if the read value is changed. In the HomeScreenState example, the state is read from the first composable, and processed in the sub composable (a.k.a HomeContent ), but the main screen was drawn unnecessarily again even though its structure was not changed besides the HomeContent which should’ve been just update itself.

The most common way to define a variable is this:

var state: Int by remember { mutableIntStateOf(0) }

This creates a delegate. A delegate in Kotlin is unique: when the variable is accessed, the delegate’s getValue function will be called. At this stage, compose subscribes to the value so when its counterpart setValue is called (a.k.a when it is reassigned), if the value is changed, the subscribed composition scopes will react and redraw themselves, calling getValue again and re-subscribing.

Let’s see how this is affected in this example:

// Define a class that has no mutable state, so the
// composable does not accidentally react to the
// change. This is only defined to track down
// how many times the code in the screen, a.k.a
// the "Text" is called.
private class Values(
var calledTimes: Int = 0
)

@Composable
fun DelegatedStateScreen() {

// The value that the sub-screen will react to
var stateValue: Int by remember { mutableIntStateOf(0) }

// The internal value that does not trigger anything upon update
// by itself
val values: Values = remember { Values() }

// Start a timer to update
LaunchedEffect(key1 = Unit) {
while (isActive) {
delay(1.seconds)
stateValue++
}
}

// Main content
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically)
) {
val mainScreenCalledTimes = ++(values.calledTimes)
Text(text = "Main screen text changed: $mainScreenCalledTimes")
SubComposable(stateValue) // value is read here
}
}

@Composable
private fun SubComposable(changedStateValue: Int) {
Text(text = "Sub composable text changed: $changedStateValue")
}

When this code is executed, something like this occurs:

Reading a value using a delegate
Reading a value using a delegate

It makes sense that “Sub composable text changed” is updating, but “Main screen text changed” counter should not update because only the sub composable’s mutable state is changing. It is updating because the value is read in the main composable. If, however, the value was read in the sub composable, this would not happen. Let’s tweak the last function and convert it to this by using a lambda:

// Main content
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically)
) {
val mainScreenCalledTimes = ++(values.calledTimes)
Text(text = "Main screen text changed: $mainScreenCalledTimes")
SubComposable { stateValue } // value is read inside the lambda
}

@Composable
private fun SubComposable(changedStateValue: () -> Int) {
Text(text = "Sub composable text changed: ${changedStateValue()}")
}

And observe the change:

Reading a value using a delegate via a lambda

And… voila! The main screen text is not updating this time. The difference between using a lambda and using the variable directly changes where the value is read, hence compose skips the main screen this time and updates the sub composable directly.

Is it always bad to not use a lambda?

Well, this depends on the use case. If the app has multiple animations or is doing manual calculations inside a composable function, skipping the optimization step will unnecessarily call the code / functions that return the result unnecessarily, causing an extra unwanted overhead, or the animations can update places where they shouldn’t. In the end, the visuals may look fine because the state is the same but, if the state is the same, why call the code again? It is not needed, right?

However, it’s not all that bad. If animations are in place or if manually called code (e.g. some layout computations or text formatting) is present in the function, they can be avoided of course. But, if a composable function calls another composable function, even if it’s parent is updated, another child may not be called.

What does that mean? Let’s see another example:

// Define a class that has no mutable state, so the
// composable does not accidentally react to the
// change. This is only defined to track down
// how many times the code in the screen, a.k.a
// the "Text" is called.
private class Values2(
var calledTimes: Int = 0
)

@Composable
fun DelegatedStateScreenWithSubComposable() {

// The value that the sub-screen will react to
var stateValue: Int by remember { mutableIntStateOf(0) }

// The internal value that does not trigger anything upon update
// by itself
val values: Values2 = remember { Values2() }

LaunchedEffect(key1 = Unit) {
while (isActive) {
delay(1.seconds)
stateValue++
}
}

Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically)
) {
val mainScreenCalledTimes = ++(values.calledTimes)
Text(text = "Main screen text changed: $mainScreenCalledTimes")
AnotherComposable()
SubComposable(stateValue)
}
}

private var anotherComposableValue = 0

@Composable
private fun AnotherComposable() {
Text(text = "Another composable text changed: ${++anotherComposableValue}")
}

@Composable
private fun SubComposable(changedStateValue: Int) {
Text(text = "Sub composable text changed: ${changedStateValue}")
}

In this example, there is an AnotherComposable() call right before the SubComposable(stateValue) call, and notice the read value is in the main screen instead of through a lambda. So, in this example we should see the “Main screen text changed” and “Sub composable text changed” updating as we’ve seen earlier. However, what happens to “Another composable text changed”? It should be updated because it’s called in the main screen right?

Let’s observe:

Adding another composable under main screen with no optimization

Oh. The expected change does not happen. Why though?

Well, in a normal function the “Another composable text changed” should have been called and the value should’ve been updated. But composable functions are special. If the called function has no parameters or its parameters’ values not changed, the compose just skips it plainly. Compose is built to optimize itself wherever it can. But a parameter or a data class addition can prevent this, since compiler decides whether a function is optimizable or not in certain conditions. Composable functions with no parameters are always optimizable.

Inline functions in Compose

Inline functions’ base principal is to let the function to be implemented in the code directly, a.k.a the caller function will act like the inline function was written inside the original function already. For example, this implementation:

fun task() {
anotherTask()
}

inline fun anotherTask() {
println("another task")
}

basically becomes this:

fun task() {
println("another task")
}

The anotherTask function technically disappears. What does this mean in compose?

Well, in compose, it is usually or, almost always, better to divide the implementations into separate functions to allow the app to work as optimized as possible. This is done by using different composition scopes when different functions are defined. Inline functions remove this ability and the function uses its parent’s composition scope. So when a variable is read, it will use its parent scope, causing the parent to be updated as well. Composables such as Row, Column and Box use this behavior, and generally layout composables will do so, and that is because the composers (the structure that a composable subscribes when a mutable state value is read for example) change between composition scopes.

In general, as long as the composable is not responsible of drawing other children by applying a manual layout, using inline will only place more overhead to its parent, a.k.a its caller. In the example of the lambda, if the SubComposable function was called as inline, it would cause the parent to update as well.

Conclusion

Understanding compose optimizations can be difficult sometimes, since compose is not too close to object-oriented approaches but is a bit closer on the functional-side of things. And not everything works the same as functional languages as well, there are special cases as described in this article. However, it is almost always better to divide your implementations, create different functions for better readability, as well as allowing compose to optimize.

I hope this article was useful at how compose optimizes itself & how to implement them in apps. You can find the playground with these & more advanced implementations in this github link: https://github.com/appcentfurkanyurdakul/states-example

Happy optimizing!

--

--