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:
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user