Blog Infos
Author
Published
Topics
,
Author
Published

ai generated image

 

We often make the mistake of performing heavy operations inside Composables.

However, a Composable is just a function that constructs the UI and runs on the MainThread.

This means that during a Composable’s execution, the entire process of constructing and rendering the UI tree occurs on the main thread.

But what happens if heavy operations run concurrently at this point?

The UI update fails to process in time, causing the screen to momentarily freeze or stutter.

This is called Jank.

FrameTime

The FPS(frames per second) varies by device, but Generally, Android operates at 60 FPS.

This means that 60 frames must be drawn within 1 second (1000ms),

so the time available to process one frame is as follows.

1000 / 60 = 16.63ms

 

In other words, UI updates must complete within 16.63ms for the screen to display smoothly.

Exceeding this time limit causes frames to pile up, resulting in noticeable stuttering.

Jank Example

Let’s look at the following code.

@Composable
fun Test() {
LazyColumn(
modifier = Modifier
.statusBarsPadding()
.height(300.dp)
.fillMaxWidth()
) {
item {
Text(
text = "Janky Ui",
fontSize = 32.sp
)
}

items(100) {
Text(computeHeavy(it))
}
}
}

fun computeHeavy(value: Int): String {
Thread.sleep(100)

return "$value computed!"
}

 

run on emulator

Each time a LazyColumn item is drawn, computeHeavy() is called,
causing the UI update to completely halt while this function runs on the main thread.
100ms far exceeds the frame time of 16.63ms,
resulting in multiple frames being pushed back and severe screen stuttering (Jank).

In other words, the UI still attempts to draw at 60FPS,
but the main thread is too busy with computation to secure drawing time.

So how should heavy tasks be handled to avoid Jank?

Here are 3 ways to handle this within the Compose environment.

Handled in the ViewModel and displayed only in the UI

Personally, I would choose this method first.

Heavy tasks are handled asynchronously in the ViewModel, and the UI simply displays the results, resulting in clean code and clear responsibilities.

class TestViewModel : ViewModel() {
private val _computed = MutableStateFlow<List<String>>(emptyList())
val computed = _computed.asStateFlow()

init {
viewModelScope.launch {
println("launch: ${Thread.currentThread().name}")
withContext(Dispatchers.IO) {
val results = (0 until 100).map {
async {
println("Async: ${Thread.currentThread().name}")
computeHeavy(it)
}
}.awaitAll()

_computed.update { results }
}
}
}
}

@Composable
fun Test(viewModel: TestViewModel) {
val computed by viewModel.computed.collectAsState()

LazyColumn(
modifier = Modifier
.statusBarsPadding()
.height(300.dp)
.fillMaxWidth()
) {
item {
Text(
text = "viewModel",
fontSize = 32.sp
)
}

items(computed) {
Text(it)
}
}
}

 

run on emulator

An important point to note is that viewModelScope uses Dispatchers.Main.immediate by default. This means that unless you explicitly specify a different dispatcher, all coroutines started within it will run on the main thread.

Therefore, you must explicitly switch heavy tasks to Dispatchers.IO or Dispatchers.Default to avoid running them on the MainThread and prevent jank.

When context switching is not performed with WithContext

When context switching is performed using WithContext

Handle asynchronously via ProduceState

ProduceState, which fetches state asynchronously, can be an effective solution.

@Composable
fun Test() {
val computed by produceState(emptyList()) {
value = withContext(Dispatchers.IO) {
(0 until 100).map {
async { computeHeavy(it) }
}.awaitAll()
}
}

LazyColumn(
modifier = Modifier
.statusBarsPadding()
.height(300.dp)
.fillMaxWidth()
) {
item {
Text(
text = "produceState",
fontSize = 32.sp
)
}

items(computed) {
Text(it)
}
}
}

 

run on emulator

Within produceState, you can use suspend to initialize asynchronously,
making it an effective approach.

In this case as well, make sure to switch the context with Dispatchers.IO or Dispatchers.Default.

Handle asynchronously via LaunchedEffect

Using LaunchedEffect to handle processing within the EffectHandler is also a valid approach.

@Composable
fun Test() {
val computed = remember { mutableStateListOf<String>() }

LaunchedEffect(Unit) {
val results = withContext(Dispatchers.IO) {
(0 until 100).map {
async { computeHeavy(it) }
}.awaitAll()
}

computed.addAll(results)
}

LazyColumn(
modifier = Modifier
.statusBarsPadding()
.height(300.dp)
.fillMaxWidth()
) {
item {
Text(
text = "LaunchedEffect",
fontSize = 32.sp
)
}

items(computed) {
Text(it)
}
}
}

 

run on emulator

LaunchedEffect must also switch contexts using Dispatchers.IO or Dispatchers.Default.

ProduceState vs LaunchedEffect Which one should I use?

produceState internally uses LaunchedEffect

implementation of produceState

but as its name suggests, it is focused on producing state.

Think about it. Wouldn’t it feel awkward to imagine some Effect occurring every time state is initialized?

Therefore, If you simply need to initialize a state asynchronously, using produceState is appropriate.

On the other hand, if you need additional effects alongside initialization, using LaunchedEffect is preferable.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

Reimagining Android Dialogs with Jetpack Compose

Traditional Android dialogs are hard to test, easy to leak, and painful to customize — and in a world of Compose-first apps, they’re overdue for an upgrade.
Watch Video

Reimagining Android Dialogs with Jetpack Compose

Keith Abdulla
Staff Android Engineer, Design Systems
Block

Reimagining Android Dialogs with Jetpack Compose

Keith Abdulla
Staff Android Engine ...
Block

Reimagining Android Dialogs with Jetpack Compose

Keith Abdulla
Staff Android Engineer, D ...
Block

Jobs

In Closing

There’s rarely a need to perform heavy tasks directly in the UI,
and most processing will be handled in the ViewModel.

However, if your schedule is tight or you lack experience with Compose,
you might end up handling a significant amount of work inside Composables to display it in the UI without realizing it.

If that time comes, I hope you’ll recall the solutions introduced here and find them somewhat helpful.

 

This article was previously published on proandroiddev.com

Menu