Jetpack Compose Tutorial: Improving Performance in Dribbble Audio App

exyte
ProAndroidDev
Published in
8 min readMar 20, 2023

--

Improving Jetpack Compose performance with Compose Compiler Metrics

At Exyte we try to contribute to open-source as much as we can, from releasing libraries and components to writing articles and tutorials. One type of tutorials we do is replicating — taking a complex UI component, implementing it using new frameworks and writing a tutorial alongside. We started with SwiftUI some years ago, but this is our first foray into Android, using Google’s declarative UI framework: Jetpack Compose.

This is the fifth and last part of the Compose dribbble Replicating series. While the previous parts (Part 1, Part 2, Part 3, Part 4) focused on implementing complex UI and animations, this part will be about Compose compiler metrics. You will see how to gather information with metrics and how it can in with fixing performance issues.

In Jetpack Compose 1.2.0, a new feature was added to the Compose compiler that can display various performance metrics during the build. This is a great tool to find potential performance issues.

First step is to configure the metrics — to do this, add the following to your build.gradle file:

subprojects {
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {
if (project.findProperty("musicApp.enableComposeCompilerReports") == "true") {
freeCompilerArgs += [
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
project.buildDir.absolutePath + "/compose_metrics"
]
freeCompilerArgs += [
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
project.buildDir.absolutePath + "/compose_metrics"
]
}
}
}
}

Now run the build with the following options enabled (please note that the build must be compiled in release mode).

./gradlew assembleRelease -PmusicApp.enableComposeCompilerReports=true

After the build, the compose_metrics folder will hold the files we need to look at.

  • app_release-classes.txt - class stability information.
  • app_release-composables.txt - information about whether the method is restartable or skippable.
  • app_release-composables.csv - the same information about the methods, only as a table.
  • app_release-module.json - general metric information about the project.

First let’s review app_release-module.json and look at composables metrics.

{
"skippableComposables": 147,
"restartableComposables": 156,
"readonlyComposables": 0,
"totalComposables": 168,
}

skippableComposables - a composition function is "skipped" if the framework skips its call when the function parameters do not change.

restartableComposables - a composition function can be restarted (re-invoked) independent of the function parameters changes.

So, according to the statistics, we have functions that are restarted but not skipped. This potentially means they are restarted for no reason — if the parameters didn’t change, the function could potentially be skipped and not recalculated. As the official documentation puts it:

If you see a function that is restartable but not skippable, it’s not always a bad sign, but it sometimes is an opportunity to do one of two things:

* Make the function skippable by ensuring all of its parameters are stable

* Make the function not restartable by marking it as aNonRestartableComposable

Let’s now look at the app_release-composables.csv table. Sorting the table by skipabblelets us determine restartable but not skippable methods. These are the ones we need to investigate closer.

Let’s look for these composables in app_release-composables.txt, starting with the AlbumScreen function.

restartable scheme("[androidx.compose.ui.UiComposable]") fun AlbumScreen(
unstable screenState: PlayerScreenState
stable playbackData: PlaybackData
stable sharedProgress: Float
stable onBackClick: Function0<Unit>
stable onInfoClick:
Function4<@[ParameterName(name = 'info')] ModelAlbumInfo,
@[ParameterName(name = 'offsetX')] Float,
@[ParameterName(name = 'offsetY')] Float,
@[ParameterName(name = 'size')] Int, Unit>
)

As you can see, the problem is in the unstable screenState: PlayerScreenState. We can make this parameter stable to make the function skippable. Let us find it in app_release-classes.txt

unstable class PlayerScreenState {
stable val density: Density
stable var maxContentWidth: Int
stable var maxContentHeight: Int
stable val easing: Easing
stable var currentScreen$delegate: MutableState<Screen>
stable var currentDragOffset$delegate: MutableState<Float>
stable val songInfoContainerHeight: Int
stable val playerContainerHeight: Int
stable val songInfoContentHeight: Int
stable val albumContainerHeight: Int
stable val albumImageWidth: Dp
stable val commentsContainerHeight: Int
stable val backHandlerEnabled$delegate: State<Boolean>
stable val fromPlayerControlsToAlbumsListProgress$delegate: State<Float>
stable val playerControlOffset$delegate: State<Float>
stable val commentsListOffset$delegate: State<Int>
stable val songInfoOffset$delegate: State<Float>
stable val albumsInfoSize: Dp
stable val photoScale$delegate: State<Float>
<runtime stability> = Unstable
}

All members of the class are stable, the compiler only shows <runtime stability> = Unstable. Here is what the docs say about this:

Runtime means that stability depends on other dependencies which will be resolved at runtime (a type parameter or a type in an external module) … The line at the bottom indicates the “expression” that is used to resolve this stability at runtime.

So we need to try and make the class itself stable — let’s mark the class with the @Stableannotation:

@Stable
class PlayerScreenState(
constraints: Constraints,
private val density: Density,
isInPreviewMode: Boolean = false,
) {
//...
}

Next, we clear the cache and build again to see how the result has changed. We again open the app_release-composables.csv table, and find restartable but not skippable methods. After our changes the number of such functions has decreased, so the @Stable annotation helped.

Next, we’ll look at other composables. One main problem stands out: Collections.

restartable scheme("[androidx.compose.ui.UiComposable]") fun AlbumsListContainer(
stable modifier: Modifier? = @static Companion
stable listScrollState: ScrollState? = @dynamic rememberScrollState(0, $composer, 0, 0b0001)
unstable albumData: Collection<ModelAlbumInfo>
stable albumImageWidth: Dp = @static 150.dp
stable transitionAnimationProgress: Float = @static 0.0f
stable appearingAnimationProgress: Float = @static 1.0f
stable onBackClick: Function0<Unit>? = @static {}
stable onShareClick: Function0<Unit>? = @static {}

Let’s deal with the Collections we use (Lists, Sets, etc.). The compiler can’t determine when the list is immutable — List (as well as Set and other standard collection classes) are defined as interfaces in Kotlin, which means that the underlying implementation may still be mutable. For example, you could write:
val set: List<String> = mutableListOf(“foo”)
In this case the variable is constant, its declared type is not mutable but its implementation is still mutable. The Compose compiler cannot be sure of the immutability of this class as it only has access to the declared type that states that this class is unstable. Let’s look into how we can change this.

  • Make a custom wrapper for the collection, annotating it with @Immutable.
  • Using the kotlin.collections.immutable library.

Both of these methods are applicable, but we chose the second one because it is a ready-made solution, although it’s still in alpha at the time of writing. So, let’s implement the dependency:

implementation "org.jetbrains.kotlinx:kotlinx-collections-immutable:x.x.x"

After we add stable lists where necessary:

@Stable
@Immutable
data class ModelAlbumInfo(
@DrawableRes val cover: Int,
val title: String,
val author: String,
val year: Int,
- val songs: List<ModelSongInfo>,
+ val songs: ImmutableList<ModelSongInfo>,
)
- private val songs = listOf(
+ private val songs = persistentListOf(
ModelSongInfo(0L, "Aurora", "All Is Soft Inside", "3:54"),
ModelSongInfo(1L, "Aurora", "Queendom", "5:47"),
ModelSongInfo(2L, "Aurora", "Gentle Earthquakes", "4:32"),
ModelSongInfo(3L, "Aurora", "Awakening", "6:51"),
ModelSongInfo(4L, "Aurora", "All Is Soft Inside", "3:54"),
ModelSongInfo(5L, "Aurora", "Queendom", "5:47"),
)

In addition to List, there is a problem with the SectionSelector function that renders tabs:

restartable scheme("[androidx.compose.ui.UiComposable]") fun SectionSelector(
stable modifier: Modifier? = @static Companion
unstable sections: Collection<Section>
unstable selection: Section
stable onClick: Function1<Section, Unit>? = @static { it: Section -> }
)

Let’s take a closer look at its implementation:

@Composable
fun SectionSelector(modifier: Modifier = Modifier, onSelect: (Section) -> Unit = {}) {
val sectionTitles = remember {
listOf(
Section("Comments"),
Section("Popular"),
)
}
var currentSelection by remember { mutableStateOf(sectionTitles.last()) }

SectionSelector(
modifier = modifier,
sections = sectionTitles,
selection = currentSelection,
onClick = { selection ->
if (selection != currentSelection) {
currentSelection = selection
onSelect(selection)
}
}
)
}

We see that the function is just a wrapper that contains a list and the current tab selection logic. We can get rid of this function by moving these elements into the next function in the hierarchy and therefore get rid of the non-skippable function.
Let’s gather the compiler statistics again. Now, there aren’t any restartable but skippable composables, which is great!

{
"skippableComposables": 155,
"restartableComposables": 155,
"readonlyComposables": 0,
"totalComposables": 167,
}

Dynamic expressions

Another thing we need to pay attention to is default parameter expressions that are @dynamic.

The documentation says:

Default expressions should be @static in every case except for the following two cases:

* You are explicitly reading an observable dynamic variable. Composition Locals and state variables are an important example of this. In these cases, you need to rely on the fact that the default expression will be re-executed when the value changes.

*You are explicitly calling a composable function, such as remember. The most common use case for this is state hoisting.

The most common way you’ll encounter the first case is MaterialTheme as default on your composables. Here’s an example — marking the color parameter @dynamic is normal in this case.

restartable skippable scheme("[androidx.compose.ui.UiComposable, [androidx.compose.ui.UiComposable]]") fun RoundedCornersSurface(
stable modifier: Modifier? = @static Companion
stable topPadding: Dp = @static 0.dp
stable elevation: Dp = @static 0.dp
stable color: Color = @dynamic MaterialTheme.colors.surface
stable content: @[ExtensionFunctionType] Function3<BoxScope, Composer, Int, Unit>
)

The second case is the use of remember. In our case, we remember the state of the scroll:

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun CommentsList(
stable modifier: Modifier? = @static Companion
stable scrollState: ScrollState? = @dynamic rememberScrollState(0, $composer, 0, 0b0001)
stable comments: ImmutableList<ModelComment>
stable onActionClick: Function1<Action, Unit>? = @static { it: Action -> }
stable topPadding: Dp = @static 0.dp
)

In all other cases where the default parameter is marked with @dynamic, you should try to get rid of the default values or use the @Stable annotation. For example, in the NowPlayingAlbumScreen function the parameter insets is marked as @dynamic.

@Composable
fun NowPlayingAlbumScreen(
insets: DpInsets = DpInsets.Zero,
)

Let’s annotate it with @Stable to show the compiler that this parameter is stable by default.

companion object {
@Stable
fun from(topInset: Dp, bottomInset: Dp) = DpInsets(DpSize(topInset, bottomInset))

+ @Stable
val Zero: DpInsets get() = DpInsets(DpSize.Zero)
}

Another case in this project was specifying a default parameter to recompose a function as a unit. In this case we just get rid of the default parameter.

@Composable
@Stable
fun rememberCollapsingHeaderState(
- key: Any = Unit,
+ key: Any,
topInset: Dp
) = remember(key1 = key) {
CollapsingHeaderState(topInset = topInset)
}

Yet another case occurred when a default parameter was specified as a different default parameter from the same function.

@Composable
fun AnimatedVolumeLevelBar(
modifier: Modifier = Modifier,
barWidth: Dp = 2.dp,
- gapWidth: Dp = barWidth,
+ gapWidth: Dp = 2.dp,
barColor: Color = MaterialTheme.colors.onPrimary,
isAnimating: Boolean = false,
)

Conclusion

As a result of all this work, all the restartable functions are skippable, all classes in the report file are stable, and we got rid of default parameter expressions that are @dynamicwhen not needed. The profiler shows no more jank frames when running the app.

Here is a before and after graph of the time it takes to draw each frame. The improved implementation never goes above the hard cut-off time for rendering a frame, with a healthy margin left.

Before:

After:

This article closes the “Replicating dribbble in Jetpack Compose” miniseries (Part 1, Part 2, Part 3, Part 4). As you can see, using Jetpack Compose you can make complex screens and animations using a declarative approach. It might be different from what you’re used to with AndroidView, but it’s a powerful tool nonetheless and will only become more and more widespread in the future.

The repo contains the full implementation. Make sure to check our blog for more Jetpack Compose and iOS SwiftUI tutorials and open-source libraries!

--

--