Jetpack Compose is a powerful tool that simplifies creating UIs in Android, but it also presents a learning curve for developers. Many encounter unexpected behavior and issues that may not seem obvious at first glance. In this article, we’ll look at one such issue — how to prevent recomposition from getting stuck in a loop in Compose.
Example Code
Let’s start with an example:
data class MyDataClass(
val i: Int = 0,
val block: () -> Unit = {},
)
class MyScreenViewModel : ViewModel() {
private val dataSource = MutableSharedFlow<Int>(1)
val stateValue: StateFlow<MyDataClass>
get() = dataSource
.map { number ->
MyDataClass(number, { println("Hello, World!") })
}
.stateIn(viewModelScope, SharingStarted.Eagerly, MyDataClass())
}
@Composable
fun MyScreen(viewModel: MyScreenViewModel) {
Log.d("[TAG]", "Recomposition!")
val state by viewModel.stateValue.collectAsStateWithLifecycle()
val checked = remember { mutableStateOf(false) }
Column {
Checkbox(
checked = checked.value,
onCheckedChange = { isChecked -> checked.value = isChecked }
)
Text("state: ${state.i}")
}
}
At first glance, this code seems fine. But if you run it, toggle the checkbox, and check LogCat or Layout Inspector, you’ll see that recomposition is stuck in a loop.
Why does this happen? Let’s investigate.
Understanding the Problem
The core of the problem lies in the collectAsStateWithLifecycle() extension function. Here’s what it looks like under the hood:
@Composable
fun <T> Flow<T>.collectAsStateWithLifecycle(
initialValue: T,
lifecycle: Lifecycle,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
context: CoroutineContext = EmptyCoroutineContext
): State<T> {
return produceState(initialValue, this, lifecycle, minActiveState, context) {
lifecycle.repeatOnLifecycle(minActiveState) {
if (context == EmptyCoroutineContext) {
this@collectAsStateWithLifecycle.collect { this@produceState.value = it }
} else withContext(context) {
this@collectAsStateWithLifecycle.collect { this@produceState.value = it }
}
}
}
}
@Composable
fun <T> produceState(
initialValue: T,
vararg keys: Any?,
producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
val result = remember { mutableStateOf(initialValue) }
@Suppress("CHANGING_ARGUMENTS_EXECUTION_ORDER_FOR_NAMED_VARARGS")
LaunchedEffect(keys = keys) {
ProduceStateScopeImpl(result, coroutineContext).producer()
}
return result
}
The function creates a Compose state and subscribes to the flow using LaunchedEffect, allowing us to observe new values emitted by the flow.
So, now we can see two key issues in our code:
- Each time we access stateValue, a new StateFlow instance is created because the get() block in stateValue re-evaluates, which leads to a new LaunchedEffect being triggered in collectAsStateWithLifecycle() and a new subscription being added;
- Each new StateFlow instance creates a new MyDataClass instance, which, despite being a data class, is not comparable due to the lambda function inside it. Consequently, Compose treats each new instance as a distinct value, triggering a recomposition.
Why Lambdas Are Not Comparable
A simple expression like:
{ println("Hello, World!") } != { println("Hello, World!") }
may look surprising but illustrates the issue here. Lambdas in Kotlin are instances of FunctionX interfaces (where X represents the number of parameters). Thus, two FunctionX instances are only considered equal by reference, not by content.
// An example of a FunctionX: Function3 with 3 input parameters
public interface Function3<in P1, in P2, in P3, out R> : kotlin.Function<R> {
public abstract operator fun invoke(p1: P1, p2: P2, p3: P3): R
}
Solution
To fix this problem, we have two options:
- Exclude the lambda from equals and hashCode calculations in MyDataClass. This prevents unnecessary recompositions by ensuring MyDataClass(1, {}) objects are seen as equal;
- Make stateValue stable by avoiding the re-evaluation of the get() function, so it does not create a new StateFlow instance on each access.
The most optimal solution is to combine both approaches, as shown below:
data class MyDataClass(
val i: Int = 1,
val block: () -> Unit = {},
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MyDataClass
return i == other.i
}
override fun hashCode(): Int {
return i
}
}
class MyScreenViewModel : ViewModel() {
private val dataSource = MutableSharedFlow<Int>(1)
val stateValue: StateFlow<MyDataClass> = dataSource
.map { number -> MyDataClass(number, { println("Hello, World!") }) }
.stateIn(viewModelScope, SharingStarted.Eagerly, MyDataClass())
}
Thank you for reading! If you found this article helpful, please give it an applaud and share your thoughts in the comments. Your feedback helps me improve!