Parse a reply's markdown once per composition, not once per delta
Scrolling was laggy, and measuring said where the time went. Instrumenting the transcript's main-thread work on the emulator, against `/stream 200`: markdown parsing ran fifty-eight times in three seconds -- once per streamed delta, each one re-parsing the whole message the reply had grown into -- for 49-78ms of main-thread work per three seconds, with single parses reaching 7.3ms. That is most of a frame at 60Hz and more than a whole one at 120. Everything else the transcript does per event was under a tenth of it. So only the first parse stays on the composing thread. That one has to: the renderer's asynchronous path draws an empty loading slot until its result arrives, which measures a row at nothing before it is measured at its real height, and the whole transcript above it collapses and springs back. Every parse after the first is the same row growing, and there is a previous parse to keep drawing until the new one lands -- so those go to a background thread and no frame is ever without a height. What is on screen stays a real prefix of the reply rather than a guess at it; it is simply one parse behind. The same measurement found `loaded` costing an ArrayList copy per event, and nothing reading it. It recorded every event the screen had ever seen against the possibility that a page arriving in front of them would need the events themselves to stitch on -- but `joinPages` heals the boundary from the folded rows and has since it was written, so this was a list that only ever grew. Checked on the emulator with ui-trace at 1kHz. Streaming at the newest end: the row's bottom edge holds at y=1940 while it grows upward, and the header, status row and composer do not move for six seconds. Scrolled back with a reply streaming: nothing moves at all, 0 of 45 elements over five seconds. Scrolling a mixed transcript: rows keep a constant height as they translate, so none of them arrives blank and fills in afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
30ebf4e25c
commit
62c22e38b7
2 files changed
+36
-23
No files matched your search
@@ -2,6 +2,8 @@ package com.example.aiapp
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
@@ -11,7 +13,10 @@ import androidx.compose.ui.unit.TextUnit
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import com.mikepenz.markdown.m3.markdownColor
|
||||
import com.mikepenz.markdown.m3.markdownTypography
|
||||
import com.mikepenz.markdown.model.State
|
||||
import com.mikepenz.markdown.model.parseMarkdown
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* An assistant's reply, rendered as the markdown it is written in.
|
||||
@@ -26,20 +31,7 @@ import com.mikepenz.markdown.model.parseMarkdown
|
||||
@Composable
|
||||
fun MarkdownText(text: String, modifier: Modifier = Modifier) {
|
||||
val body = MaterialTheme.typography.bodyLarge
|
||||
// Parsed here, in the composition, rather than by the overload that takes the text itself.
|
||||
// That one parses in a coroutine and draws an empty loading slot until the result arrives --
|
||||
// so a row is measured at nothing before it is measured at its real height, and the
|
||||
// transcript above it collapses and springs back. Seen with five replies on screen at once,
|
||||
// every one of them blank, the whole conversation shrunk to fit a single screen; a moment
|
||||
// later it was all there again. That is the "skipping up and down" this list must never do,
|
||||
// and no amount of scroll anchoring can survive a row that lies about its height first.
|
||||
//
|
||||
// The cost is the parse on the main thread, which is the trade being made deliberately: a
|
||||
// few milliseconds of work at the moment a row is composed, against a layout that is wrong
|
||||
// every time one is. If a very long reply ever makes that visible, the answer is the
|
||||
// renderer's streaming state -- which parses incrementally -- and not going back to a
|
||||
// placeholder with no height.
|
||||
val parsed = remember(text) { parseMarkdown(text) }
|
||||
val parsed = parsedMarkdown(text)
|
||||
Markdown(
|
||||
parsed,
|
||||
colors =
|
||||
@@ -112,3 +104,33 @@ fun MarkdownText(text: String, modifier: Modifier = Modifier) {
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [text] parsed: on the composing thread the first time this row is drawn, and off it every time
|
||||
* afterwards.
|
||||
*
|
||||
* The first parse has to be inline. The renderer's own asynchronous path draws an empty loading
|
||||
* slot until its result arrives, so a row is measured at nothing before it is measured at its real
|
||||
* height, and the transcript above it collapses and springs back. Seen with five replies on screen
|
||||
* at once, every one of them blank, the whole conversation shrunk to fit a single screen; a moment
|
||||
* later it was all there again. That is the "skipping up and down" this list must never do, and no
|
||||
* amount of scroll anchoring can survive a row that lies about its height first.
|
||||
*
|
||||
* Every parse *after* the first is a different case, and it is the one that was costing: a reply
|
||||
* arrives as hundreds of deltas, each one re-parsing the whole message it has grown into. Measured
|
||||
* against `/stream 200` on the emulator, that was fifty-eight parses and 78ms of main-thread work
|
||||
* in three seconds, with single parses reaching 7ms -- most of a frame at 60Hz and more than one at
|
||||
* 120. Those go to a background thread, and the row keeps drawing the parse it already has until
|
||||
* the new one lands, so there is never a frame without a height. What is on screen is always a
|
||||
* real prefix of the reply rather than a guess at it; it is simply one parse behind.
|
||||
*/
|
||||
@Composable
|
||||
private fun parsedMarkdown(text: String): State {
|
||||
// The text each parse came from, so the first composition's is not immediately repeated.
|
||||
val parsed = remember { mutableStateOf(text to parseMarkdown(text)) }
|
||||
LaunchedEffect(text) {
|
||||
if (parsed.value.first == text) return@LaunchedEffect
|
||||
parsed.value = text to withContext(Dispatchers.Default) { parseMarkdown(text) }
|
||||
}
|
||||
return parsed.value.second
|
||||
}
|
||||
@@ -595,9 +595,6 @@ fun SessionScreen(
|
||||
// screen starts with the end of the conversation and fetches earlier
|
||||
// pages only when somebody scrolls to them.
|
||||
var oldestSeq by remember { mutableLongStateOf(0L) }
|
||||
// Every transcript event loaded, in order, beside the rows they folded
|
||||
// into. See `apply`.
|
||||
var loaded by remember { mutableStateOf(listOf<SessionEvent>()) }
|
||||
// Sent, but not yet read by the session -- which is when the backend
|
||||
// records it and it comes back as a row. Until then it is drawn below
|
||||
// the working indicator, because that is where it is in the session's
|
||||
@@ -680,11 +677,6 @@ fun SessionScreen(
|
||||
if (event is SessionEvent.CommandSent) {
|
||||
waitingCommands = waitingCommands.filterNot { it.first == event.id }
|
||||
}
|
||||
// Kept as well as folded. Folding is one-way -- a tool's
|
||||
// start and end become one row -- so a page arriving in
|
||||
// front of what is already here cannot be stitched on
|
||||
// without the events themselves.
|
||||
loaded = loaded + event
|
||||
items = foldEvent(items, entry)
|
||||
}
|
||||
|
||||
@@ -824,7 +816,6 @@ fun SessionScreen(
|
||||
// screen -- `apply` refills them, and scrolling
|
||||
// up pages the rest back in as it always does.
|
||||
items = listOf()
|
||||
loaded = listOf()
|
||||
held = listOf()
|
||||
oldestSeq = 0L
|
||||
moreHistory = true
|
||||
|
||||
Reference in new issue
Block a user