Hello folks,
Let’s dive into a chapter on Jetpack Compose, where we’ll focus on optimizing performance by cutting down on unnecessary recomposition. A lot of people have shared ways to minimize recomposition using side effects, passing lambdas as parameters, and other tricks.
But here’s something even more awesome that often gets overlooked — we’re going to explore the one view-one state pattern, which can seriously cut down recomposition.
Ready to see how this works? Let’s jump in! 🚀
First, let’s take a look at how we’d typically implement a feature. I’ll use a simple example — a ticking timer with some basic UI elements.
We’ll create one activity and one ViewModel (just an example, not necessarily following best practices).
class MainActivity : ComponentActivity() {
@RequiresApi(Build.VERSION_CODES.O)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val mainViewModel = hiltViewModel<MainViewModel>()
val state by mainViewModel.state.collectAsState()
LaunchedEffect(key1 = Unit) {
mainViewModel.processAction(MainAction.ContinueData)
}
Data(state)
}
}
}
@Composable
fun Data(state: MainState) {
Column(
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(vertical = 16.dp)
.fillMaxSize()
) {
ContinueUpdateText(state.text)
PlayerA(state.playerAText, state.listA)
PlayerB(state.playerBText, state.listB)
PlayerC(state.playerCText, state.listC)
}
}
@Composable
fun ContinueUpdateText(value: String) {
Text(text = value, fontSize = 12.sp, color = Color.Black)
}
@Composable
fun PlayerA(value: String, list: List<Int>) {
LaunchedEffect(key1 = value) {
Log.d("PlayerA", "Recomposed with value: $value, $list")
}
Text(text = value, fontSize = 12.sp, color = Color.Magenta)
}
@Composable
fun PlayerB(value: String, list: List<Int>) {
LaunchedEffect(key1 = value) {
Log.d("PlayerB", "Recomposed with value: $value, $list")
}
Text(text = value, fontSize = 12.sp, color = Color.Magenta)
}
@Composable
fun PlayerC(value: String, list: List<Int>) {
LaunchedEffect(key1 = value) {
Log.d("PlayerC", "Recomposed with value: $value, $list")
}
Text(text = value, fontSize = 12.sp, color = Color.Magenta)
}
@HiltViewModel
class MainViewModel @Inject constructor() : BaseViewModel<MainState, MainAction>() {
override fun processAction(action: MainAction) {
when (action) {
MainAction.ContinueData -> {
viewModelScope.launch {
var counter = 0
while (true) {
counter = ++counter
if (counter % 2 == 0) {
setState(
getValue().copy(
playerAText = "A-${counter}",
listA = getValue().listA + counter
)
)
}
if (counter % 3 == 0) {
setState(
getValue().copy(
playerBText = "B-${counter}",
listB = getValue().listB + counter
)
)
}
if (counter % 5 == 0) {
setState(
getValue().copy(
playerCText = "C-${counter}",
listC = getValue().listC + counter
)
)
}
setState(
getValue().copy(
text = counter.toString()
)
)
delay(1000)
}
}
}
}
}
override fun initialState(): MainState =
MainState("", "A-0", "B-0", "C-0", true, emptyList(), emptyList(), emptyList())
}
data class MainState(
val text: String,
val playerAText: String,
val playerBText: String,
val playerCText: String,
val shouldVisible: Boolean,
val listA: List<Int>,
val listB: List<Int>,
val listC: List<Int>
)
sealed interface MainAction {
data object ContinueData : MainAction
}
Output:-
Explanation:-
- In this output, you’ll notice that recomposition is happening across all the components.
Many people might suggest making the list stable will solve our problem.
So let’s try this out to make this list stable.
@Stable
data class ImmutableList<T>(
val items: List<T>
)
We’ll use the ImmutableList
data class instead of our usual List
interface. Let’s check out the results after making this change.
Output:-
Explanation:-
- Using
ImmutableList
did help reduce some recompositions, but we had to create a newImmutableList
to see the effect. - There can be other scenarios also.
This highlights that while ImmutableList
helps, there’s more to consider for effective optimization.
Job Offers
Here’s the question: Why is PlayerA skipping recomposition when changes happen in PlayerB’s composable? And, wait — did you see? The Data
composable is still recomposing.
Why can’t we make them completely separate, where Composable A doesn’t know anything about Composable B?
Wouldn’t that solve our recomposition issue more effectively?
That’s what I call the one view, one state pattern.
Let’s dive into the code to see how this works in practice.
@HiltViewModel
class MainViewModel @Inject constructor() : BaseViewModel<MainState, MainAction>() {
override fun processAction(action: MainAction) {
when (action) {
MainAction.ContinueData -> {
viewModelScope.launch {
var counter = 0
while (true) {
counter = ++counter
if (counter % 2 == 0) {
setState(
getValue().copy(
playerAData = getValue().playerAData.copy(
text = "A-${counter}",
list = getValue().playerAData.list + counter
)
)
)
}
if (counter % 3 == 0) {
setState(
getValue().copy(
playerBData = getValue().playerBData.copy(
text = "B-${counter}",
list = getValue().playerBData.list + counter
)
)
)
}
if (counter % 5 == 0) {
setState(
getValue().copy(
playerCData = getValue().playerCData.copy(
text = "C-${counter}",
list = getValue().playerCData.list + counter
)
)
)
}
setState(
getValue().copy(
text = counter.toString()
)
)
delay(1000)
}
}
}
}
}
override fun initialState(): MainState =
MainState(
"",
PlayerAData.initialData(),
PlayerBData.initialData(),
PlayerCData.initialData()
)
}
data class MainState(
val text: String,
val playerAData: PlayerAData,
val playerBData: PlayerBData,
val playerCData: PlayerCData,
) {}
data class PlayerAData(
val text: String,
val list: List<Int>
) {
companion object {
fun initialData() = PlayerAData("A-0", emptyList())
}
}
data class PlayerBData(
val text: String,
val list: List<Int>
) {
companion object {
fun initialData() = PlayerBData("B-0", emptyList())
}
}
data class PlayerCData(
val text: String,
val list: List<Int>
) {
companion object {
fun initialData() = PlayerCData("C-0", emptyList())
}
}
sealed interface MainAction {
data object ContinueData : MainAction
}
- Here, we’ve created three distinct data classes for the three different composables and updated the state accordingly.
class MainActivity : ComponentActivity() {
@RequiresApi(Build.VERSION_CODES.O)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val mainViewModel = hiltViewModel<MainViewModel>()
LaunchedEffect(key1 = Unit) {
mainViewModel.processAction(MainAction.ContinueData)
}
Data()
}
}
}
@Composable
fun Data() {
Column(
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(vertical = 16.dp)
.fillMaxSize()
) {
ContinueUpdateText()
PlayerA()
PlayerB()
PlayerC()
}
}
@Composable
fun ContinueUpdateText() {
val mainViewModel = hiltViewModel<MainViewModel>()
val text by mainViewModel.state.map { it.text }.collectAsState(initial = "")
Text(text = text, fontSize = 12.sp, color = Color.Black)
}
@Composable
fun PlayerA() {
val mainViewModel = hiltViewModel<MainViewModel>()
val playerAData by mainViewModel.state.map { it.playerAData }.collectAsState(initial = PlayerAData.initialData())
LaunchedEffect(key1 = playerAData.text) {
Log.d("PlayerA", "Recomposed with value: ${playerAData.text}, ${playerAData.list}")
}
Text(text = playerAData.text, fontSize = 12.sp, color = Color.Magenta)
}
@Composable
fun PlayerB() {
val mainViewModel = hiltViewModel<MainViewModel>()
val playerBData by mainViewModel.state.map { it.playerBData }.collectAsState(initial = PlayerBData.initialData())
LaunchedEffect(key1 = playerBData.text) {
Log.d("PlayerB", "Recomposed with value: ${playerBData.text}, ${playerBData.list}")
}
Text(text = playerBData.text, fontSize = 12.sp, color = Color.Magenta)
}
@Composable
fun PlayerC() {
val mainViewModel = hiltViewModel<MainViewModel>()
val playerCData by mainViewModel.state.map { it.playerCData }.collectAsState(initial = PlayerCData.initialData())
LaunchedEffect(key1 = playerCData.text) {
Log.d("PlayerC", "Recomposed with value: ${playerCData.text}, ${playerCData.list}")
}
Text(text = playerCData.text, fontSize = 12.sp, color = Color.Magenta)
}
Output:-
Explanation:-
- Individual Recomposition: Each composable is recomposing individually based on its specific state update. This ensures that changes in one composable don’t trigger unnecessary recompositions in others.
- No Need to Skip Recomposition: With this approach, there’s no need to skip recompositions. Each composable manages its own state efficiently.
- Parent Composable Stability: The parent composable (
Data
) does not undergo recomposition when changes occur in child composables. Each child composable is isolated. - State Mapping: We’ve mapped the state for each composable using the
map
operator. This creates a new flow that observes only the portion of the state relevant to each composable. - Efficient Observation: By providing a new flow for each composable, we ensure that only the necessary state changes are observed, enhancing performance and reducing unnecessary updates.
If you have any questions, just drop a comment, and I’ll get back to you ASAP. We’ll dive deeper into Jetpack Compose soon.
Until then, happy coding!
This article is previously published on proandroiddev.com