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
+24 -140

No files matched your search

@@ -5,31 +5,14 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.model.State import com.mikepenz.markdown.model.State
import com.mikepenz.markdown.model.parseMarkdown import com.mikepenz.markdown.model.parseMarkdown
/**
* Whether a piece of a row is close enough to the screen to be worth drawing.
*
* Defined here, where it is needed, and implemented by the list that knows where rows are -- so
* drawing a message does not have to know it is inside a transcript, and a message drawn anywhere
* else simply draws all of itself.
*
* The offsets are the row's own: a block's top measured from the top of the message it is part of.
*/
interface RowWindow {
fun visible(top: Int, height: Int): Boolean
}
val LocalRowWindow = compositionLocalOf<RowWindow?> { null }
/** /**
* A message's top-level markdown blocks, cut where the parser says the blocks are. * A message's top-level markdown blocks, cut where the parser says the blocks are.
* *
@@ -79,34 +62,19 @@ fun BlockedMarkdown(text: String, replies: ParsedReplies, modifier: Modifier = M
MarkdownText(blocks.first(), replies, modifier) MarkdownText(blocks.first(), replies, modifier)
return return
} }
val window = LocalRowWindow.current
val spacing = with(LocalDensity.current) { BLOCK_SPACING.roundToPx() }
val offsets = remember(blocks, spacing) { BlockOffsets(blocks.size, spacing) }
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(BLOCK_SPACING)) { Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(BLOCK_SPACING)) {
blocks.forEachIndexed { index, block -> blocks.forEach { block ->
Box( Box(
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.onSizeChanged { offsets.measured(index, it.height) } // One layer per block, for the reason the row has one: a block's glyphs are
// recorded once and afterwards moved. Splitting the message is what makes each
// of those layers a paragraph rather than a whole reply, which is what bounds
// both the recording and the memory the display list costs.
.graphicsLayer()
.drawWithContent { .drawWithContent {
val near =
window == null ||
window.visible(offsets.top(index), offsets.height(index))
if (near) {
DebugStats.count("block drawn")
// The number that says whether splitting bounded anything. A row can
// stay enormous and be fine, so long as no single *drawn* piece of it
// is -- and one node the parser will not divide, a long fenced code
// block above all, stays one piece however tall it is.
DebugStats.atLeast(
"tallest drawn block px",
offsets.height(index).toLong(),
)
val started = System.nanoTime() val started = System.nanoTime()
drawContent() drawContent()
DebugStats.record("draw: one block", System.nanoTime() - started) DebugStats.record("record: one block", System.nanoTime() - started)
} else {
DebugStats.count("block skipped")
}
} }
) { ) {
MarkdownText(block, replies) MarkdownText(block, replies)
@@ -115,38 +83,5 @@ fun BlockedMarkdown(text: String, replies: ParsedReplies, modifier: Modifier = M
} }
} }
/**
* Where each block of one message sits inside it, added up from the heights before it.
*
* The same shape as the list's own bookkeeping and for the same reason: adding the heights up on
* every lookup would be quadratic, and the lookup happens once per block per frame.
*/
private class BlockOffsets(count: Int, private val spacing: Int) {
private val heights = IntArray(count)
private var tops = IntArray(count)
private var stale = true
fun measured(index: Int, height: Int) {
if (index in heights.indices && heights[index] != height) {
heights[index] = height
stale = true
}
}
fun height(index: Int) = heights.getOrElse(index) { 0 }
fun top(index: Int): Int {
if (stale) {
var y = 0
for (i in heights.indices) {
tops[i] = y
y += heights[i] + spacing
}
stale = false
}
return tops.getOrElse(index) { 0 }
}
}
/** The gap between one block of a reply and the next. */ /** The gap between one block of a reply and the next. */
private val BLOCK_SPACING = 6.dp private val BLOCK_SPACING = 6.dp
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -25,6 +24,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent 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.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.layout.onPlaced
@@ -121,39 +121,6 @@ class TranscriptScroll(internal val scroll: ScrollState) {
topsStale = false 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. */ /** Where the last touch went down, in the content's own coordinates. */
private var tapY = 0f private var tapY = 0f
@@ -237,22 +204,6 @@ class TranscriptScroll(internal val scroll: ScrollState) {
val roomAbove: Int val roomAbove: Int
get() = scroll.maxValue - scroll.value 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? { private fun rowTop(seq: Long): Int? {
val index = rowIndex[seq] ?: return null val index = rowIndex[seq] ?: return null
refreshTops() 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. */ /** The height a row was last measured at, for the spacer that stands in for it. */
fun heightOf(seq: Long): Int = heights[seq] ?: 0 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. */ /** The height of the visible area, 0 until the first measurement. */
val viewport: Int val viewport: Int
get() = scroll.viewportSize get() = scroll.viewportSize
@@ -460,21 +403,27 @@ private fun RetainedRow(
} }
Column( Column(
Modifier.fillMaxWidth() 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) } .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 { .drawWithContent {
if (!state.onScreen(item.startSeq)) return@drawWithContent
val started = System.nanoTime() val started = System.nanoTime()
drawContent() 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 row(item)
// own part of it; see [RowWindow].
val window = remember(state, item.startSeq) { state.windowFor(item.startSeq) }
CompositionLocalProvider(LocalRowWindow provides window) { row(item) }
} }
} }