Engineering

The Layout Inspector shows you a tree you didn't write

Layout Inspector renders the composition as an inspectable tree with per-composable recomposition and skip counts. Knowing what it can and cannot tell you is the difference between a diagnosis and a guess.

MobileByteSensei 8 min read
Day 93 — The inspector shows a tree you didn't write
androidcomposekotlintoolingperformance

Day 93 of 100. Day 83 placed this tool among four; today it gets read properly, because most of its value is in details people skip past.

The case this post is about

One inspector session on a slow order screen, and four numbers that produced no conclusion:

OrderScreen                 1 recomp     0 skips  fine
  Header                    1 recomp     0 skips  fine
  OrderSummary            312 recomp     0 skips  CANNOT SKIP — unstable param or a new instance each pass
  LineItemRow            1560 recomp  1248 skips  re-invoked by a parent; not itself the cause

LineItemRow has the worst number on the screen by a factor of five, and it is not the problem. It is skipping four times out of five and being re-invoked 312 times by something above it.

Fixing it would change nothing. This post follows those four rows, because the pair of counts is the diagnostic and the larger number on its own points at the wrong composable.

The symptom: an inspector session that produces no conclusion

An inspector session that produces no conclusion:

"OrderRow shows 340 recompositions. LazyColumn shows 1. Column shows 340. Text shows 340. Which one is the problem?"

Four numbers, no obvious next step. The instinct is to optimise the one with the biggest number, which here is a tie between three composables that are all doing the same thing — being re-invoked by something above them.

Why the obvious reading fails

The obvious reading is that a high count means that composable is expensive.

It doesn't. A count says a body ran; it says nothing about cost, and — crucially — nothing about why. Three of those four numbers are consequences of the fourth, and the tool doesn't order them for you.

The reading that produces an answer needs the second column.

Recompositions and skips together; the pair identifies which of two different problems

The mechanism: Layout Inspector renders the composition as a tree

Layout Inspector attaches to a debuggable process and renders the composition as a tree, with three things worth knowing about what you're looking at.

The tree isn't your source. Modifiers appear as nodes, inlined composables may be collapsed, and the library's internal composables are interleaved with yours. A Card in your code is several nodes here. The filter for "show only my code" is the first thing to turn on.

Every node carries its parameters, including the modifier chain and the resolved values. This is the fastest way to answer "what is actually being passed to this thing" — faster than a log, and it shows the values after defaults are applied.

Two counts per composable, and the pair is the whole diagnostic:

Recompositions Skips Reading
High 0 It cannot skip — unstable parameter, or a new instance each time (Days 79–80)
High High It's being invoked repeatedly — a parent is invalidating (Day 7)
Low Low Not the problem

Applied to the four numbers above: whichever ancestor shows a high count with few skips below it is where the invalidation starts. The children showing high counts with high skips are just being re-invoked, and optimising them changes nothing.

The table is a diagnosis, and a diagnosis over a tree is worth running rather than eyeballing — because the node with the worst numbers is usually not the one to fix:

data class Measured(val name: String, val recompositions: Int, val skips: Int,
                    val children: List<Measured> = emptyList())

fun classify(m: Measured): String = when {
    m.recompositions < 10 -> "fine"
    m.skips == 0 -> "CANNOT SKIP — unstable param or a new instance each pass"
    m.skips > m.recompositions / 2 -> "re-invoked by a parent; not itself the cause"
    else -> "partially skipping — mixed parameters"
}

// The CAUSE is the shallowest node that cannot skip. Everything under it is a symptom,
// however bad its own numbers look.
fun diagnose(m: Measured, depth: Int = 0): Measured? {
    println("%s%-22s %5d recomp %5d skips  %s"
        .format("  ".repeat(depth), m.name, m.recompositions, m.skips, classify(m)))
    if (m.recompositions >= 10 && m.skips == 0) return m
    return m.children.firstNotNullOfOrNull { diagnose(it, depth + 1) }
}

The order screen as the inspector reported it:

val tree = Measured("OrderScreen", 1, 0, listOf(
    Measured("Header", 1, 0),
    Measured("OrderSummary", 312, 0, listOf(
        Measured("LineItemList", 312, 0, listOf(
            Measured("LineItemRow", 1560, 1248),
            Measured("PriceText", 1560, 1248))))),
    Measured("Footer", 1, 0),
))
println("\ncause: ${diagnose(tree)?.name}")
OrderScreen                 1 recomp     0 skips  fine
  Header                    1 recomp     0 skips  fine
  OrderSummary            312 recomp     0 skips  CANNOT SKIP — unstable param or a new instance each pass
cause: OrderSummary

LineItemRow has the largest numbers on the screen — 1,560 recompositions — and it is not the problem; it is skipping four times in five and being re-invoked by an ancestor 312 times. The scan stops at OrderSummary and never descends, which is the behaviour you want: the fix is one unstable parameter on one composable, and the five rows underneath it go quiet for free.

Reset before you measure

The single most-skipped step, and it invalidates most sessions.

Counts accumulate from the moment the inspector attaches, so they include the initial composition of every screen you've visited. A number that includes app startup tells you nothing about a scroll.

The workflow that produces usable numbers:

  1. Attach, navigate to the screen, let it settle.
  2. Reset the counts.
  3. Perform the one interaction you're investigating — one scroll, one toggle.
  4. Read.

Without step 2 you're reading a lifetime total, which is why a LazyColumn item appearing to have "300 recompositions" is usually 300 different rows composed once each.

What it costs

Worth being explicit, since it changes what conclusions are safe:

It requires a debuggable build, which Day 78 established is not representative — live literals turn every constant into a state read, and R8 isn't applied. Counts are directionally useful; timings are not.

The instrumentation itself has overhead. Enabling recomposition counting slows the app, so "it feels janky with the inspector attached" is not evidence of anything.

Both are reasons the tool localises a problem rather than confirming one. Day 83's order stands: Macrobenchmark says whether there's a problem, the inspector says where.

The other views

Three panels people don't open:

3D mode rotates the tree to show layering. It's how you find an invisible view intercepting touches, or a Box stacking children in an unintended order — the kind of bug that's invisible in a flat screenshot. It's also the quickest way to see that AnimatedVisibility is still holding an exited element (Day 64), since the node is there in the tree with nothing visible on screen.

Component tree filtering by recomposition count sorts the tree by activity, which turns a large screen into a short list of candidates. Combined with the source-only filter, it usually reduces a hundred-node tree to three or four worth looking at — which is the tool's genuine strength, and the reason to reach for it before a trace when you have no hypothesis yet.

The attributes panel's modifier list shows the chain in resolved order, which is the practical way to debug Day 20's ordering problems — you can see that clip came after background rather than reasoning about it.

It's also the fastest way to answer a layout question the counts can't touch: why is this element 200dp wide? The panel shows the constraints it received and the size it reported — Day 21's negotiation, made visible rather than inferred.

The alternative when the tool is too heavy

For a specific hypothesis, three lines beat attaching a profiler:

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

Works in any build, no attach step, no overhead worth worrying about, and it answers "is this running when I think it is" immediately.

A slightly richer version counts without logging every line:

@Composable
fun RecompositionCounter(tag: String) {
    val count = remember { mutableIntStateOf(0) }
    SideEffect { count.intValue++ }
    Log.d("Recompose", "$tag: ${count.intValue}")
}

The inspector is better when you don't yet know where to look. The log is better once you have a suspect. Both beat guessing, which is the option people actually take most often.

How to prove it: the reset-then-interact loop

Take a screen you believe is fine and run the reset-then-interact loop on it. Two useful outcomes: either the counts are low, which is a fact worth having, or something is recomposing that you didn't expect — which is usually a value read too high up (Day 7) or a lambda allocation defeating skipping (Day 80).

Then do the same on a screen with a known problem and check that the two-column reading points at the file you'd already identified. Calibrating the tool against a case you understand is what makes it trustworthy on one you don't.

What this generalizes to: a measurement needs a baseline and a scope

The principle is a measurement needs a baseline and a scope. Counts since attach, on a debug build, across every screen you've visited, are a number without either. Reset, one interaction, one screen — and the same number becomes evidence.

That's true of every profiler in every ecosystem, and it's the step most often skipped because the tool happily shows a number without it. A number that's easy to obtain and hard to interpret is worse than no number, because it feels like data.

The habit that follows: before reading any measurement, say out loud what would count as normal. "This row should compose once per appearance" turns 340 from an alarming number into a specific question about what's invoking it 339 extra times.

Tomorrow, Day 94 closes the tools pillar with lint and the editor actions — the checks that run without you asking.


Day 93 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Layout Inspector.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS