Keep eight screens of rows built, and stand the rest down
Iris's readings made the shape unarguable: smooth with one page loaded, a step worse at the next, worse again at the one after, and 62.7ms at the median by 390 rows -- with layout at 0.1ms and the GPU at 1.8ms the whole time. The cost was following what was *loaded* rather than what was on screen, and keeping every row built is the only thing in here that does that. So the window is bounded. Eight screens either side stay fully built, which is about sixteen times what a lazy list keeps: everything somebody has just read is still there, and only a deliberate journey back through the conversation pays to rebuild anything. That was the point of retaining rows and it survives; what does not survive is retaining all of them. A row outside the window is replaced by a spacer of the height it was last measured at, so the transcript's total height is unchanged and nothing under the reader moves. A row that has never been measured has no height to stand in for it and is kept whatever its distance, which is exactly the row that has just been paged in. Each row decides for itself, in a composable of its own, from a derived state -- so a row hears about the scroll only when its own answer changes. Read from the list's body instead, every row would recompose whenever any row crossed the edge. On the emulator, same transcript and same gesture, the frame's draw phase goes from 2.7ms at the median to 0.4ms. Iris's own timing reading from before this says where the rest of her frame goes: the transcript's whole draw is 3.0ms mean against a 4.2ms median draw phase, so the median frame was already close to budget and what is left is the tail -- 20ms of waiting and 2.3 seconds of parsing in bursts, both of which arrive with a page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bdc62b6442
commit
cc41a03746
1 file changed
+86
-26
@@ -6,7 +6,9 @@ import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -14,6 +16,7 @@ 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
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -256,6 +259,38 @@ class TranscriptScroll(internal val scroll: ScrollState) {
|
||||
return tops.getOrNull(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the row named by [seq] is close enough to be worth keeping composed at all.
|
||||
*
|
||||
* The window that could not be avoided. Keeping every loaded row alive is what makes scrolling
|
||||
* back over a message cost nothing, and it is also the thing whose cost grows with the
|
||||
* conversation rather than with the screen -- measured on a Pixel 9 Pro XL as a step: smooth
|
||||
* with one page loaded, worse at the next, worse again at the one after, with the frame going
|
||||
* into the draw phase while almost nothing was being recorded.
|
||||
*
|
||||
* Eight screens either side is about sixteen times what a lazy list keeps, which is the whole
|
||||
* point: everything somebody has just read stays built, and only a deliberate journey back
|
||||
* through the conversation pays to rebuild anything. A row outside it is replaced by a spacer
|
||||
* of the height it was last measured at, so the transcript's total height does not change and
|
||||
* nothing under the reader moves.
|
||||
*
|
||||
* A row that has never been measured has no height to stand in for it and is kept whatever its
|
||||
* distance -- that is a row that has just been paged in, and it is about to be looked at.
|
||||
*/
|
||||
fun retains(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 * RETAIN_SCREENS
|
||||
return top + height >= viewportTop - margin &&
|
||||
top <= viewportTop + scroll.viewportSize + margin
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -385,32 +420,7 @@ fun TranscriptColumn(
|
||||
rows.forEach { item ->
|
||||
// Keyed so that a row keeps its composition -- and so the state inside it, an open
|
||||
// tool call, stays with the row rather than with the position.
|
||||
key(item.key) {
|
||||
Column(
|
||||
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.
|
||||
// Timed as well as counted. Counting said what was skipped, and the
|
||||
// answer stopped being useful the moment almost everything was: 4,090 rows
|
||||
// drawn against 94,815 skipped, and drawing still took 30.9ms. A timer
|
||||
// says which of the two remaining answers is true -- that the little being
|
||||
// drawn is somehow expensive, or that the time is not in the transcript at
|
||||
// all and every count here is beside the point.
|
||||
.drawWithContent {
|
||||
if (!state.onScreen(item.startSeq)) return@drawWithContent
|
||||
val started = System.nanoTime()
|
||||
drawContent()
|
||||
DebugStats.record("draw: 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) }
|
||||
}
|
||||
}
|
||||
key(item.key) { RetainedRow(state, item, row) }
|
||||
}
|
||||
below()
|
||||
}
|
||||
@@ -420,3 +430,53 @@ fun TranscriptColumn(
|
||||
val TRANSCRIPT_SPACING: Dp = 8.dp
|
||||
|
||||
val TRANSCRIPT_PADDING: PaddingValues = PaddingValues(16.dp)
|
||||
|
||||
/**
|
||||
* One row, kept built while it is near the screen and stood in for by its own height when it is
|
||||
* not.
|
||||
*
|
||||
* A composable of its own rather than a block inside the list, and that is what makes the window
|
||||
* affordable: whether a row is retained is read here, so Compose can invalidate this row alone when
|
||||
* the answer changes. Read from the list's own body instead, every row would recompose every time
|
||||
* any row crossed the edge of the window.
|
||||
*/
|
||||
@Composable
|
||||
private fun RetainedRow(
|
||||
state: TranscriptScroll,
|
||||
item: TranscriptRow,
|
||||
row: @Composable (TranscriptRow) -> Unit,
|
||||
) {
|
||||
// Derived, so a row hears about the scroll only when its own answer changes rather than on
|
||||
// every frame; see [TranscriptScroll.retains].
|
||||
val retained by
|
||||
remember(state, item.startSeq) { derivedStateOf { state.retains(item.startSeq) } }
|
||||
if (!retained) {
|
||||
DebugStats.count("row stood down")
|
||||
Spacer(
|
||||
Modifier.fillMaxWidth()
|
||||
.height(with(LocalDensity.current) { state.heightOf(item.startSeq).toDp() })
|
||||
)
|
||||
return
|
||||
}
|
||||
Column(
|
||||
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)) return@drawWithContent
|
||||
val started = System.nanoTime()
|
||||
drawContent()
|
||||
DebugStats.record("draw: 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) }
|
||||
}
|
||||
}
|
||||
|
||||
/** How far either side of the screen a row stays built; see [TranscriptScroll.retains]. */
|
||||
private const val RETAIN_SCREENS = 8
|
||||
Reference in new issue
Block a user