diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index caa6d2c8..d48404bd 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -81,8 +81,19 @@ android { // null when no upload key is configured → unsigned release. signingConfig = signingConfigs.findByName("release") } + create("profile") { + initWith(getByName("release")) + // Optimized/non-debuggable, but unminified so diagnostic stacks + // and instrumentation access remain readable. Never distributed. + isMinifyEnabled = false + isShrinkResources = false + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks += "release" + } } + testBuildType = providers.gradleProperty("hapiTestBuildType").orElse("debug").get() + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -161,6 +172,7 @@ dependencies { implementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) + add("profileImplementation", libs.androidx.compose.ui.test.manifest) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.test.runner) diff --git a/android/app/src/androidTest/kotlin/app/hapi/companion/feature/chat/ChatFrameProfileTest.kt b/android/app/src/androidTest/kotlin/app/hapi/companion/feature/chat/ChatFrameProfileTest.kt new file mode 100644 index 00000000..53774bd4 --- /dev/null +++ b/android/app/src/androidTest/kotlin/app/hapi/companion/feature/chat/ChatFrameProfileTest.kt @@ -0,0 +1,247 @@ +package app.hapi.companion.feature.chat + +import android.os.Handler +import android.os.HandlerThread +import android.os.SystemClock +import android.view.Choreographer +import android.view.FrameMetrics +import android.view.MotionEvent +import android.view.Window +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.lifecycleScope +import androidx.test.core.app.ActivityScenario +import androidx.test.platform.app.InstrumentationRegistry +import app.hapi.companion.ui.markdown.LocalMarkdownRenderCache +import app.hapi.companion.ui.markdown.MarkdownRenderCache +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.data.store.ChatHistoryPagingState +import app.hapi.protocol.chat.AgentTextBlock +import app.hapi.protocol.chat.VisibleChatBlock +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test + +/** Opt-in diagnostics. Deliberately NO Compose test rule/virtual frame clock. */ +class ChatFrameProfileTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + + private fun source(index: Int, rich: Boolean): String { + val paragraph = "Message $index. " + "Native transcript scrolling keeps the reading position stable. ".repeat(1 + index % 4) + if (!rich) return paragraph + return when (index % 4) { + 0 -> "## Review $index\n\n$paragraph\n\n- **Stable identity** and `cached layout`\n- A [reference](https://example.com) with *details*\n- [x] Verified" + 1 -> "### Code $index\n\n```swift\nfunc render() {\n" + (0..<14).joinToString("\n") { " let item$it = messages[$it] // row $index" } + "\n}\n```" + 2 -> "| File | Added | Removed |\n| --- | ---: | ---: |\n" + (0..<10).joinToString("\n") { "| source-$index-$it.swift | ${it + 2} | 1 |" } + else -> "$paragraph\n\n> A longer explanation with **emphasis**.\n\n$paragraph" + } + } + + private fun row(index: Int, text: String) = AgentTextBlock( + id = "profile-$index", localId = null, createdAt = index.toLong(), invokedAt = null, + text = text, meta = null, + ) + + private fun state(rows: List) = ChatUiState( + sessionId = "frame-profile", header = ChatHeaderUi("Frame profile", subtitle = null, active = false, thinking = false), + flavor = null, basePath = "/workspace/hapi", blocks = rows, permissionOverrides = emptyMap(), + hasMore = true, isLoadingOlder = false, isSyncingTail = false, isInitialLoading = false, + loadFailed = false, warning = null, tailRevision = 0, + ) + + @Test fun profileRealFrames() { + assumeTrue("Opt in with -e hapiScrollProfile true", InstrumentationRegistry.getArguments().getString("hapiScrollProfile") == "true") + val output = File(instrumentation.targetContext.getExternalFilesDir(null), "scroll-profile").apply { mkdirs() } + val records = File(output, "frames.jsonl").apply { writeText("") } + for (scenarioName in listOf("plain", "rich", "rich-updates")) { + val sources = (0..<800).map { source(it, scenarioName != "plain") } + val updates = (0..<100).map { sources[799] + "\n\nStreaming revision $it" } + val cache = MarkdownRenderCache().apply { prepare(sources.toSet()) } + val state = mutableStateOf(state(sources.mapIndexed(::row))) + lateinit var list: LazyListState + ActivityScenario.launch(ComponentActivity::class.java).use { activity -> + activity.onActivity { host -> + host.setContent { + list = rememberLazyListState() + HapiTheme(darkTheme = false, dynamicColor = false) { + CompositionLocalProvider(LocalMarkdownRenderCache provides cache) { + ChatTranscript( + state.value, ChatHistoryPagingState(), 0, false, + onViewport = { _, _ -> }, onLayout = { _, _ -> }, + onRetryHistory = {}, onJumpToLatest = {}, listState = list, + ) + } + } + } + } + SystemClock.sleep(2_000) + for (repetition in 1..3) { + // Real touch first gives history intent, then place each + // repetition at the same row. Setup excluded from samples. + swipe(activity, duration = 300) + activity.onActivity { it.lifecycleScope.launch { list.scrollToItem(350, 0) } } + SystemClock.sleep(1_000) + val probe = FrameProbe() + var startIndex = 0 + var width = 0 + var height = 0 + var refreshRate = 60f + shell("dumpsys gfxinfo ${instrumentation.targetContext.packageName} reset") + activity.onActivity { host -> + startIndex = list.firstVisibleItemIndex + width = host.window.decorView.width + height = host.window.decorView.height + @Suppress("DEPRECATION") + refreshRate = host.windowManager.defaultDisplay.refreshRate + probe.start(host.window) + if (scenarioName == "rich-updates") host.lifecycleScope.launch { + var revision = 0 + while (isActive && probe.active.get()) { + state.value = state.value.copy( + blocks = state.value.blocks.dropLast(1) + row(799, updates[revision % updates.size]), + messagesVersion = state.value.messagesVersion + 1, + ) + revision++ + delay(100) + } + } + } + android.os.Trace.beginSection("HapiScroll:$scenarioName:$repetition") + repeat(8) { + swipe(activity, duration = 450) + SystemClock.sleep(250) // includes natural fling/deceleration + } + SystemClock.sleep(800) + android.os.Trace.endSection() + var endIndex = 0 + activity.onActivity { host -> endIndex = list.firstVisibleItemIndex; probe.stop(host.window) } + probe.finish() + File(output, "$scenarioName-$repetition-gfxinfo.txt").writeText(shell("dumpsys gfxinfo ${instrumentation.targetContext.packageName} framestats")) + val record = probe.result(refreshRate.toDouble()).apply { + put("scenario", scenarioName); put("repetition", repetition); put("rows", 800) + put("viewportWidthPx", width); put("viewportHeightPx", height) + put("startIndex", startIndex); put("endIndex", endIndex) + put("api", android.os.Build.VERSION.SDK_INT) + put("metric", "window-frame-metrics-and-choreographer-not-presented-fps") + } + records.appendText(record.toString() + "\n") + val summary = JSONObject(record.toString()).apply { remove("framesMs"); remove("intervalsMs") } + android.util.Log.i("HapiFrameProfile", summary.toString()) + assertTrue("Probe must scroll across rows", startIndex - endIndex > 5) + assertTrue("Must capture real rendered frames", record.getInt("renderedFrames") > 60) + } + } + } + } + + private fun shell(command: String): String = instrumentation.uiAutomation.executeShellCommand(command).use { + android.os.ParcelFileDescriptor.AutoCloseInputStream(it).bufferedReader().readText() + } + + private fun swipe(activity: ActivityScenario, duration: Long) { + var width = 0 + var height = 0 + activity.onActivity { width = it.window.decorView.width; height = it.window.decorView.height } + val down = SystemClock.uptimeMillis() + fun send(action: Int, fraction: Float) { + val event = MotionEvent.obtain(down, SystemClock.uptimeMillis(), action, width * 0.5f, height * (0.22f + fraction * 0.56f), 0) + event.source = android.view.InputDevice.SOURCE_TOUCHSCREEN + try { instrumentation.sendPointerSync(event) } finally { event.recycle() } + } + android.os.Trace.beginSection("HapiGesture") + try { + send(MotionEvent.ACTION_DOWN, 0f) + for (step in 1..30) { + val wait = down + duration * step / 30 - SystemClock.uptimeMillis() + if (wait > 0) SystemClock.sleep(wait) + send(MotionEvent.ACTION_MOVE, step / 30f) + } + send(MotionEvent.ACTION_UP, 1f) + } finally { + android.os.Trace.endSection() + } + } + + private class FrameProbe : Choreographer.FrameCallback { + val active = AtomicBoolean(false) + private val thread = HandlerThread("HapiFrameMetrics").apply { start() } + private val frames = mutableListOf() + private val intervals = mutableListOf() + private var droppedReports = 0 + private var previous = 0L + private var began = 0L + private var ended = 0L + private val listener = Window.OnFrameMetricsAvailableListener { _, metrics, dropped -> + if (active.get() && metrics.getMetric(FrameMetrics.FIRST_DRAW_FRAME) == 0L) { + droppedReports += dropped + frames += doubleArrayOf( + metrics.getMetric(FrameMetrics.TOTAL_DURATION) / 1e6, + metrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION) / 1e6, + metrics.getMetric(FrameMetrics.DRAW_DURATION) / 1e6, + metrics.getMetric(FrameMetrics.SYNC_DURATION) / 1e6, + metrics.getMetric(FrameMetrics.COMMAND_ISSUE_DURATION) / 1e6, + ) + } + } + + fun start(window: Window) { + window.addOnFrameMetricsAvailableListener(listener, Handler(thread.looper)) + began = System.nanoTime() + active.set(true) + Choreographer.getInstance().postFrameCallback(this) + } + + override fun doFrame(frameTimeNanos: Long) { + if (!active.get()) return + val now = System.nanoTime() + if (previous != 0L) intervals += (now - previous) / 1e6 + previous = now + Choreographer.getInstance().postFrameCallback(this) + } + + fun stop(window: Window) { + ended = System.nanoTime() + active.set(false) + Choreographer.getInstance().removeFrameCallback(this) + window.removeOnFrameMetricsAvailableListener(listener) + thread.quitSafely() + } + + fun finish() { thread.join() } + + fun result(refreshRate: Double): JSONObject { + fun percentile(values: List, quantile: Double): Double = + values.sorted().let { if (it.isEmpty()) 0.0 else it[((it.size - 1) * quantile).toInt()] } + val total = frames.map { it[0] } + val seconds = (ended - began) / 1e9 + return JSONObject().apply { + put("refreshRateHz", refreshRate); put("seconds", seconds) + put("renderedFrames", frames.size); put("droppedMetricReports", droppedReports) + put("callbackFPS", intervals.size / seconds) + put("intervalP95Ms", percentile(intervals, 0.95)) + put("intervalP99Ms", percentile(intervals, 0.99)) + put("frameP50Ms", percentile(total, 0.5)); put("frameP95Ms", percentile(total, 0.95)) + put("frameP99Ms", percentile(total, 0.99)); put("maxFrameMs", total.maxOrNull()) + put("framesOverBudget", total.count { it > 1000 / refreshRate }) + put("framesOver50Ms", total.count { it > 50 }) + put("layoutP95Ms", percentile(frames.map { it[1] }, 0.95)) + put("drawP95Ms", percentile(frames.map { it[2] }, 0.95)) + put("syncP95Ms", percentile(frames.map { it[3] }, 0.95)) + put("commandP95Ms", percentile(frames.map { it[4] }, 0.95)) + put("framesMs", JSONArray(frames.map { JSONArray(it.toList()) })) + put("intervalsMs", JSONArray(intervals)) + } + } + } +} diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..1af7801d --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/scripts/profile-transcript.sh b/android/scripts/profile-transcript.sh new file mode 100755 index 00000000..8952292e --- /dev/null +++ b/android/scripts/profile-transcript.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Dedicated disposable emulator. Never install into a connected personal device. +set -euo pipefail +cd "$(dirname "$0")/.." +sdk=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}} +if [ -z "$sdk" ] && [ -f local.properties ]; then sdk=$(sed -n 's/^sdk.dir=//p' local.properties); fi +: "${sdk:?Set ANDROID_HOME to an installed Android SDK}" +export ANDROID_HOME="$sdk" +output=${HAPI_PROFILE_OUTPUT:-$(mktemp -d /tmp/hapi-android-frames.XXXXXX)} +mkdir -p "$output" +output=$(cd "$output" && pwd) +export ANDROID_AVD_HOME +ANDROID_AVD_HOME=$(mktemp -d /tmp/hapi-profile-avd.XXXXXX) +serial=emulator-5584 +pid= +cleanup() { + if [ -n "$pid" ]; then kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true; fi + rm -rf "$ANDROID_AVD_HOME" +} +trap cleanup EXIT +adb="$sdk/platform-tools/adb" +if "$adb" devices | grep -q "$serial"; then echo 'Test port occupied; refusing existing device'; exit 1; fi +./gradlew -PhapiTestBuildType=profile :app:assembleProfile :app:assembleProfileAndroidTest > "$output/build.log" 2>&1 +system_image=${HAPI_PROFILE_SYSTEM_IMAGE:-system-images;android-35;google_apis_playstore;arm64-v8a} +printf 'no\n' | "$sdk/cmdline-tools/latest/bin/avdmanager" create avd -n HapiFrameProfile -k "$system_image" -p "$ANDROID_AVD_HOME/device" +cat >> "$ANDROID_AVD_HOME/device/config.ini" <<'EOF' +hw.lcd.width=1080 +hw.lcd.height=1920 +hw.lcd.density=420 +hw.gpu.enabled=yes +EOF +gpu=${HAPI_PROFILE_GPU:-host} +printf 'system_image=%s\ngpu=%s\nbuild=profile (non-debuggable, unminified)\n' "$system_image" "$gpu" > "$output/environment.txt" +"$sdk/emulator/emulator" -avd HapiFrameProfile -no-window -no-audio -no-snapshot -no-boot-anim \ + -gpu "$gpu" -port 5584 -memory 4096 -cores 4 > "$output/emulator.log" 2>&1 & +pid=$! +booted=false +for attempt in $(seq 1 120); do + if [ "$("$adb" -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = 1 ]; then booted=true; break; fi + if ! kill -0 "$pid" 2>/dev/null; then cat "$output/emulator.log"; exit 1; fi + sleep 2 +done +if [ "$booted" != true ]; then echo 'Emulator boot timed out'; exit 1; fi +"$adb" -s "$serial" shell input keyevent 82 +"$adb" -s "$serial" shell settings put system screen_off_timeout 600000 +# Synthetic UI needs no network. Avoid freshly booted Play Store downloads / +# dexopt competing with the app; this is ONLY our disposable emulator. +"$adb" -s "$serial" shell cmd connectivity airplane-mode enable +"$adb" -s "$serial" shell pm disable-user --user 0 com.android.vending >> "$output/environment.txt" 2>&1 || true +"$adb" -s "$serial" shell dumpsys SurfaceFlinger > "$output/surfaceflinger.txt" +"$adb" -s "$serial" install -r -t app/build/outputs/apk/profile/app-profile.apk +"$adb" -s "$serial" install -r -t app/build/outputs/apk/androidTest/profile/app-profile-androidTest.apk +sleep 30 # keep install/boot work out of the measured intervals +"$adb" -s "$serial" logcat -c +if [ "${HAPI_PROFILE_TRACE:-0}" = 1 ]; then + cat > "$output/perfetto.config" <<'EOF' +buffers { size_kb: 262144 fill_policy: RING_BUFFER } +duration_ms: 180000 +data_sources { config { name: "android.surfaceflinger.frametimeline" } } +data_sources { + config { + name: "linux.ftrace" + ftrace_config { + atrace_apps: "run.hapi.companion" + atrace_categories: "gfx" + atrace_categories: "view" + ftrace_events: "sched/sched_switch" + ftrace_events: "sched/sched_waking" + } + } +} +data_sources { config { name: "linux.process_stats" } } +EOF + # Perfetto's SELinux domain cannot read /data/local/tmp on user images. + "$adb" -s "$serial" shell perfetto --background-wait --txt -c - \ + -o /data/misc/perfetto-traces/hapi-scroll.pftrace < "$output/perfetto.config" > "$output/perfetto-start.txt" +fi +"$adb" -s "$serial" shell am instrument -w -r -e hapiScrollProfile true \ + -e class app.hapi.companion.feature.chat.ChatFrameProfileTest \ + run.hapi.companion.test/androidx.test.runner.AndroidJUnitRunner | tee "$output/instrumentation.log" +"$adb" -s "$serial" pull /sdcard/Android/data/run.hapi.companion/files/scroll-profile "$output/" +if [ "${HAPI_PROFILE_TRACE:-0}" = 1 ]; then + trace_pid=$(tr -d '\r' < "$output/perfetto-start.txt" | awk '/^[0-9]+$/ {print $1}') + if [ -n "$trace_pid" ]; then "$adb" -s "$serial" shell kill -INT "$trace_pid"; fi + sleep 3 + "$adb" -s "$serial" pull /data/misc/perfetto-traces/hapi-scroll.pftrace "$output/" +fi +"$adb" -s "$serial" logcat -d > "$output/logcat.txt" +grep HapiFrameProfile "$output/logcat.txt" || true +grep -q '^OK (' "$output/instrumentation.log" +echo "Profile artifacts: $output" diff --git a/android/scripts/scroll-frame-summary.sql b/android/scripts/scroll-frame-summary.sql new file mode 100644 index 00000000..5067f0d0 --- /dev/null +++ b/android/scripts/scroll-frame-summary.sql @@ -0,0 +1,37 @@ +-- Perfetto trace_processor: -q android/scripts/scroll-frame-summary.sql TRACE +-- Present cadence only during real drags; never divide frame count by idle time. +-- FrameTimeline Prediction Error / SurfaceFlinger Scheduling classifications in +-- an emulator are not a reliable app-jank score. Report app deadlines separately. +WITH phases AS ( + SELECT id, name, ts, dur FROM slice WHERE name GLOB 'HapiScroll:*' +), gestures AS ( + SELECT p.name AS phase, g.id, g.ts, g.dur + FROM phases p JOIN slice g + ON g.name = 'HapiGesture' AND g.ts >= p.ts AND g.ts + g.dur <= p.ts + p.dur +), frames AS ( + SELECT g.phase, g.id AS gesture, a.ts + a.dur AS presented, + a.jank_type, a.on_time_finish + FROM gestures g JOIN actual_frame_timeline_slice a + ON a.ts + a.dur BETWEEN g.ts AND g.ts + g.dur + WHERE a.layer_name GLOB '*run.hapi.companion*' + AND a.dur > 0 AND a.present_type != 'Dropped Frame' +), intervals AS ( + SELECT *, (presented - LAG(presented) OVER (PARTITION BY gesture ORDER BY presented)) / 1e6 AS gap_ms + FROM frames +) +SELECT phase, + COUNT(DISTINCT gesture) AS gestures, + COUNT(*) AS presented_frames, + ROUND(1000 * COUNT(gap_ms) / SUM(gap_ms), 2) AS present_cadence_fps, + ROUND(PERCENTILE(gap_ms, 50), 2) AS present_p50_ms, + ROUND(PERCENTILE(gap_ms, 95), 2) AS present_p95_ms, + ROUND(PERCENTILE(gap_ms, 99), 2) AS present_p99_ms, + SUM(CASE WHEN on_time_finish = 0 THEN 1 ELSE 0 END) AS app_late_finish, + SUM(CASE WHEN jank_type LIKE '%App Deadline Missed%' THEN 1 ELSE 0 END) AS app_deadline_missed +FROM intervals GROUP BY phase ORDER BY phase; + +-- Must have all requested phases: a too-small ring buffer silently loses setup +-- and early scrolls even though the trace is otherwise parseable. +SELECT name FROM slice WHERE name GLOB 'HapiScroll:*' ORDER BY ts; + +SELECT name, value, description FROM stats WHERE severity != 'info' AND value != 0; diff --git a/docs/native-chat-scroll-profile.md b/docs/native-chat-scroll-profile.md new file mode 100644 index 00000000..27e5c469 --- /dev/null +++ b/docs/native-chat-scroll-profile.md @@ -0,0 +1,200 @@ +# Native 聊天滚动:模拟器实测 + +日期:2026-09-10。业务代码基线:`5194271f`。 + +## 结论 + +1. **优先优化 iOS 的差量刷新与复杂内容测量,不继续盲目优化列表索引。** + 普通文本接近 60 Hz;复杂 Markdown 稳定出现约 33 ms 的帧回调间隔。 + 叠加消息更新后进一步恶化。单变量实验已验证“刷新未变可见行”的额外成本。 +2. **Android 必须区分应用工作与模拟器噪声。** 多数轮次接近 60 Hz, + 但完整复测也出现过 47–52 FPS 和超过 600 ms 的长帧;不能只展示好看的轮次。 + 长帧调用链包含主线程等待 RenderThread 的 `postAndWait`,不应直接归因于 + Compose 列表重组。保留原始异常数据,增加离线、调度记录的控制实验。 +3. 定位轮的**诊断用简化优化已回滚**。后续落地了保留 hosting roots、桥接实时 + renderer/environment 的差量刷新;见下文“iOS 优化落地”。没有冻结行高或 + 为跑分停止传递主题、字体、内容、交互环境。 + +## 测量边界 + +- 主机:Apple M4 Pro,14 核,48 GiB;macOS 26.6.2,Xcode 26.6。 +- iOS:专用 iPhone 15 / iOS 26.5 Simulator,Release `-O` / whole-module + optimization,`ENABLE_TESTABILITY=YES`,显示回调请求 60 Hz。 +- Android:专用 API 35 ARM64 模拟器,1080 × 1920 / 420 dpi,4 vCPU / 4 GiB; + `-gpu host`,日志确认 Apple M4 Pro / Metal,而不是旧脚本的 SwiftShader。 + `profile` 构建:非 debuggable,未混淆,调试签名;不是商店分发 APK。 +- 两个平台顺序运行,不同时压测。800 条合成消息,使用实际聊天行渲染器、 + Markdown 缓存及原生列表,不是几何占位方块;每种场景重复 3 次。 +- 普通文本;复杂 Markdown(标题、列表、14 行代码、10 行 × 3 列表格、引用); + 复杂 Markdown + 每秒 10 次离屏尾消息更新。AST 在开始前预热。 +- iOS:真实 CADisplayLink 驱动 1,800 pt/s、每轮 6 秒的连续滚动。 + Android:真实 MotionEvent 拖动和自然减速,每轮 8 次;**不用 Compose 测试虚拟时钟**。 +- 不包含 Hub/网络/消息归约耗时;没有覆盖分页到达、图片解码、键盘、120 Hz、 + 极长代码、真实用户会话或低端真机。不能跨平台直接比较数字,也不代表发行版验收。 + +### “FPS”分别指什么 + +- iOS 的 Instruments `Animation Hitches` 明确返回 **“Hitches is not supported + on this platform”**。因此下面是 **CADisplayLink 实际回调频率**,不是合成器 + 最终呈现 FPS。P95/P99 来自墙钟回调间隔;`>25 ms` 是诊断阈值,不是 Apple hitch 指标。 +- Time Profiler attach 在本环境无响应,已终止;CPU 证据来自另一次独立的 + macOS `sample` 1 ms 调用栈采样。带采样开销的运行不用于帧率基线。 +- Android 同时记录 Choreographer 回调、Window.FrameMetrics、gfxinfo 和 + Perfetto FrameTimeline。呈现频率仅计算 **HapiGesture 拖动区间内** 连续呈现的 + 间隔;不能拿整个测试时长(含停顿)除帧数。 +- FrameTimeline 在此模拟器有大量 `Prediction Error` / `Early Present`, + 不能把所有 `jank_type != None` 统计成应用掉帧。应用 deadline miss 单独报告。 + 各字段来自不同时间边界,FrameMetrics 超预算数不应等同 FrameTimeline missed 数。 + +## iOS 基线与单变量实验 + +| 场景 | 回调频率,三轮范围 | 回调间隔 P95 | >25 ms 间隔 / 全部间隔 | +| --- | ---: | ---: | ---: | +| 普通文本 | 59.83–59.84 /s | 16.72–16.73 ms | 3 / 1,079(0.28%) | +| 复杂 Markdown | 55.83–56.66 /s | 27.58–32.56 ms | 68 / 1,013(6.71%) | +| 复杂 Markdown + 10 Hz 更新 | 53.86–54.83 /s | 33.26–33.27 ms | 103 / 979(10.52%) | + +独立 CPU 栈采样:32,440 个主线程样本,其中排除明确等待叶节点后 6,014 个。 +路径占比可重叠,不是可相加的 CPU 分账,也不是精确耗时: + +- `_UIHosting…` 路径:40.2%;`sizeThatFits`:32.8%; + `preferredLayoutAttributesFitting`:27.6%;富文本布局 `StyledText`:16.9%。 +- `TranscriptCollectionController.drain()` 路径:19.9%。 +- `TranscriptLayout`:2.3%。主线程未采到 `MarkdownBlockTree` 或 Highlightr 路径。 + +源码对应:`AnchoredTranscriptList.swift` 的 `drain()` 在任何配置更新时,把 +**所有可见 ID** 加入 `reconfigureItems`,即使变化只有离屏尾消息。原意是刷新 +SwiftUI 环境快照;代价是重复配置 hosting roots、自适应测量和富文本布局。 + +诊断实验只删除 `|| visibleIDs.contains($0.id)`,其他代码与输入不变,重新启动进程 +重复三轮,然后恢复原文件: + +| 场景 | 原均值 → 实验均值 | >25 ms 间隔数 | +| --- | ---: | ---: | +| 普通文本 | 59.84 → 59.84 /s | 3 → 3 | +| 复杂 Markdown | 56.22 → 56.11 /s | 68 → 70 | +| 复杂 Markdown + 更新 | **54.29 → 55.62 /s** | **103 → 79** | + +证据支持“小而明确的更新路径收益”,不是消除所有卡顿。实验没有改复杂内容本身, +静态复杂列表也没有改善。**不能直接发布这一删行方案**:主题、Dynamic Type、 +locale、basePath、链接/交互环境变化仍必须传递到已有 hosting cells。 + +## iOS 优化落地:保留 hosting roots + +改动限于列表与聊天入口,无 Markdown/协议重写: + +- `reconfigureItems` 只处理内容或宽度变化,不再无条件加入所有可见 ID。 +- 每个 hosting root 保留轻量 SwiftUI wrapper,读取共享的 observable renderer。 + 最新 builder、basePath 等普通值捕获和 action captures 继续传入;SwiftUI 自己 + 比较返回的视图,而非让 UIKit 每次重新安装 hosting 配置。 +- 在 `updateUIViewController` 读取 `context.environment`,统一桥接到已有与新建行。 + 不枚举可比较的环境键;主题、Dynamic Type、locale、方向、自定义服务都保留。 + 试验中发现:仅在逃逸 builder 中读取父视图的 `@Environment(\.self)`,可能漏掉 + 环境更新依赖。因此去掉聊天入口的手工快照,由列表负责传播。 +- 快照串行化、锚点补偿、异步 self-sizing、隐藏页布局确认、重复 ID 防御不变。 + **仍会更新轻量 wrapper;不是宣称 SwiftUI 零工作或跳过所有测量。** + +新增回归覆盖:离屏更新的零可见 cell 重配置预算、可见行增高与锚点、回收后最新 +内容、相同 Item 下的主题/basePath/链接 action 更新、语言/方向/字号变化及重新测量。 +UIKit 自身可能在方向 trait 改变时重新请求 cell;这不属于无条件消息刷新。 + +复测使用相同 Release 场景,按“新实现 → 旧实现复跑 → 新实现复跑”顺序,均独立 +进程、专用模拟器。本轮新增计时前的渲染截图和可见复杂行高度断言,已本地查看截图; +旧/新实现初始行高一致。截图在计时区间外,但可能影响暖机,**收益只与本轮同一 +测试器的旧实现复跑比较,不用上一轮 54.29 /s 直接计算提升。** + +| 场景 | 旧实现复跑,均值 | 新实现第 1 / 第 2 批,均值 | >25 ms:旧 / 新第 1 批 / 新第 2 批 | +| --- | ---: | ---: | ---: | +| 普通文本 | 59.83 /s | 59.84 / 59.84 /s | 3/1,078 / 3/1,079 / 3/1,080 | +| 复杂 Markdown | 59.73 /s | 59.34 / 59.45 /s | 5/1,078 / 12/1,070 / 10/1,073 | +| 复杂 Markdown + 更新 | 57.89 /s | 59.12 / 59.06 /s | 38/1,044 / 16/1,066 / 17/1,064 | + +每批每场景三轮。合并新实现两批:更新场景均值 59.09 /s,较本轮旧实现约 +2.1%, +长间隔比例 3.64% → 1.55%。静态复杂场景没有改善,且略有回落;旧实现更新场景 +也有 58.84、58.84、56.00 /s 的轮次差异。 +因此主要确定性收益是**消除无关 cell 重配置**,不能据此承诺真机满帧。 +本轮不增加复杂行高缓存、不改代码块/表格排版;继续保留动态测量正确性,后续用 +真机长帧调用栈决定是否值得进一步优化复杂行。 + +最终校验:Debug **14 项通过**(含工作量断言);Release 两批各 **13 项通过** +(含 opt-in 帧测试)。Debug 有一项因主机休眠耗时约 650 秒:`pmset` 确认 +13:16–13:27 的系统休眠,非应用长帧数据;Release 对应回归约 0.33 秒。 +全部计时场景每轮约 6 秒,不把普通测试耗时作为帧性能证据。 + +原始记录:`/tmp/hapi-scroll-live-environment*`、`/tmp/hapi-scroll-baseline-control*`; +渲染预览和逐轮 JSON 留在各自 xcresult attachments,仅本地校验,未上传。 + +## Android:完整结果而非挑选最好轮次 + +初次运行,多数暖机轮的 FrameMetrics P95 为 4–7 ms,Choreographer 约 59.9 /s; +普通文本第一轮 P95 21.20 ms。此轮 64 MiB trace 环形缓冲只保留后五个场景; +应用 JSON/gfxinfo 九轮完整,但不把这份 trace 当成九轮完整证据。 + +扩大为 256 MiB,并加入真实拖动标记后的复测: + +| 场景 / 轮次 | 拖动区间呈现频率 | 呈现间隔 P95 | 应用 deadline miss | +| --- | ---: | ---: | ---: | +| 普通文本 1 / 2 / 3 | 59.39 / 59.95 / 60.12 FPS | 24.90 / 19.21 / 18.10 ms | 3 / 0 / 0 | +| 复杂内容 1 / 2 / 3 | 51.66 / 49.55 / 60.13 FPS | 19.02 / 40.23 / 19.45 ms | 1 / 37 / 1 | +| 复杂内容 + 更新 1 / 2 / 3 | 59.78 / 59.60 / 47.58 FPS | 19.41 / 18.38 / 21.21 ms | 0 / 0 / 4 | + +短采样窗口边界/时间戳抖动可让估计值略高于 60,不意味着设备超过刷新率。 +最后一轮更新场景的 FrameMetrics 最大值 688.15 ms,不能用一个平均 FPS 隐藏它。 +Trace 中有约 609 / 625 ms 的 `draw-VRI → postAndWait`,主线程等渲染线程; +也有长重组段。仅有 view/gfx 的 trace 无法判断这些段究竟在运行还是被抢占。 +同期新模拟器的 GMS/Play Store 后台工作仍活跃;这只是环境竞争线索,不是定论。 + +控制实验:专用模拟器启用飞行模式、禁用 Play Store 自更新、等待 30 秒; +增加 `sched_switch` / `sched_waking` 和 process metadata。采到 495,548 条调度片段, +九个测试段均完整;**不修改应用业务代码**的结果: + +| 场景 | 拖动区间呈现频率,三轮范围 | Window.FrameMetrics P95 | 应用 deadline miss | +| --- | ---: | ---: | ---: | +| 普通文本 | 60.04–60.13 FPS | 首轮 18.34 ms,后两轮 4.23–5.02 ms | 首轮 1,后两轮 0 | +| 复杂 Markdown | 60.00–60.05 FPS | 4.68–5.02 ms | 0 | +| 复杂 Markdown + 更新 | 59.32–59.75 FPS | 4.53–5.34 ms | 0 | + +控制轮没有重现数百毫秒长帧;呈现间隔 P95 为 17.87–18.97 ms。所有运行的 +FrameMetrics dropped reports 均为 0。结果支持**环境控制对 Android 模拟器测量 +影响显著**,但多个环境变量一起改变,不能认定某个 GMS 任务就是唯一根因, +更不能声称应用优化消除了先前长帧。原始异常轮次仍保留。 + +## 下一轮优化顺序 + +1. **iOS 差量刷新已实现。** 数据/宽度变化走 diffable;环境和普通值捕获通过 + live SwiftUI wrapper 传播,不再靠消息更新时重配所有可见行。继续保留新增回归。 +2. **iOS:减少复杂行的重复测量。** 优先复用 renderer-ready 内容与 hosting 配置; + 评估基于内容版本、宽度、字体的测量缓存。异步图片、展开态、代码高亮改变高度时 + 必须失效。更长 Markdown 可评估按块虚拟化;不要先重写已是二分查询的列表索引。 +3. **Android:先归因首轮/偶发长帧。** 结合调度、RenderThread/GPU、冷/暖缓存, + 再决定 Baseline Profile、文本布局缓存或复杂行预取。现有结果不足以支持 + “把 LazyColumn 重写一遍”或“已稳定满帧”的结论。 +4. 后续验收补真实手势 iOS / 快速 fling / 分页提交 / 图片 / 键盘,最后在真机 + release/profile 构建上比较 P95/P99、deadline misses、内存;不把模拟器 FPS 设为 CI gate。 + +## 复测 + +```sh +# iOS:opt-in;默认 CI 跳过帧率诊断。脚本创建并删除自己的模拟器。 +TEST_RUNNER_HAPI_SCROLL_PROFILE=1 ios/scripts/test-transcript.sh \ + -configuration Release ENABLE_TESTABILITY=YES SWIFT_EMIT_LOC_STRINGS=NO \ + -only-testing:HapiTests/TranscriptFrameProfileTests +# 可选:TEST_RUNNER_HAPI_SCROLL_SCENARIOS=rich,rich-updates + +# Android:要求已安装对应 system image;所有 adb 操作绑定专用 emulator-5584。 +ANDROID_HOME=/path/to/sdk HAPI_PROFILE_TRACE=1 \ + HAPI_PROFILE_OUTPUT=/tmp/hapi-frame-run android/scripts/profile-transcript.sh +# Intel/Linux 主机替换为本机已安装的 x86_64 system image: +# HAPI_PROFILE_SYSTEM_IMAGE='system-images;android-35;google_apis;x86_64' + +# 使用 Perfetto 官方 trace_processor 离线分析,不上传 trace: +trace_processor /tmp/hapi-frame-run/hapi-scroll.pftrace \ + -q android/scripts/scroll-frame-summary.sql +``` + +Android trace 必须包含全部 9 个 `HapiScroll` 段与每段 8 个 `HapiGesture`。 +本机 `ftrace_setup_errors=36` 为部分系统类别/事件不可用;已采到的应用段、 +FrameTimeline 仍可使用,不能把缺失事件推断成零工作。 + +配套摘要:`performance/native-scroll-2026-09-10.json`。原始 JSON 数组 / gfxinfo / +Perfetto / xcresult / sample 位于本机 `/tmp/hapi-scroll-frame-*`,未提交大型 trace。 diff --git a/docs/native-chat-scrolling.md b/docs/native-chat-scrolling.md index d6dde86f..b63fcb31 100644 --- a/docs/native-chat-scrolling.md +++ b/docs/native-chat-scrolling.md @@ -57,6 +57,15 @@ affected suffix's geometry, without allocating attributes for offscreen rows. Attributes are cached lazily and never mutated after being handed to UIKit; unchanged IDs/width do not rebuild geometry on a content-only refresh. +Only changed row values or widths request diffable reconfiguration. Existing +hosting roots read a shared observable renderer and the latest representable +environment; SwiftUI diffs their content without reinstalling every visible +hosting configuration. This preserves theme, Dynamic Type, locale, direction, +services, and action/value captures without an incomplete environment whitelist. +The row builder executes inside a SwiftUI body so row-owned observable reads +belong to the row. Dynamic heights still use UIKit self-sizing; no frozen-height +or measurement-result cache is introduced. + `ChatPresentationState` keeps expansion/form state outside recycled cells, pruned to retained message/request IDs. Expanded tool groups become individual display rows, without changing protocol groups. The per-chat Markdown cache @@ -130,6 +139,11 @@ coordinator/store cancellation, retry, cursor, and retention behavior. Stateful group-ID tests cover splits, inherited-ID collisions, prepends, and stable recomputes. UIKit tests also verify that regrouping preserves both groups and the existing reading anchor, plus duplicate-delivery handling. +`TranscriptRefreshTests` verifies that offscreen streaming does not reconfigure +unchanged visible cells, while visible edits resize, recycled rows see the +latest data, and equal items receive updated environments and action captures. +Debug counters also check the zero-configuration budget for ordinary context +updates; UIKit may request cells itself when layout-direction traits change. Replay-gap tests cover same-epoch retention, epoch/reset replacement, and trailing validation. App coordinators cover a slow invalidated page returning after tail sync; Compose tests cover saved-screen and saved-instance-state @@ -140,6 +154,9 @@ and restores expanded child-row anchors across navigation and saved state. These are deterministic layout regressions, not proof of release-device FPS. +Real-clock simulator measurements, CPU sampling, differential-refresh results, +and limitations: [Simulator scroll profiling](native-chat-scroll-profile.md). + ### Scroll work budgets Measured before/after this optimization with dedicated simulators/emulators diff --git a/docs/performance/native-scroll-2026-09-10.json b/docs/performance/native-scroll-2026-09-10.json new file mode 100644 index 00000000..b7c7fb59 --- /dev/null +++ b/docs/performance/native-scroll-2026-09-10.json @@ -0,0 +1,2192 @@ +{ + "schemaVersion": 1, + "date": "2026-09-10", + "businessCodeBaseline": "5194271f", + "host": { + "cpu": "Apple M4 Pro", + "cores": 14, + "memoryGiB": 48, + "os": "macOS 26.6.2", + "xcode": "26.6" + }, + "scope": "800 synthetic messages, actual native renderers, UI-only; no network/pagination/images; 3 repetitions per scenario", + "metricCaveats": [ + "iOS values are real CADisplayLink callback cadence, NOT compositor-presented FPS.", + "iOS CPU-sampled run excluded from timing results; overlapping stack-path percentages are not additive.", + "Android present cadence uses only real drag intervals, excludes pauses between gestures.", + "Android emulator Prediction Error/Early Present classifications are not app-jank counts.", + "Android profile build is non-debuggable and unminified, not the store APK.", + "Android initial 64 MiB trace lost early intervals; its app JSON/gfxinfo are complete.", + "Android controlled run changed environment, not app business code; no app FPS improvement claim.", + "No real-device FPS claim or CI timing threshold." + ], + "iosBaseline": [ + { + "callbackFPS": 59.835856118858985, + "callbacks": 360, + "distancePoints": 10799.666666666664, + "intervalP50Ms": 16.66574997943826, + "intervalP95Ms": 16.71550000901334, + "intervalP99Ms": 16.741750005166978, + "intervalsOver25Ms": 1, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.10416665044613, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.8911666518542916, + "os": "26.5", + "repetition": 1, + "rows": 800, + "scenario": "plain", + "seconds": 6.016459416656289, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 59.838464817681064, + "callbacks": 360, + "distancePoints": 10799.333333333328, + "intervalP50Ms": 16.665875009493902, + "intervalP95Ms": 16.721541673177853, + "intervalP99Ms": 16.755458345869556, + "intervalsOver25Ms": 1, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 32.65629164525308, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.607750000199303, + "os": "26.5", + "repetition": 2, + "rows": 800, + "scenario": "plain", + "seconds": 6.0161971249908675, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 59.83293361634186, + "callbacks": 359, + "distancePoints": 10770, + "intervalP50Ms": 16.665666655171663, + "intervalP95Ms": 16.728499991586432, + "intervalP99Ms": 17.154375003883615, + "intervalsOver25Ms": 1, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 32.643500017002225, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.4645416629500687, + "os": "26.5", + "repetition": 3, + "rows": 800, + "scenario": "plain", + "seconds": 6.000040083308704, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 56.66026051610371, + "callbacks": 340, + "distancePoints": 10771.333333333336, + "intervalP50Ms": 16.674708313075826, + "intervalP95Ms": 27.575958345551044, + "intervalP99Ms": 33.30479166470468, + "intervalsOver25Ms": 20, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.38941663969308, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.5523333384189755, + "os": "26.5", + "repetition": 1, + "rows": 800, + "scenario": "rich", + "seconds": 6.000678374985, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 56.18475236535162, + "callbacks": 338, + "distancePoints": 10798.666666666672, + "intervalP50Ms": 16.67508331593126, + "intervalP95Ms": 32.56079167476855, + "intervalP99Ms": 33.27533332048915, + "intervalsOver25Ms": 23, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.32341666100547, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.5029166641179472, + "os": "26.5", + "repetition": 2, + "rows": 800, + "scenario": "rich", + "seconds": 6.015867041685851, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 55.82756758609029, + "callbacks": 335, + "distancePoints": 10771, + "intervalP50Ms": 16.679666645359248, + "intervalP95Ms": 32.55633334629238, + "intervalP99Ms": 33.30287500284612, + "intervalsOver25Ms": 25, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.42083335155621, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.5006249994039536, + "os": "26.5", + "repetition": 3, + "rows": 800, + "scenario": "rich", + "seconds": 6.000619666679995, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 53.85873676189395, + "callbacks": 324, + "distancePoints": 10798.333333333336, + "intervalP50Ms": 16.67712500784546, + "intervalP95Ms": 33.27216664911248, + "intervalP99Ms": 33.38325000368059, + "intervalsOver25Ms": 37, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.87570832273923, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 1.2182083446532488, + "os": "26.5", + "repetition": 1, + "rows": 800, + "scenario": "rich-updates", + "seconds": 6.015737083333079, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 54.189111646722964, + "callbacks": 326, + "distancePoints": 10798.666666666672, + "intervalP50Ms": 16.67679165257141, + "intervalP95Ms": 33.26404167455621, + "intervalP99Ms": 33.369624987244606, + "intervalsOver25Ms": 35, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.38958331733011, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 1.192208321299404, + "os": "26.5", + "repetition": 2, + "rows": 800, + "scenario": "rich-updates", + "seconds": 6.015968708350556, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 54.83325412958423, + "callbacks": 329, + "distancePoints": 10770, + "intervalP50Ms": 16.675374994520098, + "intervalP95Ms": 33.2626250165049, + "intervalP99Ms": 33.36345832212828, + "intervalsOver25Ms": 31, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.40625000419095, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 1.2326249852776527, + "os": "26.5", + "repetition": 3, + "rows": 800, + "scenario": "rich-updates", + "seconds": 6.000008666684153, + "viewportHeight": 759, + "viewportWidth": 393 + } + ], + "iosAblationReverted": [ + { + "callbackFPS": 59.836764061208584, + "callbacks": 360, + "distancePoints": 10799.333333333328, + "intervalP50Ms": 16.6659583337605, + "intervalP95Ms": 16.72525002504699, + "intervalP99Ms": 17.057249991921708, + "intervalsOver25Ms": 1, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 32.61362499324605, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.7799166487529874, + "os": "26.5", + "repetition": 1, + "rows": 800, + "scenario": "plain", + "seconds": 6.0163681249832734, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 59.835738846595184, + "callbacks": 360, + "distancePoints": 10799.666666666664, + "intervalP50Ms": 16.66637501330115, + "intervalP95Ms": 16.77149999886751, + "intervalP99Ms": 17.381374986143783, + "intervalsOver25Ms": 1, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 32.605749991489574, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.4877499886788428, + "os": "26.5", + "repetition": 2, + "rows": 800, + "scenario": "plain", + "seconds": 6.016471208335133, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 59.84020214251916, + "callbacks": 360, + "distancePoints": 10798.666666666664, + "intervalP50Ms": 16.666249983245507, + "intervalP95Ms": 16.872041655005887, + "intervalP99Ms": 17.382541671395302, + "intervalsOver25Ms": 1, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.405208348995075, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.5602083110716194, + "os": "26.5", + "repetition": 3, + "rows": 800, + "scenario": "plain", + "seconds": 6.0160224583232775, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 56.1771483770243, + "callbacks": 338, + "distancePoints": 10799.666666666672, + "intervalP50Ms": 16.673333331709728, + "intervalP95Ms": 32.53729167045094, + "intervalP99Ms": 33.29974997905083, + "intervalsOver25Ms": 23, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.584208344109356, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.520875008078292, + "os": "26.5", + "repetition": 1, + "rows": 800, + "scenario": "rich", + "seconds": 6.016681333334418, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 55.82998508511244, + "callbacks": 335, + "distancePoints": 10770, + "intervalP50Ms": 16.673333331709728, + "intervalP95Ms": 32.53129165386781, + "intervalP99Ms": 33.28450000844896, + "intervalsOver25Ms": 25, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.3017083175946, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.5340416682884097, + "os": "26.5", + "repetition": 2, + "rows": 800, + "scenario": "rich", + "seconds": 6.0003598333278205, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 56.329473590707714, + "callbacks": 338, + "distancePoints": 10771, + "intervalP50Ms": 16.67487499071285, + "intervalP95Ms": 32.53479168051854, + "intervalP99Ms": 33.280500007094815, + "intervalsOver25Ms": 22, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.413999975891784, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.5221666651777923, + "os": "26.5", + "repetition": 3, + "rows": 800, + "scenario": "rich", + "seconds": 6.000411125016399, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 55.18471285393305, + "callbacks": 332, + "distancePoints": 10799, + "intervalP50Ms": 16.674249985953793, + "intervalP95Ms": 32.74024999700487, + "intervalP99Ms": 33.33033333183266, + "intervalsOver25Ms": 29, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 34.09512501093559, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 1.295499998377636, + "os": "26.5", + "repetition": 1, + "rows": 800, + "scenario": "rich-updates", + "seconds": 6.016158874990651, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 56.0182051964697, + "callbacks": 337, + "distancePoints": 10798.666666666672, + "intervalP50Ms": 16.670375014655292, + "intervalP95Ms": 32.711833307985216, + "intervalP99Ms": 33.32979168044403, + "intervalsOver25Ms": 24, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.71408334351145, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 1.2755833158735186, + "os": "26.5", + "repetition": 2, + "rows": 800, + "scenario": "rich-updates", + "seconds": 6.015901416656561, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 55.66599287621095, + "callbacks": 334, + "distancePoints": 10770, + "intervalP50Ms": 16.67262500268407, + "intervalP95Ms": 32.70000001066364, + "intervalP99Ms": 33.31570833688602, + "intervalsOver25Ms": 26, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.59474998433143, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 1.2799999967683107, + "os": "26.5", + "repetition": 3, + "rows": 800, + "scenario": "rich-updates", + "seconds": 6.00007262500003, + "viewportHeight": 759, + "viewportWidth": 393 + } + ], + "iosSample": { + "mainSamples": 32440, + "nonWaitingLeafSamples": 6014, + "overlappingPathsPercent": { + "hosting": 40.2, + "sizeThatFits": 32.8, + "preferredLayoutAttributesFitting": 27.6, + "StyledText": 16.9, + "drain": 19.9, + "TranscriptLayout": 2.3 + } + }, + "androidInitial": [ + { + "refreshRateHz": 60.000003814697266, + "seconds": 7.813022795, + "renderedFrames": 422, + "droppedMetricReports": 0, + "callbackFPS": 59.64400875653659, + "intervalP95Ms": 18.734042, + "intervalP99Ms": 20.838834, + "frameP50Ms": 9.983466, + "frameP95Ms": 21.197031, + "frameP99Ms": 24.923955, + "maxFrameMs": 26.558957, + "framesOverBudget": 170, + "framesOver50Ms": 0, + "layoutP95Ms": 0.023834, + "drawP95Ms": 0.680375, + "syncP95Ms": 0.290875, + "commandP95Ms": 6.16825, + "scenario": "plain", + "repetition": 1, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 300, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 7.887618088, + "renderedFrames": 429, + "droppedMetricReports": 0, + "callbackFPS": 59.84062548845861, + "intervalP95Ms": 18.211416, + "intervalP99Ms": 19.981041, + "frameP50Ms": 4.245264, + "frameP95Ms": 18.872012, + "frameP99Ms": 20.071473, + "maxFrameMs": 25.641215, + "framesOverBudget": 52, + "framesOver50Ms": 0, + "layoutP95Ms": 0.022625, + "drawP95Ms": 0.458833, + "syncP95Ms": 0.273084, + "commandP95Ms": 4.452875, + "scenario": "plain", + "repetition": 2, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 299, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 7.726755004, + "renderedFrames": 417, + "droppedMetricReports": 0, + "callbackFPS": 59.92166177914446, + "intervalP95Ms": 17.461167, + "intervalP99Ms": 18.055583, + "frameP50Ms": 3.304843, + "frameP95Ms": 6.611054, + "frameP99Ms": 8.186051, + "maxFrameMs": 9.472386, + "framesOverBudget": 0, + "framesOver50Ms": 0, + "layoutP95Ms": 0.022125, + "drawP95Ms": 0.3515, + "syncP95Ms": 0.168458, + "commandP95Ms": 2.5015, + "scenario": "plain", + "repetition": 3, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 300, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 8.539590254, + "renderedFrames": 455, + "droppedMetricReports": 0, + "callbackFPS": 59.95603826075564, + "intervalP95Ms": 18.284333, + "intervalP99Ms": 19.115208, + "frameP50Ms": 5.240936, + "frameP95Ms": 15.859952, + "frameP99Ms": 20.015873, + "maxFrameMs": 24.225953, + "framesOverBudget": 22, + "framesOver50Ms": 0, + "layoutP95Ms": 0.023167, + "drawP95Ms": 0.502042, + "syncP95Ms": 0.219167, + "commandP95Ms": 4.361166, + "scenario": "rich", + "repetition": 1, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 335, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 8.369519296, + "renderedFrames": 451, + "droppedMetricReports": 0, + "callbackFPS": 59.979549869718106, + "intervalP95Ms": 17.376667, + "intervalP99Ms": 18.051208, + "frameP50Ms": 2.934591, + "frameP95Ms": 4.012565, + "frameP99Ms": 4.807358, + "maxFrameMs": 10.3425, + "framesOverBudget": 0, + "framesOver50Ms": 0, + "layoutP95Ms": 0.029916, + "drawP95Ms": 0.301125, + "syncP95Ms": 0.127125, + "commandP95Ms": 1.164917, + "scenario": "rich", + "repetition": 2, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 335, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 8.366350629, + "renderedFrames": 442, + "droppedMetricReports": 0, + "callbackFPS": 59.882740063917545, + "intervalP95Ms": 17.464, + "intervalP99Ms": 18.587, + "frameP50Ms": 2.786237, + "frameP95Ms": 6.542831, + "frameP99Ms": 8.432788, + "maxFrameMs": 11.518333, + "framesOverBudget": 0, + "framesOver50Ms": 0, + "layoutP95Ms": 0.022417, + "drawP95Ms": 0.327708, + "syncP95Ms": 0.125583, + "commandP95Ms": 2.325875, + "scenario": "rich", + "repetition": 3, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 335, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 8.41502617, + "renderedFrames": 464, + "droppedMetricReports": 0, + "callbackFPS": 59.89286186616814, + "intervalP95Ms": 17.507708, + "intervalP99Ms": 18.235667, + "frameP50Ms": 3.055929, + "frameP95Ms": 4.251485, + "frameP99Ms": 5.733354, + "maxFrameMs": 6.476235, + "framesOverBudget": 0, + "framesOver50Ms": 0, + "layoutP95Ms": 0.026958, + "drawP95Ms": 0.346625, + "syncP95Ms": 0.156792, + "commandP95Ms": 1.123, + "scenario": "rich-updates", + "repetition": 1, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 335, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 8.372501546, + "renderedFrames": 459, + "droppedMetricReports": 0, + "callbackFPS": 59.95818540515322, + "intervalP95Ms": 17.691291, + "intervalP99Ms": 18.335875, + "frameP50Ms": 2.994677, + "frameP95Ms": 4.748361, + "frameP99Ms": 5.958596, + "maxFrameMs": 7.755706, + "framesOverBudget": 0, + "framesOver50Ms": 0, + "layoutP95Ms": 0.028125, + "drawP95Ms": 0.364417, + "syncP95Ms": 0.100333, + "commandP95Ms": 1.177542, + "scenario": "rich-updates", + "repetition": 2, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 335, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 8.502337962, + "renderedFrames": 462, + "droppedMetricReports": 0, + "callbackFPS": 59.86588656848313, + "intervalP95Ms": 17.554167, + "intervalP99Ms": 18.490667, + "frameP50Ms": 3.077598, + "frameP95Ms": 4.589082, + "frameP99Ms": 6.029918, + "maxFrameMs": 6.521495, + "framesOverBudget": 0, + "framesOver50Ms": 0, + "layoutP95Ms": 0.026375, + "drawP95Ms": 0.35675, + "syncP95Ms": 0.164291, + "commandP95Ms": 1.154916, + "scenario": "rich-updates", + "repetition": 3, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 335, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + } + ], + "androidFullTrace": [ + { + "refreshRateHz": 60.000003814697266, + "seconds": 7.692077254, + "renderedFrames": 410, + "droppedMetricReports": 0, + "callbackFPS": 59.93179537559543, + "intervalP95Ms": 18.694542, + "intervalP99Ms": 20.2185, + "frameP50Ms": 5.230116, + "frameP95Ms": 20.327645, + "frameP99Ms": 23.216959, + "maxFrameMs": 30.580917, + "framesOverBudget": 42, + "framesOver50Ms": 0, + "layoutP95Ms": 0.022667, + "drawP95Ms": 0.618, + "syncP95Ms": 0.21975, + "commandP95Ms": 5.294416, + "scenario": "plain", + "repetition": 1, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 300, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 7.790823212, + "renderedFrames": 422, + "droppedMetricReports": 0, + "callbackFPS": 59.942317684823365, + "intervalP95Ms": 17.890083, + "intervalP99Ms": 20.040209, + "frameP50Ms": 3.160776, + "frameP95Ms": 5.993897, + "frameP99Ms": 7.795049, + "maxFrameMs": 10.096599, + "framesOverBudget": 0, + "framesOver50Ms": 0, + "layoutP95Ms": 0.022292, + "drawP95Ms": 0.242917, + "syncP95Ms": 0.178375, + "commandP95Ms": 1.804792, + "scenario": "plain", + "repetition": 2, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 300, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 7.621035503, + "renderedFrames": 410, + "droppedMetricReports": 0, + "callbackFPS": 59.965604388026165, + "intervalP95Ms": 18.182667, + "intervalP99Ms": 20.724708, + "frameP50Ms": 3.34378, + "frameP95Ms": 4.90068, + "frameP99Ms": 7.287083, + "maxFrameMs": 9.513561, + "framesOverBudget": 0, + "framesOver50Ms": 0, + "layoutP95Ms": 0.029583, + "drawP95Ms": 0.264958, + "syncP95Ms": 0.099, + "commandP95Ms": 1.301125, + "scenario": "plain", + "repetition": 3, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 300, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 9.076300046, + "renderedFrames": 455, + "droppedMetricReports": 0, + "callbackFPS": 55.96994341586137, + "intervalP95Ms": 17.803041, + "intervalP99Ms": 18.464791, + "frameP50Ms": 4.175141, + "frameP95Ms": 14.092492, + "frameP99Ms": 18.750969, + "maxFrameMs": 641.792166, + "framesOverBudget": 22, + "framesOver50Ms": 2, + "layoutP95Ms": 0.028458, + "drawP95Ms": 0.402959, + "syncP95Ms": 0.214375, + "commandP95Ms": 2.538, + "scenario": "rich", + "repetition": 1, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 335, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 9.305935004, + "renderedFrames": 436, + "droppedMetricReports": 0, + "callbackFPS": 56.093235099495864, + "intervalP95Ms": 26.968625, + "intervalP99Ms": 44.132583, + "frameP50Ms": 4.864324, + "frameP95Ms": 59.356915, + "frameP99Ms": 115.575303, + "maxFrameMs": 150.252472, + "framesOverBudget": 137, + "framesOver50Ms": 40, + "layoutP95Ms": 0.0685, + "drawP95Ms": 1.665458, + "syncP95Ms": 0.604208, + "commandP95Ms": 21.301041, + "scenario": "rich", + "repetition": 2, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 335, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 8.542659296, + "renderedFrames": 457, + "droppedMetricReports": 0, + "callbackFPS": 59.81743884357764, + "intervalP95Ms": 17.966333, + "intervalP99Ms": 19.7025, + "frameP50Ms": 3.801933, + "frameP95Ms": 6.372749, + "frameP99Ms": 10.270121, + "maxFrameMs": 19.651056, + "framesOverBudget": 3, + "framesOver50Ms": 0, + "layoutP95Ms": 0.01875, + "drawP95Ms": 0.386041, + "syncP95Ms": 0.17, + "commandP95Ms": 2.31175, + "scenario": "rich", + "repetition": 3, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 335, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 8.407709546, + "renderedFrames": 460, + "droppedMetricReports": 0, + "callbackFPS": 59.94498230969217, + "intervalP95Ms": 18.114, + "intervalP99Ms": 19.984584, + "frameP50Ms": 4.402626, + "frameP95Ms": 7.35523, + "frameP99Ms": 8.880018, + "maxFrameMs": 10.238218, + "framesOverBudget": 0, + "framesOver50Ms": 0, + "layoutP95Ms": 0.019958, + "drawP95Ms": 0.48925, + "syncP95Ms": 0.1755, + "commandP95Ms": 2.354541, + "scenario": "rich-updates", + "repetition": 1, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 335, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 8.188906837, + "renderedFrames": 447, + "droppedMetricReports": 0, + "callbackFPS": 59.83704659894643, + "intervalP95Ms": 17.995917, + "intervalP99Ms": 19.706625, + "frameP50Ms": 4.082474, + "frameP95Ms": 5.866943, + "frameP99Ms": 8.565051, + "maxFrameMs": 11.024602, + "framesOverBudget": 0, + "framesOver50Ms": 0, + "layoutP95Ms": 0.020459, + "drawP95Ms": 0.402041, + "syncP95Ms": 0.163666, + "commandP95Ms": 1.76975, + "scenario": "rich-updates", + "repetition": 2, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 335, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 9.780545754, + "renderedFrames": 438, + "droppedMetricReports": 0, + "callbackFPS": 49.58823486937291, + "intervalP95Ms": 18.504333, + "intervalP99Ms": 51.028167, + "frameP50Ms": 3.471694, + "frameP95Ms": 28.938498, + "frameP99Ms": 402.628, + "maxFrameMs": 688.14682, + "framesOverBudget": 35, + "framesOver50Ms": 11, + "layoutP95Ms": 0.036708, + "drawP95Ms": 0.560584, + "syncP95Ms": 0.181333, + "commandP95Ms": 2.614959, + "scenario": "rich-updates", + "repetition": 3, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 335, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + } + ], + "androidFullPresent": [ + { + "phase": "HapiScroll:plain:1", + "gestures": "8", + "presented_frames": "251", + "present_cadence_fps": "59.390000", + "present_p50_ms": "16.640000", + "present_p95_ms": "24.900000", + "present_p99_ms": "32.910000", + "app_late_finish": "6", + "app_deadline_missed": "3" + }, + { + "phase": "HapiScroll:plain:2", + "gestures": "8", + "presented_frames": "249", + "present_cadence_fps": "59.950000", + "present_p50_ms": "16.630000", + "present_p95_ms": "19.210000", + "present_p99_ms": "20.180000", + "app_late_finish": "0", + "app_deadline_missed": "0" + }, + { + "phase": "HapiScroll:plain:3", + "gestures": "8", + "presented_frames": "250", + "present_cadence_fps": "60.120000", + "present_p50_ms": "16.640000", + "present_p95_ms": "18.100000", + "present_p99_ms": "20.050000", + "app_late_finish": "0", + "app_deadline_missed": "0" + }, + { + "phase": "HapiScroll:rich-updates:1", + "gestures": "8", + "presented_frames": "247", + "present_cadence_fps": "59.780000", + "present_p50_ms": "16.670000", + "present_p95_ms": "19.410000", + "present_p99_ms": "20.680000", + "app_late_finish": "0", + "app_deadline_missed": "0" + }, + { + "phase": "HapiScroll:rich-updates:2", + "gestures": "8", + "presented_frames": "248", + "present_cadence_fps": "59.600000", + "present_p50_ms": "16.700000", + "present_p95_ms": "18.380000", + "present_p99_ms": "22.260000", + "app_late_finish": "0", + "app_deadline_missed": "0" + }, + { + "phase": "HapiScroll:rich-updates:3", + "gestures": "8", + "presented_frames": "250", + "present_cadence_fps": "47.580000", + "present_p50_ms": "16.580000", + "present_p95_ms": "21.210000", + "present_p99_ms": "126.840000", + "app_late_finish": "4", + "app_deadline_missed": "4" + }, + { + "phase": "HapiScroll:rich:1", + "gestures": "8", + "presented_frames": "243", + "present_cadence_fps": "51.660000", + "present_p50_ms": "16.720000", + "present_p95_ms": "19.020000", + "present_p99_ms": "23.500000", + "app_late_finish": "1", + "app_deadline_missed": "1" + }, + { + "phase": "HapiScroll:rich:2", + "gestures": "8", + "presented_frames": "243", + "present_cadence_fps": "49.550000", + "present_p50_ms": "16.950000", + "present_p95_ms": "40.230000", + "present_p99_ms": "49.940000", + "app_late_finish": "37", + "app_deadline_missed": "37" + }, + { + "phase": "HapiScroll:rich:3", + "gestures": "8", + "presented_frames": "243", + "present_cadence_fps": "60.130000", + "present_p50_ms": "16.630000", + "present_p95_ms": "19.450000", + "present_p99_ms": "21.200000", + "app_late_finish": "1", + "app_deadline_missed": "1" + } + ], + "androidControlled": [ + { + "refreshRateHz": 60.000003814697266, + "seconds": 8.302935129, + "renderedFrames": 446, + "droppedMetricReports": 0, + "callbackFPS": 59.97878970059818, + "intervalP95Ms": 18.305042, + "intervalP99Ms": 19.135625, + "frameP50Ms": 3.096805, + "frameP95Ms": 18.344605, + "frameP99Ms": 19.594523, + "maxFrameMs": 21.201661, + "framesOverBudget": 53, + "framesOver50Ms": 0, + "layoutP95Ms": 0.046, + "drawP95Ms": 0.416792, + "syncP95Ms": 0.077542, + "commandP95Ms": 1.097875, + "scenario": "plain", + "repetition": 1, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 299, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 7.808233504, + "renderedFrames": 421, + "droppedMetricReports": 0, + "callbackFPS": 59.93673213797372, + "intervalP95Ms": 18.263125, + "intervalP99Ms": 19.968042, + "frameP50Ms": 2.983799, + "frameP95Ms": 5.02079, + "frameP99Ms": 6.332286, + "maxFrameMs": 7.453886, + "framesOverBudget": 0, + "framesOver50Ms": 0, + "layoutP95Ms": 0.024792, + "drawP95Ms": 0.220083, + "syncP95Ms": 0.098291, + "commandP95Ms": 1.166375, + "scenario": "plain", + "repetition": 2, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 300, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 8.165651837, + "renderedFrames": 441, + "droppedMetricReports": 0, + "callbackFPS": 59.76252842287329, + "intervalP95Ms": 17.716459, + "intervalP99Ms": 18.685542, + "frameP50Ms": 2.886752, + "frameP95Ms": 4.227977, + "frameP99Ms": 5.153542, + "maxFrameMs": 7.713967, + "framesOverBudget": 0, + "framesOver50Ms": 0, + "layoutP95Ms": 0.026084, + "drawP95Ms": 0.224875, + "syncP95Ms": 0.123209, + "commandP95Ms": 1.224459, + "scenario": "plain", + "repetition": 3, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 299, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 8.363113879, + "renderedFrames": 449, + "droppedMetricReports": 0, + "callbackFPS": 59.90591629488918, + "intervalP95Ms": 17.923625, + "intervalP99Ms": 18.872125, + "frameP50Ms": 3.009076, + "frameP95Ms": 4.677595, + "frameP99Ms": 6.093391, + "maxFrameMs": 7.714561, + "framesOverBudget": 0, + "framesOver50Ms": 0, + "layoutP95Ms": 0.024875, + "drawP95Ms": 0.354333, + "syncP95Ms": 0.132167, + "commandP95Ms": 1.226916, + "scenario": "rich", + "repetition": 1, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 335, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 8.303201004, + "renderedFrames": 446, + "droppedMetricReports": 0, + "callbackFPS": 59.85643365258462, + "intervalP95Ms": 18.13325, + "intervalP99Ms": 19.423291, + "frameP50Ms": 3.02533, + "frameP95Ms": 5.017462, + "frameP99Ms": 5.870265, + "maxFrameMs": 6.96109, + "framesOverBudget": 0, + "framesOver50Ms": 0, + "layoutP95Ms": 0.024209, + "drawP95Ms": 0.314083, + "syncP95Ms": 0.122, + "commandP95Ms": 1.277709, + "scenario": "rich", + "repetition": 2, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 335, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 8.490963713, + "renderedFrames": 453, + "droppedMetricReports": 0, + "callbackFPS": 59.946081175768185, + "intervalP95Ms": 17.921875, + "intervalP99Ms": 19.711333, + "frameP50Ms": 3.045132, + "frameP95Ms": 4.868426, + "frameP99Ms": 6.473459, + "maxFrameMs": 9.665024, + "framesOverBudget": 0, + "framesOver50Ms": 0, + "layoutP95Ms": 0.023375, + "drawP95Ms": 0.315875, + "syncP95Ms": 0.118541, + "commandP95Ms": 1.326208, + "scenario": "rich", + "repetition": 3, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 335, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 8.640686171, + "renderedFrames": 472, + "droppedMetricReports": 0, + "callbackFPS": 59.94894268218181, + "intervalP95Ms": 18.205292, + "intervalP99Ms": 19.878041, + "frameP50Ms": 3.214469, + "frameP95Ms": 5.190686, + "frameP99Ms": 6.666866, + "maxFrameMs": 7.691038, + "framesOverBudget": 0, + "framesOver50Ms": 0, + "layoutP95Ms": 0.025583, + "drawP95Ms": 0.33125, + "syncP95Ms": 0.088375, + "commandP95Ms": 1.219833, + "scenario": "rich-updates", + "repetition": 1, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 335, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 8.555092546, + "renderedFrames": 464, + "droppedMetricReports": 0, + "callbackFPS": 59.96428410816633, + "intervalP95Ms": 18.055708, + "intervalP99Ms": 19.1395, + "frameP50Ms": 3.143474, + "frameP95Ms": 5.338377, + "frameP99Ms": 6.545578, + "maxFrameMs": 7.721652, + "framesOverBudget": 0, + "framesOver50Ms": 0, + "layoutP95Ms": 0.024833, + "drawP95Ms": 0.323083, + "syncP95Ms": 0.084416, + "commandP95Ms": 1.238208, + "scenario": "rich-updates", + "repetition": 2, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 335, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + }, + { + "refreshRateHz": 60.000003814697266, + "seconds": 8.42015742, + "renderedFrames": 460, + "droppedMetricReports": 0, + "callbackFPS": 59.975125738207396, + "intervalP95Ms": 17.671167, + "intervalP99Ms": 18.529792, + "frameP50Ms": 3.042369, + "frameP95Ms": 4.527647, + "frameP99Ms": 6.027186, + "maxFrameMs": 7.06432, + "framesOverBudget": 0, + "framesOver50Ms": 0, + "layoutP95Ms": 0.024958, + "drawP95Ms": 0.298, + "syncP95Ms": 0.12625, + "commandP95Ms": 1.205959, + "scenario": "rich-updates", + "repetition": 3, + "rows": 800, + "viewportWidthPx": 1080, + "viewportHeightPx": 1920, + "startIndex": 350, + "endIndex": 335, + "api": 35, + "metric": "window-frame-metrics-and-choreographer-not-presented-fps" + } + ], + "androidControlledPresent": [ + { + "phase": "HapiScroll:plain:1", + "gestures": "8", + "presented_frames": "245", + "present_cadence_fps": "60.130000", + "present_p50_ms": "16.680000", + "present_p95_ms": "18.400000", + "present_p99_ms": "20.050000", + "app_late_finish": "1", + "app_deadline_missed": "1" + }, + { + "phase": "HapiScroll:plain:2", + "gestures": "8", + "presented_frames": "249", + "present_cadence_fps": "60.040000", + "present_p50_ms": "16.690000", + "present_p95_ms": "18.620000", + "present_p99_ms": "20.360000", + "app_late_finish": "0", + "app_deadline_missed": "0" + }, + { + "phase": "HapiScroll:plain:3", + "gestures": "8", + "presented_frames": "247", + "present_cadence_fps": "60.130000", + "present_p50_ms": "16.670000", + "present_p95_ms": "17.870000", + "present_p99_ms": "18.750000", + "app_late_finish": "0", + "app_deadline_missed": "0" + }, + { + "phase": "HapiScroll:rich-updates:1", + "gestures": "8", + "presented_frames": "247", + "present_cadence_fps": "59.320000", + "present_p50_ms": "16.680000", + "present_p95_ms": "18.970000", + "present_p99_ms": "27.120000", + "app_late_finish": "0", + "app_deadline_missed": "0" + }, + { + "phase": "HapiScroll:rich-updates:2", + "gestures": "8", + "presented_frames": "243", + "present_cadence_fps": "59.750000", + "present_p50_ms": "16.630000", + "present_p95_ms": "18.750000", + "present_p99_ms": "19.700000", + "app_late_finish": "0", + "app_deadline_missed": "0" + }, + { + "phase": "HapiScroll:rich-updates:3", + "gestures": "8", + "presented_frames": "244", + "present_cadence_fps": "59.640000", + "present_p50_ms": "16.630000", + "present_p95_ms": "18.030000", + "present_p99_ms": "19.640000", + "app_late_finish": "0", + "app_deadline_missed": "0" + }, + { + "phase": "HapiScroll:rich:1", + "gestures": "8", + "presented_frames": "244", + "present_cadence_fps": "60.050000", + "present_p50_ms": "16.630000", + "present_p95_ms": "18.380000", + "present_p99_ms": "19.590000", + "app_late_finish": "0", + "app_deadline_missed": "0" + }, + { + "phase": "HapiScroll:rich:2", + "gestures": "8", + "presented_frames": "247", + "present_cadence_fps": "60.020000", + "present_p50_ms": "16.690000", + "present_p95_ms": "18.610000", + "present_p99_ms": "19.600000", + "app_late_finish": "0", + "app_deadline_missed": "0" + }, + { + "phase": "HapiScroll:rich:3", + "gestures": "8", + "presented_frames": "243", + "present_cadence_fps": "60.000000", + "present_p50_ms": "16.640000", + "present_p95_ms": "18.660000", + "present_p99_ms": "20.130000", + "app_late_finish": "0", + "app_deadline_missed": "0" + } + ], + "androidControlledSchedulerSlices": 495548, + "rawArtifactPrefix": "/tmp/hapi-scroll-frame-", + "iosDifferentialRefresh": { + "implementation": "Retain UIHostingConfiguration roots; observable row builder and full representable environment; only changed data/width requests diffable reconfiguration.", + "harnessChanges": "Added pre-timing rendered screenshot and initialVisibleHeights assertion. Compare only with the same-harness baseline control, not directly with the earlier baseline.", + "baselineControl": [ + { + "callbackFPS": 59.838603236325454, + "callbacks": 360, + "distancePoints": 10799, + "initialVisibleHeights": [ + 36, + 36, + 54, + 54, + 71.66666666666667, + 71.66666666666667, + 71.66666666666667, + 89.66666666666667, + 89.66666666666667, + 89.66666666666667 + ], + "intervalP50Ms": 16.66650001425296, + "intervalP95Ms": 16.67816666304134, + "intervalP99Ms": 16.684125002939254, + "intervalsOver25Ms": 1, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.1304999999702, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.17479166854172945, + "os": "26.5", + "repetition": 1, + "rows": 800, + "scenario": "plain", + "seconds": 6.0161832083249465, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 59.833075302972695, + "callbacks": 359, + "distancePoints": 10770, + "initialVisibleHeights": [ + 36, + 36, + 54, + 54, + 71.66666666666667, + 71.66666666666667, + 71.66666666666667, + 89.66666666666667, + 89.66666666666667, + 89.66666666666667 + ], + "intervalP50Ms": 16.66683334042318, + "intervalP95Ms": 16.692583332769573, + "intervalP99Ms": 16.712999989977106, + "intervalsOver25Ms": 1, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.19808331434615, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.27529167709872127, + "os": "26.5", + "repetition": 2, + "rows": 800, + "scenario": "plain", + "seconds": 6.000025875022402, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 59.833289289532615, + "callbacks": 359, + "distancePoints": 10770, + "initialVisibleHeights": [ + 36, + 36, + 54, + 54, + 71.66666666666667, + 71.66666666666667, + 71.66666666666667, + 89.66666666666667, + 89.66666666666667, + 89.66666666666667 + ], + "intervalP50Ms": 16.66658333851956, + "intervalP95Ms": 16.678208339726552, + "intervalP99Ms": 16.689708310877904, + "intervalsOver25Ms": 1, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.23645834461786, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.15308332513086498, + "os": "26.5", + "repetition": 3, + "rows": 800, + "scenario": "plain", + "seconds": 6.000004416651791, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 59.83987265622547, + "callbacks": 360, + "distancePoints": 10799, + "initialVisibleHeights": [ + 145, + 237.33333333333334, + 330.3333333333333, + 333.3333333333333 + ], + "intervalP50Ms": 16.66658333851956, + "intervalP95Ms": 16.678791667800397, + "intervalP99Ms": 16.7032916797325, + "intervalsOver25Ms": 1, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 32.80400001676753, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.14666665811091661, + "os": "26.5", + "repetition": 1, + "rows": 800, + "scenario": "rich", + "seconds": 6.016055583342677, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 59.501851496453405, + "callbacks": 358, + "distancePoints": 10800, + "initialVisibleHeights": [ + 145, + 237.33333333333334, + 330.3333333333333, + 333.3333333333333 + ], + "intervalP50Ms": 16.667208343278617, + "intervalP95Ms": 16.695375001290813, + "intervalP99Ms": 17.208875011419877, + "intervalsOver25Ms": 3, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.236000017495826, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.2795833279378712, + "os": "26.5", + "repetition": 2, + "rows": 800, + "scenario": "rich", + "seconds": 6.016619500005618, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 59.83438009801074, + "callbacks": 360, + "distancePoints": 10800, + "initialVisibleHeights": [ + 145, + 237.33333333333334, + 330.3333333333333, + 333.3333333333333 + ], + "intervalP50Ms": 16.666458337567747, + "intervalP95Ms": 16.682624991517514, + "intervalP99Ms": 16.695250000339, + "intervalsOver25Ms": 1, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.30883334274404, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.1441249914932996, + "os": "26.5", + "repetition": 3, + "rows": 800, + "scenario": "rich", + "seconds": 6.0166078333277255, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 58.83662173330803, + "callbacks": 354, + "distancePoints": 10800, + "initialVisibleHeights": [ + 145, + 237.33333333333334, + 330.3333333333333, + 333.3333333333333 + ], + "intervalP50Ms": 16.666666662786156, + "intervalP95Ms": 16.689791664248332, + "intervalP99Ms": 33.32570835482329, + "intervalsOver25Ms": 7, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.36604166543111, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.7088333077263087, + "os": "26.5", + "repetition": 1, + "rows": 800, + "scenario": "rich-updates", + "seconds": 6.016660875000525, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 58.836684481636496, + "callbacks": 354, + "distancePoints": 10800, + "initialVisibleHeights": [ + 145, + 237.33333333333334, + 330.3333333333333, + 333.3333333333333 + ], + "intervalP50Ms": 16.666541661834344, + "intervalP95Ms": 16.695708327461034, + "intervalP99Ms": 33.33137498702854, + "intervalsOver25Ms": 7, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.34479164914228, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.7620416581630707, + "os": "26.5", + "repetition": 2, + "rows": 800, + "scenario": "rich-updates", + "seconds": 6.016654458333505, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 55.9999984443421, + "callbacks": 336, + "distancePoints": 10770, + "initialVisibleHeights": [ + 145, + 237.33333333333334, + 330.3333333333333, + 333.3333333333333 + ], + "intervalP50Ms": 16.668458352796733, + "intervalP95Ms": 33.30104169435799, + "intervalP99Ms": 33.35312500712462, + "intervalsOver25Ms": 24, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.36624999064952, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.8560833521187305, + "os": "26.5", + "repetition": 3, + "rows": 800, + "scenario": "rich-updates", + "seconds": 6.000000166677637, + "viewportHeight": 759, + "viewportWidth": 393 + } + ], + "optimizedFirst": [ + { + "callbackFPS": 59.833255217776966, + "callbacks": 359, + "distancePoints": 10770, + "initialVisibleHeights": [ + 36, + 36, + 54, + 54, + 71.66666666666667, + 71.66666666666667, + 71.66666666666667, + 89.66666666666667, + 89.66666666666667, + 89.66666666666667 + ], + "intervalP50Ms": 16.666624986100942, + "intervalP95Ms": 16.679208318237215, + "intervalP99Ms": 16.693541692802683, + "intervalsOver25Ms": 1, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.229874999960884, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.13220831169746816, + "os": "26.5", + "repetition": 1, + "rows": 800, + "scenario": "plain", + "seconds": 6.000007833325071, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 59.83948266572789, + "callbacks": 360, + "distancePoints": 10799, + "initialVisibleHeights": [ + 36, + 36, + 54, + 54, + 71.66666666666667, + 71.66666666666667, + 71.66666666666667, + 89.66666666666667, + 89.66666666666667, + 89.66666666666667 + ], + "intervalP50Ms": 16.66691666468978, + "intervalP95Ms": 16.696125007001683, + "intervalP99Ms": 16.72020831028931, + "intervalsOver25Ms": 1, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 32.92229166254401, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.32645833562128246, + "os": "26.5", + "repetition": 2, + "rows": 800, + "scenario": "plain", + "seconds": 6.0160947916447185, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 59.83991575883514, + "callbacks": 360, + "distancePoints": 10799, + "initialVisibleHeights": [ + 36, + 36, + 54, + 54, + 71.66666666666667, + 71.66666666666667, + 71.66666666666667, + 89.66666666666667, + 89.66666666666667, + 89.66666666666667 + ], + "intervalP50Ms": 16.666624986100942, + "intervalP95Ms": 16.67625000118278, + "intervalP99Ms": 16.695750004146248, + "intervalsOver25Ms": 1, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 32.726625009672716, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.19091664580628276, + "os": "26.5", + "repetition": 3, + "rows": 800, + "scenario": "plain", + "seconds": 6.016051249986049, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 59.83688672459432, + "callbacks": 360, + "distancePoints": 10799.333333333336, + "initialVisibleHeights": [ + 145, + 237.33333333333334, + 330.3333333333333, + 333.3333333333333 + ], + "intervalP50Ms": 16.666791663737968, + "intervalP95Ms": 16.680041648214683, + "intervalP99Ms": 16.957791667664424, + "intervalsOver25Ms": 1, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.23870833264664, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.2045833389274776, + "os": "26.5", + "repetition": 1, + "rows": 800, + "scenario": "rich", + "seconds": 6.016355791653041, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 58.999980743296796, + "callbacks": 354, + "distancePoints": 10770, + "initialVisibleHeights": [ + 145, + 237.33333333333334, + 330.3333333333333, + 333.3333333333333 + ], + "intervalP50Ms": 16.666541690938175, + "intervalP95Ms": 17.271083313971758, + "intervalP99Ms": 32.73558331420645, + "intervalsOver25Ms": 6, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.34137500496581, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.4137916548643261, + "os": "26.5", + "repetition": 2, + "rows": 800, + "scenario": "rich", + "seconds": 6.00000195830944, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 59.16905825013408, + "callbacks": 356, + "distancePoints": 10800, + "initialVisibleHeights": [ + 145, + 237.33333333333334, + 330.3333333333333, + 333.3333333333333 + ], + "intervalP50Ms": 16.668166645104066, + "intervalP95Ms": 17.013166652759537, + "intervalP99Ms": 29.368374991463497, + "intervalsOver25Ms": 5, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.32741666235961, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.3776666708290577, + "os": "26.5", + "repetition": 3, + "rows": 800, + "scenario": "rich", + "seconds": 6.016658208332956, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 59.17493805074361, + "callbacks": 356, + "distancePoints": 10799, + "initialVisibleHeights": [ + 145, + 237.33333333333334, + 330.3333333333333, + 333.3333333333333 + ], + "intervalP50Ms": 16.666541661834344, + "intervalP95Ms": 16.688833333319053, + "intervalP99Ms": 32.716958317905664, + "intervalsOver25Ms": 5, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 32.8319999971427, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.30941664590500295, + "os": "26.5", + "repetition": 1, + "rows": 800, + "scenario": "rich-updates", + "seconds": 6.016060374997323, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 59.175078626231695, + "callbacks": 356, + "distancePoints": 10799, + "initialVisibleHeights": [ + 145, + 237.33333333333334, + 330.3333333333333, + 333.3333333333333 + ], + "intervalP50Ms": 16.666041658027098, + "intervalP95Ms": 16.686249990016222, + "intervalP99Ms": 32.71937498357147, + "intervalsOver25Ms": 5, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 32.82379166921601, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.310541654471308, + "os": "26.5", + "repetition": 2, + "rows": 800, + "scenario": "rich-updates", + "seconds": 6.016046083328547, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 58.999768098180425, + "callbacks": 354, + "distancePoints": 10770, + "initialVisibleHeights": [ + 145, + 237.33333333333334, + 330.3333333333333, + 333.3333333333333 + ], + "intervalP50Ms": 16.666958341374993, + "intervalP95Ms": 16.687124996678904, + "intervalP99Ms": 32.73108333814889, + "intervalsOver25Ms": 6, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.353541657561436, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.3126666706521064, + "os": "26.5", + "repetition": 3, + "rows": 800, + "scenario": "rich-updates", + "seconds": 6.000023583328584, + "viewportHeight": 759, + "viewportWidth": 393 + } + ], + "artifactPaths": [ + "/tmp/hapi-scroll-live-environment.xcresult", + "/tmp/hapi-scroll-baseline-control.xcresult", + "/tmp/hapi-scroll-live-environment-repeat.xcresult" + ], + "measurementCaveat": "Display-link callback cadence, not compositor-presented FPS. Static rich-content benefit not established; Simulator variation remains.", + "optimizedRepeat": [ + { + "callbackFPS": 59.835430128999235, + "callbacks": 360, + "distancePoints": 10799.666666666664, + "initialVisibleHeights": [ + 36, + 36, + 54, + 54, + 71.66666666666667, + 71.66666666666667, + 71.66666666666667, + 89.66666666666667, + 89.66666666666667, + 89.66666666666667 + ], + "intervalP50Ms": 16.666791663737968, + "intervalP95Ms": 16.67645832640119, + "intervalP99Ms": 16.678958316333592, + "intervalsOver25Ms": 1, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.34520832868293, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.12779165990650654, + "os": "26.5", + "repetition": 1, + "rows": 800, + "scenario": "plain", + "seconds": 6.016502249985933, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 59.83450813858699, + "callbacks": 360, + "distancePoints": 10800, + "initialVisibleHeights": [ + 36, + 36, + 54, + 54, + 71.66666666666667, + 71.66666666666667, + 71.66666666666667, + 89.66666666666667, + 89.66666666666667, + 89.66666666666667 + ], + "intervalP50Ms": 16.666583309415728, + "intervalP95Ms": 16.676625004038215, + "intervalP99Ms": 16.681000008247793, + "intervalsOver25Ms": 1, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.35387498373166, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.1788749941624701, + "os": "26.5", + "repetition": 2, + "rows": 800, + "scenario": "plain", + "seconds": 6.016594958317, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 59.83959290690969, + "callbacks": 360, + "distancePoints": 10799, + "initialVisibleHeights": [ + 36, + 36, + 54, + 54, + 71.66666666666667, + 71.66666666666667, + 71.66666666666667, + 89.66666666666667, + 89.66666666666667, + 89.66666666666667 + ], + "intervalP50Ms": 16.66670831036754, + "intervalP95Ms": 16.680375003488734, + "intervalP99Ms": 16.9588333228603, + "intervalsOver25Ms": 1, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 32.75404166197404, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.16408335068263113, + "os": "26.5", + "repetition": 3, + "rows": 800, + "scenario": "plain", + "seconds": 6.016083708324004, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 59.668155304974995, + "callbacks": 359, + "distancePoints": 10800, + "initialVisibleHeights": [ + 145, + 237.33333333333334, + 330.3333333333333, + 333.3333333333333 + ], + "intervalP50Ms": 16.66658333851956, + "intervalP95Ms": 16.696250007953495, + "intervalP99Ms": 17.16387498890981, + "intervalsOver25Ms": 2, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.35012501338497, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.33845833968371153, + "os": "26.5", + "repetition": 1, + "rows": 800, + "scenario": "rich", + "seconds": 6.0166096666653175, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 58.8367121885632, + "callbacks": 354, + "distancePoints": 10800, + "initialVisibleHeights": [ + 145, + 237.33333333333334, + 330.3333333333333, + 333.3333333333333 + ], + "intervalP50Ms": 16.66629165993072, + "intervalP95Ms": 17.3813333385624, + "intervalP99Ms": 32.64641665737145, + "intervalsOver25Ms": 7, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.30391668714583, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.3699999942909926, + "os": "26.5", + "repetition": 2, + "rows": 800, + "scenario": "rich", + "seconds": 6.016651625017403, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 59.839683255364115, + "callbacks": 360, + "distancePoints": 10799, + "initialVisibleHeights": [ + 145, + 237.33333333333334, + 330.3333333333333, + 333.3333333333333 + ], + "intervalP50Ms": 16.666750016156584, + "intervalP95Ms": 16.680083354003727, + "intervalP99Ms": 16.686499991919845, + "intervalsOver25Ms": 1, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 32.732833322370425, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.14704166096635163, + "os": "26.5", + "repetition": 3, + "rows": 800, + "scenario": "rich", + "seconds": 6.016074624989415, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 59.17499460858117, + "callbacks": 356, + "distancePoints": 10799, + "initialVisibleHeights": [ + 145, + 237.33333333333334, + 330.3333333333333, + 333.3333333333333 + ], + "intervalP50Ms": 16.666125011397526, + "intervalP95Ms": 16.683499998180196, + "intervalP99Ms": 32.73041668580845, + "intervalsOver25Ms": 5, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.348708326229826, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.3216666809748858, + "os": "26.5", + "repetition": 1, + "rows": 800, + "scenario": "rich-updates", + "seconds": 6.016054625011748, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 58.999900847301625, + "callbacks": 354, + "distancePoints": 10770, + "initialVisibleHeights": [ + 145, + 237.33333333333334, + 330.3333333333333, + 333.3333333333333 + ], + "intervalP50Ms": 16.666458337567747, + "intervalP95Ms": 16.6927500104066, + "intervalP99Ms": 32.73458333569579, + "intervalsOver25Ms": 6, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.35904167033732, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.30879167024977505, + "os": "26.5", + "repetition": 2, + "rows": 800, + "scenario": "rich-updates", + "seconds": 6.000010083342204, + "viewportHeight": 759, + "viewportWidth": 393 + }, + { + "callbackFPS": 58.999851680753, + "callbacks": 354, + "distancePoints": 10770, + "initialVisibleHeights": [ + 145, + 237.33333333333334, + 330.3333333333333, + 333.3333333333333 + ], + "intervalP50Ms": 16.666791663737968, + "intervalP95Ms": 16.691750002792105, + "intervalP99Ms": 32.72487499634735, + "intervalsOver25Ms": 6, + "intervalsOver50Ms": 0, + "maximumFramesPerSecond": 60, + "maxIntervalMs": 33.3505000162404, + "metric": "display-link-callback-cadence-not-presented-fps", + "offsetUpdateMaxMs": 0.3138750034850091, + "os": "26.5", + "repetition": 3, + "rows": 800, + "scenario": "rich-updates", + "seconds": 6.0000150833511725, + "viewportHeight": 759, + "viewportWidth": 393 + } + ], + "runOrder": [ + "optimizedFirst", + "baselineControl", + "optimizedRepeat" + ], + "correctness": { + "debug": "14 tests passed, including zero-configuration work budgets.", + "release": "13 tests passed in each optimized batch, including the opt-in frame profile.", + "debugElapsedCaveat": "One geometry integration test elapsed about 650 seconds during host sleep, confirmed by local pmset events (13:16-13:27). Not a frame timing result; the Release counterpart took about 0.33 seconds." + } + } +} diff --git a/ios/Hapi/Features/Chat/ChatTranscriptView.swift b/ios/Hapi/Features/Chat/ChatTranscriptView.swift index 0d22c45b..5126b6b0 100644 --- a/ios/Hapi/Features/Chat/ChatTranscriptView.swift +++ b/ios/Hapi/Features/Chat/ChatTranscriptView.swift @@ -21,7 +21,6 @@ private enum TranscriptRow: Identifiable, Equatable { struct ChatTranscriptView: View { let model: ChatModel - @Environment(\.self) private var environment private var rows: [TranscriptRow] { var rows: [TranscriptRow] = [.history(model.historyPaging.phase, model.hasMore)] @@ -50,8 +49,8 @@ struct ChatTranscriptView: View { }, onLayout: { version, progress in model.historyLaidOut(version: version, madeProgress: progress) } ) { row in - // HostingConfiguration creates a new root: inherit app services. - AnyView(rowView(row).environment(\.self, environment)) + // The list bridges the current environment into its hosting roots. + AnyView(rowView(row)) } .overlay(alignment: .bottomTrailing) { if !model.followsTail || model.hasTrimmedTail || model.isJumpingToLatest { diff --git a/ios/HapiTests/TranscriptFrameProfileTests.swift b/ios/HapiTests/TranscriptFrameProfileTests.swift new file mode 100644 index 00000000..1a11686f --- /dev/null +++ b/ios/HapiTests/TranscriptFrameProfileTests.swift @@ -0,0 +1,209 @@ +import HapiUI +import Observation +import OSLog +import SwiftUI +import UIKit +import XCTest +@testable import Hapi +@testable import HapiProtocol + +/// Opt-in, real-vsync diagnostic. No FPS assertion: host load affects Simulator. +/// CADisplayLink cadence is NOT compositor-presented FPS; correlate with Instruments. +@MainActor +final class TranscriptFrameProfileTests: XCTestCase { + struct Row: Identifiable, Equatable { + let id: String + let block: VisibleChatBlock + } + + @Observable @MainActor + final class Driver { + var rows: [Row] + let cache = MarkdownRenderCache() + let presentation = ChatPresentationState() + init(rows: [Row]) { self.rows = rows } + } + + private struct Harness: View { + let driver: Driver + var body: some View { + AnchoredTranscriptList( + items: driver.rows, historyVersion: 0, jumpToken: 0, + historyControlID: "history", onViewport: { _ in }, onLayout: { _, _ in } + ) { row in + AnyView(ChatBlockCard(block: row.block, basePath: "/workspace/hapi") + .environment(\.hapiMarkdownCache, driver.cache) + .environment(\.chatPresentationState, driver.presentation) + .hapiTheme(.light)) + } + } + } + + private static func source(_ index: Int, rich: Bool) -> String { + let paragraph = "Message \(index). " + String(repeating: "Native transcript scrolling keeps the reading position stable. ", count: 1 + index % 4) + guard rich else { return paragraph } + switch index % 4 { + case 0: return "## Review \(index)\n\n\(paragraph)\n\n- **Stable identity** and `cached layout`\n- A [reference](https://example.com) with *details*\n- [x] Verified" + case 1: + let code = (0..<14).map { " let item\($0) = messages[\($0)] // row \(index)" }.joined(separator: "\n") + return "### Code \(index)\n\n```swift\nfunc render() {\n\(code)\n}\n```" + case 2: + return "| File | Added | Removed |\n| --- | ---: | ---: |\n" + (0..<10).map { "| source-\(index)-\($0).swift | \($0 + 2) | 1 |" }.joined(separator: "\n") + default: return "\(paragraph)\n\n> A longer explanation with **emphasis**.\n\n" + paragraph + } + } + + private static func row(_ index: Int, text: String) -> Row { + let id = "profile-\(index)" + return Row(id: id, block: .block(.agentText(AgentTextBlock( + id: id, localId: nil, createdAt: index, invokedAt: nil, + durationMs: nil, usage: nil, model: nil, text: text, meta: nil + )))) + } + + func testProfileRealDisplayFrames() async throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["HAPI_SCROLL_PROFILE"] == "1", + "Run explicitly with TEST_RUNNER_HAPI_SCROLL_PROFILE=1; never a CI timing gate.") + let log = OSLog(subsystem: "run.hapi.profile", category: .pointsOfInterest) + let delay = Double(ProcessInfo.processInfo.environment["HAPI_SCROLL_PROFILE_DELAY"] ?? "0") ?? 0 + print("HAPI_PROFILE_READY pid=\(ProcessInfo.processInfo.processIdentifier)") + try await Task.sleep(for: .seconds(delay)) + let scenarios = (ProcessInfo.processInfo.environment["HAPI_SCROLL_SCENARIOS"] ?? "plain,rich,rich-updates").split(separator: ",").map(String.init) + for scenario in scenarios { + XCTAssertTrue(["plain", "rich", "rich-updates"].contains(scenario)) + let sources = (0..<800).map { Self.source($0, rich: scenario != "plain") } + let driver = Driver(rows: sources.enumerated().map { Self.row($0.offset, text: $0.element) }) + let updates = (0..<100).map { sources[799] + "\n\nStreaming revision \($0)" } + await driver.cache.prepare(sources) + let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let window = UIWindow(windowScene: scene) + let host = UIHostingController(rootView: Harness(driver: driver)) + window.rootViewController = host + window.makeKeyAndVisible() + defer { window.isHidden = true } + try await Task.sleep(for: .seconds(1)) + func find(_ view: UIView) -> UICollectionView? { + if let collection = view as? UICollectionView { return collection } + return view.subviews.lazy.compactMap(find).first + } + let collection = try XCTUnwrap(find(host.view)) + collection.delegate?.scrollViewWillBeginDragging?(collection) + collection.scrollToItem(at: IndexPath(item: 350, section: 0), at: .top, animated: false) + try await Task.sleep(for: .seconds(1)) + let start = collection.contentOffset.y + let initialHeights = collection.visibleCells.map { $0.frame.height }.sorted() + if scenario != "plain" { + XCTAssertGreaterThan(initialHeights.max() ?? 0, 200, + "Rich rows must actually render and self-size, not remain empty estimates") + } + let preview = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + let previewAttachment = XCTAttachment(image: preview) + previewAttachment.name = "\(scenario)-rendered-transcript" + previewAttachment.lifetime = .keepAlways + add(previewAttachment) + print("HAPI_PROFILE_SCENARIO \(scenario)") + for repetition in 1...3 { + collection.contentOffset.y = start + try await Task.sleep(for: .milliseconds(500)) + let label = "\(scenario)-\(repetition)" + let signpost = OSSignpostID(log: log) + os_signpost(.begin, log: log, name: "Transcript Scroll", signpostID: signpost, "%{public}s", label) + var previousRevision = -1 + let probe = DisplayProbe(collection: collection, seconds: 6, speed: 1_800) { elapsed in + if scenario == "rich-updates" { + let revision = min(updates.count - 1, Int(elapsed * 10)) + if revision != previousRevision { + previousRevision = revision + driver.rows[799] = Self.row(799, text: updates[revision]) + } + } + } + let result = await probe.run() + os_signpost(.end, log: log, name: "Transcript Scroll", signpostID: signpost) + var record = result + record["scenario"] = scenario + record["repetition"] = repetition + record["rows"] = 800 + record["viewportWidth"] = collection.bounds.width + record["viewportHeight"] = collection.bounds.height + record["initialVisibleHeights"] = initialHeights + record["maximumFramesPerSecond"] = window.screen.maximumFramesPerSecond + record["os"] = UIDevice.current.systemVersion + record["metric"] = "display-link-callback-cadence-not-presented-fps" + let data = try JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) + print("HAPI_FRAME_PROFILE \(String(decoding: data, as: UTF8.self))") + let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json") + attachment.name = label + attachment.lifetime = .keepAlways + add(attachment) + XCTAssertGreaterThan(result["distancePoints"] as? Double ?? 0, 5_000, "Probe must actually scroll") + } + } + } + + @MainActor + private final class DisplayProbe: NSObject { + let collection: UICollectionView + let seconds: Double + let speed: Double + let update: (Double) -> Void + var link: CADisplayLink? + var continuation: CheckedContinuation<[String: Any], Never>? + var began: Double? + var previous: Double? + var intervals: [Double] = [] + var updateTimes: [Double] = [] + var initialOffset = 0.0 + + init(collection: UICollectionView, seconds: Double, speed: Double, update: @escaping (Double) -> Void) { + self.collection = collection + self.seconds = seconds + self.speed = speed + self.update = update + } + + func run() async -> [String: Any] { + await withCheckedContinuation { continuation in + self.continuation = continuation + initialOffset = collection.contentOffset.y + let link = CADisplayLink(target: self, selector: #selector(tick(_:))) + link.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 60, preferred: 60) + self.link = link + link.add(to: .main, forMode: .common) + } + } + + @objc func tick(_ link: CADisplayLink) { + let now = CACurrentMediaTime() + if began == nil { began = now } + let elapsed = now - began! + if let previous { intervals.append((now - previous) * 1_000) } + self.previous = now + if elapsed >= seconds { + link.invalidate() + self.link = nil + let sorted = intervals.sorted() + func percentile(_ p: Double) -> Double { sorted[min(sorted.count - 1, Int(Double(sorted.count - 1) * p))] } + let result: [String: Any] = [ + "seconds": elapsed, "callbacks": intervals.count, + "callbackFPS": Double(intervals.count) / elapsed, + "intervalP50Ms": percentile(0.5), "intervalP95Ms": percentile(0.95), + "intervalP99Ms": percentile(0.99), "maxIntervalMs": sorted.last ?? 0, + "intervalsOver25Ms": intervals.filter { $0 > 25 }.count, + "intervalsOver50Ms": intervals.filter { $0 > 50 }.count, + "distancePoints": collection.contentOffset.y - initialOffset, + "offsetUpdateMaxMs": updateTimes.max() ?? 0, + "intervalsMs": intervals, + ] + continuation?.resume(returning: result) + continuation = nil + return + } + collection.contentOffset.y = initialOffset + elapsed * speed + update(elapsed) + updateTimes.append((CACurrentMediaTime() - now) * 1_000) + } + } +} diff --git a/ios/HapiTests/TranscriptGeometryTests.swift b/ios/HapiTests/TranscriptGeometryTests.swift index 9873392f..57a45843 100644 --- a/ios/HapiTests/TranscriptGeometryTests.swift +++ b/ios/HapiTests/TranscriptGeometryTests.swift @@ -21,14 +21,15 @@ final class TranscriptGeometryTests: XCTestCase { } layout.prepare() - func verify(width: CGFloat) { + @MainActor func verify(width: CGFloat) { var y: CGFloat = 10 let expected = ids.map { id -> CGRect in let frame = CGRect(x: 12, y: y, width: width - 24, height: heights[id] ?? 100) y = frame.maxY + 10 return frame } - XCTAssertEqual(layout.collectionViewContentSize.height, y, accuracy: 0.01) + let actualHeight = layout.collectionViewContentSize.height + XCTAssertEqual(actualHeight, y, accuracy: 0.01) let rects = stride(from: -500, to: Int(y) + 500, by: 377).map { CGRect(x: 0, y: $0, width: Int(width), height: 844) } + [.zero, .null, .infinite, CGRect(x: 1000, y: 0, width: 100, height: 100000)] diff --git a/ios/HapiTests/TranscriptRefreshTests.swift b/ios/HapiTests/TranscriptRefreshTests.swift new file mode 100644 index 00000000..7c7713a0 --- /dev/null +++ b/ios/HapiTests/TranscriptRefreshTests.swift @@ -0,0 +1,188 @@ +import Observation +import SwiftUI +import UIKit +import XCTest +@testable import HapiUI + +@MainActor +final class TranscriptRefreshTests: XCTestCase { + struct Row: Identifiable, Equatable { + let id: String + var height: CGFloat = 120 + } + + struct Sample: Equatable { + let basePath: String + let dark: Bool + let size: DynamicTypeSize + let locale: String + let direction: LayoutDirection + } + + @Observable @MainActor + final class Driver { + var rows = (0..<80).map { Row(id: String($0)) } + var version = 0 + var basePath = "/before" + var dark = false + var size = DynamicTypeSize.large + var locale = Locale(identifier: "en") + var direction = LayoutDirection.leftToRight + var actionToken = 0 + @ObservationIgnored var lastAction = -1 + @ObservationIgnored var layouts: [Int] = [] + @ObservationIgnored var samples: [String: Sample] = [:] + @ObservationIgnored var actions: [String: HapiOpenURLAction] = [:] + } + + private struct Harness: View { + let driver: Driver + var body: some View { + let token = driver.actionToken + ListView(driver: driver) + .hapiTheme(driver.dark ? .dark : .light) + .environment(\.dynamicTypeSize, driver.size) + .environment(\.locale, driver.locale) + .environment(\.layoutDirection, driver.direction) + .environment(\.hapiOpenURL, HapiOpenURLAction { _ in + Task { @MainActor in driver.lastAction = token } + }) + } + } + + private struct ListView: View { + let driver: Driver + var body: some View { + // An ordinary value capture, not a live observable read in a row. + let basePath = driver.basePath + AnchoredTranscriptList( + items: driver.rows, historyVersion: driver.version, jumpToken: 0, + historyControlID: "history", onViewport: { _ in }, + onLayout: { version, _ in driver.layouts.append(version) } + ) { row in + AnyView(ProbeRow(row: row, basePath: basePath, driver: driver)) + } + } + } + + private struct ProbeRow: View { + let row: Row + let basePath: String + let driver: Driver + @Environment(\.dynamicTypeSize) private var size + var body: some View { + Text(row.id) + .frame(maxWidth: .infinity) + .frame(height: row.height + (size.isAccessibilitySize ? 60 : 0)) + .background(Probe(id: row.id, basePath: basePath, driver: driver)) + } + } + + private struct Probe: UIViewRepresentable { + let id: String + let basePath: String + let driver: Driver + func makeUIView(context: Context) -> UIView { UIView() } + func updateUIView(_ view: UIView, context: Context) { + let environment = context.environment + driver.samples[id] = Sample(basePath: basePath, dark: environment.hapiTheme.isDark, + size: environment.dynamicTypeSize, locale: environment.locale.identifier, + direction: environment.layoutDirection) + driver.actions[id] = environment.hapiOpenURL + } + } + + private func host(_ driver: Driver) throws -> (UIWindow, TranscriptCollectionController, UICollectionView) { + let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let window = UIWindow(windowScene: scene) + let host = UIHostingController(rootView: Harness(driver: driver)) + window.rootViewController = host + window.makeKeyAndVisible() + host.view.layoutIfNeeded() + func find(_ controller: UIViewController) -> TranscriptCollectionController? { + if let result = controller as? TranscriptCollectionController { return result } + return controller.children.lazy.compactMap(find).first + } + let controller = try XCTUnwrap(find(host)) + return (window, controller, try XCTUnwrap(controller.view as? UICollectionView)) + } + + private func settle(_ condition: () -> Bool = { true }, file: StaticString = #filePath, line: UInt = #line) async { + for _ in 0..<100 { + try? await Task.sleep(for: .milliseconds(20)) + if condition() { + try? await Task.sleep(for: .milliseconds(100)) + return + } + } + XCTFail("Transcript did not settle", file: file, line: line) + } + + private func browse(_ view: UICollectionView, index: Int) async { + view.delegate?.scrollViewWillBeginDragging?(view) + view.scrollToItem(at: IndexPath(item: index, section: 0), at: .top, animated: false) + await settle() + } + + func testOffscreenUpdatesKeepVisibleHostingConfigurationsAndStillResizeChangedRows() async throws { + let driver = Driver() + let (window, controller, view) = try host(driver) + defer { window.isHidden = true } + await settle { driver.layouts.contains(0) } + await browse(view, index: 25) + let cell = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 25, section: 0))) + let offset = cell.frame.minY - view.contentOffset.y + #if DEBUG + let configurations = controller.cellConfigurationCount + #endif + for version in 1...3 { + driver.rows[79].height += 20 + driver.version = version + await settle { driver.layouts.contains(version) } + } + #if DEBUG + XCTAssertEqual(controller.cellConfigurationCount, configurations, + "Offscreen streaming must not reinstall unchanged visible hosting roots") + #endif + XCTAssertEqual(cell.frame.minY - view.contentOffset.y, offset, accuracy: 1) + driver.rows[25].height += 70 + await settle { abs(cell.frame.height - 190) < 1 } + XCTAssertEqual(cell.frame.minY - view.contentOffset.y, offset, accuracy: 1) + await browse(view, index: 79) + let tail = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 79, section: 0))) + XCTAssertEqual(tail.frame.height, 180, accuracy: 1, "Recycled cells must use the latest offscreen value") + } + + func testEqualItemsReceiveLatestEnvironmentValueCapturesAndActions() async throws { + let driver = Driver() + let (window, controller, view) = try host(driver) + defer { window.isHidden = true } + await settle { driver.layouts.contains(0) } + await browse(view, index: 25) + #if DEBUG + let configurations = controller.cellConfigurationCount + #endif + driver.basePath = "/after" + driver.dark = true + driver.actionToken = 1 + let expected = Sample(basePath: "/after", dark: true, size: .large, locale: "en", direction: .leftToRight) + await settle { driver.samples["25"] == expected } + XCTAssertEqual(driver.samples["25"], expected) + #if DEBUG + XCTAssertEqual(controller.cellConfigurationCount, configurations) + #endif + try XCTUnwrap(driver.actions["25"])(URL(string: "hapi-file://?path=test")!) + await settle { driver.lastAction == 1 } + // UIKit may itself request cells again when layout-direction traits + // change; only ordinary data/context updates have a zero-config budget. + driver.locale = Locale(identifier: "ar") + driver.direction = .rightToLeft + driver.size = .accessibility2 + let recycled = Sample(basePath: "/after", dark: true, size: .accessibility2, locale: "ar", direction: .rightToLeft) + await settle { driver.samples["25"] == recycled } + let cell = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 25, section: 0))) + XCTAssertEqual(cell.frame.height, 180, accuracy: 1, "Environment-only changes must still self-size") + await browse(view, index: 60) + XCTAssertEqual(driver.samples["60"], recycled) + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiUI/Chat/AnchoredTranscriptList.swift b/ios/Packages/HapiKit/Sources/HapiUI/Chat/AnchoredTranscriptList.swift index 4b8bbb99..2c537803 100644 --- a/ios/Packages/HapiKit/Sources/HapiUI/Chat/AnchoredTranscriptList.swift +++ b/ios/Packages/HapiKit/Sources/HapiUI/Chat/AnchoredTranscriptList.swift @@ -1,4 +1,5 @@ #if os(iOS) +import Observation import SwiftUI import UIKit import os @@ -19,6 +20,7 @@ public struct AnchoredTranscriptList: UIViewCont public var onViewport: (TranscriptViewport) -> Void public var onLayout: (Int, Bool) -> Void public var content: (Item) -> AnyView + fileprivate var environment = EnvironmentValues() public init(items: [Item], historyVersion: Int, jumpToken: Int, historyControlID: String, onViewport: @escaping (TranscriptViewport) -> Void, @@ -38,7 +40,9 @@ public struct AnchoredTranscriptList: UIViewCont } public func updateUIViewController(_ controller: TranscriptCollectionController, context: Context) { - controller.update(self) + var next = self + next.environment = context.environment + controller.update(next) } } @@ -204,6 +208,25 @@ private final class TranscriptCollectionView: UICollectionView { } } +/// Keep hosting roots alive across unrelated transcript updates. The small +/// SwiftUI wrapper reads the latest builder and inherited environment, +/// including action captures. SwiftUI can diff the resulting row instead of UIKit +/// reinstalling every visible hosting configuration and measuring it again. +@MainActor @Observable +private final class TranscriptContent { + var render: (Item) -> AnyView = { _ in AnyView(EmptyView()) } + var environment = EnvironmentValues() +} + +private struct TranscriptHostedRow: View { + let item: Item + let content: TranscriptContent + + var body: some View { + content.render(item).environment(\.self, content.environment) + } +} + public final class TranscriptCollectionController: UIViewController, UICollectionViewDelegate where Item.ID == String { private let layout = TranscriptLayout() private lazy var collection = TranscriptCollectionView(frame: .zero, collectionViewLayout: layout) @@ -218,6 +241,10 @@ public final class TranscriptCollectionController() + #if DEBUG + private(set) var cellConfigurationCount = 0 + #endif private let logger = Logger(subsystem: "run.hapi", category: "Transcript") public override func loadView() { view = collection } @@ -237,10 +264,13 @@ public final class TranscriptCollectionController(collectionView: collection) { [weak self] view, index, id in let cell = view.dequeueReusableCell(withReuseIdentifier: "message", for: index) - guard let self, let item = self.values[id], let configuration = self.configuration else { return cell } + guard let self, let item = self.values[id] else { return cell } + #if DEBUG + self.cellConfigurationCount += 1 + #endif let width = max(1, view.bounds.width - 24) cell.contentConfiguration = UIHostingConfiguration { - configuration.content(item) + TranscriptHostedRow(item: item, content: self.content) .id(id) .frame(width: width, alignment: .leading) }.margins(.all, 0) @@ -303,11 +333,7 @@ public final class TranscriptCollectionController 0.5 - // Hosting roots inherit a snapshot of SwiftUI's environment. Refresh - // only visible roots for context changes (theme, text size, basePath), - // in addition to rows whose value actually changed. - let visibleIDs = Set(collection.indexPathsForVisibleItems.compactMap { source.itemIdentifier(for: $0) }) - let changed = next.items.filter { widthChanged || values[$0.id] != $0 || visibleIDs.contains($0.id) }.map(\.id) + let changed = next.items.filter { widthChanged || values[$0.id] != $0 }.map(\.id) let structureChanged = items.map(\.id) != next.items.map(\.id) let jump = next.jumpToken != lastJumpToken lastJumpToken = next.jumpToken @@ -315,6 +341,10 @@ public final class TranscriptCollectionController