Give every row its own layer, and stop culling during draw

The piece of a lazy list this had not rebuilt was the render node per
item. Without one, a row's glyphs are recorded into its parent's display
list, and that list is re-recorded every frame the parent is invalidated
-- which, while the list is scrolling, is every frame. With one, the row
is recorded once and afterwards moved by a transform, and the render
thread culls the ones off screen itself.

The draw-phase culling it replaces could not have won, and the two are
mutually exclusive rather than complementary: `onScreen` and `RowWindow`
read the scroll position from inside every row's and every block's
drawing, and reading a scroll position during draw invalidates the
drawing it is in. So the machinery for deciding which rows need not be
drawn was re-recording all of them, every frame, to decide it. Keeping
both would have bought the cost of the first and none of the second.

Measured on the emulator over a thirty-swipe scroll: **one** row display
list recorded across 481 frames, against 1,544 recordings for the same
gesture before, and the frame's draw phase down from 4.2ms to 2.9ms at
the median.

Two things this also corrects. `LAYOUT_MEASURE_DURATION` is the *View*
hierarchy's pass, and Compose is one view -- `AndroidComposeView.
dispatchDraw` calls `measureAndLayout()` before it records, so all of
Compose's own measurement, text shaping above all, is reported inside
DRAW_DURATION. Every "layout is 0.0ms, so nothing is being re-measured"
reading in this file's history was reading a bucket that never contained
it. And the retain window is now load-bearing for a second reason: a
retained display list costs memory, so bounding what is retained bounds
that too.

Found by the ai-app-2-6d session reading the render reports against this
code; the diagnosis and the ordering are theirs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-31 03:18:04 -04:00
1 parent 5b9941cfaa
commit e65c961e1e
2 files changed
+26 -142

No files matched your search

@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -25,6 +24,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onPlaced
@@ -121,39 +121,6 @@ class TranscriptScroll(internal val scroll: ScrollState) {
topsStale = false
}
/**
* Whether the row named by [seq] is close enough to the viewport to be worth drawing.
*
* Every row stays composed and measured -- that is what stops a message being rebuilt when
* somebody scrolls back to it, and it is why layout costs nothing here. Drawing is the other
* half and does not work the same way: the draw pass walks the whole tree, so with the rows
* retained it was recording a display list for all of them on every frame. Measured on a Pixel
* 9 Pro XL with 138 rows loaded: 13.6ms of draw at the median against an 8.3ms budget, with the
* GPU itself at 1.9ms. The work was ours and it was all in issuing draw commands for a
* transcript that was almost entirely off screen.
*
* A screen of margin either side, so a row is already drawn by the time it is reached and
* nothing appears mid-fling.
*/
fun onScreen(seq: Long): Boolean {
val index = rowIndex[seq] ?: return true
val height = heights[seq] ?: return true
refreshTops()
val top = tops.getOrNull(index) ?: return true
val near = near(top, height)
// Counted so that "drawing costs too much" can be told apart from "drawing was skipped and
// still costs too much". They need opposite fixes: the first is a row that should not have
// been drawn, the second is a single row too tall to record cheaply -- and one enormous
// reply on screen records as many glyph runs as a hundred short ones.
if (near) {
DebugStats.count("row drawn")
DebugStats.atLeast("tallest drawn row px", height.toLong())
} else {
DebugStats.count("row skipped")
}
return near
}
/** Where the last touch went down, in the content's own coordinates. */
private var tapY = 0f
@@ -237,22 +204,6 @@ class TranscriptScroll(internal val scroll: ScrollState) {
val roomAbove: Int
get() = scroll.maxValue - scroll.value
/**
* What a block inside the row named by [seq] should ask to find out whether it is on screen.
*
* The list is the only thing that knows where a row sits, and a message being drawn is the only
* thing that knows where its blocks sit inside it, so the two meet at [RowWindow]: the row
* supplies the base and the message supplies the offset. Remembered per row by the caller,
* because it is captured by every block's draw.
*/
fun windowFor(seq: Long): RowWindow =
object : RowWindow {
override fun visible(top: Int, height: Int): Boolean {
val base = rowTop(seq) ?: return true
return near(base + top, height)
}
}
private fun rowTop(seq: Long): Int? {
val index = rowIndex[seq] ?: return null
refreshTops()
@@ -291,14 +242,6 @@ class TranscriptScroll(internal val scroll: ScrollState) {
/** The height a row was last measured at, for the spacer that stands in for it. */
fun heightOf(seq: Long): Int = heights[seq] ?: 0
/** Whether a span of content, in content coordinates, is within a screen of the viewport. */
private fun near(top: Int, height: Int): Boolean {
val viewportTop = scroll.maxValue - scroll.value
val margin = scroll.viewportSize
return top + height >= viewportTop - margin &&
top <= viewportTop + scroll.viewportSize + margin
}
/** The height of the visible area, 0 until the first measurement. */
val viewport: Int
get() = scroll.viewportSize
@@ -460,21 +403,27 @@ private fun RetainedRow(
}
Column(
Modifier.fillMaxWidth()
// A layer of its own, which is the piece of a lazy list this had not rebuilt.
//
// Without one a row's glyphs are recorded into its parent's display list, and that
// list is re-recorded every frame the parent is invalidated -- which, while the list
// is scrolling, is every frame. With one the row is recorded once and afterwards moved
// by a transform, and the render thread culls the ones off screen itself.
//
// This replaces draw-phase culling that read the scroll position from inside every
// row's drawing. That arrangement could not win, and the two are mutually exclusive:
// reading a scroll position during draw invalidates the drawing it is in, so it
// re-recorded every row on every frame in order to decide most of them need not be
// drawn. Keeping both would have bought the cost of the first and none of the second.
.graphicsLayer()
.onSizeChanged { state.height(item.startSeq, it.height) }
// Composed and measured whether or not it is drawn; see [TranscriptScroll.onScreen].
// The check reads the scroll position from the draw phase, so moving the list
// invalidates drawing and nothing else.
.drawWithContent {
if (!state.onScreen(item.startSeq)) return@drawWithContent
val started = System.nanoTime()
drawContent()
DebugStats.record("draw: one row", System.nanoTime() - started)
DebugStats.record("record: one row", System.nanoTime() - started)
}
) {
// So a block of a long reply can ask the same question the row just answered, about its
// own part of it; see [RowWindow].
val window = remember(state, item.startSeq) { state.windowFor(item.startSeq) }
CompositionLocalProvider(LocalRowWindow provides window) { row(item) }
row(item)
}
}