HapeLee 1 месяц назад
Родитель
Сommit
5e317d7a79

+ 22 - 0
app/src/main/java/io/legado/app/feature/reader/core/navigation/ReaderPartialPagePolicy.kt

@@ -50,6 +50,28 @@ object ReaderPartialPagePolicy {
         else -> true
     }
 
+    /**
+     * 换章(layout key 变化)时该保留哪个章节"已流出但整章还没提交"的页。
+     *
+     * 旧 View 的页是**增量追加**的:`TextChapterLayout.onPageCompleted()`
+     * (`7a61ea86d^` `TextChapterLayout.kt`)把成型的页 `textPages.add(textPage)`,整章排完才置
+     * `TextChapter.isCompleted`;`TextChapter.isLayoutRunning` 的注释写明它用来区分"还在排"和
+     * "排到一半就废了",**前者可以继续用已排出的页**。所以换章只是换阅读位置,已排好的页照旧
+     * 可见,尾部交给空白页承接——而不是把它们摘掉、等新一批从头排完再重新出现。
+     *
+     * 两种不能保留的情况:
+     * - 排版环境变化(对照旧 `TextChapter.isLayoutSizeMatch()` 失败):几何全部失效,必须整批重排;
+     * - 新当前章本来就没有部分页:没有可承接的页,与旧行为一致。
+     *
+     * @param streamedChapters 当前挂着"部分页"的章节下标集合。
+     * @return 需要保留的章节下标;null 表示不保留任何部分页。
+     */
+    fun retainedStreamedChapter(
+        chapterIndex: Int,
+        environmentChanged: Boolean,
+        streamedChapters: Set<Int>,
+    ): Int? = chapterIndex.takeIf { !environmentChanged && it in streamedChapters }
+
     /** 旧 `ReadBook.loadContent`:下一章排到 `page.index > 1` 就不再提前重绘。 */
     private const val NEXT_CHAPTER_EARLY_PAGE_LIMIT = 1
 

+ 50 - 3
app/src/main/java/io/legado/app/ui/book/read/MarkingDelegate.kt

@@ -43,14 +43,61 @@ class MarkingDelegate(
     private var inlineSaveVersion = 0L
 
     interface Host {
+        val activeSheet: ReadBookSheet?
+        fun setActiveSheet(sheet: ReadBookSheet?)
         fun reloadCurrentChapter()
-        fun dismissMarkingSheet()
         fun showToast(message: String)
     }
 
     private val _uiState = MutableStateFlow(MarkingUiState())
     val uiState = _uiState.asStateFlow()
 
+    /**
+     * 标记 sheet 的「来源」:从目录 Sheet 的标记列表进入编辑时记下目录,
+     * 保存/删除/取消后回到它,而不是把用户丢回阅读页。从划词菜单新建时为 null。
+     *
+     * 这个状态属于标记域自己的会话,不该散落在 ViewModel 里。
+     */
+    private var returnSheet: ReadBookSheet? = null
+
+    /** 从划词菜单新建标记:没有可返回的来源 sheet。 */
+    fun openFromMenu(selection: Bookmark) {
+        returnSheet = null
+        open(selection)
+        host.setActiveSheet(ReadBookSheet.Marking)
+    }
+
+    /** 从目录 Sheet 的标记列表进入编辑:记住目录以便关闭时返回。 */
+    fun openForEditFromSheet(markingId: String) {
+        returnSheet = host.activeSheet
+        openForEdit(markingId)
+        host.setActiveSheet(ReadBookSheet.Marking)
+    }
+
+    /** 划词菜单的快速标记:以行内模式打开,不改动当前 sheet。 */
+    fun openQuick(selection: Bookmark) {
+        returnSheet = null
+        open(selection, inlineMode = true)
+    }
+
+    /** 划词菜单的快速标记编辑。 */
+    fun openQuickForEdit(markingId: String) {
+        returnSheet = null
+        openForEdit(markingId, inlineMode = true)
+    }
+
+    /** 用户主动关闭标记 sheet:清空会话并回到进入前的 sheet。 */
+    fun dismissSheetByUser() {
+        onSheetDismissed()
+        dismissSheet()
+    }
+
+    private fun dismissSheet() {
+        val target = returnSheet
+        returnSheet = null
+        host.setActiveSheet(target)
+    }
+
     fun open(selection: Bookmark, inlineMode: Boolean = false) {
         val book = ReadBook.book
         _uiState.update {
@@ -142,7 +189,7 @@ class MarkingDelegate(
                     }
                     if (!current.inlineMode) {
                         host.reloadCurrentChapter()
-                        host.dismissMarkingSheet()
+                        dismissSheet()
                     }
                 }.onFailure { error ->
                     host.showToast(error.localizedMessage ?: context.getString(R.string.error))
@@ -159,7 +206,7 @@ class MarkingDelegate(
                 saveMarkingUseCase.delete(editing.id)
             }.onSuccess {
                 host.reloadCurrentChapter()
-                host.dismissMarkingSheet()
+                dismissSheet()
             }.onFailure { error ->
                 host.showToast(error.localizedMessage ?: context.getString(R.string.error))
             }

+ 46 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadAiDelegate.kt

@@ -19,6 +19,8 @@ import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.model.ReadBook
+import io.legado.app.model.translation.TranslationChapterKey
+import io.legado.app.model.translation.TranslationChapterStatus
 import io.legado.app.model.translation.TranslationManager
 import io.legado.app.utils.MD5Utils
 import kotlinx.collections.immutable.toImmutableList
@@ -29,6 +31,7 @@ import kotlinx.coroutines.Job
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.takeWhile
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
@@ -76,6 +79,11 @@ class ReadAiDelegate(
         suspend fun findChapter(bookUrl: String, chapterIndex: Int): BookChapter?
 
         suspend fun listChapters(bookUrl: String): List<BookChapter>
+
+        /** 翻译任务状态落在阅读态快照里,由阅读页统一渲染。 */
+        val translationStatus: TranslationChapterStatus
+
+        fun updateTranslationStatus(status: TranslationChapterStatus)
     }
 
     private val _uiState = MutableStateFlow(ReadAiUiState())
@@ -86,6 +94,44 @@ class ReadAiDelegate(
     private var aiTextRewriteJob: Job? = null
     private var pendingAiTextCleanRequest: PendingAiTextCleanRequest? = null
     private var pendingAiTextRewriteRequest: PendingAiTextRewriteRequest? = null
+    private var translationStatusJob: Job? = null
+    private var observedTranslationKey: TranslationChapterKey? = null
+
+    /**
+     * 观察当前章节的翻译任务状态,并把变化推给阅读页。
+     *
+     * 返回当前状态,供阅读态快照同步取用;同一章节已在观察中时直接复用,不重建订阅。
+     */
+    fun observeChapterTranslation(book: Book?, chapterIndex: Int): TranslationChapterStatus {
+        val key = book
+            ?.takeIf { it.getTranslationMode() }
+            ?.let { TranslationChapterKey(it.bookUrl, chapterIndex) }
+        if (key == observedTranslationKey && translationStatusJob?.isActive == true) {
+            return host.translationStatus
+        }
+
+        translationStatusJob?.cancel()
+        val taskFlow = key?.let {
+            TranslationManager.getChapterTaskStateFlow(it.bookUrl, it.chapterIndex)
+        }
+        if (taskFlow == null) {
+            observedTranslationKey = null
+            return TranslationChapterStatus.Idle
+        }
+
+        observedTranslationKey = key
+        translationStatusJob = scope.launch {
+            taskFlow.takeWhile { taskState ->
+                if (observedTranslationKey == taskState.key) {
+                    host.updateTranslationStatus(taskState.status)
+                }
+                taskState.status == TranslationChapterStatus.Translating ||
+                        taskState.status == TranslationChapterStatus.Thinking
+            }.collect {}
+            if (observedTranslationKey == key) observedTranslationKey = null
+        }
+        return taskFlow.value.status
+    }
 
     /**
      * 关闭 sheet 时的收尾:取消在途任务并清空对应子状态。

+ 293 - 212
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -360,15 +360,33 @@ class ReadBookController(
     private var composeVisibleBodyTextPositionProvider: (() -> ReaderVisibleTextPosition?)? = null
     private var composeImageClickAt = 0L
     private var composeImageDoubleClick = false
-    private var directReaderLayoutJob: Job? = null
-    private var directReaderAdjacentLayoutJob: Job? = null
     private var directReaderLayoutKey: String? = null
 
     /** 排版环境不含当前章身份;用它区分普通换章和主题/规则引起的整窗重排。 */
     private var directReaderPaginationEnvironmentKey: String? = null
 
-    /** Only chapter-window changes may reuse adjacent pages; a reflow invalidates their geometry. */
-    private var directReaderMayReuseAdjacentPages = false
+    /**
+     * 章 → 正在跑的分页任务(连同它依据的章节身份)。
+     *
+     * 旧 View 的 `TextChapterLayout` 与章节一一对应,且排版任务跨章存活:`ReadBook.loadContent`
+     * 只对 `else`(窗口外的章)调 `cancelLayout()`,当前章与相邻章的排版从不因为"当前章换成了
+     * 别人"而重启,页一旦成型就留在 `TextChapter.textPages` 里。这里用同样的粒度登记,
+     * 使切到邻章时它已经排好的页直接接上,而不是整批作废、再从第一个字符重排一次。
+     *
+     * 身份用于识别"同一章换了一份内容"(重新加载、源站更新):此时旧任务排出的页已经作废,
+     * 必须按旧 View `publishTextChapter`(替换章节前先 `cancelLayout()`)的做法重排。
+     *
+     * 注意分页是顺序累积的(要得到第 N 页必须从第 0 个字符排起),所以"从断点续排"省不下计算,
+     * 不打断任务才是唯一有效的对齐方式。
+     */
+    private val readerChapterPaginationJobs =
+        mutableMapOf<Int, ReaderChapterPaginationTask>()
+
+    /** 一章的分页任务:结果只在 [identity] 与当前内容一致、且排版环境未变时才有意义。 */
+    private class ReaderChapterPaginationTask(
+        val identity: LegacyReaderChapterLayoutIdentity,
+        val job: Job,
+    )
     private var directReaderPages = emptyList<io.legado.app.feature.reader.core.model.ReaderPage>()
 
     /**
@@ -433,10 +451,8 @@ class ReadBookController(
 
     fun clearTts() {
         ReadBook.unregisterRender(this)
-        directReaderLayoutJob?.cancel()
-        directReaderLayoutJob = null
-        directReaderAdjacentLayoutJob?.cancel()
-        directReaderAdjacentLayoutJob = null
+        readerChapterPaginationJobs.values.forEach { it.job.cancel() }
+        readerChapterPaginationJobs.clear()
         readerImageLoads.values.forEach { it.cancel() }
         readerImageLoads.clear()
         readerImageCache.evictAll()
@@ -656,8 +672,7 @@ class ReadBookController(
         var viewportPaginationStyle: ReaderAndroidPaginationStyle? = null
         if (viewportChanged) {
             // Width, height, density, or content insets participate in every page's geometry.
-            // Do not bridge this reflow with an adjacent page from the previous viewport.
-            directReaderMayReuseAdjacentPages = false
+            // 它们都在 layout key 的排版环境部分里,环境变化会在分页调度处作废全部章任务。
             updateComposeReaderBackground(widthPx, heightPx)
             val style = LegacyReaderPaginationStyleFactory.create()
             viewportPaginationStyle = style
@@ -698,23 +713,23 @@ class ReadBookController(
         // The legacy View keeps its completed page visible while an already loaded adjacent
         // chapter is laying out. The Canvas paginator creates a chapter's page list as one
         // background batch, so clearing the window here turned that local/cached hand-off into
-        // a misleading “loading” screen. Only clear when the target chapter content itself is
-        // absent; a real remote/content miss still uses the normal loading placeholder.
+        // a misleading “loading” screen — which is why the window is only reset when the target
+        // chapter's own content has not arrived. Even then it must not end up empty: the View
+        // reader's `TextPageFactory.curPage` always yields a fallback page
+        // (`currentChapter.getPage(pageIndex) ?: TextPage(title = it.title).format()`), so rapid
+        // chapter turns should show a loading page rather than a blank surface.
         if (!currentInputIsReady &&
             _readerPageWindow.value.current?.id?.chapterIndex != ReadBook.durChapterIndex
         ) {
-            updateReaderPageWindow(ReaderPageWindow())
+            publishLoadingReaderWindow()
         }
     }
 
     private fun rebuildDirectReaderPages() {
-        directReaderLayoutJob?.cancel()
-        directReaderLayoutJob = null
-        directReaderAdjacentLayoutJob?.cancel()
-        directReaderAdjacentLayoutJob = null
+        readerChapterPaginationJobs.values.forEach { it.job.cancel() }
+        readerChapterPaginationJobs.clear()
         directReaderLayoutKey = null
         directReaderPaginationEnvironmentKey = null
-        directReaderMayReuseAdjacentPages = false
         directReaderStreamGeneration += 1
         clearStreamedReaderChapters()
         ReadBook.clearReaderPagination()
@@ -1117,19 +1132,7 @@ class ReadBookController(
         }
         // 首屏只依赖当前章;相邻章异步到达不应重启当前章测量。环境身份则单独保存:
         // 普通换章可复用相邻页,主题/高亮规则/排版参数变化必须废弃整窗旧页。
-        val chapterLayoutIdentity = LegacyReaderChapterLayoutIdentity(
-            chapterIndex = chapter.chapter.index,
-            chapterUrl = chapter.chapter.url,
-            chapterBaseUrl = chapter.chapter.baseUrl,
-            displayTitle = chapter.displayTitle,
-            isVolume = chapter.chapter.isVolume,
-            contentHash = chapter.contentHash,
-            contentProcessesHash = chapter.contentProcessesHash,
-            sourceHash = chapter.sourceHash,
-            bookUrl = chapter.book.bookUrl,
-            bookOrigin = chapter.book.origin,
-            bookSourceHash = chapter.bookSourceHash,
-        )
+        val chapterLayoutIdentity = chapter.layoutIdentity()
         val paginationEnvironmentKey = buildString {
             append('|').append(width).append('x').append(height)
             append('|').append(contentPadding.left).append(',').append(contentPadding.top)
@@ -1178,8 +1181,8 @@ class ReadBookController(
                     directReaderPageIndex = index
                     publishDirectReaderWindow(index)
                 }
-            scheduleAdjacentReaderPagination(
-                key, chapter, chapters, width, height, contentPadding, resolvedPaginationStyle
+            scheduleAdjacentReaderChapterPagination(
+                chapters, chapter, width, height, contentPadding, resolvedPaginationStyle
             )
             return true
         }
@@ -1201,235 +1204,313 @@ class ReadBookController(
         if (directReaderLayoutKey != key) {
             val environmentChanged = directReaderPaginationEnvironmentKey != null &&
                     directReaderPaginationEnvironmentKey != paginationEnvironmentKey
-            if (environmentChanged) {
-                // 当前章先提交、相邻章后台预排的两阶段策略只适用于同一排版环境。
-                // 主题/高亮规则变化后继续保留相邻旧页,会在翻到该章时先显示旧几何再跳变。
-                directReaderMayReuseAdjacentPages = false
-            }
             val paginationGeneration = ReadBook.readerPaginationGeneration
-            directReaderLayoutJob?.cancel()
-            directReaderAdjacentLayoutJob?.cancel()
-            directReaderAdjacentLayoutJob = null
             directReaderLayoutKey = key
             directReaderPaginationEnvironmentKey = paginationEnvironmentKey
             updateReaderPaginationError(null)
             ReadBook.clearReaderPagination()
-            // 新的排版环境:作废上一轮还挂在页表里的部分页,并让在飞的流出回调失效。
-            directReaderStreamGeneration += 1
-            clearStreamedReaderChapters()
-            val streamGeneration = directReaderStreamGeneration
-            directReaderLayoutJob = activity.lifecycleScope.launch(IO) {
-                val highlightRules = HighlightRuleRepository()
-                    .loadEnabled(ReadBookConfig.durConfig.name)
-                suspend fun paginate(candidate: ReaderChapterInput, phase: String) =
-                    candidate.chapter.index to ReaderPerfTrace.suspendSection("pagination.$phase") {
-                        withContext(Main) {
-                            beginStreamedReaderChapter(candidate.chapter.index, streamGeneration)
-                        }
-                        paginateLegacyReaderChapterSafely {
-                        LegacyReaderChapterPaginator.paginate(
-                                book = candidate.book,
-                                bookSource = candidate.bookSource,
-                                chapter = candidate.chapter,
-                                displayTitle = candidate.displayTitle,
-                                content = candidate.content,
-                                source = candidate.source,
-                                revision = 31L * key.hashCode() + candidate.chapter.index,
-                                viewportWidthPx = width,
-                                viewportHeightPx = height,
-                                contentPaddingLeftPx = contentPadding.left,
-                                contentPaddingTopPx = contentPadding.top,
-                                contentPaddingRightPx = contentPadding.right,
-                                contentPaddingBottomPx = contentPadding.bottom,
-                                paginationStyle = resolvedPaginationStyle,
-                                highlightRules = highlightRules,
-                            onPage = { page ->
-                                onReaderPageStreamed(
-                                    streamGeneration,
-                                    candidate.chapter.index,
-                                    page
-                                )
-                            },
-                            )
-                        }
-                    }
-
-                val currentResult = paginate(chapter, "current")
-                val currentBatch = collectLegacyReaderPaginationBatch(
-                    currentChapterIndex = chapter.chapter.index,
-                    results = listOf(currentResult),
+            if (environmentChanged) {
+                // 排版环境变了:几何全部失效,作废所有章的排版任务与在飞的逐页流出
+                // (对照旧 View:`TextChapter.isLayoutSizeMatch()` 失败后整章重建)。
+                readerChapterPaginationJobs.values.forEach { it.job.cancel() }
+                readerChapterPaginationJobs.clear()
+                directReaderStreamGeneration += 1
+                // 旧页是按旧几何排的,必须整窗清掉:留着它们会让下面的调度以为"当前章已经有页"
+                // 而不再重排,页面会一直停在旧字号/旧主题的几何上。清空后窗口由加载占位页承接,
+                // 各章的新页随各自的批次填回(旧 View 重建 TextChapter 后同样是整章重排)。
+                directReaderPages = emptyList()
+                directReaderPageContexts.clear()
+            }
+            // 换章接力:新当前章若在旧 key 下已经排出过部分页(上一轮邻章预排的产物),保留它们。
+            // 切章后画布继续显示"已排好的几页 + 尾部加载中",而不是先把它们摘掉退化成占位页、
+            // 再等新批次从头排完才重新出现。旧 View 就是增量语义:`TextChapterLayout
+            // .onPageCompleted` 把成型的页 `textPages.add(...)`,`TextChapter.isLayoutRunning`
+            // 只区分"还在排"与"排废了",前者继续用已排出的页。
+            clearStreamedReaderChapters(
+                keepChapterIndex = ReaderPartialPagePolicy.retainedStreamedChapter(
+                    chapterIndex = chapter.chapter.index,
+                    environmentChanged = environmentChanged,
+                    streamedChapters = directReaderStreamedPages.keys,
                 )
-                withContext(Main) {
-                    applyDirectReaderPaginationBatch(
-                        key = key,
-                        currentChapter = chapter,
-                        chapters = chapters,
-                        batch = currentBatch,
-                        paginationGeneration = paginationGeneration,
-                        layoutComplete = chapters.size == 1 || !currentBatch.hasCurrentChapter,
-                    )
-                    if (currentBatch.hasCurrentChapter) {
-                        scheduleAdjacentReaderPagination(
-                            key,
-                            chapter,
-                            chapters,
-                            width,
-                            height,
-                            contentPadding,
-                            resolvedPaginationStyle
-                        )
-                    }
-                }
-            }
+            )
+            // 换章不重启窗口内的章:新当前章往往正是上一轮作为邻章启动的那个任务,
+            // 旧 View 的 `ReadBook.loadContent` 同样不会因为当前章切换而取消它。
+            ensureReaderChapterPagination(
+                paginationGeneration = paginationGeneration,
+                chapters = chapters,
+                current = chapter,
+                width = width,
+                height = height,
+                padding = contentPadding,
+                style = resolvedPaginationStyle,
+            )
         }
         return false
     }
 
-    private fun scheduleAdjacentReaderPagination(
-        key: String,
+    /**
+     * 保证窗口(`durChapterIndex ± 1`)里的章各自有一个分页任务。
+     *
+     * 与旧 View 对齐:`ReadBook.loadContent` 只对窗口外的章 `cancelLayout()`,同一章的任务不会
+     * 因为"当前章换成了别人"而重启。于是从章 N 翻到 N+1 时,N+1 上一轮作为邻章排好的页直接
+     * 接上;往回翻时 N 的任务也还在,不必重排。
+     *
+     * 当前章优先:当前章还没有页时就先跑它,等它落地再由 [publishReaderPageWindow] 补邻章
+     * (沿用原来的两阶段策略,避免三章同时开跑把首屏排版挤慢)。
+     */
+    private fun ensureReaderChapterPagination(
+        paginationGeneration: Long,
+        chapters: List<ReaderChapterInput>,
         current: ReaderChapterInput,
+        width: Int,
+        height: Int,
+        padding: ReaderPadding,
+        style: ReaderAndroidPaginationStyle,
+    ) {
+        recycleReaderChapterPaginationJobs()
+        val currentIndex = current.chapter.index
+        // 该章已经有任务在跑(很可能正是上一轮作为邻章启动的那个):不打断,等它收尾。
+        // 内容换了一份时身份不同,会落到下面按新内容重排。
+        if (isReaderChapterPaginationRunning(current)) return
+        val hasShapedPages = directReaderPages.any {
+            it.id.chapterIndex == currentIndex && !it.isPlaceholder
+        }
+        // 页表里还挂着"排了一半"的残留(任务已被取消、部分页却没清干净)时同样要重排,
+        // 否则这一章会停在半成品上、尾部一直挂着"加载中"。
+        val stalledPartialPages = currentIndex in directReaderStreamingChapters
+        if (!hasShapedPages || stalledPartialPages) {
+            startReaderChapterPagination(
+                paginationGeneration, current, width, height, padding, style,
+            )
+            return
+        }
+        // 当前章已经有整批页(例如刚翻回来):直接补窗口里的邻章。
+        scheduleAdjacentReaderChapterPagination(chapters, current, width, height, padding, style)
+    }
+
+    /** 回收已经离开窗口的章任务:对照旧 `loadContent` 的 `else -> textChapter.cancelLayout()`。 */
+    private fun recycleReaderChapterPaginationJobs() {
+        val visible = ReadBook.durChapterIndex.let { it - 1..it + 1 }
+        readerChapterPaginationJobs.keys
+            .filter { it !in visible }
+            .forEach { index ->
+                readerChapterPaginationJobs.remove(index)?.job?.cancel()
+                // 任务已经作废,它流出一半、还挂在页表里的页也一并撤掉
+                // (旧 View 对被顶出窗口的章同样先 `cancelLayout()`)。
+                clearStreamedReaderChapter(index)
+            }
+    }
+
+    /** 撤掉某一章"已流出但整章还没提交"的部分页与流出标记。 */
+    private fun clearStreamedReaderChapter(chapterIndex: Int) {
+        val dropping = directReaderStreamedPages.remove(chapterIndex) ?: return
+        val partialIds = dropping.mapTo(mutableSetOf()) { it.id }
+        directReaderPages = directReaderPages.filterNot { it.id in partialIds }
+        directReaderStreamingChapters.remove(chapterIndex)
+    }
+
+    /**
+     * 该章是否已经有一个仍然有效的分页任务:任务在跑,且所依据的章节内容没有换过。
+     *
+     * 内容换了(重新加载 / 源站更新)就必须重排,否则会把按旧正文排出的页当成本章的页。
+     */
+    private fun isReaderChapterPaginationRunning(candidate: ReaderChapterInput): Boolean {
+        val task = readerChapterPaginationJobs[candidate.chapter.index] ?: return false
+        return task.job.isActive && task.identity == candidate.layoutIdentity()
+    }
+
+    /** 章节身份:正文哈希 + 呈现参数,用来判断"这一章的排版依据是否变了"。 */
+    private fun ReaderChapterInput.layoutIdentity() = LegacyReaderChapterLayoutIdentity(
+        chapterIndex = chapter.index,
+        chapterUrl = chapter.url,
+        chapterBaseUrl = chapter.baseUrl,
+        displayTitle = displayTitle,
+        isVolume = chapter.isVolume,
+        contentHash = contentHash,
+        contentProcessesHash = contentProcessesHash,
+        sourceHash = sourceHash,
+        bookUrl = book.bookUrl,
+        bookOrigin = book.origin,
+        bookSourceHash = bookSourceHash,
+    )
+
+    /**
+     * 预热窗口里还没有页的邻章。
+     *
+     * 只负责把缺失的章补上:已经有页、已经有任务在跑、或已经不在窗口里的章都跳过,
+     * 所以它既可以在当前章落地后被调用,也可以在给出暖页后立刻调用,不会重复排版。
+     */
+    private fun scheduleAdjacentReaderChapterPagination(
         chapters: List<ReaderChapterInput>,
+        current: ReaderChapterInput,
         width: Int,
         height: Int,
         padding: ReaderPadding,
         style: ReaderAndroidPaginationStyle,
     ) {
-        if (directReaderLayoutKey != key) return
+        val visible = ReadBook.durChapterIndex.let { it - 1..it + 1 }
         val missing = chapters.filter { candidate ->
-            candidate.chapter.index != current.chapter.index &&
-                    directReaderPages.none { it.id.chapterIndex == candidate.chapter.index && !it.isPlaceholder }
+            val index = candidate.chapter.index
+            index != current.chapter.index &&
+                    index in visible &&
+                    directReaderPages.none { it.id.chapterIndex == index && !it.isPlaceholder } &&
+                    !isReaderChapterPaginationRunning(candidate)
         }
         if (missing.isEmpty()) return
-        directReaderAdjacentLayoutJob?.cancel()
-        // 上一轮相邻章的部分页随旧任务一起作废(当前章自己的流出态保留)。
-        clearStreamedReaderChapters(keepChapterIndex = current.chapter.index)
+        val paginationGeneration = ReadBook.readerPaginationGeneration
+        missing.forEach { candidate ->
+            startReaderChapterPagination(
+                paginationGeneration, candidate, width, height, padding, style,
+            )
+        }
+    }
+
+    /**
+     * 启动一章的分页任务。收尾交给 [publishReaderPageWindow]:它会在当前章已有页时走
+     * "窗口预热"分支补邻章,与旧 View `loadContent` 的递归装载同一形状。
+     */
+    private fun startReaderChapterPagination(
+        paginationGeneration: Long,
+        candidate: ReaderChapterInput,
+        width: Int,
+        height: Int,
+        padding: ReaderPadding,
+        style: ReaderAndroidPaginationStyle,
+    ) {
+        val chapterIndex = candidate.chapter.index
+        val environmentKey = directReaderPaginationEnvironmentKey
         val streamGeneration = directReaderStreamGeneration
-        val generation = ReadBook.readerPaginationGeneration
-        directReaderAdjacentLayoutJob = activity.lifecycleScope.launch(IO) {
-            val rules = HighlightRuleRepository().loadEnabled(ReadBookConfig.durConfig.name)
-            val results = missing.map { candidate ->
-                candidate.chapter.index to ReaderPerfTrace.suspendSection("pagination.adjacent") {
-                    withContext(Main) {
-                        beginStreamedReaderChapter(candidate.chapter.index, streamGeneration)
-                    }
-                    paginateLegacyReaderChapterSafely {
-                        LegacyReaderChapterPaginator.paginate(
-                            book = candidate.book,
-                            bookSource = candidate.bookSource,
-                            chapter = candidate.chapter,
-                            displayTitle = candidate.displayTitle,
-                            content = candidate.content,
-                            source = candidate.source,
-                            revision = 31L * key.hashCode() + candidate.chapter.index,
-                            viewportWidthPx = width,
-                            viewportHeightPx = height,
-                            contentPaddingLeftPx = padding.left,
-                            contentPaddingTopPx = padding.top,
-                            contentPaddingRightPx = padding.right,
-                            contentPaddingBottomPx = padding.bottom,
-                            paginationStyle = style,
-                            highlightRules = rules,
-                            onPage = { page ->
-                                onReaderPageStreamed(
-                                    streamGeneration,
-                                    candidate.chapter.index,
-                                    page
-                                )
-                            },
-                        )
-                    }
+        val identity = candidate.layoutIdentity()
+        readerChapterPaginationJobs.remove(chapterIndex)?.job?.cancel()
+        // 旧任务作废:它流出一半的页可能是按旧正文/旧几何排的,必须先撤掉,否则新任务排出的
+        // 同 id 页会被"已经存在"挡掉,页表里反而留下旧内容的那几页。
+        clearStreamedReaderChapter(chapterIndex)
+        // 先登记、后启动:任务收尾要判断"表里的还是不是自己",若先启动,快速跑完的任务会在
+        // 主线程登记之前就把自己摘掉,随后又被登记回去,留下一个永不清理的残留条目。
+        val job = activity.lifecycleScope.launch(IO, start = CoroutineStart.LAZY) {
+            val highlightRules =
+                HighlightRuleRepository().loadEnabled(ReadBookConfig.durConfig.name)
+            withContext(Main) {
+                beginStreamedReaderChapter(chapterIndex, streamGeneration)
+            }
+            val result = ReaderPerfTrace.suspendSection("pagination.chapter") {
+                paginateLegacyReaderChapterSafely {
+                    LegacyReaderChapterPaginator.paginate(
+                        book = candidate.book,
+                        bookSource = candidate.bookSource,
+                        chapter = candidate.chapter,
+                        displayTitle = candidate.displayTitle,
+                        content = candidate.content,
+                        source = candidate.source,
+                        // 几何只由排版环境决定(旧 View 的 `isLayoutSizeMatch` 同样只比尺寸),
+                        // 所以修订号取环境身份,而不是含章身份的整串 layout key。
+                        revision = 31L * (environmentKey?.hashCode() ?: 0) + chapterIndex,
+                        viewportWidthPx = width,
+                        viewportHeightPx = height,
+                        contentPaddingLeftPx = padding.left,
+                        contentPaddingTopPx = padding.top,
+                        contentPaddingRightPx = padding.right,
+                        contentPaddingBottomPx = padding.bottom,
+                        paginationStyle = style,
+                        highlightRules = highlightRules,
+                        onPage = { page ->
+                            onReaderPageStreamed(streamGeneration, chapterIndex, page)
+                        },
+                    )
                 }
             }
             withContext(Main) {
                 applyDirectReaderPaginationBatch(
-                    key, current, chapters,
-                    collectLegacyReaderPaginationBatch(current.chapter.index, results),
-                    generation, layoutComplete = true,
+                    environmentKey = environmentKey,
+                    chapter = candidate,
+                    batch = collectLegacyReaderPaginationBatch(
+                        chapterIndex,
+                        listOf(chapterIndex to result),
+                    ),
+                    paginationGeneration = paginationGeneration,
                 )
-                if (directReaderAdjacentLayoutJob === coroutineContext[Job]) {
-                    directReaderAdjacentLayoutJob = null
+                if (readerChapterPaginationJobs[chapterIndex]?.job === coroutineContext[Job]) {
+                    readerChapterPaginationJobs.remove(chapterIndex)
                 }
+                publishReaderPageWindow()
             }
         }
+        readerChapterPaginationJobs[chapterIndex] = ReaderChapterPaginationTask(identity, job)
+        job.start()
     }
 
     private fun applyDirectReaderPaginationBatch(
-        key: String,
-        currentChapter: ReaderChapterInput,
-        chapters: List<ReaderChapterInput>,
+        environmentKey: String?,
+        chapter: ReaderChapterInput,
         batch: LegacyReaderPaginationBatch,
         paginationGeneration: Long,
-        layoutComplete: Boolean,
     ) {
         ReaderPerfTrace.section("pagination.commit") {
-            if (directReaderLayoutKey != key) return@section
-        if (layoutComplete) directReaderLayoutJob = null
-        batch.unsupportedChapters.forEach { (chapterIndex, reason) ->
-            AppLog.putDebug("Compose reader pagination unsupported: chapter=$chapterIndex reason=$reason")
-        }
-        updateReaderPaginationError(batch.failureReasonFor(currentChapter.chapter.index))
-        val previousPages = directReaderPages.associateBy { it.id }
-        val replacementChapterIndexes = batch.pages.mapTo(mutableSetOf()) { it.id.chapterIndex }
-            val retainedPages = if (directReaderMayReuseAdjacentPages ||
-                currentChapter.chapter.index !in replacementChapterIndexes
-            ) {
-            directReaderPages.filterNot { it.id.chapterIndex in replacementChapterIndexes }
-        } else {
-            emptyList()
-        }
+            // 排版环境已经换掉:这一批的几何不再有效(旧 View 的 `isLayoutSizeMatch` 失败之后
+            // 同样会丢弃旧排版结果)。
+            if (directReaderPaginationEnvironmentKey != environmentKey) return@section
+            val chapterIndex = chapter.chapter.index
+            // 该章已经离开窗口(连续翻页越过了它):与旧 View 窗口外的 `cancelLayout()` 一致地丢弃。
+            if (chapterIndex !in ReadBook.durChapterIndex - 1..ReadBook.durChapterIndex + 1) {
+                return@section
+            }
+            batch.unsupportedChapters.forEach { (index, reason) ->
+                AppLog.putDebug("Compose reader pagination unsupported: chapter=$index reason=$reason")
+            }
+            updateReaderPaginationError(batch.failureReasonFor(chapterIndex))
+            val previousPages = directReaderPages.associateBy { it.id }
+            // 每个任务只负责自己那一章:其它章的页原样保留。旧 View 各章的 `TextChapter.textPages`
+            // 也是各自独立累积的,一章重排不会牵动别章的页,因此这里不再需要"整窗重建"分支。
+            val replacementChapterIndexes = batch.pages.mapTo(mutableSetOf()) { it.id.chapterIndex }
+            val retainedPages =
+                directReaderPages.filterNot { it.id.chapterIndex in replacementChapterIndexes }
             val replacementPages = batch.pages.map { page ->
-            previousPages[page.id]?.takeIf(page::hasSameGeometryAs)?.let { previous ->
-                page.copy(layoutRevision = previous.layoutRevision)
-            } ?: page
-            }
-        // Pagination deliberately publishes the current chapter before the adjacent chapters.
-        // Keep an already shaped adjacent page during that first batch: replacing the whole
-        // window here made every chapter turn recreate a "loading" placeholder even when the
-        // target page was still valid in memory.
-        directReaderPages = (retainedPages + replacementPages)
-            .sortedWith(compareBy({ it.id.chapterIndex }, { it.id.pageIndex }))
-        if (layoutComplete) {
-            // A complete batch establishes one shared pagination environment. Subsequent
-            // chapter-window changes may retain its already-shaped adjacent pages.
-            directReaderMayReuseAdjacentPages = true
-        }
+                previousPages[page.id]?.takeIf(page::hasSameGeometryAs)?.let { previous ->
+                    page.copy(layoutRevision = previous.layoutRevision)
+                } ?: page
+            }
+            directReaderPages = (retainedPages + replacementPages)
+                .sortedWith(compareBy({ it.id.chapterIndex }, { it.id.pageIndex }))
             // 批次提交即"这一章排完了"(旧 `TextChapter.isCompleted = true`):撤掉流出态与尾部承接页。
             directReaderStreamingChapters.removeAll(replacementChapterIndexes)
             directReaderStreamedPages.keys.removeAll(replacementChapterIndexes)
-        // 重排可能改变元素位置与页 endPosition,页上下文缓存全部失效。
-        directReaderPageContexts.clear()
-        directReaderChapterPageCounts = directReaderPages.groupingBy { it.id.chapterIndex }.eachCount()
+            // 重排可能改变元素位置与页 endPosition,页上下文缓存全部失效。
+            directReaderPageContexts.clear()
+            directReaderChapterPageCounts =
+                directReaderPages.groupingBy { it.id.chapterIndex }.eachCount()
             directReaderPageIndex = directReaderPages.takeIf { it.isNotEmpty() }?.let { pages ->
                 // 当前章在批次结果中缺失时 locate 会折叠成 0(全书首页):保留原下标,
                 // 由下面的 publishDirectReaderWindow 重新收敛到合法范围,避免跳回书首。
                 ReaderPageNavigator.locateOrNull(
                     pages,
-                    currentChapter.chapter.index,
+                    chapterIndex,
                     ReadBook.durChapterPos
                 )
                     ?: directReaderPageIndex?.coerceIn(pages.indices)
                     ?: 0
-        }
-        ReadBook.publishReaderPagination(
-            directReaderPages.groupBy { it.id.chapterIndex }.mapNotNull { (chapterIndex, pages) ->
-                chapters.firstOrNull { it.chapter.index == chapterIndex } ?: return@mapNotNull null
-                // 占位页不是分页结果:把它当成该章的分页快照会以 pageCount=1 污染整书页数
-                // 估算(wholeBookPageCoordinator.correctChapter 拿 realPageCount 校正)。
-                // 邻章正文已缓存但还没排版时也会预置占位页,必须显式排除。
-                if (pages.all { it.isPlaceholder }) return@mapNotNull null
-                val contentEnd = ReaderPageNavigator.pageContext(
-                    pages,
-                    pages.lastIndex,
-                )?.endPosition ?: return@mapNotNull null
-                ReaderChapterPaginationSnapshot(
-                    chapterIndex = chapterIndex,
-                    pageStarts = pages.map(ReaderPageNavigator::pageStart),
-                    contentEnd = contentEnd,
-                    generation = paginationGeneration,
-                )
             }
-        )
-        directReaderPageIndex?.let(::publishDirectReaderWindow)
+            val snapshotRange = ReadBook.durChapterIndex.let { it - 1..it + 1 }
+            ReadBook.publishReaderPagination(
+                directReaderPages.groupBy { it.id.chapterIndex }.mapNotNull { (index, pages) ->
+                    // 与旧 `chapters` 窗口等价:只有窗口内的章才值得发快照。
+                    if (index !in snapshotRange) return@mapNotNull null
+                    // 占位页不是分页结果:把它当成该章的分页快照会以 pageCount=1 污染整书页数
+                    // 估算(wholeBookPageCoordinator.correctChapter 拿 realPageCount 校正)。
+                    // 邻章正文已缓存但还没排版时也会预置占位页,必须显式排除。
+                    if (pages.all { it.isPlaceholder }) return@mapNotNull null
+                    val contentEnd = ReaderPageNavigator.pageContext(
+                        pages,
+                        pages.lastIndex,
+                    )?.endPosition ?: return@mapNotNull null
+                    ReaderChapterPaginationSnapshot(
+                        chapterIndex = index,
+                        pageStarts = pages.map(ReaderPageNavigator::pageStart),
+                        contentEnd = contentEnd,
+                        generation = paginationGeneration,
+                    )
+                }
+            )
+            directReaderPageIndex?.let(::publishDirectReaderWindow)
         }
     }
 

+ 20 - 82
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -75,9 +75,7 @@ import io.legado.app.model.activeReadAloudProgress
 import io.legado.app.model.analyzeRule.AnalyzeRule
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
-import io.legado.app.model.translation.TranslationChapterKey
 import io.legado.app.model.translation.TranslationChapterStatus
-import io.legado.app.model.translation.TranslationManager
 import io.legado.app.service.BaseReadAloudService
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetTab
 import io.legado.app.ui.book.searchContent.SearchResult
@@ -102,7 +100,6 @@ import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.callbackFlow
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.takeWhile
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
@@ -226,12 +223,15 @@ class ReadBookViewModel(
         highlightRuleRepository = highlightRuleRepository,
         saveMarkingUseCase = saveMarkingUseCase,
         host = object : MarkingDelegate.Host {
-            override fun reloadCurrentChapter() {
-                contentProcessDelegate.reloadCurrentChapterPreservingSnapshot()
+            override val activeSheet: ReadBookSheet?
+                get() = _uiState.value.activeSheet
+
+            override fun setActiveSheet(sheet: ReadBookSheet?) {
+                _uiState.update { it.copy(activeSheet = sheet) }
             }
 
-            override fun dismissMarkingSheet() {
-                restoreMarkingReturnSheet()
+            override fun reloadCurrentChapter() {
+                contentProcessDelegate.reloadCurrentChapterPreservingSnapshot()
             }
 
             override fun showToast(message: String) {
@@ -241,17 +241,6 @@ class ReadBookViewModel(
     ) }
 
     val markingState get() = markingDelegate.uiState
-    /**
-     * 划线笔记编辑可能从目录 Sheet 进入:保存/删除/取消后应回到原 sheet(目录),
-     * 而不是被丢回阅读页。从划词菜单新建时无原 sheet,回 null。
-     */
-    private var markingReturnSheet: ReadBookSheet? = null
-
-    private fun restoreMarkingReturnSheet() {
-        val returnSheet = markingReturnSheet
-        markingReturnSheet = null
-        _uiState.update { it.copy(activeSheet = returnSheet) }
-    }
 
     // --- 跳转校验域:书签/笔记跳转前比对源与章节标题 ---
 
@@ -306,6 +295,13 @@ class ReadBookViewModel(
 
         override suspend fun listChapters(bookUrl: String): List<BookChapter> =
             bookRepository.getChapters(bookUrl)
+
+        override val translationStatus: TranslationChapterStatus
+            get() = _uiState.value.translationStatus
+
+        override fun updateTranslationStatus(status: TranslationChapterStatus) {
+            _uiState.update { it.copy(translationStatus = status) }
+        }
     }
 
     private val aiDelegate by lazy { ReadAiDelegate(
@@ -598,8 +594,6 @@ class ReadBookViewModel(
     val readPreferences = _readPreferences.asStateFlow()
 
     private var pendingBooksDirReloadChapterList: Boolean = false
-    private var translationStatusJob: Job? = null
-    private var observedTranslationKey: TranslationChapterKey? = null
     private var deferredReaderFeaturesStarted = false
 
     val isInitFinish: Boolean get() = _uiState.value.isInitFinish
@@ -1453,22 +1447,11 @@ class ReadBookViewModel(
 
             is ReadBookIntent.TextActionBookmark -> bookmarkDelegate.openEditor(intent.bookmark)
 
-            is ReadBookIntent.OpenMarking -> {
-                // 从划词菜单新建:无原 sheet 可回
-                markingReturnSheet = null
-                markingDelegate.open(intent.selection)
-                _uiState.update { it.copy(activeSheet = ReadBookSheet.Marking) }
-            }
+            is ReadBookIntent.OpenMarking -> markingDelegate.openFromMenu(intent.selection)
 
-            is ReadBookIntent.OpenQuickMarking -> {
-                markingReturnSheet = null
-                markingDelegate.open(intent.selection, inlineMode = true)
-            }
+            is ReadBookIntent.OpenQuickMarking -> markingDelegate.openQuick(intent.selection)
 
-            is ReadBookIntent.OpenQuickMarkingEdit -> {
-                markingReturnSheet = null
-                markingDelegate.openForEdit(intent.id, inlineMode = true)
-            }
+            is ReadBookIntent.OpenQuickMarkingEdit -> markingDelegate.openQuickForEdit(intent.id)
 
             is ReadBookIntent.ApplyQuickMarking -> {
                 viewModelScope.launch {
@@ -1484,17 +1467,9 @@ class ReadBookViewModel(
 
             ReadBookIntent.DismissQuickMarking -> markingDelegate.closeInlineSession()
 
-            is ReadBookIntent.EditMarking -> {
-                // 从目录 Sheet 进入:记住原 sheet,保存/删除/取消后返回
-                markingReturnSheet = _uiState.value.activeSheet
-                markingDelegate.openForEdit(intent.id)
-                _uiState.update { it.copy(activeSheet = ReadBookSheet.Marking) }
-            }
+            is ReadBookIntent.EditMarking -> markingDelegate.openForEditFromSheet(intent.id)
 
-            is ReadBookIntent.DismissMarking -> {
-                markingDelegate.onSheetDismissed()
-                restoreMarkingReturnSheet()
-            }
+            ReadBookIntent.DismissMarking -> markingDelegate.dismissSheetByUser()
 
             is ReadBookIntent.SaveMarking -> {
                 markingDelegate.save(intent.style, intent.note)
@@ -2042,7 +2017,7 @@ class ReadBookViewModel(
         val chapterInput = ReadBook.readerChapterInputWindow.current
         val canvasPage = composePagePosition
             ?.takeIf { it.chapterIndex == ReadBook.durChapterIndex }
-        val translationStatus = observeCurrentTranslation(book, ReadBook.durChapterIndex)
+        val translationStatus = aiDelegate.observeChapterTranslation(book, ReadBook.durChapterIndex)
         return current.copy(
             book = book,
             bookSource = ReadBook.bookSource,
@@ -2126,42 +2101,6 @@ class ReadBookViewModel(
         )
     }
 
-    private fun observeCurrentTranslation(
-        book: Book?,
-        chapterIndex: Int,
-    ): TranslationChapterStatus {
-        val key = book
-            ?.takeIf { it.getTranslationMode() }
-            ?.let { TranslationChapterKey(it.bookUrl, chapterIndex) }
-        if (key == observedTranslationKey && translationStatusJob?.isActive == true) {
-            return _uiState.value.translationStatus
-        }
-
-        translationStatusJob?.cancel()
-        val taskFlow = key?.let {
-            TranslationManager.getChapterTaskStateFlow(it.bookUrl, it.chapterIndex)
-        }
-        if (taskFlow == null) {
-            observedTranslationKey = null
-            return TranslationChapterStatus.Idle
-        }
-
-        observedTranslationKey = key
-        translationStatusJob = viewModelScope.launch {
-            taskFlow.takeWhile { taskState ->
-                if (observedTranslationKey == taskState.key) {
-                    _uiState.update { state ->
-                        state.copy(translationStatus = taskState.status)
-                    }
-                }
-                taskState.status == TranslationChapterStatus.Translating ||
-                    taskState.status == TranslationChapterStatus.Thinking
-            }.collect {}
-            if (observedTranslationKey == key) observedTranslationKey = null
-        }
-        return taskFlow.value.status
-    }
-
     private fun calculateSeekProgress(): Int {
         return when (readSettingsRepository.currentSettings.progressBarBehavior) {
             "page" -> composePagePosition
@@ -2708,7 +2647,6 @@ class ReadBookViewModel(
     }
 
     override fun onCleared() {
-        translationStatusJob?.cancel()
         super.onCleared()
         if (BaseReadAloudService.isRun && BaseReadAloudService.pause) {
             ReadAloud.stop(context)

+ 39 - 0
app/src/test/java/io/legado/app/feature/reader/core/navigation/ReaderPartialPagePolicyTest.kt

@@ -1,5 +1,7 @@
 package io.legado.app.feature.reader.core.navigation
 
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
 import org.junit.Assert.assertTrue
 import org.junit.Test
 
@@ -93,4 +95,41 @@ class ReaderPartialPagePolicyTest {
             )
         )
     }
+
+    @Test
+    fun chapterTurnKeepsTheNewCurrentChaptersStreamedPages() {
+        // 旧 `TextChapter.isLayoutRunning`:"还在排"时已排出的页继续可用,换章不摘掉它们。
+        assertEquals(
+            5,
+            ReaderPartialPagePolicy.retainedStreamedChapter(
+                chapterIndex = 5,
+                environmentChanged = false,
+                streamedChapters = setOf(4, 5, 6),
+            )
+        )
+    }
+
+    @Test
+    fun chapterWithoutStreamedPagesKeepsNothing() {
+        // 新当前章还没有部分页:没有可承接的页,不改变原有行为。
+        assertNull(
+            ReaderPartialPagePolicy.retainedStreamedChapter(
+                chapterIndex = 5,
+                environmentChanged = false,
+                streamedChapters = setOf(4, 6),
+            )
+        )
+    }
+
+    @Test
+    fun reflowDropsStreamedPagesOfEveryChapter() {
+        // 排版环境变化(旧 `TextChapter.isLayoutSizeMatch()` 失败):旧几何失效,整批重排。
+        assertNull(
+            ReaderPartialPagePolicy.retainedStreamedChapter(
+                chapterIndex = 5,
+                environmentChanged = true,
+                streamedChapters = setOf(5),
+            )
+        )
+    }
 }

+ 67 - 3
app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

@@ -234,16 +234,80 @@ class ReadBookDomainSplitBoundaryTest {
      * 而该快照的构造点就在 VM 的 `buildStyleConfig()`;单独拆一个类只会得到
      * 「把一行赋值搬进新文件」的空壳。其余改动都在 `ReaderPageTurnSpeed`、
      * `ReaderCanvasSurface` 与配置链路(`ReadBookConfig` / `ReadStyleGateway`),不占 VM 行。
+     *
+     * 2747 → 2696:加入书架冲突处理把 `ReadBookViewModel` 顶到 2755(超线 8 行)后的一次定向
+     * 清偿 —— 摘的是**域状态**,不是接线:
+     * 1. 翻译章节状态的观察与两个字段(`translationStatusJob` / `observedTranslationKey`)
+     *    整体下沉到 `ReadAiDelegate`(它本就依赖 `TranslationManager`),新增 Host 的
+     *    `translationStatus` / `updateTranslationStatus` 两个反向依赖;
+     * 2. 划线笔记 sheet 的「来源 sheet」(`markingReturnSheet` 与恢复函数)下沉到
+     *    `MarkingDelegate`,并把 `OpenMarking` / `EditMarking` / 快速标记四个入口收敛成
+     *    delegate 的语义方法,VM 侧只剩单行转发。
+     * 同期新增 `域状态不回流进 ReadBookViewModel` 守卫:这类散落在 VM 的域内状态比总行数
+     * 更能说明边界是否干净,行数只作粗棘轮。本次按实测值把线校准到 2696。
      */
     @Test
-    fun `ReadBookViewModel 不超过 R2 验收的 2747 行`() {
+    fun `ReadBookViewModel 不超过 R2 验收的 2696 行`() {
         val lineCount = mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt")
             .readLines().size
         assertTrue(
-            "ReadBookViewModel 涨到了 $lineCount 行,超过 R2 验收线 2747。\n" +
+            "ReadBookViewModel 涨到了 $lineCount 行,超过 R2 验收线 2696。\n" +
                 "新功能请摘成 io/legado/app/ui/book/read/ 下的 XxxDelegate," +
                 "并在本测试的 DOMAINS 里加一条边界。",
-            lineCount <= 2747,
+            lineCount <= 2696,
+        )
+    }
+
+    /**
+     * 允许留在宿主 ViewModel 的私有状态,以及它必须留下的理由。
+     *
+     * 键必须与 VM 里实际声明的状态一一对应,双向校验:
+     * - 出现未登记的字段 → 说明有域状态回流。能摘进 delegate 的就摘;
+     * - 登记的字段已不存在 → 必须删条目,防止名单只增不减、变成永久豁免。
+     *
+     * 锚点字段(`_xxx` 流后备)与可变状态(`private var`)都算;`by lazy` 的 delegate
+     * 字段与依赖注入不在此列。
+     */
+    private val hostOwnedState = mapOf(
+        "_uiState" to "阅读页唯一的对外状态源,宿主渲染直接消费",
+        "_effects" to "一次性效果与 toast 通道",
+        "_readAloudProgress" to "朗读进度流,服务层与 UI 协作",
+        "_readPreferences" to "阅读偏好流,多屏共用",
+        "readBookSyncJob" to "UiState 全量重建的尾随合并,属于宿主渲染节流",
+        "backupJob" to "退出/切后台触发的备份任务,绑定宿主生命周期",
+        "pendingBooksDirReloadChapterList" to "跨 Activity Result 回调的参数,由宿主持有",
+        "deferredReaderFeaturesStarted" to "入口动画结束后的多域启动协调,属于宿主编排",
+        "composePagePosition" to "Compose 阅读页跨帧进度(待下沉:进度/排版域)",
+        "composePageContext" to "Compose 跨帧渲染上下文(待下沉:进度/排版域)",
+        "composeProgressJob" to "Compose 进度节流任务(待下沉:进度/排版域)",
+        "justInitData" to "加载域经 Host 暴露的状态(待下沉:加载域)",
+        "closeReadBookKeepReadAloud" to "关闭阅读是否保留朗读的参数(待下沉:朗读域)",
+    )
+
+    @Test
+    fun `域状态不回流进 ReadBookViewModel`() {
+        val source = mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt").readText()
+        val declared = buildSet {
+            Regex("""^\s*(?:@\w+\s+)?private var (\w+)""", RegexOption.MULTILINE)
+                .findAll(source)
+                .forEach { add(it.groupValues[1]) }
+            Regex("""^\s+private val (_\w+)""", RegexOption.MULTILINE)
+                .findAll(source)
+                .forEach { add(it.groupValues[1]) }
+        }
+
+        val unregistered = declared - hostOwnedState.keys
+        assertTrue(
+            "以下状态没有登记归属:$unregistered。\n" +
+                    "域状态请摘进 io/legado/app/ui/book/read/ 下对应的 XxxDelegate;" +
+                    "确实属于宿主编排的,在 hostOwnedState 里补一条并写明理由。",
+            unregistered.isEmpty(),
+        )
+
+        val stale = hostOwnedState.keys - declared
+        assertTrue(
+            "hostOwnedState 里这些字段已不在 ViewModel 中,请删除条目:$stale",
+            stale.isEmpty(),
         )
     }