How can I debug recompositions in Jetpack Compose?
It's been many months since the first stable release of Jetpack Compose went live. Mutiple companies have already embraced Compose for powering their Android apps and thousands of Android engineers are using Jetpack Compose on a daily basis.
There's a significant amount of documentation already available for helping developers embrace this new paradigm. In spite of all the documentation, there's
one concept that's been the source of a lot of confusion. It's called Recomposition
and it is fundamental to how Compose operates.
Recomposition is the process of calling your composable functions again when inputs change. This happens when the function's inputs change. When Compose recomposes based on new inputs, it only calls the functions or lambdas that might have changed, and skips the rest. By skipping all functions or lambdas that don't have changed parameters, Compose can recompose efficiently.
If you are new to this topic, I go in a lot more detail about Recomposition
in this article.
For most use-cases, we don't want a composable function to be reinvoked unless its parameters changed (this is an oversimplification). The Compose compiler is also
really smart about it and does its best to do this optimization out of the box when it has enough information available (for example, all parameters that are primitives are stable
by design). For cases where enough information isn't available, Compose allows you to provide metadata to help the Compose compiler make this decision through
the usage of @Stable and @Immutable annotations.
This all makes sense in theory, however, it would be super helpful if developers had a way to see how their composable functions were being recomposed. This has been a top requested feature and there's a lot of work going on to create tooling in Android Studio that gives you this information very easily. If you are as impatient as I am, you are probably wondering what you could be doing for debugging recompositions in Jetpack Compose until first-class tooling is available. After all, recompositions play a meaningful role in the performance of your features that are built using Jetpack Compose as unnecessary recompositions can cause UI jank.
Maker OS is an all-in-one productivity system for developers
I built Maker OS to track, manage & organize my life. Now you can do it too!
Printing log statements
The easiest way to debug recompositions is to use good ol' log statements to see which composables functions are being called and the frequency with which they are being called. This is really obvious but there's some nuance to it - we want to trigger these log statements only when recompositions are happening. This sounds like the perfect use case for SideEffect, a composable function that is reinvoked on every successful composition (& recomposition). Sean McQuillan wrote the code snippet below that you can use to debug your recompositions. This is just a starting point and you can make adjustments to it as you see fit.
class Ref(var value: Int)
// Note the inline function below which ensures that this function is essentially
// copied at the call site to ensure that its logging only recompositions from the
// original call site.
@Composable
inline fun LogCompositions(tag: String, msg: String) {
if (BuildConfig.DEBUG) {
val ref = remember { Ref(0) }
SideEffect { ref.value++ }
Log.d(tag, "Compositions: $msg ${ref.value}")
}
}
Here's this helper function in action -
@Composable
fun MyComponent() {
val counter by remember { mutableStateOf(0) }
LogCompositions(TAG, "MyComposable function")
CustomText(
text = "Counter: $counter",
modifier = Modifier
.clickable {
counter++
},
)
}
@Composable
fun CustomText(
text: String,
modifier: Modifier = Modifier,
) {
LogCompositions(TAG, "CustomText function")
Text(
text = text,
modifier = modifier.padding(32.dp),
style = TextStyle(
fontSize = 20.sp,
textDecoration = TextDecoration.Underline,
fontFamily = FontFamily.Monospace
)
)
}
On running this example, we notice that both MyComponent
& CustomText
are recomposed every time the value of the counter changes.
Visualizing recompositions on runtime
The Google Play team were among the first internal teams at Google to leverage Jetpack Compose. They worked very closely with the Compose team and even wrote a
case study describing their experience migrating to Compose. One of the golden
nuggets from that post was a Modifier
that they developed to visualize recompositions. You can find code for the Modifier here.
For ease of access, I'm adding that snippet below but I deserve absolutely no credit for it - it was developed by the Google Play team.
/*
* Copyright 2022 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
* except in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
package com.example.android.compose.recomposehighlighter
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import kotlin.math.min
import kotlinx.coroutines.delay
/**
* A [Modifier] that draws a border around elements that are recomposing. The border increases in
* size and interpolates from red to green as more recompositions occur before a timeout.
*/
@Stable
fun Modifier.recomposeHighlighter(): Modifier = this.then(recomposeModifier)
// Use a single instance + @Stable to ensure that recompositions can enable skipping optimizations
// Modifier.composed will still remember unique data per call site.
private val recomposeModifier =
Modifier.composed(inspectorInfo = debugInspectorInfo { name = "recomposeHighlighter" }) {
// The total number of compositions that have occurred. We're not using a State<> here be
// able to read/write the value without invalidating (which would cause infinite
// recomposition).
val totalCompositions = remember { arrayOf(0L) }
totalCompositions[0]++
// The value of totalCompositions at the last timeout.
val totalCompositionsAtLastTimeout = remember { mutableStateOf(0L) }
// Start the timeout, and reset everytime there's a recomposition. (Using totalCompositions
// as the key is really just to cause the timer to restart every composition).
LaunchedEffect(totalCompositions[0]) {
delay(3000)
totalCompositionsAtLastTimeout.value = totalCompositions[0]
}
Modifier.drawWithCache {
onDrawWithContent {
// Draw actual content.
drawContent()
// Below is to draw the highlight, if necessary. A lot of the logic is copied from
// Modifier.border
val numCompositionsSinceTimeout =
totalCompositions[0] - totalCompositionsAtLastTimeout.value
val hasValidBorderParams = size.minDimension > 0f
if (!hasValidBorderParams || numCompositionsSinceTimeout <= 0) {
return@onDrawWithContent
}
val (color, strokeWidthPx) =
when (numCompositionsSinceTimeout) {
// We need at least one composition to draw, so draw the smallest border
// color in blue.
1L -> Color.Blue to 1f
// 2 compositions is _probably_ okay.
2L -> Color.Green to 2.dp.toPx()
// 3 or more compositions before timeout may indicate an issue. lerp the
// color from yellow to red, and continually increase the border size.
else -> {
lerp(
Color.Yellow.copy(alpha = 0.8f),
Color.Red.copy(alpha = 0.5f),
min(1f, (numCompositionsSinceTimeout - 1).toFloat() / 100f)
) to numCompositionsSinceTimeout.toInt().dp.toPx()
}
}
val halfStroke = strokeWidthPx / 2
val topLeft = Offset(halfStroke, halfStroke)
val borderSize = Size(size.width - strokeWidthPx, size.height - strokeWidthPx)
val fillArea = (strokeWidthPx * 2) > size.minDimension
val rectTopLeft = if (fillArea) Offset.Zero else topLeft
val size = if (fillArea) size else borderSize
val style = if (fillArea) Fill else Stroke(strokeWidthPx)
drawRect(
brush = SolidColor(color),
topLeft = rectTopLeft,
size = size,
style = style
)
}
}
}
Using this Modifier is really straighforward - just stick the recomposeHighlighter
modifier to the Modifier chain of the composables whose recompositions you'd like
to track. The modifier draws a box around the composables its attached to and uses color and border width to represent the amount of recompositions happening in the
composable.
Border Color | Number of compositions |
---|---|
Blue | 1 |
Green | 2 |
Yellow to Red | 3+ |
Let's look at what it looks like in action. Our example has a simple composable function that has a button which increments a counter when its clicked. We are
using the recomposeHighlighter modifier in two places - the root of the MyButtonComponent
composable itself and the MyTextComponent
composable which is a
slot for the button itself.
@Composable
fun MyButtomComponent(
modifier: Modifier = Modifier.recomposeHighlighter()
) {
var counter by remember { mutableStateOf(0) }
OutlinedButton(
onClick = { counter++ },
modifier = modifier,
) {
MyTextComponent(
text = "Counter: $counter",
modifier = Modifier.clickable {
counter++
},
)
}
}
@Composable
fun MyTextComponent(
text: String,
modifier: Modifier = Modifier,
) {
Text(
text = text,
modifier = modifier
.padding(32.dp)
.recomposeHighlighter(),
)
}
On running this example, we notice that the button and the text inside the button both have a blue bounding box initially. This makes sense as that's the first
composition and it corresponds to the two places where we are using the recomposeHighlighter()
Modifier. When we click on the button, we notice the bounding box
only around the text inside the button and not the button itself. This is because Compose is smart about recompositions and it doesn't need to recompose the entire
button - just the composable that depends on the counter value that was updated.
recomposeHighlighter
Modifier in action
Using this modifier, we were able to visualize how recompositions are happening in our Composable functions. This is a really powerful tool and I can imagine this being extended in really creative ways.
Compose Compiler Metrics
The previous two methods of debugging recomposition are super helpful and rely on observation and visualization. However, wouldn't it be amazing if we had some more conclusive evidence of how the Compose compiler was interpreting our code? After all, a lot of this feels like magic and we often don't know if the compiler interpreted thigs the way we intended them to.
Turns out, the Compose compiler does have a mechanism to spit out a report with exactly that information. I discovered it last month and it blew my mind 🤯 There's also some documentation available that I would strongly recommend reading.
Enabling the report is super easy - you simply need to add these compiler arguments in the build.gradle
file of your Compose enabled modules:
compileKotlin {
// Compose Compiler Metrics
freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=<directory>"
)
// Compose Compiler Report
freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=<directory>"
)
}
Let's dive deeper and look at what these metrics are telling us. As I was writing this blog post, engineer extraordinaire Chris Banes
released a blogpost that described these compiler metrics and the information he presented was identical to what
I was hoping to cover. I figured it would be wiser to just redirect to that blog post since he already does a great job explaining it in more detail.
tldr; these metrics have a lot of information for each class and composable function in the modules that they are configured to run on. It primarily focuses on their stability which have a direct impact on how they get recomposed.
I do want to highlight a couple things that surprised me as I was experimenting with these metrics and I'm confident that its going to be surprising to a vast majority of y'all as well.
Note: I would encourage you to at least glance through the documentation for the rest of the article to make sense. I was trying to avoid duplicating information that's already documented.
If you are using classes from modules that don't have compose enabled, the compose compiler won't be able to infer their stability
Let's look at an example to understand what this means and how the Compose compiler reports helped me discover this nuance -
data class ArticleMetadata(
val id: Int,
val title: String,
val url: String
)
We have a simple data class called ArticleMetadata
. Since all its properties are primitive values, the Compose compiler would be able to infer its stability very
easily. Something that's worth calling out is that this class is defined in a module that does not have compose enabled.
Since this is a simple data class, we leverage it directly in our composable function. This function is defined in a different module that has Jetpack Compose enabled.
@Composable
fun ArticleCard(
articleMetadata: ArticleMetadata,
modifier: Modifier = Modifier,
) { .. }
When we run the Compose Compiler Metrics, here's what we find in one of the files (composables.txt
) that's generated by the Compose plugin -
restartable fun ArticleCard(
unstable articleMetadata: ArticleMetadata
stable modifier: Modifier? = @static Companion
)
We see that the ArticleCard
composable function is restartable but not skippable. This means that the Composable complier won't be able to do smart optimiations like
skipping the executation of this function when the parameters didn't change. Sometimes that's by choice but in this case, we definitely want to skip the execution of this
function if the parameters didn't change 🤔
The reason we see this behavior is because we are using a class from a module where compose isn't enabled. This prevents the Compose compiler from being able to smartly
infer stability and so it treats this parameter as unstable
, which has an impact of how recompositions happen for this composable.
There are two ways to fix this:
- Add compose support to the module that has the data class
- Convert it to an alternate class (eg. a UI Model Class) from a module that has compose support and make your composable function take that as the parameter.
List parameter cannot be inferred as stable, even if its a list of primitives
Let's look at another composable function whose metrics we'd like to analyze -
@Composable
fun TagsCard(
tagList: List<String>,
modifier: Modifier = Modifier,
)
When we run the Compose Compiler Metrics, here's what we see -
restartable fun TagsCard(
unstable tagList: List<String>
stable modifier: Modifier? = @static Companion
)
Uh oh! TagsCard
has the same problem that the previous example had as well - this function is restartable but not skippable 😭 It's because the tagList
parameter
is unstable. Even though its a List of a primitive type (String
), the Compose compiler does not infer List as a stable type. This is probably because List is an interface
whose implementation can either be mutable or immutable.
One way to get around this would be to use a wrapper class and annotate it appropriately to let the Compose compiler know about its stability explicitly.
@Immutable
data class TagMetadata(
val tagList: List<String>,
)
@Composable
fun TagsCard(
tagMetadata: TagMetadata,
modifier: Modifier = Modifier,
)
When we run the Compose Compiler Metrics again, we see that the compiler is able to infer the stability of this function correctly 🎉
restartable skippable fun TagsCard(
stable tagMetadata: TagMetadata
stable modifier: Modifier? = @static Companion
)
As this is a fairly common use case, I like the reusable wrapper classes snippet that Chris Banes proposed in his blog post that I linked above.
Summary
As you saw from this article, there's a few ways of debugging recompositions in Jetpack Compose. You probably want to expose all 3 mechanisms for debugging your composable functions in your codebase, especially since most teams are still ramping up on this new way to building Android apps. I do expect first class support for debugging recompositions in Android Studio itself, but until then, you have some options 😉 I would encourage y'all to play with some of these options that I showed in this article - I'm confident that you will find some surprises the same way I did.
I hope I was able to teach you something new today. There's more articles in the pipeline that I'm excited to share with y'all and if you are interested in getting early access to them, consider signing up to the newsletter that's linked below. Until next time!
Subscribe for exclusive content and early access to content 👇