Four tools, four questions, and none of them answers another's

Layout Inspector, the compiler reports, Macrobenchmark and the system trace each answer a different question. Using the wrong one produces confident, wrong conclusions, and knowing the split is most of performance work.

6 min read
androidcomposekotlinperformance

Day 83 — Four tools, four questions

Day 83 of 100, closing the performance pillar. Five days of techniques; today, how to know which one you need.

The symptom

A performance investigation that produces a confident wrong answer:

"Layout Inspector says OrderRow recomposes 200 times during a scroll, so that's the problem. I'll make Order stable."

Two days later Order is stable, the recomposition count is unchanged, and nobody knows why. The count was real; the conclusion drawn from it wasn't supported by it.

Why the obvious approach fails

The obvious approach is to reach for whichever tool is already open — usually Layout Inspector, because it's in the IDE and gives a number.

The trouble is that a recomposition count answers one question: how often did this composable's body run. It cannot say whether that was expensive, whether it caused a dropped frame, whether the composable could have skipped, or whether the frame time went somewhere else entirely.

Four instruments, four questions. Using one to answer another's is how investigations go sideways.

Each tool answers one question; the order runs from 'is it Compose' to 'why this composable'

The actual mechanism

1. Macrobenchmark — "is there a problem, and did I fix it?"

The only tool that produces a number you can compare across time.

@Test fun scroll() = benchmarkRule.measureRepeated(
    packageName = "com.example.app",
    metrics = listOf(FrameTimingMetric()),
    iterations = 10,
    startupMode = StartupMode.WARM,
    compilationMode = CompilationMode.Partial(),
) {
    startActivityAndWait()
    device.findObject(By.res("order-list")).fling(Direction.DOWN)
}

Reports frame duration at P50/P90/P95/P99. P99 is where jank lives — a P50 of 5ms with a P99 of 80ms is a list that feels broken despite a good average.

Runs on a real device against a release build, which makes it the only measurement in this list that reflects what users experience. Start and end every investigation here.

2. System trace — "where did the time go?"

Android Studio's profiler, or Perfetto. Shows the frame timeline with each frame's work attributed: composition, layout, draw, GC, your own code, binder calls.

This is the tool that answers Day 78's first question — is it Compose at all? If a dropped frame is 40ms of database query, no amount of Compose work helps, and the trace says so in seconds.

Compose emits trace sections for composition, so you can see recomposition cost rather than just its count. Trace.beginSection markers around your own suspicious code make it sharper.

3. Layout Inspector — "which composable, and how often?"

Recomposition and skip counts per composable, live.

Its real strength is localisation: it points at a file. Its limits are worth stating plainly — it doesn't say whether the recompositions were expensive, it adds overhead that makes absolute timings unreliable, and it works against a debuggable build, which Day 78 warned is not representative.

Use it to find the component, then use the trace to find out whether it mattered.

4. Compiler reports — "could it have skipped?"

Day 79's *-classes.txt and *-composables.txt. A static answer, generated at build time, about what the compiler concluded.

This is the one that explains why a count is high. High recompositions with zero skips plus an unstable line in the report is a complete diagnosis; the same count with skips present means the parent is invalidating and you're looking at the wrong file.

The order

The sequence matters more than any individual tool:

1. Macrobenchmark — establish that there is a problem and record the number. Skipping this is how "it feels faster" becomes the only evidence.

2. System trace — find out which phase or subsystem the time is in. This routes you to one of Day 78's four categories.

3. Layout Inspector (only if the trace says composition) — find which composable.

4. Compiler report — find out whether it could have skipped and why not.

5. Fix, then Macrobenchmark again — against the number from step 1.

Steps 3 and 4 are the ones people start with, and they're the two that produce numbers without context.

Reading the two counts together

Layout Inspector's pair, restated because it's the highest-value reading in the whole pillar:

Recompositions Skips Means
High 0 It can't skip — unstable parameter or new instance each time (Days 79–80)
High High It's being invoked constantly — the parent is invalidating (Day 7)
Low Low Not your problem; look elsewhere

The first two point at completely different files. Getting them confused is the single most common way a Compose performance investigation wastes a day.

What each tool cannot tell you

Worth being explicit, since the gaps are where wrong conclusions come from:

  • Macrobenchmark says that a frame was slow, never why.
  • The trace says where time went, and won't attribute it to a specific composable in your source without markers.
  • Layout Inspector says how often, never how expensive — and its own overhead distorts timing.
  • The compiler report says what's possible, never what happened at runtime.

Each is a partial view, and the technique is triangulation rather than trusting one.

Where the counts mislead

Two specific traps worth naming, because both produce a number that looks alarming and isn't.

Lazy list items reset. Scrolling a LazyColumn disposes and re-creates items, so their recomposition counts climb continuously by design. A row at "300 recompositions" after a long scroll may be 300 different rows each composed once. Layout Inspector's reset button between measurements is what makes this readable.

The first composition always counts. A screen freshly navigated to shows every composable at one recomposition, which is not a problem — it's the definition of rendering. Reset after the screen settles, then interact.

The lightest-weight tool of all

Worth ending on, because it's underrated:

@Composable
fun OrderRow(order: Order) {
    SideEffect { Log.d("Recompose", "OrderRow ${order.id}") }
    …
}

Three lines, no tooling, works in any build, and answers "is this running when I think it is" immediately. For a specific hypothesis it's often faster than opening anything — and Day 66's mainClock control makes the same approach work in tests.

What this generalizes to

The performance pillar's conclusion: an instrument answers the question it was built for, and the discipline is knowing which question you're asking. A recomposition count is a fact; "therefore make it stable" is an inference the count doesn't support.

Six days of performance reduce to a short list — measure the phase not the symptom, read the report rather than guessing, defer reads to the phase that needs them, ship a baseline profile, and check the toolchain before believing advice about it. Everything else is a consequence of those, and most screens need none of it.

That last clause is the one worth carrying out of the pillar. The techniques here are sharp, and reaching for them on a screen that isn't dropping frames adds annotations, indirection and remember calls that a future reader can neither justify nor safely remove. Performance work without a measured problem is just complexity with a good reputation.

Tomorrow, Day 84 opens the testing pillar.


Day 83 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Recomposition counts.