diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MessageBlocks.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MessageBlocks.kt index b1ea9d7..66ce84e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MessageBlocks.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MessageBlocks.kt @@ -5,31 +5,14 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp import com.mikepenz.markdown.model.State 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 { null } - /** * 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) 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)) { - blocks.forEachIndexed { index, block -> + blocks.forEach { block -> Box( 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 { - 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() - drawContent() - DebugStats.record("draw: one block", System.nanoTime() - started) - } else { - DebugStats.count("block skipped") - } + val started = System.nanoTime() + drawContent() + DebugStats.record("record: one block", System.nanoTime() - started) } ) { 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. */ private val BLOCK_SPACING = 6.dp diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt index 30536c1..5db4920 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt @@ -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) } }