Irbisa · Cheatsheet
Compose Performance & Stability
Stability, skipping, deferred reads, phases, Lazy keys, Modifier.Node, compiler metrics
Before you open a profiler, what do you do by default to keep a Compose screen fast?
Why does a Compose list stutter in a debug build and scroll smoothly in release, and what build do you profile instead?
// app/build.gradle.kts: release code, debug signing, profileableandroid { buildTypes { create("profiling") { initWith(getByName("release")) // R8 and release flags signingConfig = signingConfigs.getByName("debug")…What is wrong with this composable, and why does the screen keep the CPU busy while nothing on it changes?
@Composablefun Feed(posts: List<Post>) { var renders by remember { mutableIntStateOf(0) } Text("Rendered $renders times") LazyColumn { items(posts, key = { it.id }) { PostRow(it) } } renders++}Find the bug: while the network banner slides in, the Layout Inspector shows
ChatScreenrecomposing on every frame of the animation.@Composablefun ChatScreen(messages: List<Message>, showBanner: Boolean) { val bannerOffset by animateDpAsState( targetValue = if (showBanner) 0.dp else (-64).dp, label = "banner", ) Box(Modifier.fillMaxSize()) { MessageList(messages) NetworkBanner(Modifier.offset(y = bannerOffset).height(64.dp)) }}Find the bug: the contact list never changes when the user types into the search field.
@Composablefun ContactList(contacts: List<Contact>, query: String) { // filtering is expensive, so cache it val visible by remember { derivedStateOf { contacts.filter { it.name.contains(query, ignoreCase = true) } } } LazyColumn { items(visible, key = { it.id }) { ContactRow(it) } }}What do
keyandcontentTypeeach control in aLazyColumn, and what goes wrong on a fast fling whencontentTypeis missing?sealed interface FeedItem { data class Header(val id: String, val title: String) : FeedItem data class Photo(val id: String, val url: String) : FeedItem data class Comment(val id: String, val text: String) : FeedItem}…The Layout Inspector shows 300 recompositions on a header whose content never changes. How do you read that number and trace it back to a state read?
// A count readable without the inspector, in any build:@Composablefun LogRecompositions(tag: String) { val count = remember { IntArray(1) } SideEffect { Log.d("recompose", "$tag = ${++count[0]}") }}…Your feed screen recomposes far more than it should. How do you get the Compose compiler itself to tell you which parameter is to blame?
// build.gradle.ktscomposeCompiler { // ./gradlew assembleRelease -PcomposeReports if (project.findProperty("composeReports") != null) { reportsDestination = layout.buildDirectory.dir("compose_reports") metricsDestination = layout.buildDirectory.dir("compose_metrics")…What makes a type stable in Compose, and what do
@Stableand@Immutableactually promise?// <module>-classes.txt from the compiler report:// unstable class Article {// stable val id: String// unstable val tags: List<Tag> <- this field makes the class unstable// }…Strong skipping has been the default since the Compose compiler shipped with Kotlin 2.0.20. What did it change, and what does it still not fix?
data class Article(val id: String, val tags: List<Tag>) // unstable param @Composablefun Feed(articles: List<Article>) { LazyColumn { items(articles, key = { it.id }) { article ->…progresschanges every frame. Why doesControlsrecompose every frame too, and how do you fix it without the callback going stale?@Composablefun Player(progress: Float, onSeek: (Float) -> Unit) { Text("At ${progress.toInt()} s") Controls(onSkip = { onSeek(progress + 10f) })}What does the Compose compiler plugin do to a
@Composablefunction, and what are$composer,$changed, groups and the slot table?When
wideflips (a foldable unfolds, the window resizes) aColumnbecomes aRowand the player restarts. How do you move a subtree to a new parent, keeping state?// Two call sites: when `wide` flips, the player is disposed and rebuilt@Composablefun ScreenBroken(wide: Boolean, url: String) { if (wide) Row { Player(url); Details() } else Column { Player(url); Details() }}…Your custom
Modifier.pulsingBorder()is built withcomposed { }. What does that cost, and what changes if you rewrite it as aModifier.Node?// composed: a composition group per usage, no equals; the read of `alpha`// in the factory body invalidates compositionfun Modifier.pulsingBorderSlow(color: Color) = composed { val alpha by rememberInfiniteTransition(label = "pulse") .animateFloat(0f, 1f, infiniteRepeatable(tween(800)), label = "alpha") Modifier.border(2.dp, color.copy(alpha = alpha)) // read in the factory body…Someone put a
BoxWithConstraintsinside everyLazyColumnitem and the scroll got worse. What does subcomposition actually cost, and what should they have used?// A SubcomposeLayout per row: composition moves into the measure passLazyColumn { items(posts, key = { it.id }) { post -> BoxWithConstraints { if (maxWidth > 600.dp) WideRow(post) else NarrowRow(post) }…The compiler report says your feed row is unskippable because of a model class from a third-party SDK. What are your options, and what does each one cost you?
// build.gradle.ktscomposeCompiler { stabilityConfigurationFiles.add( rootProject.layout.projectDirectory.file("compose_stability.conf") ) reportsDestination = layout.buildDirectory.dir("compose_reports")…A freshly installed build janks through the first scroll of a Compose list and is smooth on every scroll after. What is the runtime doing, and what actually fixes it?
// Generate the profile for THIS journey, not just for cold start@RunWith(AndroidJUnit4::class)class FeedBaselineProfile { @get:Rule val rule = BaselineProfileRule() @Test fun feedScroll() = rule.collect(packageName = "com.example.app") {…Recomposition counts look clean but frames are still late and the time is sitting on RenderThread. Where does a Compose UI usually burn GPU time?
// An offscreen buffer for a fade that did not need oneBox(Modifier.alpha(fade)) { Row { Icon(icon, null); Text(label) } } // Children do not overlap -> modulate alpha per draw op, no offscreen bufferBox( Modifier.graphicsLayer {…How do you stop a Compose performance regression from merging, and what makes a scroll number stable enough to fail a pull request on?