Blog Infos
Author
Published
Topics
, , , ,
Published

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 new ImmutableList 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

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Kobweb:Creating websites in Kotlin leveraging Compose HTML

Kobweb is a Kotlin web framework that aims to make web development enjoyable by building on top of Compose HTML and drawing inspiration from Jetpack Compose.
Watch Video

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author of Kobweb

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author o ...

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author of Kob ...

Jobs

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

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
Menu