Draw a peer message a block at a time, and parse it off the drawing thread
A message from another agent was the one markdown in the app still rendered whole: one parse and one display list for the entire thing. Every settled reply has been cut into blocks since the transcript was made lazy, and `warm` has been making those parses ahead on a background thread -- but it filtered for assistant replies alone, so the longest message a transcript holds was also the only one parsed on the thread that draws. Measured on the emulator against a 43KB peer message, opening it: 177ms in `markdown parsed while composing`, against none afterwards and 156 blocks already ready. What is left is the card being a single list item, so all 156 blocks are still measured, placed and recorded at once -- 118ms of placement in that same frame. The `when` in `warm` is now the rule rather than a filter: every row that draws markdown belongs in it. Blocks are spaced by the transcript's own BLOCK_SPACING rather than the renderer's internal padding, which moves a heading about 6px (2.3dp) closer to the paragraph above it. The message's total height is unchanged, and it now matches every reply in the transcript. While here: FrameStats was remembered per session screen and DebugStats is a global emptied only by the copy button, so the two halves of a render report covered different stretches of time -- and `drawAccounting` divides one by the other. A report copied after visiting two sessions claimed 36.8 seconds of placement inside a 13.5 second window, and clamped "everything else" to 0.00ms (0%), which reads as a screen whose entire cost is this app's code. One FrameStats for the app, so both halves mean "since this was last copied". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6d12388063
commit
80aaf286c2
4 files changed
+56
-33
No files matched your search
@@ -10,7 +10,6 @@ import android.view.FrameMetrics
|
||||
import android.view.Window
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/**
|
||||
@@ -26,8 +25,15 @@ import androidx.compose.ui.platform.LocalContext
|
||||
* The phases are the platform's own: [FrameMetrics] reports each frame's cost in nanoseconds,
|
||||
* broken down into the parts the UI thread is responsible for -- handling input, running
|
||||
* animations, measuring and laying out, recording the draw -- and the parts after it.
|
||||
*
|
||||
* One of these for the app, like [DebugStats], because the two are read as one report and
|
||||
* [drawAccounting] divides one by the other. Held per screen it was emptied by leaving a session
|
||||
* and the counters were not, so a report copied after visiting two sessions divided every session's
|
||||
* work by the newest one's frame count -- and printed the result as a per-frame measurement. It
|
||||
* said 36.8 seconds of placement inside a 13.5 second window, and left "everything else" clamped at
|
||||
* 0.00ms (0%), which reads as a screen whose whole cost is this app's own code.
|
||||
*/
|
||||
class FrameStats {
|
||||
object FrameStats {
|
||||
private val total = ArrayList<Long>()
|
||||
private val waited = ArrayList<Long>()
|
||||
private val input = ArrayList<Long>()
|
||||
@@ -108,28 +114,28 @@ class FrameStats {
|
||||
}
|
||||
|
||||
private fun percent(part: Int, whole: Int) = "%.1f%%".format(100.0 * part / whole)
|
||||
|
||||
private companion object {
|
||||
/** Enough for a couple of minutes of scrolling; this is a diagnostic, not a log. */
|
||||
const val CAP = 20_000
|
||||
}
|
||||
}
|
||||
|
||||
/** Enough for a couple of minutes of scrolling; this is a diagnostic, not a log. */
|
||||
private const val CAP = 20_000
|
||||
|
||||
/**
|
||||
* Frame timings for as long as this screen is on it.
|
||||
* Records into [FrameStats] for as long as this screen is on it.
|
||||
*
|
||||
* The listener is what comes and goes; what it writes into does not, so a report covers the same
|
||||
* stretch of time as the counters beside it. See [FrameStats].
|
||||
*
|
||||
* The listener is handed its own thread because the platform calls it for every frame and the
|
||||
* documentation is explicit that doing that on the main thread taxes the very thing being measured.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberFrameStats(): FrameStats {
|
||||
val stats = remember { FrameStats() }
|
||||
fun recordFrames() {
|
||||
val window = LocalContext.current.activity()?.window
|
||||
DisposableEffect(window) {
|
||||
if (window == null) return@DisposableEffect onDispose {}
|
||||
val thread = HandlerThread("frame-stats").apply { start() }
|
||||
val listener = Window.OnFrameMetricsAvailableListener { _, metrics, _ ->
|
||||
stats.add(metrics)
|
||||
FrameStats.add(metrics)
|
||||
}
|
||||
window.addOnFrameMetricsAvailableListener(listener, Handler(thread.looper))
|
||||
onDispose {
|
||||
@@ -137,7 +143,6 @@ fun rememberFrameStats(): FrameStats {
|
||||
thread.quitSafely()
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
/** The activity behind a composable's context, which is what owns the window. */
|
||||
|
||||
Reference in new issue
Block a user