Keep every row laid out, but only draw the ones near the screen
Iris's Pixel 9 Pro XL said which half was costing, and it was not the half either of us was looking at. With 138 rows loaded: layout 0.0ms at the median, GPU 1.9ms, and **draw 13.6ms** against a 120Hz budget of 8.3ms. Nothing was being re-measured and the phone's rasteriser was idle; the UI thread was recording draw commands for a transcript that was almost entirely off screen. Retaining rows was right and stays. What does not follow the same rule is drawing: the draw pass walks the whole tree, so a list that keeps every row alive records every row every frame, and that cost grows with each page of history -- which is exactly what "worse afterwards" was. Composition and measurement are what must not be thrown away, because they are what has to be rebuilt from nothing when the reader comes back. A display list is rebuilt from a layout that is still there. So each row skips its own draw when it is off screen, by more than a screen's margin either side. The check reads the scroll position from the draw phase, so moving the list invalidates drawing and nothing else, and it is a lookup rather than a sum -- the running totals are rebuilt at most once a frame and only after something has actually changed height, since adding them up per row per lookup would have made the fix quadratic in the thing it was fixing. Also reports the three frame phases that were missing, which is why the phases on that reading did not add up to the total: the time a frame spent waiting for the UI thread to be free, handling input, and running animations. About 15ms of the 30.6 was in that gap and unattributed. On the emulator, same transcript, before and after: draw p50 3.9ms -> 2.5ms, p90 7.3ms -> 4.2ms, p99 48.6ms -> 5.2ms. The saving there is small because only 60 rows were loaded; it is proportional to how much is off screen, and on the phone that was 95,000px of content against a 1,474px viewport. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3e653c4797
commit
f9432b69cc
2 files changed
+76
-3
No files matched your search
@@ -29,6 +29,9 @@ import androidx.compose.ui.platform.LocalContext
|
||||
*/
|
||||
class FrameStats {
|
||||
private val total = ArrayList<Long>()
|
||||
private val waited = ArrayList<Long>()
|
||||
private val input = ArrayList<Long>()
|
||||
private val animation = ArrayList<Long>()
|
||||
private val layout = ArrayList<Long>()
|
||||
private val draw = ArrayList<Long>()
|
||||
private val sync = ArrayList<Long>()
|
||||
@@ -43,6 +46,12 @@ class FrameStats {
|
||||
if (metrics.getMetric(FrameMetrics.FIRST_DRAW_FRAME) == 1L) return
|
||||
if (total.size >= CAP) return
|
||||
total += metrics.getMetric(FrameMetrics.TOTAL_DURATION)
|
||||
// How long the frame waited for the UI thread to be free before it could start. Reported
|
||||
// because the phases otherwise do not add up to the total, and the gap is the interesting
|
||||
// part: it is the frame being held up by work that is not the frame's.
|
||||
waited += metrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION)
|
||||
input += metrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION)
|
||||
animation += metrics.getMetric(FrameMetrics.ANIMATION_DURATION)
|
||||
layout += metrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION)
|
||||
draw += metrics.getMetric(FrameMetrics.DRAW_DURATION)
|
||||
sync += metrics.getMetric(FrameMetrics.SYNC_DURATION)
|
||||
@@ -55,7 +64,9 @@ class FrameStats {
|
||||
|
||||
@Synchronized
|
||||
fun reset() {
|
||||
listOf(total, layout, draw, sync, issue, swap, gpu).forEach { it.clear() }
|
||||
listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
|
||||
it.clear()
|
||||
}
|
||||
since = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
@@ -71,6 +82,9 @@ class FrameStats {
|
||||
" late: $late (${percent(late, total.size)})" +
|
||||
if (total.size >= CAP) " [capped]" else "",
|
||||
phase("total ", total),
|
||||
phase("waited", waited),
|
||||
phase("input ", input),
|
||||
phase("anim ", animation),
|
||||
phase("layout", layout),
|
||||
phase("draw ", draw),
|
||||
phase("sync ", sync),
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.runtime.remember
|
||||
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.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onPlaced
|
||||
@@ -78,14 +79,67 @@ class TranscriptScroll(internal val scroll: ScrollState) {
|
||||
private var spacing = 0
|
||||
private var padTop = 0
|
||||
|
||||
/** Each row's top edge, added up from the heights before it; see [refreshTops]. */
|
||||
private var tops = IntArray(0)
|
||||
private var rowIndex = HashMap<Long, Int>()
|
||||
private var topsStale = true
|
||||
|
||||
internal fun laidOut(order: List<Long>, spacing: Int, padTop: Int) {
|
||||
this.order = order
|
||||
this.spacing = spacing
|
||||
this.padTop = padTop
|
||||
rowIndex = HashMap(order.size)
|
||||
order.forEachIndexed { index, seq -> rowIndex[seq] = index }
|
||||
topsStale = true
|
||||
}
|
||||
|
||||
internal fun height(seq: Long, height: Int) {
|
||||
heights[seq] = height
|
||||
if (heights.put(seq, height) != height) topsStale = true
|
||||
}
|
||||
|
||||
/**
|
||||
* The running total of every row's top edge, recomputed at most once per frame and only after
|
||||
* something has actually changed height.
|
||||
*
|
||||
* Adding it up per row per lookup would be quadratic, and the lookup happens once per row per
|
||||
* frame -- so on a long transcript the thing meant to save the frame would have been the thing
|
||||
* costing it.
|
||||
*/
|
||||
private fun refreshTops() {
|
||||
if (!topsStale) return
|
||||
val out = IntArray(order.size)
|
||||
var y = padTop
|
||||
order.forEachIndexed { index, seq ->
|
||||
out[index] = y
|
||||
y += (heights[seq] ?: 0) + spacing
|
||||
}
|
||||
tops = out
|
||||
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 viewportTop = scroll.maxValue - scroll.value
|
||||
val margin = scroll.viewportSize
|
||||
return top + height >= viewportTop - margin &&
|
||||
top <= viewportTop + scroll.viewportSize + margin
|
||||
}
|
||||
|
||||
/** Where the last touch went down, in the content's own coordinates. */
|
||||
@@ -289,7 +343,12 @@ fun TranscriptColumn(
|
||||
// tool call, stays with the row rather than with the position.
|
||||
key(item.key) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().onSizeChanged { state.height(item.startSeq, it.height) }
|
||||
Modifier.fillMaxWidth()
|
||||
.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)) drawContent() }
|
||||
) {
|
||||
row(item)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user