Read a Compose UI as a UI, and what it remembers as the UI's - #2944
Merged
Conversation
pyricau
force-pushed
the
create-ownership-rule-that-bit
branch
from
August 5, 2026 12:39
381eaa1 to
d0dcbbf
Compare
pyricau
force-pushed
the
read-a-compose-ui-as-a-ui
branch
2 times, most recently
from
August 5, 2026 17:36
ee539a4 to
8fb7d30
Compare
pyricau
force-pushed
the
create-ownership-rule-that-bit
branch
from
August 5, 2026 17:45
4ddf433 to
6b4dfcc
Compare
pyricau
force-pushed
the
read-a-compose-ui-as-a-ui
branch
2 times, most recently
from
August 6, 2026 03:45
560b7da to
8633922
Compare
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
force-pushed
the
read-a-compose-ui-as-a-ui
branch
from
August 6, 2026 03:47
8633922 to
e521d41
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #2939 — the base is
create-ownership-rule-that-bit, so this diff is just the two commitshere. 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 everyrememberof 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
independentPathsBelowDominatorwhy aLayoutNodewas a child of the root, on a dump ofleakcanary-apptaken off an API 36emulator:
The sample app has no Compose in it, and the images these numbers move around came from a screen added
to
leakcanary-appfor the measurement and deleted again, so a dump of it as it stands has the hierarchyand not the images.
AndroidComposeView.layoutNodes, a registry of every node of the window by semantics id, so ascreen's node tree hangs off the view as a list.
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:
InputMethodManager→ViewRootImpl→ViewTreeObserver.mOnGlobalFocusListeners,SnapshotKt.applyObservers→Recomposer, and the layerdependency graph.
What this does
Hold a Compose UI's nodes and modifiers where they belong— the two mechanisms a view hierarchyalready has, for a
LayoutNode: a virtual reference from a parent to each child(
LayoutNodeChildReferenceReader, bounded by the vector'ssizethe way the view reader is bounded bymChildrenCount), andOwnerRules saying a node belongs to its parent, the top node of a window to theAndroidComposeViewhosting it, and aModifier$Nodeto the chain it is on.Read what a composable remembers as the node's, not the composition's—SlotTableReferenceReaderreads the
int[]that describes the slot array (five ints a group, bit 30 of the group info meaning thegroup 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
ViewGroupbecause theView[]sits under theparent, 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 nothingper 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:AndroidComposeViewretainedLayoutNoderetainedThe
BitmapPainters andAndroidImageBitmaps of a screen are now dominated by aLayoutNodeunderAndroidComposeView→ComposeView→ … →DecorView→MainActivity→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 anddraws 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 andGraphicsLayers themselves are still flat under the root, afew 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 withits 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.