Blog Infos
Author
Published
Topics
Published

This is the second and final blog post in this series. If you haven’t read the first part, I would strongly recommend going through that here.

As a quick recap, in the last post, we saw the different phases of compose and applied a couple of fixes to our UI by introducing remember blocks and by making all our composables skippable.

Let’s dive back into some of the other optimizations that we can do

This part talks about
  1. Defer state reads
  2. Lambda Modifiers
  3. Introducing derivedStateOf
Defer state reads

Whenever a state changes, compose runtime triggers the recomposition of the nearest restartable function in the parent UI tree. Ensuring that a state is read later in the UI tree, ensures that a smaller section of the UI is recomposed whenever that state changes.

@Composable
fun ToDoItem(task: String) {
    val isDone = remember { mutableStateOf(false) }
    Row {
        Text(task)
        CheckBox(isChecked = isDone, onCheckChange = { isDone = !isDone }
    }
}

In the example above, any change in the isDone state would trigger a recomposition of ToDoItem since it’s the nearest skippable function. Row being an inline non-skippable function will be recomposed as well. Text will be skipped since its state (task) hasn’t changed and then the checkbox will be recomposed with the new state.

If the state is hoisted up in the UI tree, we can use lambda to wrap our state reads and pass this lambda to our child composable instead to defer state reads.

Let’s try to understand this better using our example. In our parent composable ComposePerformanceScreen, we have the scroll state for our scrollable column. This state is required by child composables to

  1. decide the position of ScrollPositionIndicator
  2. toggle visibility of ScrollToTopButton

Let’s take a look at the logs to verify what composables are recomposed whenever the scroll state changes.

As a quick recap, in the last post, we saw the different phases of compose and applied a couple of fixes to our UI by introducing remember blocks and by making all our composables skippable.

The parent composable does not rely on this state for its own UI. This state is only read by the parent composable to pass it down to its children. If we are able to defer read of this state to child composables, whenever this state changes, our parent composable (ComposePerformanceScreen) wouldn’t have to recompose. Let’s try to wrap the state reads in lambda functions and pass that lambda instead to child composables.

Before

@Composable
private fun ScrollPositionIndicator(
    modifier: Modifier = Modifier,
    progress: Float
) {}
@Composable
private fun ScrollToTopButton(
    isVisible: Boolean,
    modifier: Modifier = Modifier,
    onClick: () -> Unit
) {}

Changing these states to lambda

@Composable
private fun ScrollPositionIndicator(
    modifier: Modifier = Modifier,
    progress: () -> Float
) {}
@Composable
private fun ScrollToTopButton(
    isVisible: () -> Boolean,
    modifier: Modifier = Modifier,
    onClick: () -> Unit
) {}

Now let’s try to pass in these lambdas from our parent composable

ScrollPositionIndicator(progress = { scrollState.value / (scrollState.maxValue * 1f) })
ScrollToTopButton(
  isVisible = {
    Logger.d(
      message = "Recalculating showScrollToTopButton",
      filter = LogFilter.ReAllocation
    )
    scrollState.value / (scrollState.maxValue * 1f) > .5
  },
  onClick = {
    scope.launch {
    scrollState.scrollTo(0)
  }
)

Awesome, looks like we were able to wrap state reads inside lambda functions and now they are effectively read-only inside the child composables. Hence whenever the scroll state updates, the nearest recomposition scope is now the child composables themselves. Let’s try to look at the logs again

As a quick recap, in the last post, we saw the different phases of compose and applied a couple of fixes to our UI by introducing remember blocks and by making all our composables skippable.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

Nice! We no longer see the statement “Recomposing entire Screen” meaning the parent composable is no longer being recomposed on scroll state change.

The source code for this step can be accessed here.

Lambda modifiers

To decide the position of the scroll indicator, we are using the offset modifier to set its distance on the X-Axis.

Now if we remember the first two phases of composes, the first phase was about “What to show” and the second was about “Where to show”. In this case, what to show is not changing. The same UI element is displayed at a different location (where) on the screen. In other words, the state is governing where to show the UI instead of what to show. Does this mean we can skip the first phase of composition somehow?

Since right now we are reading the scroll state in this composable, this composable is being recomposed every time the scroll position changes. Some modifiers, like offset modifier, have a lambda version which can help with this optimization. Using a lambda modifier whenever available is always preferable since it can further defer the state reads.

In our example right now, we can see in the logs from the previous section that ScrollPositionIndicator is being recomposed every time the scroll position changes

Let’s look at how can we use the lambda version of the offset modifier.

Before

val xOffset = with(LocalDensity.current) {
    ((progressWidth - 16.dp).toPx() * progress()).toDp() + 4.dp
}        
Box(
    modifier = Modifier
        .offset(xOffset, 0.dp)
        .size(16.dp)
        .align(Alignment.CenterStart)
        .background(Color.Red)
)

After

Box(
    modifier = Modifier
        .offset {
            IntOffset(
                (((progressWidth - 16.dp) * progress()) + 4.dp)
                    .toPx()
                    .toInt(), 0
                )
            }
            .size(16.dp)
            .align(Alignment.CenterStart)
            .background(Color.Red)
)

Let’s go back to our logs and see if the recomposition is still being triggered

As a quick recap, in the last post, we saw the different phases of compose and applied a couple of fixes to our UI by introducing remember blocks and by making all our composables skippable.

Awesome! No recomposition even though the position is changing!

The source code for this step can be accessed here.

Introducing derivedStateOf

We have come a long way in terms of the optimization. Let’s take a quick look at the logs being generated now as we scroll.

As a quick recap, in the last post, we saw the different phases of compose and applied a couple of fixes to our UI by introducing remember blocks and by making all our composables skippable.

The only logs we see are related to ScrollToTopButton. This button should appear once we scroll more than 50% and hide otherwise. This means it does depend on the scroll position but it does not need to care about the exact state of the scroll position. All that matters, in this case, is whether our position is more than 50% or less.

derivedStateOf is a powerful API which can be used in cases when the state is getting updated more frequently than when we want to update our UI.

Since the scroll value is updating much more frequently than what we care about (50% threshold in this case), we can wrap it inside the derivedStateOf block. This will ensure we don’t recompose the screen on every scroll change for our button but only when we go above or below the 50% threshold value

Before

ScrollToTopButton(
        isVisible = {
        Logger.d(
            message = "Recalculating showScrollToTopButton",
            filter = LogFilter.ReAllocation
        )
        scrollState.value / (scrollState.maxValue * 1f) > .5
        }) {
            scope.launch {
            scrollState.scrollTo(0)
        }
}

After

val showScrollToTopButton by remember {
        derivedStateOf {
            Logger.d(
                message = "Recalculating showScrollToTopButton",
                filter = LogFilter.ReAllocation
            )
            scrollState.value / (scrollState.maxValue * 1f) > .5
        }
    }
ScrollToTopButton(isVisible = { showScrollToTopButton }) {
      scope.launch {
          scrollState.scrollTo(0)
      }
 }

if you now look at the logs, even though the calculation is done every time the scroll state changes, recomposition is triggered only when go above or below the 50% threshold.

As a quick recap, in the last post, we saw the different phases of compose and applied a couple of fixes to our UI by introducing remember blocks and by making all our composables skippable.

The source code for this step can be accessed here.

Conclusion

Looking at the progress we have made along each step of the way, it is clear that even though we can get a functional UI pretty easily in compose, it might not be the most performant one. Compose runtime might be doing much more work than required at every frame which could cause the UI to jank.

In such cases, it becomes important to invest in adopting some of the techniques described above to enhance the performance of the app. Knowing about these tools and techniques can help when we experience below-par performance of your app.

With that, I’d like to leave a couple of points before we start sprinting with compose:

  • Make sure you do a performance analysis of your app on release builds. Compose already works a lot smoother on release build because of a bunch of optimisations so debug builds do not give you an accurate picture.
  • Do not optimise prematurely. While some basic points like avoiding heavy calculations inside a composable must be followed, not all composables and scenarios require you to invest deeply into performance. If your UI is working well, there is little reason to invest deeply into each and every aspect we have talked about here.

📔 Take home exercise: There is one important optimization which is not covered here. For the item list, we should be using a lazy column instead of a column. Wanna give it a try?

Do share your feedback and queries in the comment section.

Thanks for reading, and as always, compose away!

This article was 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

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu