Skip to content

Read a Compose UI as a UI, and what it remembers as the UI's - #2944

Merged
pyricau merged 3 commits into
mainfrom
read-a-compose-ui-as-a-ui
Aug 6, 2026
Merged

Read a Compose UI as a UI, and what it remembers as the UI's#2944
pyricau merged 3 commits into
mainfrom
read-a-compose-ui-as-a-ui

Conversation

@pyricau

@pyricau pyricau commented Aug 5, 2026

Copy link
Copy Markdown
Member

Stacked on #2939 — the base is create-ownership-rule-that-bit, so this diff is just the two commits
here. Third of the three things asked for in that thread: after reading an evicting cache as a cache,
this is the Compose slot table work.

The problem

On a Compose screen the explorer had nothing to say. Every image was flat under the root, and the answer
to "what is holding this bitmap" was a composition — one Object[] per window holding every remember
of every composable in it, side by side, so an image one screen remembers is held by the same array as an
image five screens away.

Three things were flattening a Compose UI, each found by asking independentPathsBelowDominator why a
LayoutNode was a child of the root, on a dump of leakcanary-app taken off an API 36
emulator:

The sample app has no Compose in it, and the images these numbers move around came from a screen added
to leakcanary-app for the measurement and deleted again, so a dump of it as it stands has the hierarchy
and not the images.

  • AndroidComposeView.layoutNodes, a registry of every node of the window by semantics id, so a
    screen's node tree hangs off the view as a list.
  • The modifier graph, which is bidirectional and therefore all one piece — a node points at its
    coordinator, a coordinator at its layer and at its neighbours, a layer at the layers it depends on. Three
    references reached into it from outside, each a GC root away: InputMethodManagerViewRootImpl
    ViewTreeObserver.mOnGlobalFocusListeners, SnapshotKt.applyObserversRecomposer, and the layer
    dependency graph.
  • The composition itself.

What this does

Hold a Compose UI's nodes and modifiers where they belong — the two mechanisms a view hierarchy
already has, for a LayoutNode: a virtual reference from a parent to each child
(LayoutNodeChildReferenceReader, bounded by the vector's size the way the view reader is bounded by
mChildrenCount), and OwnerRules saying a node belongs to its parent, the top node of a window to the
AndroidComposeView hosting it, and a Modifier$Node to the chain it is on.

Read what a composable remembers as the node's, not the composition'sSlotTableReferenceReader
reads the int[] that describes the slot array (five ints a group, bit 30 of the group info meaning the
group emitted a node into its first slot, a group's slots running to the next group's anchor) and hands
each slot out from the node whose composable is inside it.

That reader is the one that replaces an object's references rather than adding to them, which is the
thing to know before changing it. Adding works for a ViewGroup because the View[] sits under the
parent, so both paths start there. A composition's array sits under the composition, nowhere near the UI,
so an added reference would move a bitmap's dominator up to whatever dominates both. The array is still
a node holding its own bytes, still reached through the table's own field, and every element is still
reached exactly once. A table that doesn't validate — a dump caught mid-write, or Compose's newer
chunked linkbuffer.SlotTable — keeps every reference of its own and says so in the log, all or nothing
per table, since half a table read would leave objects reachable through nothing.

Measured

On that API 36 dump of leakcanary-app, the two commits together:

Before After
AndroidComposeView retained 2,619,473 B 10,368,941 B
its root LayoutNode retained 8,895 B 7,744,454 B

The BitmapPainters and AndroidImageBitmaps of a screen are now dominated by a LayoutNode under
AndroidComposeViewComposeView → … → DecorViewMainActivity
ActivityThread$ActivityClientRecord. A second dump of the same app: 12,844,587 B and 5,139,099 B.

Two honest limits, both written down in notes/dominator-tree.md: an image the UI both remembers and
draws occupies a slot in more than one group, so it lands on the composable containing both rather than on
the exact one; and the NodeCoordinators and GraphicsLayers themselves are still flat under the root, a
few hundred bytes each, their graph having no one entry worth naming as the owner.

Tests

ComposeUiReferencesTest, nine of them over one synthetic dump shaped like a Compose window — a view with
its node registry, a node tree two deep, a node left in the parent's array past the size it counts, a
chain of two modifiers with a focus listener pointing into it, a gapbuffer slot table remembering a value
against each node, and a chunked one this can't read. Including the invariant that matters for a reader
that moves edges: the tree still retains every byte of the dump and nothing reads as garbage.

@pyricau
pyricau force-pushed the create-ownership-rule-that-bit branch from 381eaa1 to d0dcbbf Compare August 5, 2026 12:39
@pyricau
pyricau force-pushed the read-a-compose-ui-as-a-ui branch 2 times, most recently from ee539a4 to 8fb7d30 Compare August 5, 2026 17:36
@pyricau
pyricau force-pushed the create-ownership-rule-that-bit branch from 4ddf433 to 6b4dfcc Compare August 5, 2026 17:45
@pyricau
pyricau force-pushed the read-a-compose-ui-as-a-ui branch 2 times, most recently from 560b7da to 8633922 Compare August 6, 2026 03:45
pyricau added 3 commits August 6, 2026 05:47
A Compose UI is a tree of LayoutNodes, but nothing in a heap dump reads it as
one. A node keeps its children behind a vector with a mutation callback around
it, so every two levels of UI have three objects of bookkeeping between them and
it's an array that gets to own each node. And the rivals are worse than a
view's: AndroidComposeView keeps a flat registry of every node of a window by
semantics id, so a screen's whole node tree hangs off the view as a list, and
Compose's modifier nodes point back at their coordinators, their layers and each
other, so one reference into any of them reaches the lot — measured on an API 36
dump, three from outside, an input method manager's focus listener, the snapshot
observer's static, and the Recomposer.

So the same two things a view hierarchy already gets: a virtual reference from a
parent node to each child, and rules saying that a node belongs to its parent, a
window's top node to the view hosting it, and a modifier to the chain it is on.
On a real dump that's a Compose screen's bytes drawn inside the hierarchy of the
screen showing it rather than at the top of the heap.
A SlotTable is one Object[] per window holding every remember of every
composable in it, every composition local map, every lambda and every LayoutNode
side by side, plus an int[] describing it. Read as it is stored, a composition is
a flat list of thousands of unrelated objects: an image one screen remembers is
held by the same array as an image five screens away, and the answer to "what is
holding this bitmap" is a composition rather than a piece of UI.

The int[] is what makes the shape recoverable — five ints a group in the order
the composables ran, each group saying how many groups it contains, where its
slots start, and whether it emitted a node — so this walks it and hands each
slot out from the node whose composable is inside. A table it can't make sense of
keeps every reference of its own and says so in the log, since half a table read
would leave objects reachable through nothing.

Unlike every other reader here this replaces the array's references rather than
adding to them, which is the thing to know before changing it: the array sits
under the composition, nowhere near the UI, so an added reference would push a
bitmap further up rather than down. The array is still a node holding its own
bytes and every element is still reached exactly once.

Measured with the commit before it, on an API 36 dump of the sample app:
AndroidComposeView retains 10.4 MB where it retained 2.6 MB, its root LayoutNode
7.7 MB where it retained 8.9 kB, and the BitmapPainters and AndroidImageBitmaps
of a screen are now drawn under the composables that show them. The numbers, and
what is still flat under the root, are in notes/dominator-tree.md.
`leakcanary-app`, not `leakcanary-android-sample`: the sample app has no Compose
in it, so the note sent anyone reproducing this to an app where none of it fires.
Say too that the images came from a screen added for the measurement and deleted
again, since a dump of that app as it stands has the hierarchy and not the images.
@pyricau
pyricau force-pushed the read-a-compose-ui-as-a-ui branch from 8633922 to e521d41 Compare August 6, 2026 03:47
Base automatically changed from create-ownership-rule-that-bit to main August 6, 2026 04:46
@pyricau
pyricau merged commit 9808ef1 into main Aug 6, 2026
16 checks passed
@pyricau
pyricau deleted the read-a-compose-ui-as-a-ui branch August 6, 2026 04:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant