Просмотр исходного кода

refactor(reader): 排版 viewport 解耦与首帧就绪信号

- ContentTextView 的排版尺寸改由不可变 ReaderViewport 传入,取消对
  View 尺寸的隐式读取
- ReadBookController 在内容更新前等待 viewport 就绪,超时回退旧路径
- 新增渲染器无关的首帧探针 ReaderFirstFrameTracker
- 补 ReaderLayoutCoordinator 的 viewport 边界测试
- 附带一批阅读器渲染实验期的设计文档与真机基线脚本

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5151561 3 месяцев назад
Родитель
Сommit
7067b36547

+ 13 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -95,6 +95,11 @@ class ReadBookController(
 
     var refs: ReadBookViewRefs? = null
 
+    internal val layoutController = ReaderLayoutCoordinator(
+        updateLayoutSize = ChapterProvider::upViewSize,
+        relayoutContent = ReadBook::relayoutContent,
+    )
+
     // Fallback handler for effects not yet migrated to controller
     var onUnhandledEffect: (ReadBookEffect) -> Unit = {}
     var onClose: (() -> Unit)? = null
@@ -417,6 +422,7 @@ class ReadBookController(
     // ── ContentTextView.CallBack ──────────────────────────────────────
 
     override val headerHeight: Int get() = refs?.readView?.curPage?.headerHeight ?: 0
+    override val readerLayoutController: ReaderLayoutController get() = layoutController
     override val imgBgPaddingStart: Int get() = refs?.readView?.curPage?.imgBgPaddingStart ?: 0
     override val pageFactory: TextPageFactory
         get() = refs?.readView?.pageFactory ?: error("ReadView not ready")
@@ -853,6 +859,9 @@ class ReadBookController(
         resetPageOffset: Boolean,
         success: (() -> Unit)?
     ) {
+        if (layoutController.awaitViewport() == null) {
+            AppLog.putDebug("upContentAwait 等待 ReaderViewport 超时,继续旧内容更新路径")
+        }
         withContext(Main.immediate) {
             handleEffect(ReadBookEffect.UpContent(relativePosition, resetPageOffset, success))
         }
@@ -900,7 +909,9 @@ class ReadBookController(
                         ConfigUpdateAction.UpdateBackgroundAlpha -> r.readView.upBgAlpha()
                         ConfigUpdateAction.UpdatePageSlopSquare -> r.readView.upPageSlopSquare()
                         ConfigUpdateAction.ReloadContent -> if (viewModel.isInitFinish) ReadBook.loadContent(resetPageOffset = false)
-                        ConfigUpdateAction.RelayoutContent -> if (viewModel.isInitFinish) ReadBook.relayoutContent()
+                        ConfigUpdateAction.RelayoutContent -> if (viewModel.isInitFinish) {
+                            layoutController.requestRelayout()
+                        }
                         ConfigUpdateAction.UpdateContent -> r.readView.upContent(resetPageOffset = false)
                         ConfigUpdateAction.UpdateChapterStyle -> {
                             ChapterProvider.upStyle()
@@ -990,6 +1001,7 @@ class ReadBookController(
             }
 
             is ReadBookEffect.LayoutPageCompleted -> {
+                layoutController.publishPageLayout(effect.index)
                 upSeekBarThrottle.invoke()
                 refs?.readView?.onLayoutPageCompleted(effect.index, effect.page)
             }

+ 36 - 4
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -6,8 +6,10 @@ import android.hardware.Sensor
 import android.hardware.SensorEvent
 import android.hardware.SensorEventListener
 import android.hardware.SensorManager
+import android.os.SystemClock
 import android.view.KeyEvent
 import android.view.View
+import android.view.ViewTreeObserver
 import android.widget.FrameLayout
 import android.widget.ImageView
 import androidx.activity.compose.rememberLauncherForActivityResult
@@ -532,13 +534,28 @@ fun ReadBookRouteScreen(
 
     var showSelectMenuConfigSheet by rememberSaveable { mutableStateOf(false) }
 
-    Box(
-        Modifier.fillMaxSize()
-    ) {
+    val firstFrameTracker = remember(controller) {
+        val requestedStart = controller.activity.intent.getLongExtra(
+            EXTRA_FIRST_FRAME_STARTED_AT_NANOS,
+            0L,
+        )
+        ReaderFirstFrameTracker(
+            startedAtNanos = requestedStart.takeIf { it > 0L }
+                ?: SystemClock.elapsedRealtimeNanos(),
+        )
+    }
+
+    Box(Modifier.fillMaxSize()) {
         key(controller) {
             ReadBookViewLayer(
                 modifier = Modifier
-                    .then(if (useMenuHazeSource) Modifier.hazeSource(menuHazeState) else Modifier)
+                    .then(
+                        if (useMenuHazeSource) {
+                            Modifier.hazeSource(menuHazeState)
+                        } else {
+                            Modifier
+                        }
+                    )
                     .layerBackdrop(menuBackdrop),
                 onRefsReady = { controller.onRefsReady(it) },
                 onCursorTouch = controller,
@@ -546,6 +563,7 @@ fun ReadBookRouteScreen(
                 contentTextViewCallBack = controller,
                 isDarkTheme = isDarkTheme,
                 onThemeChanged = controller::onAppThemeChanged,
+                onFirstContentDrawn = firstFrameTracker::report,
             )
         }
         ReadBookColorTheme(
@@ -674,6 +692,7 @@ private fun ReadBookViewLayer(
     contentTextViewCallBack: ContentTextView.CallBack,
     isDarkTheme: Boolean,
     onThemeChanged: (Boolean) -> Unit,
+    onFirstContentDrawn: () -> Unit,
 ) {
     AndroidView(
         modifier = modifier.fillMaxSize(),
@@ -742,6 +761,19 @@ private fun ReadBookViewLayer(
                         navigationBar = navigationBar,
                     )
                 )
+                val root = this
+                val firstDrawListener = object : ViewTreeObserver.OnDrawListener {
+                    override fun onDraw() {
+                        if (readView.curPage.textPage.lines.isEmpty()) return
+                        onFirstContentDrawn()
+                        root.post {
+                            if (root.viewTreeObserver.isAlive) {
+                                root.viewTreeObserver.removeOnDrawListener(this)
+                            }
+                        }
+                    }
+                }
+                viewTreeObserver.addOnDrawListener(firstDrawListener)
             }
         },
         update = {

+ 25 - 0
app/src/main/java/io/legado/app/ui/book/read/ReaderFirstFrame.kt

@@ -0,0 +1,25 @@
+package io.legado.app.ui.book.read
+
+import android.os.SystemClock
+import android.util.Log
+import io.legado.app.BuildConfig
+import java.util.concurrent.atomic.AtomicBoolean
+
+internal const val EXTRA_FIRST_FRAME_STARTED_AT_NANOS = "readerFirstFrameStartedAtNanos"
+
+/**
+ * Debug-only 首帧探针:从 intent 传入的启动时刻到正文第一次绘制之间的耗时,
+ * 通过 logcat tag `ReaderFirstFrame` 输出,供 tools 侧脚本采集。
+ */
+internal class ReaderFirstFrameTracker(
+    private val startedAtNanos: Long,
+) {
+    private val reported = AtomicBoolean(false)
+
+    fun report() {
+        if (!BuildConfig.DEBUG) return
+        if (!reported.compareAndSet(false, true)) return
+        val durationMillis = (SystemClock.elapsedRealtimeNanos() - startedAtNanos) / 1_000_000f
+        Log.i("ReaderFirstFrame", "renderer=legacy durationMs=$durationMillis")
+    }
+}

+ 118 - 0
app/src/main/java/io/legado/app/ui/book/read/ReaderViewport.kt

@@ -0,0 +1,118 @@
+package io.legado.app.ui.book.read
+
+import androidx.compose.runtime.Immutable
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withTimeoutOrNull
+
+@Immutable
+enum class ReaderLayoutMode {
+    PAGED,
+    SCROLL,
+    DOUBLE_PAGE,
+}
+
+@Immutable
+data class ReaderPadding(
+    val left: Int = 0,
+    val top: Int = 0,
+    val right: Int = 0,
+    val bottom: Int = 0,
+) {
+    val horizontal: Int get() = left + right
+    val vertical: Int get() = top + bottom
+}
+
+@Immutable
+data class ReaderViewport(
+    val widthPx: Int,
+    val heightPx: Int,
+    val density: Float,
+    val contentPadding: ReaderPadding = ReaderPadding(),
+    val mode: ReaderLayoutMode = ReaderLayoutMode.PAGED,
+) {
+    val contentWidthPx: Int get() = (widthPx - contentPadding.horizontal).coerceAtLeast(0)
+    val contentHeightPx: Int get() = (heightPx - contentPadding.vertical).coerceAtLeast(0)
+    val isReady: Boolean get() = contentWidthPx > 0 && contentHeightPx > 0 && density > 0f
+}
+
+@Immutable
+data class ReaderLayoutResult(
+    val revision: Long,
+    val pageIndex: Int,
+    val viewport: ReaderViewport,
+)
+
+interface ReaderLayoutController {
+    val viewport: StateFlow<ReaderViewport?>
+    val layoutResults: Flow<ReaderLayoutResult>
+
+    fun updateViewport(viewport: ReaderViewport)
+
+    fun requestRelayout()
+
+    suspend fun awaitViewport(timeoutMillis: Long = VIEWPORT_WAIT_TIMEOUT_MILLIS): ReaderViewport?
+
+    companion object {
+        const val VIEWPORT_WAIT_TIMEOUT_MILLIS = 2_000L
+    }
+}
+
+/**
+ * Track C · C4 的尺寸输入接缝。分页仍由 ChapterProvider 完成;本类只负责把
+ * View/Compose 的测量结果统一成正文尺寸,并暴露排版完成 revision。
+ */
+internal class ReaderLayoutCoordinator(
+    private val updateLayoutSize: (width: Int, height: Int) -> Unit,
+    private val relayoutContent: () -> Unit,
+) : ReaderLayoutController {
+
+    private val _viewport = MutableStateFlow<ReaderViewport?>(null)
+    override val viewport: StateFlow<ReaderViewport?> = _viewport.asStateFlow()
+
+    private val _layoutResults = MutableSharedFlow<ReaderLayoutResult>(extraBufferCapacity = 16)
+    override val layoutResults: Flow<ReaderLayoutResult> = _layoutResults.asSharedFlow()
+
+    private var appliedContentWidth = 0
+    private var appliedContentHeight = 0
+    private var layoutRevision = 0L
+
+    override fun updateViewport(viewport: ReaderViewport) {
+        if (!viewport.isReady) return
+        _viewport.value = viewport
+        if (
+            viewport.contentWidthPx == appliedContentWidth &&
+            viewport.contentHeightPx == appliedContentHeight
+        ) return
+        appliedContentWidth = viewport.contentWidthPx
+        appliedContentHeight = viewport.contentHeightPx
+        updateLayoutSize(appliedContentWidth, appliedContentHeight)
+    }
+
+    override fun requestRelayout() {
+        relayoutContent()
+    }
+
+    override suspend fun awaitViewport(timeoutMillis: Long): ReaderViewport? {
+        return withTimeoutOrNull(timeoutMillis) {
+            viewport.filter { it?.isReady == true }.first()
+        }
+    }
+
+    fun publishPageLayout(pageIndex: Int) {
+        val currentViewport = _viewport.value ?: return
+        _layoutResults.tryEmit(
+            ReaderLayoutResult(
+                revision = ++layoutRevision,
+                pageIndex = pageIndex,
+                viewport = currentViewport,
+            )
+        )
+    }
+}

+ 11 - 1
app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt

@@ -12,6 +12,8 @@ import io.legado.app.help.book.isOnLineTxt
 import io.legado.app.model.ReadBook
 import io.legado.app.ui.association.OpenUrlConfirmActivity
 import io.legado.app.ui.book.read.page.delegate.PageDelegate
+import io.legado.app.ui.book.read.ReaderLayoutController
+import io.legado.app.ui.book.read.ReaderViewport
 import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.TextPos
@@ -54,6 +56,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
     private val visibleRect = ChapterProvider.visibleRect
     val selectStart = TextPos(0, -1, -1)
     private val selectEnd = TextPos(0, -1, -1)
+    val selectEndPosition: TextPos get() = selectEnd
     var textPage: TextPage = TextPage()
         private set
     var isMainView = false
@@ -98,7 +101,13 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
     override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
         super.onSizeChanged(w, h, oldw, oldh)
         if (!isMainView || MainNavigator.backNavigationInProgress) return
-        ChapterProvider.upViewSize(w, h)
+        requireCallBack.readerLayoutController.updateViewport(
+            ReaderViewport(
+                widthPx = w,
+                heightPx = h,
+                density = resources.displayMetrics.density,
+            )
+        )
         textPage.format()
     }
 
@@ -773,6 +782,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
     }
 
     interface CallBack {
+        val readerLayoutController: ReaderLayoutController
         val headerHeight: Int
         val imgBgPaddingStart: Int
         val pageFactory: TextPageFactory

+ 10 - 0
app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt

@@ -91,6 +91,14 @@ class PageView(
             return binding.vwRoot.paddingStart
         }
 
+    val contentOffsetX: Int get() = binding.contentTextView.left
+
+    val contentOffsetY: Int get() = binding.contentTextView.top
+
+    val contentWidth: Int get() = binding.contentTextView.width
+
+    val contentHeight: Int get() = binding.contentTextView.height
+
     init {
         callBack?.let { binding.contentTextView.setCallBack(it) }
         upStyle()
@@ -822,6 +830,8 @@ class PageView(
 
     val selectStartPos get() = binding.contentTextView.selectStart
 
+    val selectEndPos get() = binding.contentTextView.selectEndPosition
+
     companion object {
         /**
          * 缓存枚举数组,避免每次调用 `values()` 触发 Kotlin 内部数组克隆。

+ 1 - 0
app/src/main/java/io/legado/app/ui/main/MainNavigator.kt

@@ -322,6 +322,7 @@ object MainNavigator {
             MainRouteConst.ROUTE_SETTINGS_AI -> MainRouteSettingsAi
             MainRouteConst.ROUTE_AI_CHAT -> MainRouteAiChat
             MainRouteConst.ROUTE_SETTINGS_CUSTOM_THEME -> MainRouteSettingsCustomTheme
+            MainRouteConst.ROUTE_SETTINGS_LAB_CONFIG -> MainRouteSettingsLabConfig
             MainRouteConst.ROUTE_SETTINGS_DOWNLOAD_CACHE -> MainRouteSettingsDownloadCache
             MainRouteConst.ROUTE_SETTINGS_TRANSLATION -> MainRouteSettingsTranslation
             MainRouteConst.ROUTE_IMPORT_LOCAL -> MainRouteImportLocal

+ 95 - 0
app/src/test/java/io/legado/app/ui/book/read/ReaderLayoutCoordinatorTest.kt

@@ -0,0 +1,95 @@
+package io.legado.app.ui.book.read
+
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.CoroutineStart
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class ReaderLayoutCoordinatorTest {
+
+    @Test
+    fun `View 与 Compose 上报相同正文尺寸时只更新一次分页引擎`() {
+        val appliedSizes = mutableListOf<Pair<Int, Int>>()
+        val coordinator = ReaderLayoutCoordinator(
+            updateLayoutSize = { width, height -> appliedSizes += width to height },
+            relayoutContent = {},
+        )
+
+        coordinator.updateViewport(
+            ReaderViewport(widthPx = 800, heightPx = 1200, density = 2f)
+        )
+        coordinator.updateViewport(
+            ReaderViewport(
+                widthPx = 1000,
+                heightPx = 1500,
+                density = 2f,
+                contentPadding = ReaderPadding(left = 100, top = 100, right = 100, bottom = 200),
+            )
+        )
+
+        assertEquals(listOf(800 to 1200), appliedSizes)
+        assertEquals(100, coordinator.viewport.value?.contentPadding?.left)
+    }
+
+    @Test
+    fun `无效测量不覆盖 viewport 且显式重排版经过接缝`() {
+        var relayoutCount = 0
+        val coordinator = ReaderLayoutCoordinator(
+            updateLayoutSize = { _, _ -> error("无效尺寸不应进入分页引擎") },
+            relayoutContent = { relayoutCount++ },
+        )
+
+        coordinator.updateViewport(ReaderViewport(0, 1200, 2f))
+        coordinator.requestRelayout()
+
+        assertEquals(null, coordinator.viewport.value)
+        assertEquals(1, relayoutCount)
+    }
+
+    @Test
+    fun `排版结果携带当前 viewport 与单调 revision`() = runBlocking {
+        val coordinator = ReaderLayoutCoordinator(
+            updateLayoutSize = { _, _ -> },
+            relayoutContent = {},
+        )
+        val viewport = ReaderViewport(800, 1200, 2f, mode = ReaderLayoutMode.PAGED)
+        coordinator.updateViewport(viewport)
+        val first = async(start = CoroutineStart.UNDISPATCHED) { coordinator.layoutResults.first() }
+
+        coordinator.publishPageLayout(pageIndex = 3)
+        val result = first.await()
+
+        assertEquals(1L, result.revision)
+        assertEquals(3, result.pageIndex)
+        assertSame(viewport, result.viewport)
+        assertSame(viewport, coordinator.awaitViewport())
+    }
+
+    @Test
+    fun `没有 View 测量时 Compose viewport 仍驱动分页尺寸`() = runBlocking {
+        val appliedSizes = mutableListOf<Pair<Int, Int>>()
+        val coordinator = ReaderLayoutCoordinator(
+            updateLayoutSize = { width, height -> appliedSizes += width to height },
+            relayoutContent = {},
+        )
+
+        coordinator.updateViewport(ReaderViewport(1080, 2340, density = 3f))
+
+        assertEquals(listOf(1080 to 2340), appliedSizes)
+        assertEquals(1080, coordinator.awaitViewport()?.contentWidthPx)
+    }
+
+    @Test
+    fun `viewport 未测量时有界等待后返回 null`() = runBlocking {
+        val coordinator = ReaderLayoutCoordinator(
+            updateLayoutSize = { _, _ -> },
+            relayoutContent = {},
+        )
+
+        assertNull(coordinator.awaitViewport(timeoutMillis = 20))
+    }
+}

+ 83 - 8
docs/dev/mad-modernization-plan.md

@@ -33,7 +33,22 @@
 
 **③ F1 收尾**:仍缺一个**显式跑 `testAppDebugUnitTest` + `lintAppDebug` 的 CI job**——这是 F1 唯一没关上的门(当前单测失败/新增 lint 违规不会让 CI 红)。
 
-**④ Track A**:**A1–A5 已全部落地**。A1–A3:`ReadBook.book/durChapterIndex/durChapterPos` 已 `private set`,外部 6 处写入改走 `replaceCurrentBook`/`clearCurrentBook`/`updateReadingPosition` + `isCurrentBook` 谓词,编译器证明外部写入点为 0。A4:`LegacyReaderSnapshot` + `ReadBook.snapshot: StateFlow`,`publishSnapshot()` 落在 14 个内部 mutator + 3 个命令的原子末尾。A5:`ReaderSession` 接口 + `LegacyReaderSession` 桥接(`model/ReaderSession.kt`,Koin 注册),`ReadBookViewModel` 已注入并 `collectReaderSession()` 反应式消费。**Track B / C**:均未开始。
+**④ Track A**:**A1–A5 已全部落地**。A1–A3:`ReadBook.book/durChapterIndex/durChapterPos` 已 `private set`,外部 6 处写入改走 `replaceCurrentBook`/`clearCurrentBook`/`updateReadingPosition` + `isCurrentBook` 谓词,编译器证明外部写入点为 0。A4:`LegacyReaderSnapshot` + `ReadBook.snapshot: StateFlow`,`publishSnapshot()` 落在 14 个内部 mutator + 3 个命令的原子末尾。A5:`ReaderSession` 接口 + `LegacyReaderSession` 桥接(`model/ReaderSession.kt`,Koin 注册),`ReadBookViewModel` 已注入并 `collectReaderSession()` 反应式消费。
+
+**⑤ Track B**:**B1/B2 已落地**(渲染子集下沉),端游剩余项见下。**Track C**:**C0–C5 已落地后按 2026-07-21 决策暂停**(Compose overlay 冻结为 flag 下的可选渲染器,不推进 C6–C9),详见下方 ⑥ 与 `docs/dev/track-c-compose-reader-plan.md` 顶部状态。
+
+- **B1(接口切分)**:`ReadBook.CallBack` 切成 `ReaderRenderCallback`(渲染子集:`upContent`/`upContentAwait`/`pageChanged`/`contentLoadFinish`/`upPageAnim`/`cancelSelect` + `LayoutProgressListener`)与状态子集 `CallBack`(`upMenuView`/`loadChapterList`/`notifyBookChanged`/`sureNewProgress`)。
+- **B2(渲染下沉)**:新增 `ReadBook.renderCallBack: ReaderRenderCallback` 独立槽位;30+ 处渲染调用点由 `callBack?.` 改走 `renderCallBack?.`。`ReadBookController` 实现 `ReaderRenderCallback`,`onRefsReady` 注册 / `clearTts` 注销,渲染方法复用既有 `handleEffect` 分支(零渲染逻辑漂移)。**线程关键点**:`ReadBook` 在 `Coroutine.async`(默认 `Dispatchers.IO`) 里回调渲染,旧路径靠 VM 的 `SharedFlow`→生命周期收集器切主线程;B2 用 `handler.post`/`withContext(Main)` 保留同样的异步-切主线程语义。业务状态刷新改由 `collectReaderSession()` 收集快照驱动——为内容加载路径(`contentLoadFinish`/`contentLoadFinishAwait` 布局完成、`loadOrUpContent`、`upMsg`)补 `publishSnapshot()`,补齐旧 VM 渲染回调顺带做的 `syncFromReadBook`(尤其 `seekMax` 依赖 `curTextChapter.pages.size`)。`isInitFinish` 作为纯业务标志留在 VM,由 `controller.contentLoadFinish` 幂等置位。
+- **Track B 端游剩余(未做)**:① VM **仍实现** `ReadBook.CallBack`(状态子集),完整 B 验收「VM 不再实现 CallBack」需把状态子集也迁到 `ReaderSession`;② `emitEffectWhenSubscribed` 的 `subscriptionCount` 握手仍在(现仅服务于 `UpdateReadViewConfig` 这类非渲染 init 时序,一处),完整移除属端游;③ `PageChanged`/`ContentLoadFinish`/`LayoutPageCompleted` 三个 Effect 现在只由 controller 的渲染方法**直接**触发 `handleEffect`(不再经 `_effects` 总线),可在后续把这三个 handler 内联进渲染方法体、删掉对应 Effect 类。
+- **B2 验证边界(真机)**:翻页/换章时章节号与进度、seek bar 上限(page 模式依赖 `pages.size`,B2 改为布局完成后单次 `publishSnapshot` 刷新,非逐页)、首帧渲染(`registerRender` 已在有内容时立即同步一次)、朗读中翻页、`upContentAwait` 时序(B2 下 `withContext(Main)` 会在 await 内**同步**跑完视图更新,比旧实现「只 await emit」更靠后完成,需确认换章无闪烁/错序)。
+
+**⑥ 方向调整(2026-07-21):Compose 阅读器暂停于 C5,新增 Track D(`ReadView` 自身业务解耦)为近期主线。**
+
+依据:C3 真机帧基线显示 Compose 渲染劣于旧 View(jank 50% vs 4.13%;50/90/95/99 = 30/42/44/53ms vs 5/8/21/65ms),且仿真卷曲/选择/滚动/自动翻页等成熟能力全量重写为「高回归风险、低用户收益」的改造。故:
+
+- **Track C 暂停、不删旧栈**:C0–C5 产物(`ReaderRenderer` 契约、`ReaderRenderModel`、`ComposeReaderSurface`、不可变 `ReaderPageSnapshot`)**保留在 lab flag 后,冻结为可选可插拔渲染器**(默认关),作为「将来真要换渲染器」的逃生舱与 parity 实验台。**C6–C9 不再是当前主线**,待后续专门的性能调查后再重估「Compose 默认 + 删 View」还是「View 永久渲染岛」。
+- **新增 Track D:把 `ReadView` 自身的出站业务耦合解掉**。核实(`ui/book/read/page/ReadView.kt`):ReadView 仍直呼 `ReadBook.moveToNextChapter/moveToPrevChapter/moveToNextPage/moveToNextChapterAwait/syncProgress`、`ReadAloud.pause/resume`(509–750 行),并经 `callBack.*` 直接驱动书签/内容编辑/替换规则/目录/搜索/进度确认(504、513–519 行)。这是 A/B/C 都未触及的一面——A 收敛 `ReadBook` 所有权、B 把渲染回调移出 VM、C 让 Compose 面消费只读快照,**都没动 `ReadView` 的出站调用**。Track D 用 `ReaderEvent` 出站接口把这些业务调用路由回 `ReaderSession`/命令,让 View 阅读器本身符合 UDF——**不依赖 Compose**。设计见下方 Track D 节。
+- **C5 工作区改动保留**(`ComposeReaderSurface.kt`/`ReaderRenderModel.kt`/`ReaderRenderStateStoreTest.kt`/`ReaderPageSnapshot.kt`),作为冻结状态;可单独提交 `feat(Track C): C5 不可变快照(冻结为可选渲染器)`。
 
 ### 未提交
 
@@ -74,20 +89,23 @@
 - **不优先造扫描器**:写入面是个位数,用编译器(`private set`)约束,而不是先写 Detekt/lint 规则。
 - **不做万能 Repository**:DAO 去注入按功能逐批,不预先设计几十个大仓库。
 
-## 3. 三轨模型 + 两条横切地基
+## 3. 轨道模型 + 两条横切地基
 
-改造被拆成三条**相互解耦、可并行**的轨道,外加两条横切地基。关键洞察:三条轨道的进度互不阻塞,**除了 Track C 依赖 Track A/B 的产出**。
+改造被拆成若干**相互解耦、可并行**的轨道,外加两条横切地基。
 
 ```
 横切地基:
   F1  CI 质量门禁(测试/lint 基线/架构检查)
   F2  UI→DAO 去注入(全应用,非阅读器专属)
 
-Track A  所有权与业务状态   —— 现在就能做,最高优先级
-Track B  遗留渲染边界下沉   —— 现在就能做,与 A 并行
-Track C  声明式渲染 / Compose —— 长期,依赖 A、B 的产出
+Track A  所有权与业务状态       —— 已落地(A1–A5)
+Track B  遗留渲染边界下沉       —— 已落地(B1/B2),端游剩余
+Track C  声明式渲染 / Compose   —— C0–C5 已落地,2026-07-21 起暂停冻结为可选渲染器
+Track D  ReadView 自身业务解耦   —— 近期主线,不依赖 Compose,依赖 A(ReaderSession)
 ```
 
+> **方向修订(2026-07-21)**:原路线把 Track C(Compose 替换阅读器)当作阅读渲染的终点。基于 C3 真机性能基线(Compose 帧耗时/jank 明显劣于旧 View)与「仿真卷曲/选择/滚动/自动翻页全量重写=高回归风险、低用户收益」的判断,**改为**:保留成熟的 `ReadView` 作为渲染核心,把它从「业务+状态+渲染全包」重构成**只做绘制/手势/动画的专业渲染岛**;Compose 用于阅读器外围 UI(工具栏/菜单/设置/弹窗)与「可选可插拔渲染器」实验。**MAD ≠ Compose**:分层、UDF、状态所有权、`ViewModel+StateFlow`、生命周期感知、可测试的数据/业务层,均可在 View 上成立(Android 官方至 2026 仍维护专门的 View 架构指南)。Track C 是否最终成为默认渲染器,留待专门的性能调查后重估,不作为当前目标。
+
 ## 4. 分阶段执行
 
 ### F1 —— 建立可信的 CI 底线(最先做,成本最低)
@@ -253,6 +271,8 @@ interface ReaderSession {
 
 ### Track B —— 遗留渲染边界下沉(与 A 并行,**不等 Compose**)
 
+> **进度**:B1(接口切分)+ B2(渲染子集下沉到 `ReadBookController`)已落地并提交,端游剩余项见进度快照 ⑤。
+
 关键结论:把指令式渲染协议**从 ViewModel 移到 UI 层的渲染控制器**,不需要 Compose。Compose 只用于让渲染**声明式**(Track C)。
 
 **B1. 先拆分 `CallBack` 接口,而不是整体搬迁**。`CallBack` 是两套协议穿了一件外套:
@@ -288,9 +308,15 @@ ReadBook ──legacy render callback──▶ LegacyReaderRenderController
 
 **验收**:VM 不再实现 `ReadBook.CallBack`;`_effects` 中的渲染类 Effect(`ToggleReadAloud`/`ToggleAutoPage` 等指令)全部由渲染控制器承接;`subscriptionCount.first { it > 0 }` 握手删除。剩余 `_effects` 仅保留真正的一次性 UI 消息/导航(严格 MAD 下仍扣分,但已是可辩护的最小面)。
 
+> **B2 已达成中期目标**(渲染子集不再穿过 VM);上面这条**完整验收**尚未全绿——VM 仍实现 `CallBack` 的**状态子集**、握手仍在。三项端游剩余见进度快照 ⑤,属把状态子集也迁入 `ReaderSession` 的后续工作。
+
 ---
 
-### Track C —— 声明式渲染 / Compose 迁移(长期,依赖 A、B)
+### Track C —— 声明式渲染 / Compose 迁移(**2026-07-21 起暂停,冻结于 C5**)
+
+> **状态(2026-07-21)**:C0–C5 已落地并真机验收;此后**暂停**。Compose overlay 冻结为 lab flag(`LabSettings.composeRenderer`,默认关)下的**可选可插拔渲染器** + parity 实验台,**不推进 C6–C9、不删旧栈**。是否让 Compose 成为默认渲染器,待专门的性能调查后重估。当前阅读渲染的近期主线是 **Track D(保留 `ReadView`、解其自身业务耦合)**。暂停边界与 C0–C5 处置见 `docs/dev/track-c-compose-reader-plan.md` 顶部状态。
+
+> **执行计划见 `docs/dev/track-c-compose-reader-plan.md`**(已按两轮外部审阅重排:C0 立 `ReaderRenderer` 契约〔随 C3 补〕 → C1 抽只读 `ReaderRenderModel`〔已落地〕 → C2 flag 下 Compose 静态页 overlay〔已落地〕 → C3 稳住 overlay + parity/性能基线 → **C4 抽 `ReaderViewport`(含 `mode` 预留)让 Compose 驱动分页、旧 `ReadView` 可关、并开始拆 `upContentAwait`〔核心里程碑〕** → C5 不可变 `ReaderPageSnapshot` → C6 无动画阅读闭环〔图片/双页/点击/选择/滚动〕 → C7 停止双绘 + 删 `upContentAwait` → C8 翻页动画、仿真卷曲垫底 → C9 翻默认、删旧栈;排版引擎 `ChapterProvider` 全程不动)。**关键调整:把 viewport 解耦与不可变快照提到动画/复杂交互之前,避免在 overlay 上堆功能形成永久双栈。**
 
 ```
 Legacy Canvas View
@@ -304,6 +330,53 @@ Legacy Canvas View
 
 ---
 
+### Track D —— `ReadView` 自身业务解耦(近期主线,不依赖 Compose)
+
+> **由来(2026-07-21)**:Track A 收敛了 `ReadBook` 所有权、Track B 把渲染回调移出 VM、Track C 让 Compose 面消费只读快照——但三者都没动 **`ReadView` 自己**的出站耦合。既然决定「保留 View 作为渲染核心」,就必须把 View 阅读器本身拉回 UDF:**`ReadView` 只做绘制/手势/动画,不认识 `ReadBook`/`ReadAloud`/Activity 导航。**
+
+**已核实的耦合**(`ui/book/read/page/ReadView.kt`):
+- **直呼业务单例**:`ReadBook.moveToNextChapter/moveToPrevChapter/moveToNextPage/moveToNextChapterAwait/syncProgress`、`ReadAloud.pause/resume`(509、510、518、524、526、749、750 行)。
+- **经 `callBack` 直驱业务/导航**:`addBookmark`、`openContentEdit`、`changeReplaceRuleState`、`openChapterList`、`openSearchActivity`、`sureNewProgress`、`showActionMenu`(504、513–519 行)。
+- **直接读 `ReadBook` 取页数据**:`ReadBook.textChapter(0/1/-1)`(823–833 行,经 `pageFactory`)。
+
+**三类状态纪律(呼应反目标「不做伪状态」)**——Track D 必须守住这条线,避免为 UDF 把每个触点塞进 `StateFlow`:
+
+| 类别 | 归属 | 例 |
+|---|---|---|
+| 业务状态 | `ReaderSession`/VM | 书籍、章节、进度、配置、加载/错误、朗读态 |
+| 渲染状态 | 分页引擎产出 → View | 前/当前/后页、文本布局、图片布局、页面样式 |
+| 瞬时控件状态 | **`ReadView` 自持,绝不上浮** | 触摸坐标、`Path`/`Matrix`、`Scroller`、翻页动画进度、卷曲 `Bitmap`、阴影、选择手柄拖动、每帧 `invalidate` |
+
+> 尤其卷曲手势、动画进度、每帧 `invalidate` 是每秒几十上百次变化、且只属于 `ReadView` 的状态——**不进 `UiState`**。把它们塞进 `StateFlow` 是「把门铃伪装成恒温器」的镜像错误。
+
+**D1(出站解耦,先做、低风险、不依赖 Compose)**:定义 `ReaderEvent` 出站接口,`ReadView` 把「业务意图」以事件发出,由外层(Activity/VM)翻译成 `ReaderSession`/命令调用:
+```kotlin
+sealed interface ReaderEvent {
+    data object RequestPreviousPage : ReaderEvent
+    data object RequestNextPage : ReaderEvent
+    data object OpenMenu : ReaderEvent
+    data class SyncProgress(/* … */) : ReaderEvent
+    data class ToggleReadAloud(/* … */) : ReaderEvent
+    data object AddBookmark : ReaderEvent
+    data object OpenContentEdit : ReaderEvent
+    data object OpenChapterList : ReaderEvent
+    data object OpenSearch : ReaderEvent
+    // …翻页完成 / 选择变化 / 链接点击按需补
+}
+fun interface ReaderEventListener { fun onEvent(event: ReaderEvent) }
+```
+`ReadView` 的 `ReadBook.*`/`ReadAloud.*` 直调与 `callBack` 的业务项改为 `eventListener?.onEvent(...)`;外层 `readView.eventListener = ReaderEventListener { viewModel.onIntent(it.toReaderIntent()) }`。验收:`grep` 证明 `ReadView.kt` 不再为 mutation 引用 `ReadBook`/`ReadAloud`,业务调用全部经事件出站。**保留** `ReadView` 对 `pageFactory` 的**只读**页数据拉取(属 D2,D1 不强改签名)。
+
+> **忠实边界**:D1 只搬「谁下达业务命令」,不改翻页/排版/动画行为。`callBack` 里真正属于「瞬时 UI 副作用」的项(`screenOffTimerStart`/`upSystemUiVisibility`/`showTextActionMenu`)可留在 View↔宿主的直接协作里,不必强行事件化——它们不是业务状态。
+
+**D2(入站解耦,较大、可后置)**:把 `ReadView` 对 `ReadBook.textChapter(...)` 的直接页数据拉取,改为消费一个由会话/渲染模型派生的**只读输入**(报告里的 `ReaderRenderState`——与 Track C1/C5 的 `ReaderRenderModel`/`ReaderPageSnapshot` 同源,只是这次喂给旧 `ReadView` 而非 Compose 面)。这一步更大(涉及 `pageFactory` 取页时序),**可在 D1 稳定后再评估**,不与 D1 捆绑。
+
+**最终形态(可选,视需要)**:把「渲染岛」收敛到 `ReaderRenderer` 契约背后(Track C0 已立),当前实现 `ViewReaderRenderer`(包 `ReadView`)为**规范实现**;`ComposeReaderRenderer`(C0–C5 的冻结产物)为可选。业务层不因渲染器切换而重做。
+
+**验收**:`ReadView` 不再直接写 `ReadBook`/调 `ReadAloud`/驱动 Activity 业务导航(D1,编译器/grep 可证);瞬时控件状态全部留在 View、未上浮到 `UiState`;翻页/仿真卷曲/选择/滚动/自动翻页行为零漂移(真机 parity)。
+
+---
+
 ### ViewModel 拆分(不整体等待 Session)
 
 拆分按依赖归类,**与渲染无关的部分现在就能并行拆**:
@@ -346,6 +419,8 @@ AI 清理与改写、翻译、TTS 引擎查询与配置、换源、上传/同步
 ```
 迁移期,旧 `ReadBook.CallBack`(渲染子集)由 `LegacyReaderRenderController` 实现;最终从 `ReadBook` 抽走渲染职责。
 
+> **2026-07-21 修订**:底部「→ 最终由 Track C 替换为声明式渲染」**改为长期可选**。当前目标是 `ReadView`/`ContentTextView` 作为**稳定的规范渲染岛**保留,Track D 补一条 `ReadView → ReaderEvent → VM/ReaderSession` 的出站回边(替代 `ReadView` 现在直呼 `ReadBook`/`ReadAloud`/`callBack` 业务项)。Compose 渲染器(`ComposeReaderRenderer`)冻结在 `ReaderRenderer` 契约后,作为可选实现,非默认终点。
+
 ## 6. 强制机制(编译器 > 扫描器)
 
 | 约束 | 机制 | 强度 |
@@ -361,6 +436,6 @@ AI 清理与改写、翻译、TTS 引擎查询与配置、换源、上传/同步
 
 ## 7. 推荐执行顺序(一句话)
 
-**先用编译器冻结写入 → 集中约 7 个 mutator → 建立权威 ReaderSession(Track A);同时把渲染 Effect 从 VM 下沉到 UI 渲染控制器(Track B)→ 并行拆分 VM 中与渲染无关的业务 → 最后渐进迁移 Compose/新渲染器(Track C)。** 全程由 F1 的 CI 门禁与 F2 的 DAO 去注入护栏兜底。
+**先用编译器冻结写入 → 集中约 7 个 mutator → 建立权威 ReaderSession(Track A,已落地);同时把渲染 Effect 从 VM 下沉到 UI 渲染控制器(Track B,已落地)→ 并行拆分 VM 中与渲染无关的业务 → 把 `ReadView` 自身的业务耦合解掉、让 View 阅读器符合 UDF(Track D,近期主线,不依赖 Compose)。Compose 阅读器(Track C)暂停冻结为可选渲染器,是否默认化留待专门的性能重估。** 全程由 F1 的 CI 门禁与 F2 的 DAO 去注入护栏兜底。
 
 严格 MAD 下 Effect 仍扣分;但准确的表述是——**问题从来不是"指令式 View 无解",而是"指令式渲染协议被错误地穿过了 ViewModel"**。本计划的每一步都在把那条线拉回 UI 层,而不需要等待 Compose。

+ 387 - 0
docs/dev/track-c-compose-reader-plan.md

@@ -0,0 +1,387 @@
+# Track C —— 声明式渲染 / Compose 阅读器迁移(执行计划)
+
+> 本文是 MAD 收敛计划(`mad-modernization-plan.md`)中 **Track C** 的可落地展开。
+> Track C 是三轨里唯一**长期**、且**依赖 A、B 产出**的一条:它把阅读器的 Canvas
+> View 渲染栈渐进替换为 Compose,用声明式状态 + 本地动画状态取代大部分指令式渲染命令。
+>
+> 写作时点:**Track A(A1–A5)与 Track B(B1/B2)已落地**。B2 已把指令式渲染协议
+> 从 ViewModel 下沉到 UI 层的 `ReadBookController`(`ReadBook.renderCallBack:
+> ReaderRenderCallback`)。Track C 从这个边界继续往下走。
+
+## 状态:已删除(2026-07-25)——本文自此为历史设计记录
+
+> **2026-07-25**:C0–C5 的实现**已从代码库删除**。以下全文保留为设计记录与性能基线证据,
+> 但**描述的代码不再存在**,不要按文中路径去找文件。
+>
+> **已删除**:`ComposeReaderSurface.kt`、`ComposeReaderRenderCache.kt`、`ReaderPageSnapshot.kt`、
+> `ReaderRenderModel.kt`(含 `ReaderRenderStateStore`)、`ReaderRenderer.kt`(契约 + 两个实现 +
+> `ReaderRenderAction`)、lab flag `LabSettings.composeRenderer` 全链(PreferKey / DataStore 映射 /
+> LabConfig / 三处字符串)、`ReadBookController.publishRenderStructure()` 及其 12 个调用点、
+> 单测 `ReaderRenderStateStoreTest`/`ReaderRendererVerificationTest`。
+>
+> **保留**:`ReaderViewport.kt`(C4 的 `ReaderViewport`/`ReaderLayoutCoordinator` 接缝——
+> `ContentTextView` 与 `ReadBookController` 在用,非 Compose 专属)、
+> `ReaderFirstFrame.kt`(debug 首帧探针,`tools/capture_reader_c3_first_frame.py` 依赖)。
+>
+> **理由**:冻结态并非零成本——每次翻页/内容更新都在 `publishStructure` 里做一遍
+> 无消费方的三页快照投影。维护成本 > parity 实验台价值。若将来性能调查结论支持重启,
+> 按本文设计重新实现即可。
+
+## 原暂停决策:暂停于 C5(2026-07-21)
+
+> **C0–C5 已落地并真机验收;此后暂停,不推进 C6–C9。**
+>
+> **依据**:C3 真机帧基线显示 Compose 渲染明显劣于旧 View(jank 50% vs 4.13%;50/90/95/99 = 30/42/44/53ms vs 5/8/21/65ms,见 §3 C3),且仿真卷曲/选择/滚动/自动翻页全量重写 = 高回归风险、低用户收益。故上层 MAD 计划改为「保留 `ReadView` 作渲染核心、解其自身业务耦合(Track D)」,Compose 阅读器退为**可选**。
+>
+> **C6–C9:暂缓(非当前主线)**。它们描述的仍是「让 Compose 成为默认并删旧栈」的路线;是否重启,取决于将来一次**专门的性能调查**结论(能否把 Compose 帧耗时/jank 拉到不劣于旧 View、且仿真卷曲有等价观感)。在此之前,**不在 overlay 上继续堆图片/选择/动画/滚动**——§4 的双栈警告在「暂停」语境下同样成立:不推进,就不该扩大双栈。
+>
+> 上层背景与近期主线 Track D(`ReadView` 出站解耦)设计见 `mad-modernization-plan.md`(进度快照 ⑥、Track D 节)。
+
+## 0. 判定:为什么它必须是长期、分阶段、带开关的
+
+- **不是全量重写**(呼应反目标):`ChapterProvider` / `TextChapterLayout` /
+  `ZhLayout` 这套**分页排版引擎**不重写,其产物(`TextChapter`→`TextPage`→
+  `TextLine`→`*Column`)作为绘制数据被 Compose 复用。Track C 重写的是
+  **「绘制 + 手势 + 翻页动画 + 选择」这一层**,不是排版。
+- **必须带 feature flag 并行**:旧 `ReadView` 栈与新 Compose 栈共存,逐段达成
+  parity 后再翻默认值、最后删旧栈。任何一步都可回滚。
+- **验证只能在真机**:与 A/B 一样,「每条路径实时渲染正确」编译与单测都覆盖不到。
+
+## 1. 现状渲染栈(Track C 的手术对象)
+
+```
+ReaderRenderCallback(B2:ReadBookController 实现)
+  → ReadView (自定义 ViewGroup)
+       ├─ PageView × 3(prev/cur/next)
+       │     └─ ContentTextView(onDraw 里用 Canvas 逐 TextLine/TextColumn 画)
+       ├─ PageDelegate(翻页动画/手势)
+       │     ├─ CoverPageDelegate / SlidePageDelegate / FadePageDelegate
+       │     ├─ ScrollPageDelegate / NoAnimPageDelegate
+       │     └─ SimulationPageDelegate(仿真卷曲——最难)
+       ├─ AutoPager(自动翻页)
+       └─ 选择 / 光标 / TextActionMenu
+分页排版(**不改,复用其输出**):
+  ChapterProvider / TextChapterLayout / ZhLayout / TextPageFactory
+  → TextChapter / TextPage / TextLine / TextParagraph /
+    TextColumn·ImageColumn·ButtonColumn·ReviewColumn·TextHtmlColumn
+```
+
+关键文件(`ui/book/read/page/`):`ReadView.kt`、`PageView.kt`、
+`ContentTextView.kt`、`delegate/*.kt`、`entities/**`、`provider/**`。
+
+## 2. 目标数据流
+
+```
+                    ┌───────────── ReaderViewport(尺寸输入接缝)─────────────┐
+                    │  widthPx / heightPx / density / contentPadding          │
+                    │  旧 View 和 Compose 都能驱动同一个 ChapterProvider       │
+                    └──────────────────────────┬─────────────────────────────┘
+                                               ▼
+ReaderSession.state(Track A)──┐        ChapterProvider(排版引擎,不动)
+ReadBook.snapshot ─────────────┤              │ 产出可变 TextPage/TextLine/*Column
+选择/朗读高亮/动画进度/viewport ┘              ▼
+                                    ReaderPageSnapshot(不可变、renderer 专用)
+                                               │  聚合进 ReaderRenderModel(StateFlow)
+                                               ▼
+                           Compose 阅读表面(Canvas + pointerInput)
+                           - 分页结果 = 消费 snapshot 的只读几何,不直接读 TextPage
+                           - 翻页动画 = Compose 本地动画状态(Animatable)
+                           - 手势 = pointerInput
+                           - 反转 upContentAwait 的 model→UI→model 往返
+```
+
+`ReaderRenderModel` 是 Track C 的地基:一个**只读**的渲染视图模型,聚合
+A 的会话快照 + B 控制器持有的视图态(viewport 尺寸、动画进度、选择、朗读高亮、
+预加载反馈)。它不持有可变领域对象,滚动高频字段与低频结构字段分流
+(呼应 A4 的「高频 viewport / 低频结构」二流拆分——**Track C 才真正需要它**)。
+
+两个新的地基构件(本版计划从「C2 脚注 / A4 预留」提升为独立阶段,理由见 §4):
+
+- **`ReaderViewport`**:把「谁给 `ChapterProvider` 定尺」从 `ContentTextView.onSizeChanged`
+  唯一入口,抽成一个可由旧 View **或** Compose 驱动的输入接缝。**这不是重写分页引擎**
+  ——`ChapterProvider` 仍是唯一排版者,只是它的尺寸/重排版触发不再是 View 的私产。
+  跨过它,才可能「关掉底层正文 View,Compose 仍能独立分页」。
+- **`ReaderPageSnapshot`**:当前 `TextPage` 是满是 `var` 的 data class(还内嵌
+  `canvasRecorder` 等可变绘制缓存),跨线程被 Compose 直接消费有陈旧/竞态风险。
+  快照是 renderer 专用的**不可变**投影(文本坐标 / baseline / 字体色 / 装饰 / 图片区 /
+  交互区 / 选择范围),第一版可为既有对象的只读视图,不要求深拷贝全部文本数据。
+
+## 3. 分阶段(每阶段独立可验证、可回滚)
+
+> **本版排序修订(吸收一次外部审阅)**:原稿 C3 直接做「手势 + 翻页动画」,但当前
+> C2 是一层**仍由旧 `ReadView` 驱动分页**的 overlay。在这层 overlay 上继续堆动画/图片/
+> 选择,会把「旧 View 定尺分页 + Compose 再画一遍」焊成**永久双栈**——每加一个功能都要
+> 两边同步。故把**「解除 View 尺寸依赖(`ReaderViewport`)」与「不可变 `ReaderPageSnapshot`」
+> 提前**,作为一切复杂渲染之前的闸门;**翻页动画与复杂交互整体顺延**。本轨的核心里程碑
+> 因此不是「Compose 又支持了一种绘制」,而是:
+>
+> > **关掉底层正文 `ReadView` 后,Compose 仍能独立完成分页与静态阅读。**
+>
+> 跨过这个节点之后做图片/选择/动画,才是在建设新架构;否则只是在扩大双栈。
+> 代价要如实记账:viewport 前置是「高风险、低可演示」的一步(见 §4),先啃它意味着在拿到
+> 更多可视 parity 之前先处理最硬的接缝——这是有意的取舍,不是顺手。
+
+### C0. Renderer Contract(渲染者边界)——**已随 C3 落地**
+- 定义一个显式的渲染者接口,让「旧 Canvas 渲染」与「Compose 渲染」藏在**同一个边界**
+  后面,flag 只切换实现、而非叠加:
+  ```kotlin
+  interface ReaderRenderer {
+      fun render(snapshot: ReaderPageSnapshot)   // 类型见 C5,C0 立边界时可先用占位
+  }
+  // LegacyRenderer(包 ContentTextView/delegate)、ComposeRenderer 各实现之
+  ```
+- **目的**:堵住 `ComposeReaderSurface` 直接去戳 `ReadBookController` 各方法、慢慢又耦合
+  回来的路。有了这条边界,C7「Legacy 或 Compose 二选一」才是换实现,不是拆线。
+- **诚实标注顺序**:C1/C2 **已落地**时并未走这条边界(overlay 直连 store/controller)。
+  故 C0 是**现在补的契约**,落地时机并入 C3 稳定化——不是一个「更早做过」的阶段,而是把
+  既有 overlay 的隐性耦合收敛成接口。接口的 `snapshot` 化签名依赖 C5,C0 先立
+  「两个 renderer 一个边界、flag 切实现」这件事,参数类型到 C5 再定型。
+- 验收:`ComposeReaderSurface` 经 `ReaderRenderer` 实现被调用,`grep` 证明它不再直接
+  引用 controller 的渲染方法;flag 关时旧路径逐字节不变。
+
+### C1. 抽取只读 `ReaderRenderModel`(无行为变化)——**已落地**(`e3f935479`)
+- 在 `ReadBookController` / `ReaderSession` 之上,把当前散落在 `ReadView` 里的
+  视图态(cur/prev/next `TextPage`、`durPageIndex`、viewport 尺寸、选择区间、
+  朗读高亮 span、动画进度、loading)收敛成一个 `StateFlow<ReaderRenderModel>`。
+- 旧 `ReadView` **继续**是唯一渲染者;C1 只是**并行暴露**状态,不接管绘制。
+- 验收:编译 + 无行为变化;新 flow 的值与旧视图态逐字段一致(可加临时断言/日志)。
+
+### C2. Compose 静态渲染表面(feature flag,默认关)——**已落地(overlay 版)**
+- 实验室开关 `LabSettings.composeRenderer`(默认关,DataStore 持久化)。
+- 新增 `ComposeReaderSurface`(Compose `Canvas` + `drawIntoCanvas` → native
+  `drawText`),从 `ReaderRenderModel` 渲染**当前页**:复用排版引擎已算好的坐标
+  (`TextColumn.start` / `TextLine.lineBase`)与 `ChapterProvider.contentPaint/
+  titlePaint`,文字位置/字号/字色与旧 `ReadView` 像素一致。三区点击翻页/呼出菜单。
+- 生产者:`ReadBookController` 收集 `ReadBook.snapshot`→`publishSession`,
+  渲染回调末尾 `publishStructure` 当前页,喂给 C1 的 `ReaderRenderStateStore`。
+
+> **实现取舍:overlay,而非「替换 ReadView」。** 原稿设想 flag 开时用 Compose 表面
+> 替换 `ContentTextView`。落地时核实到关键耦合:**分页尺寸只由
+> `ContentTextView.onSizeChanged → ChapterProvider.upViewSize` 驱动**,`UP_CONFIG`
+> 重排版事件也由 View 层消费。若直接抽掉 `ReadView`,需把 `ChapterProvider` 定尺、
+> 重排版触发、`UP_CONFIG` 处理全部改由 Compose 侧驱动——风险大且当前无法真机核。
+> 故 C2 采用**叠加**:旧 `ReadView` 仍充当引擎(分页/内容加载/换章/手势),
+> `ComposeReaderSurface` 不透明覆盖其上只负责画像素 + 点击翻页。这样 flag 关时
+> 逐字节不变、可随时回退,且验证面最小。「真正替换 `ReadView`」下沉到 **C4(`ReaderViewport`
+> 接缝)**——本版计划把它从这条脚注提升为独立的核心里程碑。
+
+- **已知缺口(后续阶段)**:翻页动画(overlay 覆盖了 ReadView 的动画)、文字选择、
+  `ImageColumn`/`ButtonColumn`/`ReviewColumn`、下划线与背景色/背景图、双页、滚动模式。
+- 验收(真机):flag 开,单页正文文字与旧栈位置/字号/字色一致;点击左右/中央
+  分别上一页/下一页/呼出菜单;关闭开关即回旧栈。
+
+### C3. 稳住当前 overlay + 建立 parity 基线(不加新阅读功能)——**已完成**
+
+> 代码稳定化已落地:补齐 `ReaderRenderer` 边界,结构状态改为在旧 View 处理完
+> 渲染命令后发布,并修复 `pointerInput(Unit)` 的陈旧回调捕获。真机证据及采集脚本见
+> [`track-c3-reader-parity-baseline.md`](track-c3-reader-parity-baseline.md)。
+
+> **2026-07-21 真机结果(Samsung SM-S9310,《斗破苍穹》,20 页往返)**:稳定性检查通过——
+> Legacy / Compose 均回到同一正文页,无空白页、旧页闪回或章节错位。但静态 parity **未通过**:
+> Compose 缺旧页眉/页脚,正文颜色/高亮与旧 Canvas 不一致;全屏 MAE 25.77、差异像素 22.72%。
+> 帧基线:Legacy 50/90/95/99 = 5/8/21/65ms(jank 4.13%),Compose = 30/42/44/53ms
+> (jank 50.00%)。两路径当前动画语义不同(Legacy 有翻页动画,Compose 无动画直接换页),仅作 C7 对照基线,不直接比较优劣。
+
+> **结论**:已完成 C3 的「采集/冻结」工作,但未满足「普通文字页基本一致」的验收。继续之前先解决静态画面差异;不跨阶段进入 C4–C8。
+
+> **2026-07-21 第二轮修正**:补齐按列颜色/搜索强调/字体/字号/API 35 letter spacing 与
+> `ContentTextView` 内容偏移后,单页全屏 MAE 降至 0.023、差异像素降至 0.113%,当前普通
+> 文字测试页已基本一致。采集脚本也增加了 route 冷启动、ADB 超时、前进页变化断言和五轮往返;
+> 完整五轮复采后,Legacy / Compose 均回到起点,终点全屏差异仅 0.0127%,未见空白页、旧页
+> 闪回或章节错位。随后补采系统衬线字体 + 增大 3 级字号、明确的第 8→9 章边界、各 5 次
+> 冷启动首个非空正文帧,以及 GPU overdraw;四项均已有证据,详见
+> [`track-c3-reader-parity-baseline.md`](track-c3-reader-parity-baseline.md) §8,C3 正式放行。
+- **先不堆功能**,把 C2 的 overlay 收口,为后续所有阶段建立可对比的地基:
+  - 旧渲染与 Compose 渲染的**截图对比**(普通文字页、换字体/字号、换章、快速翻页)。
+  - 清理遗漏的状态发布入口(`publishSession`/`publishStructure` 覆盖是否有漏发)。
+  - 复核 `@Stable` 使用、`pointerInput` 回调捕获是否闭包了陈旧值。
+  - 建立性能基线(首帧、翻页帧耗时、overdraw),供 C7 停止双绘后对照。
+- 验收(真机):flag 开时,普通文字页与旧渲染基本一致,**无明显空白页、旧页闪回、
+  章节错位**。这一步只冻结现状,不推进架构。
+
+### C4. 抽出 `ReaderViewport` 接缝——让 Compose 尺寸能驱动分页(**已落地,真机验收通过**)
+
+> **2026-07-21 实施记录**:已新增 `ReaderViewport` / `ReaderPadding` /
+> `ReaderLayoutMode` 与 `ReaderLayoutController`,旧 `ContentTextView` 和 Compose
+> `snapshotFlow` 测量统一经 `ReaderLayoutCoordinator` 驱动 `ChapterProvider`;控制器按有效正文
+> 尺寸去重,避免 View/Compose 对同一尺寸重复触发分页。`UP_CONFIG(12)` 的重排版已改经该接缝,
+> `upContentAwait` 先等待 viewport 就绪,并新增 `layoutResults` revision 流作为 C7 删除旧
+> suspend 往返的雏形。Kotlin 编译、debug assemble、新旧渲染状态与 viewport 接缝单测通过。
+>
+> **2026-07-21 真机验收(Samsung SM-S9310)**:Legacy / Compose 各完成两轮、每轮 5 页
+> 往返,均回到起点;普通页全屏差异像素 0.0127%,返回页差异 0%。Compose 模式从竖屏
+> 5/9 重排为横屏 6/11,横屏连续翻至 10/11 后恢复竖屏落在对应内容的 8/9,未见空白、
+> 旧页闪回、错章或崩溃。另用临时验收构建把 legacy surface 约束为 `0×0`,Compose 仍能
+> 独立生成正文并翻页,确认分页路径不经过 `ContentTextView` layout;采证后已撤销临时改动。
+> 旧层完全不绘制时背景与页眉页脚缺失,属于 C6 静态阅读闭环的既知范围,不影响 C4
+> “Compose viewport 可独立驱动分页”的验收结论。
+>
+> **C4 回归防护补强**:`awaitViewport()` 改为最多等待 2 秒,未测量时返回 `null` 并继续旧
+> 内容更新路径,避免后台加载 / 纯 TTS / View 未测量时无限挂起;单测覆盖无 viewport 超时。
+> Debug Compose 同时新增 `readerDetachLegacySurface=true` Intent 验收入口与
+> `tools/verify_reader_c4_detached.py`,会断言 legacy surface 确实为 `0×0`、Compose 首个正文帧
+> 出现且点击后页面变化。正式 committed 路径已在 SM-S9310 复测通过,0×0 结论不再依赖
+> 一次性临时构建。
+- 把 `ChapterProvider` 的定尺入口从 `ContentTextView.onSizeChanged → upViewSize`
+  唯一来源,抽成一个显式接缝:
+  ```kotlin
+  enum class ReaderLayoutMode { PAGED, SCROLL, DOUBLE_PAGE }
+
+  data class ReaderViewport(
+      val widthPx: Int, val heightPx: Int,
+      val density: Float, val contentPadding: ReaderPadding,
+      val mode: ReaderLayoutMode,   // 预留:C6 双页/滚动会改布局,现在留位不实现
+  )
+  interface ReaderLayoutController { fun updateViewport(v: ReaderViewport) }
+  ```
+  旧 `ContentTextView` 和 Compose 表面都调用它;`UP_CONFIG` 重排版触发同样经此接缝,
+  不再是 View 层私产。**红线**:`ChapterProvider` 仍是唯一排版者,这里只搬「谁定尺 /
+  谁触发重排版」,**不重写分页内核**。
+  > `mode` 现在只有 `PAGED` 一条路径;留字段是为了 C6 的双页/滚动不必再引一个并列的
+  > `ReaderModeController` + `ChapterProvider` 特判,避免接缝二次碎裂。
+- **在本阶段后半段就开始拆 `upContentAwait`**(不要留到 C7 才动):一旦 Compose 驱动
+  viewport,若 `upContentAwait` 仍等旧 View 的 layout 完成回调,就埋了一个「Compose 表面
+  看似独立、实际仍反向等 View 排版」的隐形旧架构。C4 后半把它改成
+  `LayoutResultFlow`(排版完成信号)+ `snapshotFlow` 等测量完成的雏形;C7 只做最终删除。
+- 验收(真机):底层旧 `ReadView` 可设为**不可见、甚至不参与布局**,Compose 仍能正确
+  生成页面;屏幕旋转 / 横竖屏 / 窗口尺寸变化能重新分页;且此路径**不经过** `ContentTextView`
+  的 layout 回调。
+  > 注意:这一步**不删** `ReadView`,只解除它作为「分页生命线」的作用。跨过它,Compose
+  > 才从 overlay 变成能独立分页的渲染面。
+
+### C5. 引入不可变 `ReaderPageSnapshot`(renderer 只消费快照)——**已落地,真机验收通过**
+
+> **2026-07-21 实施记录**:新增 `ReaderPageSnapshot` / `ReaderElement` 及旧排版对象适配器,
+> 在 `ReaderRenderStateStore.publishStructure` 边界把三页可变 `TextPage/TextLine/*Column`
+> 投影为不可变绘制值;快照已包含文字坐标与完整画笔样式、装饰、图片区、交互区、选区和
+> revision,不携带排版逻辑、反向对象引用或 Canvas recorder。`ComposeReaderSurface` 只消费
+> 快照并重建私有 `TextPaint`,已无 `ChapterProvider`、旧页面实体或旧 View 绘制状态访问。
+> 单测覆盖旧行列对象在发布后继续突变不会污染既有快照,重新发布才生成更高 revision。
+>
+> **真机验收(Samsung SM-S9310)**:debug 构建安装后,以 legacy surface `0×0` 的 committed
+> detach 路径启动 Compose 阅读器,首个非空正文帧 1611ms 出现;点击下一页后画面变化
+> 17.76%,正文无空白、错位或崩溃。聚焦单测、`compileAppDebugKotlin` 与
+> `installAppDebug` 均通过。
+- 把旧引擎输出(可变 `TextPage/TextLine/*Column`)转换为 renderer 专用的不可变数据:
+  文本位置 / baseline / 字体色 / 装饰(下划线、背景)/ 图片区 / 交互区 / 选择范围。
+  ```kotlin
+  data class ReaderPageSnapshot(
+      val chapterIndex: Int, val pageIndex: Int,
+      val width: Int, val height: Int,
+      val elements: ImmutableList<ReaderElement>, val revision: Long,
+  )
+  sealed interface ReaderElement {
+      data class Text(/* 坐标、文本、样式 */) : ReaderElement
+      data class Image(/* 坐标、图片引用 */) : ReaderElement
+  }
+  ```
+- 第一版可为既有对象的**只读视图**,不要求深拷贝全部文本;但 renderer 不得再直接读
+  `ChapterProvider` 或可变页面对象。呼应 A4 的「高频 viewport / 低频结构」二流拆分——
+  滚动位置高频、结构快照低频,避免把 60fps tick 挂到 equality 快照。
+- **护栏:别让 snapshot 长成第二个 `TextPage`。** snapshot 只描述**绘制**(坐标/样式/
+  区域),不承载排版逻辑、不回挂可变对象、不塞 `canvasRecorder` 这类缓存。保持「排版
+  描述」与「绘制描述」在概念上可分(未来若真需要 Web/View 多 renderer,可再把它拆成
+  `PageLayoutSnapshot → RenderSnapshot` 两层)。**当前只有一个 Compose renderer,故不预先
+  引入两层类型**(避免为未上路的多端渲染造抽象)——只在此立「不可反向依赖排版对象」的
+  纪律,让将来分层仍可能。
+- 验收:Compose renderer 只消费 snapshot,`grep` 证明其不再访问 `ChapterProvider` /
+  旧 View 的绘制状态。
+
+### C6. 完成无动画阅读闭环(按风险从低到高)——**暂缓(见顶部状态)**
+在 snapshot 上把静态阅读功能补齐,**全程不碰翻页动画**:
+1. 普通文本 + 标题
+2. 背景色 / 背景图、下划线、朗读高亮 span
+3. `ImageColumn` / `ButtonColumn` / `ReviewColumn` 等列类型
+4. **双页模式**(提前)
+5. 三区点击 / 交互区命中
+6. 长按选择、光标、选区高亮、`TextActionMenu`(`TextActionSelectionMenu` 已是 Compose)
+7. 滚动模式(60fps 热路径,验证高频 viewport 分流)
+   > **双页为何提前到选择之前**:双页本质不是 renderer 功能,而是 **page window 管理**
+   > ——它验证 snapshot 是否支持多页、viewport(`mode=DOUBLE_PAGE`)是否正确、prev/cur/next
+   > 页缓存是否合理,比长按选择/菜单更贴近架构核心。滚动仍垫底(真正的性能模式)。
+- 验收(真机):无动画模式下,主要阅读功能可**完全脱离旧 renderer** 工作。
+
+### C7. 停止双重绘制 + 反转 `upContentAwait`——**暂缓(见顶部状态)**
+- 让 feature flag 变成 **Legacy renderer 或 Compose renderer 二选一**,而不再是
+  「Legacy renderer + Compose overlay」叠加。旧 renderer 仍可短期保留作回退,
+  但**不与 Compose 同时绘制**。
+- **最终删除 `upContentAwait`**(拆解已在 C4 后半开始):C4 已把它改成
+  `LayoutResultFlow` + `snapshotFlow` 等测量完成的雏形;C7 在切成单栈后做收尾——
+  删掉 `ReaderRenderCallback.upContentAwait` 这个 `suspend` 往返,退化为纯状态更新,
+  模型不再挂起协程反向等 View 排版。若 C4 后半未能完全脱开旧 View layout 回调,C7 必须
+  在这里补齐,不允许带着隐形往返翻默认值。
+- 验收(真机):Compose 模式下没有旧正文层参与绘制,overdraw 与帧耗时**不劣于**
+  现有 overlay(对照 C3 基线)。
+
+### C8. 翻页动画(垫底,Simulation 最后)——**暂缓(见顶部状态)**
+- 翻页动画用 Compose 本地动画状态(`Animatable`/`updateTransition`),只消费 snapshot
+  与 transition state,**不再深入改分页内核**:
+  ```kotlin
+  interface ReaderPageTransition {
+      fun draw(previous: ReaderPageSnapshot?, current: ReaderPageSnapshot,
+               next: ReaderPageSnapshot?, progress: Float)
+  }
+  ```
+- 逐个替换 `PageDelegate` 子类,**从易到难**:`NoAnim` → `Cover` → `Slide` → `Fade` →
+  `Scroll` → **`Simulation`(仿真卷曲,最后做,涉及曲面/阴影,最高风险)**。
+- 每个 transition 迁完即在 flag 下与旧栈对比。放到最后的理由:动画同时牵涉当前/上/下页、
+  手势进度、页面缓存、中途取消、快速连击、章节边界、预加载、方向变化——页面数据与
+  viewport 未稳定前提前做,必然大量返工。
+  > **Simulation 兜底**:仿真卷曲是唯一可能「Compose 做不出等价观感」的点;若代价过高,
+  > 可保留它为唯一的旧栈回退项,而非阻塞整轨。
+
+### C9. 翻默认值 + 删旧栈——**暂缓(见顶部状态)**
+- Parity 全绿后把 flag 默认打开;回归窗口后删除
+  `LegacyReaderRenderController` 承接的指令式渲染方法体、`ReadView`/`PageView`/
+  `ContentTextView`/`delegate/*`。`ReaderRenderCallback` 收敛为「发布渲染状态」
+  而非「下达渲染命令」。
+
+## 4. 顺序纪律与风险
+
+- **先跨 C4,再做复杂渲染**:核心里程碑是「关掉旧 `ReadView` 后 Compose 仍能独立
+  分页 + 静态阅读」。在此之前,overlay 仍靠旧 View 定尺——**任何在 overlay 上做的
+  图片/选择/动画都是双栈投资**,早晚要在 C4 之后重做。这是本版把 viewport 解耦
+  (C4)与不可变快照(C5)提到动画前面的唯一理由。
+- **C4 的代价要如实记账**:viewport 解耦是「高风险、低可演示」的一步——它没有新的
+  可炫耀 parity,产出是「旧 View 可以关了」这种结构性成果。原稿把它推后正是因为
+  风险大 + 当时无法真机核;本版不否认这个风险,只是判断**不先付这笔账,后面的功能
+  都建在流沙上**。若真机验证阶段发现 `UP_CONFIG`/重排版触发难以脱离 View,允许把 C4
+  拆成更小的子步,但不允许跳过它去做 C6/C8。
+- **Renderer 边界优先(C0)**:没有 `ReaderRenderer` 契约,`ComposeReaderSurface` 会慢慢
+  直连各 controller,C7 的「二选一」就退化成拆线而非换实现。C0 概念上最前,但因 C1/C2
+  已落地未走此边界,实际并入 C3 补齐。
+- **`upContentAwait` 拆解不等 C7**:C4 让 Compose 驱动 viewport 的同时就要开始拆这个
+  `suspend` 往返,否则「Compose 看似独立、实则反向等旧 View layout 回调」的隐形旧架构会
+  一直潜伏到 C7 才暴露。C7 只做最终删除与兜底。
+- **先 C1 后其余**:没有稳定的 `ReaderRenderModel`,后面每个 Compose 组件都会
+  各自去戳全局单例,变成「Compose 皮的分布式单体」。(C1 已落地。)
+- **排版引擎不动**:`ChapterProvider` 一旦被顺手重写,风险与工期都失控。C4 只搬
+  「谁定尺 / 谁触发重排版」的接缝,**不是**造新的分页引擎——`ReaderViewport` 是输入
+  层,不是 `ChapterProvider` 的替代。
+- **动画(C8)最后做、Simulation 垫底**:仿真卷曲是唯一可能「Compose 做不出等价
+  观感」的点;若代价过高,可保留其为唯一的旧栈回退项,而非阻塞整轨。
+- **性能**:滚动模式是 60fps 热路径,`ReaderRenderModel`/`ReaderPageSnapshot` 必须
+  分流高频 viewport 与低频结构(A4 预留、C5 兑现),避免把滚动 tick 挂到 equality 快照上。
+- **验证边界**:全程真机 parity;每阶段留可截图对比的 flag 开关,C3 的截图/性能基线
+  是后续所有阶段的对照物。
+
+## 5. 与 ViewModel 拆分的关系
+
+Track C 完成前,`upContentAwait`/页面排版完成/翻页动画/选择取消/局部重绘/
+page recorder 回收这些职责**进渲染控制器/分页引擎,不进 ReaderSession 或 VM**
+(呼应主计划「ViewModel 拆分」的第三类)。C9 删旧栈后,这些职责随之消失或变为
+渲染状态发布,VM 不再与渲染有任何关系。
+
+## 6. 一句话
+
+**立 `ReaderRenderer` 契约(C0,随 C3 补)→ 抽只读 `ReaderRenderModel`(C1,已落地)→
+flag 下用 Compose 画静态页 overlay(C2,已落地)→ 稳住 overlay + 建 parity/性能基线
+(C3)→ 抽 `ReaderViewport`(含 `mode` 预留)让 Compose 尺寸驱动分页、旧 `ReadView` 可关、
+并开始拆 `upContentAwait`(C4,核心里程碑)→ 引入不可变 `ReaderPageSnapshot`(C5)→
+无动画阅读闭环含图片/双页/点击/选择/滚动(C6)→ 停止双绘 + 删 `upContentAwait`(C7)→
+翻页动画、仿真卷曲垫底(C8)→ 翻默认、删旧栈(C9)。**
+排版引擎全程不动,每阶段带 flag、可回滚、真机 parity。核心不是「Compose 又多画一种
+东西」,而是**先跨过「关掉旧正文 View、Compose 独立分页」这个节点,后面才是建设新
+架构,而非扩大双栈**。
+
+> **注(2026-07-21)**:以上 C6–C9 描述的是「Compose 默认化 + 删旧栈」的完整路线,**现已暂缓**(见顶部状态)。当前落点是 C5:C0–C5 冻结为 flag 下可选渲染器,阅读渲染的近期主线转为 Track D(保留 `ReadView`、解其自身业务耦合,见 `mad-modernization-plan.md`)。C6–C9 仅在将来专门的性能调查支持「Compose 不劣于旧 View」后才重启。

+ 160 - 0
docs/dev/track-c3-reader-parity-baseline.md

@@ -0,0 +1,160 @@
+# Track C3 阅读器 parity / 性能基线
+
+> 状态:**已完成(2026-07-21)**。首轮失败与修正历史保留在 §5–§7;字体/字号、
+> 明确章节边界、首个非空正文帧和 GPU overdraw 已在 §8 补齐。
+
+## 1. 固定条件
+
+每轮 Legacy 与 Compose overlay 必须使用同一台真机、同一构建、同一本本地纯文本书、
+同一章节/页位置,并记录:
+
+| 项目 | 值 |
+|---|---|
+| Git revision | `e3a848d5c` |
+| APK variant | `appDebug` |
+| 设备 / Android / 刷新率 | Samsung SM-S9310 / Android 16(API 36)/ 采集时 30Hz,自适应最高 120Hz |
+| 分辨率 / 字体缩放 / 显示缩放 | 1080×2340 / 1.0 / 480dpi |
+| 阅读字体 / 字号 / 行距 / 边距 | 默认字体 24sp;字体场景另测系统衬线字体并增大 3 级 |
+| 测试书与章节 | 《斗破苍穹》第 8 章末页 / 第 9 章首页 |
+
+截图前关闭阅读菜单、自动翻页、朗读和系统通知浮层。页眉中的时间、电量若会变化,比较时裁掉
+页眉/页脚,只比较 `ChapterProvider.visibleRect` 对应的正文区域。
+
+## 2. parity 场景
+
+采集由脚本自动切换「实验室 → Compose 渲染」、启动同一本书、正反各翻固定页数、抓取成对
+截图和 `gfxinfo`,结束时还原原来的实验室开关:
+
+```bash
+python3 tools/capture_reader_c3_baseline.py \
+  --crop '<正文左>,<正文上>,<正文右>,<正文下>'
+```
+
+默认产物写入 `build/reader-c3-baseline/<timestamp>/`:`plain-page-*`、`forward-turn-*`、
+`rapid-turn-*`、两份 `framestats` 和带 PNG 差异指标的 `result.json`。脚本在前进操作后停留并
+截图;若起点与前进页的差异像素不足 1%,立即失败,避免“前进/后退未发生却因终点相同而误报
+通过”。`--crop` 应裁掉会变化的状态栏、页眉和页脚;
+不传时比较全屏。多台设备时传 `--serial`,翻页次数/坐标可用 `--turns`、`--next-x-ratio`、
+`--prev-x-ratio` 调整;默认执行五轮往返,冒烟时可传 `--rounds 1`。默认使用“最后阅读的书”;
+需要指定书籍时再传 `--book-url`。
+
+脚本默认目标为当前工程的 `io.legato.kazusa.debug`;采集前须安装本次构建的 `appDebug`。
+
+字体/字号场景只需先在应用中设置一次目标样式,再运行同一条脚本;章节边界场景将测试书定位到
+边界附近后运行。两者都不再需要逐页手动截图。
+
+| 场景 | 操作 | 必须核对 | Legacy | Compose |
+|---|---|---|---|---|
+| `plain-page` | 普通正文静止页 | 字形、baseline、换行、首尾行 | 通过 | 通过 |
+| `font-size` | 更换字体并调整字号 | 重排后无旧页闪回,坐标一致 | 通过 | 通过 |
+| `chapter-boundary` | 末页进入下一章,再返回 | 章节标题、页索引、正文不串章 | 通过 | 通过 |
+| `rapid-turn` | 连续前进 20 页,再后退 20 页,重复 5 次 | 无空白页、旧页闪回、章节错位 | 通过 | 通过 |
+
+首轮基线不预设像素差阈值;保留两张原图与正文裁剪图,并记录可解释差异。若普通文本的字形、
+baseline 或换行不同,C3 不通过;页眉/页脚、图片、下划线、选择和动画不属于本阶段 parity。
+
+## 3. 性能与 overdraw
+
+Legacy 与 Compose 各执行一次同一 `rapid-turn` 操作;采集器会在每次操作前清空
+`gfxinfo` 并保存 `framestats` 。
+
+记录 `Janky frames` 以及 50th / 90th / 95th / 99th percentile。用 Android Studio System Trace
+从“打开阅读页”点击到第一帧完整正文可见,记录首帧耗时;同一 trace 中记录连续翻页的
+FrameTimeline。打开开发者选项的「调试 GPU 过度绘制」,分别保存静止正文页截图并注明覆盖颜色。
+
+| 指标 | Legacy | Compose overlay | 备注 |
+|---|---:|---:|---|
+| 阅读页首个非空正文帧 P50 / P90 | 1512 / 1522ms | 1561 / 1581ms | 冷启动各 5 次,renderer draw marker |
+| 50th / 90th / 95th / 99th frame | 6 / 14 / 16 / 77ms | 27 / 40 / 46 / 48ms | `gfxinfo framestats`,C4 补采 |
+| Janky frames | 25 / 630(3.97%) | 23 / 45(51.11%) | 相同两轮、每轮 5 页往返 |
+| 正文区 overdraw | 背景约 2×,文字约 3× | 背景约 3×,文字达 4×+ | GPU overdraw 截图 |
+
+C3 的性能数据是 C7 停止双重绘制后的对照物,不作为 C3 的优化目标;但若 Compose overlay
+出现稳定空白帧或明显阻塞,必须先修复,不能把异常值冻结为“基线”。
+
+## 4. C3 放行条件
+
+- 四个 parity 场景均留有 Legacy / Compose 成对证据。
+- `rapid-turn` 五轮均无空白页、旧页闪回或章节错位。
+- 首帧、帧分位数、jank 与 overdraw 均已记录,设备与构建信息完整。
+- 关闭 feature flag 后仍走纯 Legacy renderer,行为无变化。
+
+以上真机证据未补齐前,C3 保持“实施中”,不得开始 C4 之外的复杂渲染功能。
+
+## 5. 首轮实机结果(2026-07-21)
+
+| 项目 | 结果 |
+|---|---|
+| 设备 | Samsung SM-S9310,1080×2340,`io.legato.kazusa.debug` |
+| 测试书 | 最后阅读书《斗破苍穹》,第 8 章第 5/9 页 |
+| 操作 | Legacy / Compose 各 20 页前进 + 20 页后退 |
+| 产物 | `build/reader-c3-baseline/20260721-galaxy-s25-run2/` |
+| 往返稳定性 | 通过:两模式均回到同一正文页,未见空白页、旧页闪回或章节错位 |
+| 全屏像素差 | MAE 25.77;差异像素 22.72%(全屏比较,未裁掉页眉/页脚) |
+| 静态视觉 parity | **未通过**:Compose 缺旧栈页眉/页脚;正文颜色/高亮与旧 Canvas 不一致 |
+| Legacy 帧统计 | 50/90/95/99 = 5/8/21/65ms;jank 4.13% |
+| Compose 帧统计 | 50/90/95/99 = 30/42/44/53ms;jank 50.00% |
+
+帧统计仅作为 overlay 首轮基线,不能直接判定动画性能优劣:Legacy 路径执行原有翻页动画,
+Compose overlay 当前为无动画直接换页(C8 才补动画)。下一步先处理静态绘制 parity,再复测。
+
+## 6. 第二轮修正进展(2026-07-21)
+
+- 修复采集器 route 切换:每次以 `am start -S` 冷启动目标 route,避免复用 `MainActivity`
+  后仍停留在实验室页却继续截图、点击。
+- 所有 ADB 子进程增加 15 秒超时,单次 `input tap` 异常不再无限挂起。
+- 增加前进页截图与硬断言。1 页导航冒烟中,Legacy / Compose 起点到前进页的差异像素分别为
+  19.29% / 19.70%,确认两条路径都实际完成前进,再返回起点。
+- Compose 普通文字列已补齐旧绘制语义:按列文字色、搜索结果强调色、自定义字体、标题字号,
+  以及 Android 15+ letter spacing 半字偏移;正文内容坐标增加旧 `ContentTextView` 在
+  `PageView` 内的偏移量,避免 Compose 画面上移覆盖页眉。
+- 安装修正构建后的普通页 1 页冒烟,全屏 MAE 为 0.023、差异像素为 0.113%(首轮分别为
+  25.77 / 22.72%),当前测试页的静态画面已基本一致。
+
+以上仅说明采集链路和单页代码修正成立;完整五轮结果见下一节。
+
+## 7. 五轮压力复采(2026-07-21)
+
+提交 `e3a848d5c` 对应的 `appDebug` 在 Samsung SM-S9310 上完成 Legacy / Compose 各五轮,
+每轮前进 20 页再后退 20 页:
+
+| 指标 | Legacy | Compose overlay |
+|---|---:|---:|
+| 起点 → 前进 20 页差异像素 | 19.351% | 19.351% |
+| 五轮后起点 → 终点差异像素 | 0.0212% | 0.0127% |
+| 总渲染帧 | 6351 | 423 |
+| Janky frames | 244(3.84%) | 210(49.65%) |
+| 50 / 90 / 95 / 99 分位 | 5 / 7 / 11 / 57ms | 26 / 36 / 40 / 48ms |
+
+Legacy / Compose 起点全屏差异为 0.0212%,五轮终点差异为 0.0127%。两条路径均确实前进,
+且五轮后回到各自起点;未发现空白页、旧页闪回或章节错位。证据目录:
+`build/reader-c3-baseline/20260721-c3-five-rounds/`。
+
+Compose 帧数显著更少且 jank 比例高,是“无动画直接换页 + overlay 双绘”与 Legacy 翻页动画的
+语义差异,继续只作为 C7 对照基线,不在 C3 内优化。尚未完成的放行项缩小为:字体/字号场景、
+明确章节边界场景、首帧 System Trace 和 GPU overdraw 截图。
+
+采集器随后补上两项硬性约束:每种 renderer 五轮后的终点必须与自己的起点差异不超过 1%,
+并把总帧数、jank、P50/P90/P95/P99 自动解析进 `result.json`。本轮既有截图回算结果分别为
+0.0212% / 0.0127%,满足新增约束。采集开始前还会读取当前用户的 `deviceLocked` 状态;真机
+锁定时直接失败,不创建空结果目录,也不在锁屏上继续点击。
+
+## 8. C3 放行项补采(2026-07-21)
+
+提交 `7e7418ac8` 后的 C4 构建在同一台 Samsung SM-S9310 上补齐 C3 剩余证据:
+
+- **字体/字号 parity**:正文改为系统衬线字体,字号在原配置上增大 3 级;Legacy / Compose
+  同一静态页正文裁剪差异为 0 像素,翻页再返回后的差异同为 0,字形、baseline 与换行一致。
+- **明确章节边界 parity**:从《斗破苍穹》第 8 章 9/9 前进到第 9 章 1/12,再返回;两 renderer
+  的起点、下一章标题/首页和返回页视觉一致,正文裁剪差异为 0,未出现串章或旧页闪回。
+- **首个非空正文帧**:新增 renderer 边界的一次性 `ReaderFirstFrame` draw marker,并用
+  `tools/capture_reader_c3_first_frame.py` 各冷启动 5 次。Legacy 为
+  1497.37–1522.40ms(P50 1512.33ms,P90 1522.40ms);Compose overlay 为
+  1534.04–1580.73ms(P50 1561.42ms,P90 1580.73ms)。Compose P50 慢约 49ms,
+  未观察到空白首帧;实验室设置的初值改由 `LabSettingsGateway.currentSettings` 提供后,
+  Compose 冷启动不再先绘制一帧 Legacy。此数据作为 C7 单栈后的对照,不在 C3 优化。
+- **GPU overdraw**:`debug.hwui.overdraw=show` 下,Legacy 正文背景主要为绿色(约 2×),
+  文字区域粉红/红色(约 3×);Compose overlay 正文整块升为粉红/深红(约 3×),文字区域
+  达红色(4×+)。结果确认当前开销来自预期的 legacy + Compose 双绘,C7 停止双重绘制后复测。
+
+证据目录:`build/reader-c3-supplement/`。至此 §4 的四项放行条件全部有真机证据,C3 完成。

+ 355 - 0
docs/superpowers/plans/2026-07-21-track-c1-reader-render-model.md

@@ -0,0 +1,355 @@
+# Track C1 ReaderRenderModel Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 在不改变旧 `ReadView` 渲染行为的前提下,暴露可供后续 Compose 阅读表面消费的只读结构渲染流与高频 viewport 流。
+
+**Architecture:** `ReaderRenderStateStore` 维护两个彼此独立的 `StateFlow`:低频 `ReaderRenderModel` 聚合 `LegacyReaderSnapshot`、三页绘制数据、页索引、选择、朗读高亮与 loading;高频 `ReaderRenderViewport` 只承载尺寸、触点、方向和归一化动画进度。`ReadBookController` 负责从现有 `ReadBook`/`ReadView` 真源取样,旧 View 只在已有状态变化点通知控制器,不转移状态所有权,也不改变任何绘制调用顺序。
+
+**Tech Stack:** Kotlin、StateFlow、kotlinx.collections.immutable、Android View、Robolectric/JUnit 4。
+
+---
+
+### Task 1: 定义并测试只读渲染状态容器
+
+**Files:**
+- Create: `app/src/main/java/io/legado/app/ui/book/read/ReaderRenderModel.kt`
+- Create: `app/src/test/java/io/legado/app/ui/book/read/ReaderRenderStateStoreTest.kt`
+
+- [ ] **Step 1: 写低频结构态与高频 viewport 相互独立的失败测试**
+
+```kotlin
+@RunWith(RobolectricTestRunner::class)
+class ReaderRenderStateStoreTest {
+    @Test
+    fun `viewport updates do not replace structural model`() {
+        val store = ReaderRenderStateStore(LegacyReaderSnapshot(bookUrl = "book"))
+        val structural = store.model.value
+
+        store.publishViewport(
+            width = 100,
+            height = 200,
+            startX = 0f,
+            startY = 0f,
+            touchX = 50f,
+            touchY = 0f,
+            direction = PageDirection.NEXT,
+            isAnimationRunning = true,
+        )
+
+        assertSame(structural, store.model.value)
+        assertEquals(0.5f, store.viewport.value.animationProgress, 0f)
+    }
+}
+```
+
+- [ ] **Step 2: 运行测试并确认因 `ReaderRenderStateStore` 尚不存在而失败**
+
+Run: `./gradlew --no-daemon :app:testAppDebugUnitTest --tests 'io.legado.app.ui.book.read.ReaderRenderStateStoreTest'`
+
+Expected: Kotlin 编译失败,提示 `Unresolved reference 'ReaderRenderStateStore'`。
+
+- [ ] **Step 3: 写三页、选择、朗读高亮、loading 与会话更新的失败测试**
+
+```kotlin
+@Test
+fun `structure publish snapshots render inputs`() {
+    val current = TextPage(index = 4, text = "current", chapterIndex = 2)
+    val line = TextLine().apply { isReadAloud = true }
+    current.addLine(line)
+    val mutableStart = TextPos(0, 1, 2)
+    val mutableEnd = TextPos(0, 1, 5)
+    val store = ReaderRenderStateStore()
+
+    store.publishStructure(
+        previousPage = TextPage(index = 3),
+        currentPage = current,
+        nextPage = TextPage(index = 5),
+        durPageIndex = 4,
+        selectionStart = mutableStart,
+        selectionEnd = mutableEnd,
+        message = "Loading…",
+        isLoading = true,
+    )
+    mutableStart.lineIndex = 9
+
+    val model = store.model.value
+    assertEquals(4, model.durPageIndex)
+    assertEquals(1, model.selection?.start?.lineIndex)
+    assertEquals(0, model.readAloudHighlights.single().firstLineIndex)
+    assertTrue(model.isLoading)
+}
+
+@Test
+fun `session updates preserve render pages`() {
+    val store = ReaderRenderStateStore()
+    val current = TextPage(index = 1)
+    store.publishStructure(currentPage = current, durPageIndex = 1)
+
+    store.publishSession(LegacyReaderSnapshot(bookUrl = "updated", chapterIndex = 3))
+
+    assertEquals("updated", store.model.value.session.bookUrl)
+    assertSame(current, store.model.value.currentPage?.page)
+}
+```
+
+- [ ] **Step 4: 实现最小只读模型与双 StateFlow 容器**
+
+```kotlin
+@Stable
+class ReaderRenderPage internal constructor(
+    val page: TextPage,
+    val revision: Long,
+) {
+    override fun equals(other: Any?): Boolean =
+        other is ReaderRenderPage && page === other.page && revision == other.revision
+
+    override fun hashCode(): Int = 31 * System.identityHashCode(page) + revision.hashCode()
+}
+
+@Stable
+data class ReaderTextPosition(
+    val relativePage: Int,
+    val lineIndex: Int,
+    val columnIndex: Int,
+)
+
+@Stable
+data class ReaderSelection(
+    val start: ReaderTextPosition,
+    val end: ReaderTextPosition,
+)
+
+@Stable
+data class ReaderReadAloudHighlight(
+    val relativePage: Int,
+    val firstLineIndex: Int,
+    val lastLineIndex: Int,
+)
+
+@Stable
+data class ReaderRenderModel(
+    val session: LegacyReaderSnapshot = LegacyReaderSnapshot(),
+    val previousPage: ReaderRenderPage? = null,
+    val currentPage: ReaderRenderPage? = null,
+    val nextPage: ReaderRenderPage? = null,
+    val durPageIndex: Int = 0,
+    val selection: ReaderSelection? = null,
+    val readAloudHighlights: ImmutableList<ReaderReadAloudHighlight> = persistentListOf(),
+    val message: String? = null,
+    val isLoading: Boolean = false,
+)
+
+@Stable
+data class ReaderRenderViewport(
+    val width: Int = 0,
+    val height: Int = 0,
+    val startX: Float = 0f,
+    val startY: Float = 0f,
+    val touchX: Float = 0f,
+    val touchY: Float = 0f,
+    val direction: PageDirection = PageDirection.NONE,
+    val isAnimationRunning: Boolean = false,
+    val animationProgress: Float = 0f,
+)
+
+internal class ReaderRenderStateStore(
+    initialSession: LegacyReaderSnapshot = LegacyReaderSnapshot(),
+) {
+    private val _model = MutableStateFlow(ReaderRenderModel(session = initialSession))
+    val model = _model.asStateFlow()
+    private val _viewport = MutableStateFlow(ReaderRenderViewport())
+    val viewport = _viewport.asStateFlow()
+
+    fun publishSession(session: LegacyReaderSnapshot) {
+        _model.update { it.copy(session = session) }
+    }
+
+    private var revision = 0L
+
+    fun publishStructure(
+        previousPage: TextPage? = null,
+        currentPage: TextPage? = null,
+        nextPage: TextPage? = null,
+        durPageIndex: Int = 0,
+        selectionStart: TextPos? = null,
+        selectionEnd: TextPos? = null,
+        message: String? = null,
+        isLoading: Boolean = false,
+    ) {
+        revision++
+        val pages = listOf(-1 to previousPage, 0 to currentPage, 1 to nextPage)
+        _model.update { current ->
+            current.copy(
+                previousPage = previousPage?.let { ReaderRenderPage(it, revision) },
+                currentPage = currentPage?.let { ReaderRenderPage(it, revision) },
+                nextPage = nextPage?.let { ReaderRenderPage(it, revision) },
+                durPageIndex = durPageIndex,
+                selection = if (
+                    selectionStart?.isSelected() == true && selectionEnd?.isSelected() == true
+                ) {
+                    ReaderSelection(
+                        start = selectionStart.toReaderTextPosition(),
+                        end = selectionEnd.toReaderTextPosition(),
+                    )
+                } else {
+                    null
+                },
+                readAloudHighlights = pages.mapNotNull { (relativePage, page) ->
+                    val indices = page?.lines?.indices?.filter { page.lines[it].isReadAloud }
+                        .orEmpty()
+                    if (indices.isEmpty()) null else ReaderReadAloudHighlight(
+                        relativePage = relativePage,
+                        firstLineIndex = indices.first(),
+                        lastLineIndex = indices.last(),
+                    )
+                }.toImmutableList(),
+                message = message,
+                isLoading = isLoading,
+            )
+        }
+    }
+
+    fun publishViewport(
+        width: Int,
+        height: Int,
+        startX: Float,
+        startY: Float,
+        touchX: Float,
+        touchY: Float,
+        direction: PageDirection,
+        isAnimationRunning: Boolean,
+    ) {
+        val xProgress = if (width > 0) abs(touchX - startX) / width else 0f
+        val yProgress = if (height > 0) abs(touchY - startY) / height else 0f
+        _viewport.value = ReaderRenderViewport(
+            width = width,
+            height = height,
+            startX = startX,
+            startY = startY,
+            touchX = touchX,
+            touchY = touchY,
+            direction = direction,
+            isAnimationRunning = isAnimationRunning,
+            animationProgress = max(xProgress, yProgress).coerceIn(0f, 1f),
+        )
+    }
+}
+```
+
+- [ ] **Step 5: 运行聚焦测试,确认全部通过**
+
+Run: `./gradlew --no-daemon :app:testAppDebugUnitTest --tests 'io.legado.app.ui.book.read.ReaderRenderStateStoreTest'`
+
+Expected: `BUILD SUCCESSFUL`,所有 `ReaderRenderStateStoreTest` 用例通过。
+
+- [ ] **Step 6: 提交状态模型任务**
+
+```bash
+git add app/src/main/java/io/legado/app/ui/book/read/ReaderRenderModel.kt app/src/test/java/io/legado/app/ui/book/read/ReaderRenderStateStoreTest.kt
+git commit -m "feat(Track C): 抽取阅读器只读渲染状态"
+```
+
+### Task 2: 从旧 ReadView 真源并行发布渲染状态
+
+**Files:**
+- Modify: `app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt`
+- Modify: `app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt`
+- Modify: `app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt`
+- Modify: `app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt`
+- Modify: `app/src/main/java/io/legado/app/ui/book/read/page/delegate/PageDelegate.kt`
+- Modify: `docs/dev/track-c-compose-reader-plan.md`
+- Test: `app/src/test/java/io/legado/app/ui/book/read/ReaderRenderStateStoreTest.kt`
+
+- [ ] **Step 1: 补充相同 `TextPage` 重发时 revision 递增的回归测试**
+
+```kotlin
+@Test
+fun `republishing same mutable page advances render revision`() {
+    val store = ReaderRenderStateStore()
+    val page = TextPage(index = 1)
+    store.publishStructure(currentPage = page, durPageIndex = 1)
+    val first = store.model.value.currentPage!!
+
+    page.hasReadAloudSpan = true
+    store.publishStructure(currentPage = page, durPageIndex = 1)
+
+    val second = store.model.value.currentPage!!
+    assertSame(page, second.page)
+    assertTrue(second.revision > first.revision)
+    assertNotEquals(first, second)
+}
+```
+
+- [ ] **Step 2: 运行测试确认 identity + revision 契约继续成立**
+
+Run: `./gradlew --no-daemon :app:testAppDebugUnitTest --tests 'io.legado.app.ui.book.read.ReaderRenderStateStoreTest.republishing same mutable page advances render revision'`
+
+Expected: `BUILD SUCCESSFUL`;相同可变页的后续发布具有更大的 revision,且不触发 `TextPage` 深层 equality。
+
+- [ ] **Step 3: 保持页面包装的 identity + revision 相等语义,不在接线层复制 TextPage**
+
+```kotlin
+@Stable
+class ReaderRenderPage internal constructor(
+    val page: TextPage,
+    val revision: Long,
+) {
+    override fun equals(other: Any?): Boolean =
+        other is ReaderRenderPage && page === other.page && revision == other.revision
+
+    override fun hashCode(): Int = 31 * System.identityHashCode(page) + revision.hashCode()
+}
+```
+
+- [ ] **Step 4: 在旧 View 的既有变更点仅增加取样通知**
+
+```kotlin
+// ReadView.CallBack
+fun onRenderStructureChanged()
+fun onRenderViewportChanged()
+
+// ReadView
+internal fun notifyRenderViewportChanged() = callBack.onRenderViewportChanged()
+
+// Existing methods keep their original behavior/order, then notify:
+// onSizeChanged / setStartPoint / setTouchPoint / upPageAnim -> viewport
+// upContent / onLayoutPageCompleted -> structure
+```
+
+`ContentTextView`/`PageView` 只增加返回 `TextPos.copy()` 的选择快照 getter;不得把内部可变 `TextPos` 直接放进状态。`PageDelegate.isRunning`、`isStarted` 和 `setDirection` 的 setter 在原赋值后通知 viewport,使动画停止也能发出最终状态。
+
+- [ ] **Step 5: 在 ReadBookController 暴露双只读 Flow 并从现有真源取样**
+
+```kotlin
+private val renderStateStore = ReaderRenderStateStore(ReadBook.snapshot.value)
+val readerRenderModel = renderStateStore.model
+val readerRenderViewport = renderStateStore.viewport
+
+init {
+    activity.lifecycleScope.launch {
+        ReadBook.snapshot.collect(renderStateStore::publishSession)
+    }
+}
+
+override fun onRenderStructureChanged() = publishRenderStructure()
+override fun onRenderViewportChanged() = publishRenderViewport()
+```
+
+`publishRenderStructure()` 读取 `readView.prevPage/curPage/nextPage.textPage`、`ReadBook.durPageIndex`、选择副本、`ReadBook.msg` 与当前 loading 判定;`publishRenderViewport()` 读取 View 尺寸、触点、delegate 方向/运行状态。`onRefsReady` 首次发布两条流,`upSelectedStart`、`upSelectedEnd`、`onCancelSelect` 在原 UI 更新后重发结构态。
+
+- [ ] **Step 6: 更新 C1 文档状态并运行测试与编译**
+
+Run: `./gradlew --no-daemon :app:testAppDebugUnitTest --tests 'io.legado.app.ui.book.read.ReaderRenderStateStoreTest'`
+
+Run: `./gradlew --no-daemon :app:compileAppDebugKotlin`
+
+Expected: 两条命令均 `BUILD SUCCESSFUL`;旧 `ReadView` 仍是唯一绘制者,代码中没有新增 Compose Canvas、pointerInput 或 feature flag。
+
+- [ ] **Step 7: 停止编译进程并提交接线任务**
+
+```bash
+./gradlew --stop
+git add app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt app/src/main/java/io/legado/app/ui/book/read/page/delegate/PageDelegate.kt docs/dev/track-c-compose-reader-plan.md app/src/test/java/io/legado/app/ui/book/read/ReaderRenderStateStoreTest.kt
+git commit -m "refactor(Track C): 并行发布旧阅读器渲染状态"
+```

+ 409 - 0
tools/capture_reader_c3_baseline.py

@@ -0,0 +1,409 @@
+#!/usr/bin/env python3
+"""Capture repeatable Track C3 reader parity evidence from one Android device.
+
+The script toggles the existing Lab Compose renderer flag through the app UI, restores the
+previous Lab settings on exit, opens one book, captures Legacy/Compose pairs, and records
+`gfxinfo framestats`. It intentionally does not install an APK or create reader data.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import struct
+import subprocess
+import sys
+import time
+import xml.etree.ElementTree as element_tree
+import zlib
+from datetime import datetime
+from pathlib import Path
+
+
+PACKAGE_DEFAULT = "io.legato.kazusa.debug"
+MAIN_ACTIVITY_CLASS = "io.legado.app.ui.main.MainActivity"
+LAB_ROUTE = "settings/lab_config"
+READ_ROUTE = "book/read"
+LAB_LABELS = ("Enable Lab", "启用实验室", "啟用實驗室")
+COMPOSE_LABELS = (
+    "Compose Reader Rendering",
+    "Compose 阅读渲染",
+    "Compose 閱讀渲染",
+)
+PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
+
+
+class BaselineError(RuntimeError):
+    pass
+
+
+class Adb:
+    def __init__(self, serial: str | None):
+        self.prefix = ["adb"] + (["-s", serial] if serial else [])
+
+    def run(
+        self,
+        *args: str,
+        capture: bool = True,
+        timeout_seconds: float = 15.0,
+    ) -> str:
+        completed = subprocess.run(
+            [*self.prefix, *args],
+            check=True,
+            stdout=subprocess.PIPE if capture else None,
+            stderr=subprocess.PIPE,
+            timeout=timeout_seconds,
+        )
+        return completed.stdout.decode("utf-8", errors="replace") if capture else ""
+
+    def bytes(self, *args: str, timeout_seconds: float = 15.0) -> bytes:
+        return subprocess.run(
+            [*self.prefix, *args],
+            check=True,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+            timeout=timeout_seconds,
+        ).stdout
+
+
+def connected_serial(requested: str | None) -> str:
+    if requested:
+        return requested
+    output = subprocess.run(
+        ["adb", "devices"], check=True, stdout=subprocess.PIPE, text=True
+    ).stdout.splitlines()[1:]
+    devices = [line.split("\t", 1)[0] for line in output if "\tdevice" in line]
+    if len(devices) != 1:
+        raise BaselineError("请用 --serial 指定唯一已连接且处于 device 状态的真机")
+    return devices[0]
+
+
+def screen_size(adb: Adb) -> tuple[int, int]:
+    output = adb.run("shell", "wm", "size")
+    match = re.search(r"(\d+)x(\d+)", output)
+    if not match:
+        raise BaselineError(f"无法解析屏幕尺寸:{output.strip()}")
+    return int(match.group(1)), int(match.group(2))
+
+
+def require_unlocked(adb: Adb) -> None:
+    trust = adb.run("shell", "dumpsys", "trust")
+    current_user = re.search(
+        r"\(current\):.*?deviceLocked=(\d)",
+        trust,
+        flags=re.DOTALL,
+    )
+    if current_user is not None and current_user.group(1) == "1":
+        raise BaselineError("真机已锁定;请解锁并保持屏幕常亮后重试")
+
+
+def parse_bounds(raw: str) -> tuple[int, int]:
+    values = [int(value) for value in re.findall(r"-?\d+", raw)]
+    if len(values) != 4:
+        raise BaselineError(f"无法解析控件 bounds:{raw}")
+    return (values[0] + values[2]) // 2, (values[1] + values[3]) // 2
+
+
+def dump_ui(adb: Adb) -> element_tree.Element:
+    remote = "/sdcard/codex-reader-c3-window.xml"
+    adb.run("shell", "uiautomator", "dump", remote)
+    return element_tree.fromstring(adb.bytes("exec-out", "cat", remote))
+
+
+def find_toggle(root: element_tree.Element, labels: tuple[str, ...]) -> element_tree.Element:
+    parents = {child: parent for parent in root.iter() for child in parent}
+    label_nodes = [node for node in root.iter("node") if node.attrib.get("text") in labels]
+    for label in label_nodes:
+        node: element_tree.Element | None = label
+        while node is not None:
+            if node.attrib.get("checkable") == "true":
+                return node
+            node = parents.get(node)
+    names = ", ".join(labels)
+    raise BaselineError(f"UI dump 中找不到可切换的设置:{names}")
+
+
+def open_route(adb: Adb, package: str, route: str, extra: tuple[str, str] | None = None) -> None:
+    command = [
+        # MainActivity only consumes startRoute while establishing its navigation state. Reusing
+        # the existing instance can leave the previous screen visible while the script clicks it.
+        "shell", "am", "start", "-S", "-W", "-n", f"{package}/{MAIN_ACTIVITY_CLASS}",
+        "--es", "startRoute", route,
+    ]
+    if extra is not None:
+        command.extend(["--es", extra[0], extra[1]])
+    adb.run(*command)
+
+
+def switch_state(adb: Adb, labels: tuple[str, ...], target: bool, timeout: float = 6.0) -> bool:
+    toggle = find_toggle(dump_ui(adb), labels)
+    raw_state = toggle.attrib.get("checked")
+    if raw_state not in ("true", "false"):
+        raise BaselineError(f"设置未暴露 checked 状态:{labels[0]}")
+    previous = raw_state == "true"
+    if previous == target:
+        return previous
+    adb.run("shell", "input", "tap", *(str(value) for value in parse_bounds(toggle.attrib["bounds"])))
+    deadline = time.monotonic() + timeout
+    while time.monotonic() < deadline:
+        time.sleep(0.2)
+        current = find_toggle(dump_ui(adb), labels).attrib.get("checked") == "true"
+        if current == target:
+            return previous
+    raise BaselineError(f"切换 {labels[0]} 到 {target} 超时")
+
+
+def set_compose_renderer(adb: Adb, package: str, enabled: bool) -> tuple[bool, bool]:
+    open_route(adb, package, LAB_ROUTE)
+    lab_was_enabled = switch_state(adb, LAB_LABELS, True)
+    compose_was_enabled = switch_state(adb, COMPOSE_LABELS, enabled)
+    return lab_was_enabled, compose_was_enabled
+
+
+def restore_lab_state(adb: Adb, package: str, original: tuple[bool, bool] | None) -> None:
+    if original is None:
+        return
+    lab_enabled, compose_enabled = original
+    open_route(adb, package, LAB_ROUTE)
+    switch_state(adb, LAB_LABELS, True)
+    switch_state(adb, COMPOSE_LABELS, compose_enabled)
+    switch_state(adb, LAB_LABELS, lab_enabled)
+
+
+def capture(adb: Adb, path: Path) -> None:
+    path.write_bytes(adb.bytes("exec-out", "screencap", "-p"))
+
+
+def png_rows(path: Path) -> tuple[int, int, int, list[bytearray]]:
+    data = path.read_bytes()
+    if not data.startswith(PNG_SIGNATURE):
+        raise BaselineError(f"不是 PNG:{path}")
+    pos = len(PNG_SIGNATURE)
+    width = height = bit_depth = color_type = None
+    compressed = bytearray()
+    while pos < len(data):
+        length = struct.unpack(">I", data[pos:pos + 4])[0]
+        kind = data[pos + 4:pos + 8]
+        payload = data[pos + 8:pos + 8 + length]
+        pos += 12 + length
+        if kind == b"IHDR":
+            width, height, bit_depth, color_type, compression, filtering, interlace = struct.unpack(
+                ">IIBBBBB", payload
+            )
+            if bit_depth != 8 or color_type not in (2, 6) or compression or filtering or interlace:
+                raise BaselineError(f"不支持的 PNG 格式:{path}")
+        elif kind == b"IDAT":
+            compressed.extend(payload)
+        elif kind == b"IEND":
+            break
+    if width is None or height is None or color_type is None:
+        raise BaselineError(f"PNG 缺少 IHDR:{path}")
+    channels = 4 if color_type == 6 else 3
+    stride = width * channels
+    raw = zlib.decompress(compressed)
+    rows: list[bytearray] = []
+    offset = 0
+    for _ in range(height):
+        filter_type = raw[offset]
+        offset += 1
+        row = bytearray(raw[offset:offset + stride])
+        offset += stride
+        previous = rows[-1] if rows else bytearray(stride)
+        for index, value in enumerate(row):
+            left = row[index - channels] if index >= channels else 0
+            up = previous[index]
+            up_left = previous[index - channels] if index >= channels else 0
+            if filter_type == 1:
+                row[index] = (value + left) & 0xFF
+            elif filter_type == 2:
+                row[index] = (value + up) & 0xFF
+            elif filter_type == 3:
+                row[index] = (value + ((left + up) // 2)) & 0xFF
+            elif filter_type == 4:
+                p = left + up - up_left
+                pa, pb, pc = abs(p - left), abs(p - up), abs(p - up_left)
+                row[index] = (value + (left if pa <= pb and pa <= pc else up if pb <= pc else up_left)) & 0xFF
+            elif filter_type != 0:
+                raise BaselineError(f"不支持的 PNG filter:{filter_type}")
+        rows.append(row)
+    return width, height, channels, rows
+
+
+def compare_pngs(legacy: Path, compose: Path, crop: tuple[int, int, int, int] | None) -> dict[str, float | int | list[int]]:
+    lw, lh, lc, legacy_rows = png_rows(legacy)
+    cw, ch, cc, compose_rows = png_rows(compose)
+    if (lw, lh) != (cw, ch):
+        raise BaselineError("Legacy 与 Compose 截图尺寸不同,无法比较")
+    left, top, right, bottom = crop or (0, 0, lw, lh)
+    if not (0 <= left < right <= lw and 0 <= top < bottom <= lh):
+        raise BaselineError(f"无效裁剪区域:{crop}")
+    total_difference = 0
+    changed_pixels = 0
+    pixels = (right - left) * (bottom - top)
+    for y in range(top, bottom):
+        legacy_row, compose_row = legacy_rows[y], compose_rows[y]
+        for x in range(left, right):
+            legacy_offset, compose_offset = x * lc, x * cc
+            differences = [abs(legacy_row[legacy_offset + channel] - compose_row[compose_offset + channel]) for channel in range(3)]
+            total_difference += sum(differences)
+            if max(differences) > 8:
+                changed_pixels += 1
+    return {
+        "crop": [left, top, right, bottom],
+        "meanAbsoluteChannelDifference": total_difference / (pixels * 3),
+        "changedPixelCount": changed_pixels,
+        "changedPixelPercent": changed_pixels * 100 / pixels,
+    }
+
+
+def parse_crop(value: str | None) -> tuple[int, int, int, int] | None:
+    if value is None:
+        return None
+    values = [int(item) for item in value.split(",")]
+    if len(values) != 4:
+        raise argparse.ArgumentTypeError("--crop 采用 left,top,right,bottom")
+    return tuple(values)  # type: ignore[return-value]
+
+
+def parse_framestats(path: Path) -> dict[str, int | float]:
+    text = path.read_text(encoding="utf-8")
+
+    def required(pattern: str, label: str) -> str:
+        match = re.search(pattern, text)
+        if not match:
+            raise BaselineError(f"无法从 {path.name} 解析 {label}")
+        return match.group(1)
+
+    return {
+        "totalFrames": int(required(r"Total frames rendered:\s+(\d+)", "总帧数")),
+        "jankyFrames": int(required(r"Janky frames:\s+(\d+)", "卡顿帧数")),
+        "jankyPercent": float(required(r"Janky frames:\s+\d+\s+\(([\d.]+)%\)", "卡顿率")),
+        "p50Ms": int(required(r"50th percentile:\s+(\d+)ms", "P50")),
+        "p90Ms": int(required(r"90th percentile:\s+(\d+)ms", "P90")),
+        "p95Ms": int(required(r"95th percentile:\s+(\d+)ms", "P95")),
+        "p99Ms": int(required(r"99th percentile:\s+(\d+)ms", "P99")),
+    }
+
+
+def run_mode(adb: Adb, args: argparse.Namespace, output: Path, mode: str, size: tuple[int, int]) -> dict[str, object]:
+    set_compose_renderer(adb, args.package, mode == "compose")
+    book_extra = ("bookUrl", args.book_url) if args.book_url else None
+    open_route(adb, args.package, READ_ROUTE, book_extra)
+    time.sleep(args.settle_seconds)
+    visible_texts = {node.attrib.get("text") for node in dump_ui(adb).iter("node")}
+    if any(label in visible_texts for label in (*LAB_LABELS, *COMPOSE_LABELS)):
+        raise BaselineError(f"{mode} 模式未进入阅读页,仍停留在实验室设置")
+    plain = output / f"plain-page-{mode}.png"
+    capture(adb, plain)
+
+    adb.run("shell", "dumpsys", "gfxinfo", args.package, "reset")
+    next_x, next_y = int(size[0] * args.next_x_ratio), int(size[1] * args.tap_y_ratio)
+    prev_x, prev_y = int(size[0] * args.prev_x_ratio), int(size[1] * args.tap_y_ratio)
+    forward = output / f"forward-turn-{mode}.png"
+    forward_difference: dict[str, float | int | list[int]] | None = None
+    for round_index in range(args.rounds):
+        for _ in range(args.turns):
+            adb.run("shell", "input", "tap", str(next_x), str(next_y))
+            time.sleep(args.tap_delay)
+        if round_index == 0:
+            time.sleep(args.direction_settle_seconds)
+            capture(adb, forward)
+            forward_difference = compare_pngs(plain, forward, args.crop)
+            if forward_difference["changedPixelPercent"] < 1.0:
+                raise BaselineError(f"{mode} 模式前进 {args.turns} 页后画面未发生有效变化")
+        for _ in range(args.turns):
+            adb.run("shell", "input", "tap", str(prev_x), str(prev_y))
+            time.sleep(args.tap_delay)
+    time.sleep(args.settle_seconds)
+    rapid = output / f"rapid-turn-{mode}.png"
+    capture(adb, rapid)
+    return_difference = compare_pngs(plain, rapid, args.crop)
+    if return_difference["changedPixelPercent"] > 1.0:
+        raise BaselineError(
+            f"{mode} 模式完成 {args.rounds} 轮往返后未回到起点:"
+            f"差异像素 {return_difference['changedPixelPercent']:.2f}%"
+        )
+    framestats = output / f"{mode}-framestats.txt"
+    framestats.write_text(
+        adb.run("shell", "dumpsys", "gfxinfo", args.package, "framestats"), encoding="utf-8"
+    )
+    return {
+        "plain": plain.name,
+        "forwardTurn": forward.name,
+        "rapidTurn": rapid.name,
+        "plainVsForward": forward_difference,
+        "plainVsRapidTurn": return_difference,
+        "frameStats": parse_framestats(framestats),
+    }
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--book-url", help="书架中可打开的 bookUrl;省略时自动使用最后阅读的书")
+    parser.add_argument("--serial", help="adb device serial;未指定时要求仅连接一台设备")
+    parser.add_argument("--package", default=PACKAGE_DEFAULT)
+    parser.add_argument("--turns", type=int, default=20)
+    parser.add_argument("--rounds", type=int, default=5)
+    parser.add_argument("--tap-delay", type=float, default=0.18)
+    parser.add_argument("--settle-seconds", type=float, default=1.5)
+    parser.add_argument("--direction-settle-seconds", type=float, default=1.0)
+    parser.add_argument("--next-x-ratio", type=float, default=0.84)
+    parser.add_argument("--prev-x-ratio", type=float, default=0.16)
+    parser.add_argument("--tap-y-ratio", type=float, default=0.50)
+    parser.add_argument("--crop", type=parse_crop, help="正文比较区域:left,top,right,bottom")
+    parser.add_argument("--out", type=Path)
+    args = parser.parse_args()
+    if args.turns < 1:
+        parser.error("--turns 必须大于 0")
+    if args.rounds < 1:
+        parser.error("--rounds 必须大于 0")
+
+    serial = connected_serial(args.serial)
+    adb = Adb(serial)
+    require_unlocked(adb)
+
+    output = args.out or Path("build/reader-c3-baseline") / datetime.now().strftime("%Y%m%d-%H%M%S")
+    try:
+        output.mkdir(parents=True, exist_ok=False)
+    except FileExistsError as error:
+        raise BaselineError(f"输出目录已存在:{output};请换一个 --out 路径") from error
+    original: tuple[bool, bool] | None = None
+    try:
+        original = set_compose_renderer(adb, args.package, False)
+        size = screen_size(adb)
+        artifacts = {mode: run_mode(adb, args, output, mode, size) for mode in ("legacy", "compose")}
+        comparisons = {
+            scenario: compare_pngs(
+                output / artifacts["legacy"][scenario], output / artifacts["compose"][scenario], args.crop
+            )
+            for scenario in ("plain", "rapidTurn")
+        }
+        metadata = {
+            "serial": serial,
+            "package": args.package,
+            "bookUrl": args.book_url,
+            "screen": list(size),
+            "turns": args.turns,
+            "rounds": args.rounds,
+            "crop": args.crop,
+            "artifacts": artifacts,
+            "comparisons": comparisons,
+            "device": adb.run("shell", "getprop").splitlines(),
+        }
+        (output / "result.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
+        print(json.dumps({"output": str(output), "comparisons": comparisons}, ensure_ascii=False, indent=2))
+        return 0
+    finally:
+        try:
+            restore_lab_state(adb, args.package, original)
+        except Exception as error:  # Preserve the evidence even if settings restoration fails.
+            print(f"警告:未能还原实验室设置:{error}", file=sys.stderr)
+
+
+if __name__ == "__main__":
+    try:
+        raise SystemExit(main())
+    except (BaselineError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
+        print(f"C3 基线采集失败:{error}", file=sys.stderr)
+        raise SystemExit(2)

+ 114 - 0
tools/capture_reader_c3_first_frame.py

@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+"""Capture cold route-to-first-content draw timings for both reader renderers."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+import time
+from datetime import datetime
+from pathlib import Path
+
+sys.dont_write_bytecode = True
+
+from capture_reader_c3_baseline import (  # noqa: E402
+    Adb,
+    BaselineError,
+    MAIN_ACTIVITY_CLASS,
+    PACKAGE_DEFAULT,
+    READ_ROUTE,
+    connected_serial,
+    require_unlocked,
+    restore_lab_state,
+    set_compose_renderer,
+)
+
+
+FIRST_FRAME_EXTRA = "readerFirstFrameStartedAtNanos"
+FRAME_PATTERN = re.compile(r"renderer=(legacy|compose) durationMs=([\d.]+)")
+
+
+def elapsed_realtime_nanos(adb: Adb) -> int:
+    seconds = float(adb.run("shell", "cat", "/proc/uptime").split()[0])
+    return int(seconds * 1_000_000_000)
+
+
+def percentile(values: list[float], fraction: float) -> float:
+    ordered = sorted(values)
+    index = min(len(ordered) - 1, int((len(ordered) - 1) * fraction + 0.5))
+    return ordered[index]
+
+
+def capture_mode(adb: Adb, package: str, mode: str, runs: int) -> dict[str, object]:
+    set_compose_renderer(adb, package, mode == "compose")
+    durations: list[float] = []
+    launches: list[str] = []
+    for _ in range(runs):
+        adb.run("logcat", "-c")
+        started_at = elapsed_realtime_nanos(adb)
+        launches.append(
+            adb.run(
+                "shell", "am", "start", "-S", "-W",
+                "-n", f"{package}/{MAIN_ACTIVITY_CLASS}",
+                "--es", "startRoute", READ_ROUTE,
+                "--el", FIRST_FRAME_EXTRA, str(started_at),
+            )
+        )
+        deadline = time.monotonic() + 10.0
+        duration: float | None = None
+        while time.monotonic() < deadline:
+            logs = adb.run("logcat", "-d", "-s", "ReaderFirstFrame:I", "*:S")
+            matches = FRAME_PATTERN.findall(logs)
+            matching = [float(value) for renderer, value in matches if renderer == mode]
+            if matching:
+                duration = matching[-1]
+                break
+            time.sleep(0.2)
+        if duration is None:
+            raise BaselineError(f"{mode} 冷启动未观察到首个非空正文帧")
+        durations.append(duration)
+    return {
+        "durationsMs": durations,
+        "p50Ms": percentile(durations, 0.50),
+        "p90Ms": percentile(durations, 0.90),
+        "launches": launches,
+    }
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--serial")
+    parser.add_argument("--package", default=PACKAGE_DEFAULT)
+    parser.add_argument("--runs", type=int, default=5)
+    parser.add_argument("--out", type=Path)
+    args = parser.parse_args()
+    if args.runs < 1:
+        parser.error("--runs 必须大于 0")
+
+    serial = connected_serial(args.serial)
+    adb = Adb(serial)
+    require_unlocked(adb)
+    output = args.out or Path("build/reader-c3-first-frame") / datetime.now().strftime("%Y%m%d-%H%M%S")
+    output.mkdir(parents=True, exist_ok=False)
+    original: tuple[bool, bool] | None = None
+    try:
+        original = set_compose_renderer(adb, args.package, False)
+        modes = {
+            mode: capture_mode(adb, args.package, mode, args.runs)
+            for mode in ("legacy", "compose")
+        }
+        result = {"serial": serial, "runs": args.runs, "modes": modes}
+        (output / "result.json").write_text(
+            json.dumps(result, ensure_ascii=False, indent=2),
+            encoding="utf-8",
+        )
+        print(json.dumps({"output": str(output), **result}, ensure_ascii=False, indent=2))
+        return 0
+    finally:
+        restore_lab_state(adb, args.package, original)
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 93 - 0
tools/verify_reader_c4_detached.py

@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""Verify that Compose pagination works while the legacy reader surface is laid out at 0x0."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+import time
+from datetime import datetime
+from pathlib import Path
+
+sys.dont_write_bytecode = True
+
+from capture_reader_c3_baseline import (  # noqa: E402
+    Adb,
+    BaselineError,
+    MAIN_ACTIVITY_CLASS,
+    PACKAGE_DEFAULT,
+    READ_ROUTE,
+    capture,
+    compare_pngs,
+    connected_serial,
+    require_unlocked,
+    restore_lab_state,
+    screen_size,
+    set_compose_renderer,
+)
+
+
+DETACH_EXTRA = "readerDetachLegacySurface"
+FIRST_FRAME_EXTRA = "readerFirstFrameStartedAtNanos"
+
+
+def elapsed_realtime_nanos(adb: Adb) -> int:
+    seconds = float(adb.run("shell", "cat", "/proc/uptime").split()[0])
+    return int(seconds * 1_000_000_000)
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--serial")
+    parser.add_argument("--package", default=PACKAGE_DEFAULT)
+    parser.add_argument("--settle-seconds", type=float, default=2.0)
+    parser.add_argument("--out", type=Path)
+    args = parser.parse_args()
+
+    serial = connected_serial(args.serial)
+    adb = Adb(serial)
+    require_unlocked(adb)
+    output = args.out or Path("build/reader-c4-detached") / datetime.now().strftime("%Y%m%d-%H%M%S")
+    output.mkdir(parents=True, exist_ok=False)
+    original: tuple[bool, bool] | None = None
+    try:
+        original = set_compose_renderer(adb, args.package, True)
+        adb.run("logcat", "-c")
+        started_at = elapsed_realtime_nanos(adb)
+        adb.run(
+            "shell", "am", "start", "-S", "-W",
+            "-n", f"{args.package}/{MAIN_ACTIVITY_CLASS}",
+            "--es", "startRoute", READ_ROUTE,
+            "--ez", DETACH_EXTRA, "true",
+            "--el", FIRST_FRAME_EXTRA, str(started_at),
+        )
+        time.sleep(args.settle_seconds)
+        before = output / "before.png"
+        after = output / "after.png"
+        capture(adb, before)
+        width, height = screen_size(adb)
+        adb.run("shell", "input", "tap", str(int(width * 0.84)), str(int(height * 0.5)))
+        time.sleep(args.settle_seconds)
+        capture(adb, after)
+        difference = compare_pngs(before, after, None)
+        if difference["changedPixelPercent"] < 1.0:
+            raise BaselineError("legacy surface 为 0x0 时,Compose 点击后画面未发生有效变化")
+        logs = adb.run("logcat", "-d", "-s", "ReaderDetachedLegacy:I", "ReaderFirstFrame:I", "*:S")
+        if "legacySurface=0x0" not in logs:
+            raise BaselineError("未观察到 committed detach 分支日志")
+        if "renderer=compose" not in logs:
+            raise BaselineError("未观察到 Compose 首个非空正文帧")
+        result = {"serial": serial, "difference": difference, "logs": logs.strip().splitlines()}
+        (output / "result.json").write_text(
+            json.dumps(result, ensure_ascii=False, indent=2),
+            encoding="utf-8",
+        )
+        print(json.dumps({"output": str(output), **result}, ensure_ascii=False, indent=2))
+        return 0
+    finally:
+        restore_lab_state(adb, args.package, original)
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())