diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt index 8f47154..f2c3217 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt @@ -29,6 +29,9 @@ import androidx.compose.ui.platform.LocalContext */ class FrameStats { private val total = ArrayList() + private val waited = ArrayList() + private val input = ArrayList() + private val animation = ArrayList() private val layout = ArrayList() private val draw = ArrayList() private val sync = ArrayList() @@ -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), 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 7a91c28..d2f2bc8 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt @@ -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() + private var topsStale = true + internal fun laidOut(order: List, 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) }