Irbisa · Cheatsheet

Compose Performance & Stability

Stability, skipping, deferred reads, phases, Lazy keys, Modifier.Node, compiler metrics

Senior Developer · 19 questions

  1. 01

    Before you open a profiler, what do you do by default to keep a Compose screen fast?

    Keep composition cheap and rare: give lazy lists a key and a contentType, cache expensive work with remember, read fast-changing state as late and as low in the tree as possible, and judge the result only on a release build.

  2. 02

    Why does a Compose list stutter in a debug build and scroll smoothly in release, and what build do you profile instead?

    A debug build runs the Compose runtime without R8 and with most ART optimizations switched off, so it can be several times slower than what users get; jank measured there may not exist in release at all.

    // app/build.gradle.kts: release code, debug signing, profileableandroid {  buildTypes {    create("profiling") {      initWith(getByName("release"))          // R8 and release flags      signingConfig = signingConfigs.getByName("debug")…
  3. 03

    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++}

    renders++ is a backwards write: it changes state that was already read earlier in the same composition.

  4. 04

    Find the bug: while the network banner slides in, the Layout Inspector shows ChatScreen recomposing 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))  }}

    bannerOffset is read in the body of ChatScreen (the content of Box is inline), so every animation frame invalidates the whole screen's composition.

  5. 05

    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) }  }}

    remember without keys runs its block once, and derivedStateOf re-evaluates only when snapshot state read inside it changes.

  6. 06

    What do key and contentType each control in a LazyColumn, and what goes wrong on a fast fling when contentType is missing?

    key controls identity; contentType controls reuse.

    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}…
  7. 07

    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 recomposition count is how many times that restart scope actually re-ran, and 300 on a static header means something invalidated it 300 times; the Skips column beside it tells you whether the runtime managed to bail out or not.

    // 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]}") }}…
  8. 08

    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?

    Switch on the compiler's reports and metrics in the composeCompiler block, build the variant you ship, and read <module>-composables.txt, which prints every composable with a restartable/skippable header and each parameter marked stable or unstable.

    // build.gradle.ktscomposeCompiler {  // ./gradlew assembleRelease -PcomposeReports  if (project.findProperty("composeReports") != null) {    reportsDestination = layout.buildDirectory.dir("compose_reports")    metricsDestination = layout.buildDirectory.dir("compose_metrics")…
  9. 09

    What makes a type stable in Compose, and what do @Stable and @Immutable actually promise?

    A type is stable when Compose can trust equals to say whether it changed and any change to a public property is reported through snapshot state; @Immutable promises nothing ever changes, @Stable promises changes go through snapshot state.

    // <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//   }…
  10. 10

    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?

    Strong skipping makes every restartable composable skippable by comparing unstable parameters with instance identity instead of refusing to skip at all, and it memoizes lambdas for you.

    data class Article(val id: String, val tags: List<Tag>)   // unstable param @Composablefun Feed(articles: List<Article>) {  LazyColumn {    items(articles, key = { it.id }) { article ->…
  11. 11

    progress changes every frame. Why does Controls recompose 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) })}

    Strong skipping memoizes the lambda, but keyed on what it captures: progress is new every frame, so the lambda is a new instance every frame and Controls sees a changed parameter.

  12. 12

    What does the Compose compiler plugin do to a @Composable function, and what are $composer, $changed, groups and the slot table?

    It rewrites every composable into ordinary code that takes a hidden $composer and a $changed bitmask, wraps the body in groups recorded in the slot table, and adds the skip and restart logic; that is what lets Compose remember values by position and re-run only part of the tree.

  13. 13

    When wide flips (a foldable unfolds, the window resizes) a Column becomes a Row and the player restarts. How do you move a subtree to a new parent, keeping state?

    State in Compose is identified by position in the composition, so calling the same composable from a different call site creates a different group: the old one is disposed and a new one built from scratch.

    // 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() }}…
  14. 14

    Your custom Modifier.pulsingBorder() is built with composed { }. What does that cost, and what changes if you rewrite it as a Modifier.Node?

    A composed { } modifier is a composable that runs once per usage site on every composition, and because two composed instances never compare equal it also defeats the equality check the modifier chain would otherwise use to skip work.

    // 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…
  15. 15

    Someone put a BoxWithConstraints inside every LazyColumn item and the scroll got worse. What does subcomposition actually cost, and what should they have used?

    BoxWithConstraints is a SubcomposeLayout: it moves the composition of its children into the measure pass, so each row now composes during layout, every time it is measured, instead of once during composition.

    // 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)    }…
  16. 16

    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?

    Three real options: declare the class stable in a stability configuration file, map it into a type you own, or apply the Compose compiler to the module that produces it; mapping into a type you own is the only one that needs neither an unchecked promise nor control of the SDK module.

    // build.gradle.ktscomposeCompiler {  stabilityConfigurationFiles.add(    rootProject.layout.projectDirectory.file("compose_stability.conf")  )  reportsDestination = layout.buildDirectory.dir("compose_reports")…
  17. 17

    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?

    The first pass is cold: ART interprets code that was never compiled, classes load on the frame thread, and the slot table, layout nodes and caches are built from scratch.

    // 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") {…
  18. 18

    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?

    Look for offscreen buffers and fill rate: an alpha or a blur that forces the renderer to allocate a layer, shadows and non-rectangular clips, and stacked opaque backgrounds painting the same pixels several times over.

    // 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 {…
  19. 19

    How do you stop a Compose performance regression from merging, and what makes a scroll number stable enough to fail a pull request on?

    Use two gates with different characters: a compiler-metrics diff that is fully deterministic and runs in seconds on every PR, and a Macrobenchmark scroll on one fixed device that only means anything once you control the noise.