ソースを参照

feat: 章节边界加载占位页与阅读渲染优化

- 邻章未装载时预置"加载中"占位页(对照 shutiao 占位页语义),
  拖拽/点按/滚动三种手势可自然越过章节边界,装载完成后同 id 真实页替换
- 占位页不提交阅读进度,避免覆盖上一章/下一章落点;装载完成前为死端
- 滚动模式绘制缓存、背景边界、内容避让策略等排版核心调整
HapeLee 2 ヶ月 前
親
コミット
9db5ae6d0b
21 ファイル変更、1274 行追加、178 行削除
  1. 255 73
      app/src/main/java/io/legado/app/feature/reader/ReaderCanvasSurface.kt
  2. 72 0
      app/src/main/java/io/legado/app/feature/reader/ScrollPageDrawCache.kt
  3. 4 0
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderPage.kt
  4. 18 0
      app/src/main/java/io/legado/app/feature/reader/core/navigation/ReaderPageNavigator.kt
  5. 51 0
      app/src/main/java/io/legado/app/feature/reader/core/style/ReaderBackgroundBounds.kt
  6. 15 4
      app/src/main/java/io/legado/app/feature/reader/core/transition/PageCurlGeometry.kt
  7. 28 17
      app/src/main/java/io/legado/app/feature/reader/core/transition/ReaderScrollState.kt
  8. 17 9
      app/src/main/java/io/legado/app/feature/reader/platform/AndroidReaderTextShaper.kt
  9. 178 42
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  10. 103 14
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  11. 31 4
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  12. 49 0
      app/src/main/java/io/legado/app/ui/book/read/ReaderContentAvoidancePolicy.kt
  13. 43 0
      app/src/main/java/io/legado/app/ui/main/AndroidPlatformCapabilities.kt
  14. 7 0
      app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt
  15. 66 0
      app/src/test/java/io/legado/app/feature/reader/ScrollPageDrawCacheTest.kt
  16. 42 0
      app/src/test/java/io/legado/app/feature/reader/core/navigation/ReaderPageNavigatorTest.kt
  17. 83 0
      app/src/test/java/io/legado/app/feature/reader/core/style/ReaderBackgroundBoundsTest.kt
  18. 25 4
      app/src/test/java/io/legado/app/feature/reader/core/transition/PageCurlGeometryTest.kt
  19. 67 11
      app/src/test/java/io/legado/app/feature/reader/core/transition/ReaderScrollStateTest.kt
  20. 80 0
      app/src/test/java/io/legado/app/ui/book/read/ReaderContentAvoidancePolicyTest.kt
  21. 40 0
      app/src/test/java/io/legado/app/ui/main/AndroidPlatformCapabilitiesTest.kt

+ 255 - 73
app/src/main/java/io/legado/app/feature/reader/ReaderCanvasSurface.kt

@@ -8,6 +8,8 @@ import android.graphics.Shader
 import android.os.SystemClock
 import android.graphics.drawable.Drawable
 import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.LinearEasing
 import androidx.compose.animation.core.snap
 import androidx.compose.animation.core.tween
 import androidx.compose.animation.AnimatedVisibility
@@ -88,6 +90,7 @@ import io.legado.app.feature.reader.core.selection.ReaderSelectionLifecyclePolic
 import io.legado.app.feature.reader.core.selection.ReaderSelectionMenuAnchor
 import io.legado.app.feature.reader.core.selection.ReaderSelectionPolicy
 import io.legado.app.feature.reader.core.selection.mergeSelectionBounds
+import io.legado.app.feature.reader.core.style.mergeBackgroundBounds
 import io.legado.app.feature.reader.core.gesture.ReaderPageViewportLayout
 import io.legado.app.feature.reader.platform.ReaderAndroidPaintFactory
 import io.legado.app.feature.reader.platform.ReaderPageDecorationDrawCache
@@ -117,7 +120,7 @@ private val SelectionHandleStrokeWidth = 2.dp
 
 @Composable
 fun ReaderCanvasSurface(
-    pages: ReaderPageWindow,
+    hostPages: ReaderPageWindow,
     transitionMode: ReaderTransitionMode,
     backgroundColor: Color,
     backgroundImage: Drawable?,
@@ -127,8 +130,8 @@ fun ReaderCanvasSurface(
     textAccentColor: Color,
     autoPageIndicatorColor: Color,
     modifier: Modifier = Modifier,
-    onPreviousPage: () -> Unit,
-    onNextPage: () -> Unit,
+    onPreviousPage: () -> ReaderPageWindow?,
+    onNextPage: () -> ReaderPageWindow?,
     onToggleMenu: () -> Unit,
     onToggleBookmark: () -> Unit,
     swipeToBookmarkEnabled: Boolean,
@@ -154,6 +157,19 @@ fun ReaderCanvasSurface(
     externalPageTurns: Flow<ReaderTurnDirection>,
     externalSelectionCancels: Flow<Unit>,
 ) {
+    // 滚动跨页同步换窗:跨页帧内宿主回调直接返回新窗口,先写入 pending 供绘制与
+    // 手势立即使用;宿主 StateFlow 回声(同一实例)或外部窗口变化会将其清除。
+    // 对照旧 View 版 ContentTextView.scroll 的同步折算语义。
+    var scrollPendingWindow by remember { mutableStateOf<ReaderPageWindow?>(null) }
+    var scrollPendingBase by remember { mutableStateOf<ReaderPageWindow?>(null) }
+    val pendingWindow = scrollPendingWindow
+    val pages = when {
+        pendingWindow == null -> hostPages
+        hostPages === scrollPendingBase || hostPages === pendingWindow -> pendingWindow
+        else -> hostPages
+    }
+    /** 输入/绘制热路径读取的窗口:pending 未清时优先(含跨页当帧)。 */
+    fun currentPageWindow(): ReaderPageWindow = scrollPendingWindow ?: pages
     val current = pages.current ?: return
     val pageBackgroundImage = remember(backgroundImage, backgroundRevision) {
         backgroundImage?.isolatedCopy()
@@ -186,8 +202,9 @@ fun ReaderCanvasSurface(
     val latestSwipeToBookmarkEnabled by rememberUpdatedState(swipeToBookmarkEnabled)
     val latestHasBookmark by rememberUpdatedState(hasBookmarkOnCurrentPage)
     val latestToggleBookmark by rememberUpdatedState(onToggleBookmark)
-    DisposableEffect(transitionMode, current.widthPx, current.heightPx, current.layoutRevision) {
+    DisposableEffect(transitionMode) {
         onDispose {
+            // 模式切换时滚动惯性对换页模式已无意义,必须中止所有页运动动画并复位。
             pageMotionJob?.cancel()
             pendingTurn = null
             pendingTurnOrigin = null
@@ -198,11 +215,25 @@ fun ReaderCanvasSurface(
             bookmarkArmed = false
         }
     }
+    DisposableEffect(current.widthPx, current.heightPx, current.layoutRevision) {
+        val wasScrollMode = transitionMode == ReaderTransitionMode.SCROLL
+        onDispose {
+            // 滚动模式的跨页换窗也会更换 current(含跨章 layoutRevision 变化);
+            // fling/点击步进动画每帧重读窗口尺寸,必须跨页延续。只有换页类动画
+            // 才在换页/视口变化时中止。
+            if (!wasScrollMode) pageMotionJob?.cancel()
+        }
+    }
     var curlTouchY by remember { mutableFloatStateOf(1f) }
     var curlTouchX by remember { mutableFloatStateOf(0f) }
-    var curlCornerY by remember { mutableFloatStateOf(0f) }
-    var scrollOffset by remember { mutableFloatStateOf(0f) }
-    var scrollCarryTarget by remember { mutableStateOf<ReaderPageId?>(null) }
+    var curlCornerY by remember { mutableStateOf(0f) }
+    // 滚动偏移只在 graphicsLayer 块(layer 属性期)读取:拖拽/fling 帧只更新层变换、
+    // 零重组零重绘(对照 shutiao 的 contentOffset 语义)。
+    val scrollOffsetState = remember { mutableFloatStateOf(0f) }
+    var scrollOffset by scrollOffsetState
+    // 滚动跨页折算标志:applyScrollResult 完成一次同步换窗后置位,由 current.id
+    // 效应消费——据此区分"自己跨页"与"外部换窗",后者才把滚动偏移归零。
+    var scrollOwnCrossing by remember { mutableStateOf(false) }
     var autoRevealPx by remember { mutableFloatStateOf(0f) }
     var autoPageRemainingMillis by remember(current.id, autoReadSpeedSeconds) {
         mutableLongStateOf(ReaderAutoPagePolicy.pageDurationMillis(autoReadSpeedSeconds))
@@ -222,14 +253,14 @@ fun ReaderCanvasSurface(
     ) {
         // Paged modes do not compose the adjacent page until a gesture starts. Warm its images
         // while the window is idle so the first animation frame never falls back to placeholders.
-        listOfNotNull(pages.next, pages.previous, pages.current)
+        listOfNotNull(pages.nextPlus, pages.next, pages.previous, pages.current)
             .asSequence()
             .flatMap { page -> page.elements.asSequence().filterIsInstance<ReaderElement.Image>() }
             .distinctBy { element -> element.source to element.bounds }
             .forEach { element -> launch { loadImage(element) } }
     }
     val transforms = transition.copy(offsetPx = displayOffset).transforms(transitionMode)
-    fun pageViewportLayout(window: ReaderPageWindow = latestPages): ReaderPageViewportLayout =
+    fun pageViewportLayout(window: ReaderPageWindow = currentPageWindow()): ReaderPageViewportLayout =
         if (transitionMode == ReaderTransitionMode.SCROLL) {
             ReaderPageViewportLayout.scroll(window, scrollOffset)
         } else {
@@ -302,10 +333,17 @@ fun ReaderCanvasSurface(
             curlCornerY,
             latestPages.current?.heightPx?.toFloat() ?: 0f,
         )
+        // 仿真收尾用匀速:折页滑出屏幕是收尾的主体动作,减速收尾会让它
+        // 在结束帧前停滞(对照原版 delegate 的 LinearEasing)。
+        val settleEasing = if (transitionMode == ReaderTransitionMode.SIMULATION) {
+            LinearEasing
+        } else {
+            FastOutSlowInEasing
+        }
         pageMotionJob = animationScope.launch {
             Animatable(startOffset).animateTo(
                 decision.targetOffsetPx,
-                tween(durationMillis),
+                tween(durationMillis, easing = settleEasing),
             ) {
                 displayOffset = value
                 if (transitionMode == ReaderTransitionMode.SIMULATION) {
@@ -339,27 +377,35 @@ fun ReaderCanvasSurface(
         settlePageTurn(ReaderTransitionDecision(target, commit = true))
     }
     fun applyScrollResult(result: ReaderScrollResult, window: ReaderPageWindow) {
-        if (!ReaderScrollPolicy.canApplyDelta(scrollCarryTarget)) return
         scrollOffset = result.offsetPx
+        // 跨页换窗同步完成:宿主回调当帧返回新窗口,写入 pending 供本帧之后的
+        // 绘制与手势直接使用(宿主 StateFlow 回声随后到达,仅确认不等待)。
+        // 对照旧 View 版 ContentTextView.scroll 的同步折算语义。
         when (result.crossing) {
-            ReaderScrollCrossing.PREVIOUS -> {
-                scrollCarryTarget = window.previous?.id
-                latestPreviousPage()
+            ReaderScrollCrossing.PREVIOUS -> latestPreviousPage()?.let { newWindow ->
+                scrollOwnCrossing = true
+                scrollPendingBase = window
+                scrollPendingWindow = newWindow
             }
-            ReaderScrollCrossing.NEXT -> {
-                scrollCarryTarget = window.next?.id
-                latestNextPage()
+            ReaderScrollCrossing.NEXT -> latestNextPage()?.let { newWindow ->
+                scrollOwnCrossing = true
+                scrollPendingBase = window
+                scrollPendingWindow = newWindow
             }
             null -> Unit
         }
     }
     fun tapScrollPage(direction: ReaderTurnDirection) {
-        val page = latestPages.current ?: return
-        val distance = ReaderScrollPolicy.pageStep(page, scrollOffset, direction)
+        val window = currentPageWindow()
+        val page = window.current ?: return
+        // “保留一行”步距基于三页合成可视内容:页底露出的下一页行也是目标行候选。
+        val distance = ReaderScrollPolicy.pageStep(
+            page, scrollOffset, direction, window.previous, window.next,
+        )
         pageMotionJob?.cancel()
         val steps = ReaderGestureSettingsPolicy.scrollPageAnimationSteps(latestNoAnimationScrollPage)
         if (steps == 1) {
-            val window = latestPages
+            val window = currentPageWindow()
             val currentPage = window.current ?: return
             applyScrollResult(
                 ReaderScrollPolicy.apply(
@@ -377,7 +423,7 @@ fun ReaderCanvasSurface(
         }
         pageMotionJob = animationScope.launch {
             repeat(steps) {
-                val window = latestPages
+                val window = currentPageWindow()
                 val currentPage = window.current ?: return@launch
                 val result = ReaderScrollPolicy.apply(
                     scrollOffset,
@@ -390,7 +436,8 @@ fun ReaderCanvasSurface(
                 )
                 applyScrollResult(result, window)
                 if (result.hitBoundary) return@launch
-                delay(16)
+                // 按帧驱动步进:跟随合成器节拍,掉帧时步长自动摊平,不与显示帧脱节。
+                withFrameNanos { }
             }
         }
     }
@@ -443,13 +490,21 @@ fun ReaderCanvasSurface(
             selectionMenuVisible = false
         }
     }
+    LaunchedEffect(hostPages) {
+        // pending 窗口的收尾:宿主回声(与 pending 同实例)到达后解除;外部换窗
+        // (其他实例,如跳转/重排)直接丢弃 pending。偏移归零由下方 current.id
+        // 效应依据 scrollOwnCrossing 决定,避免两个效应间执行顺序影响结果。
+        if (scrollPendingWindow != null && hostPages !== scrollPendingBase) {
+            scrollPendingWindow = null
+            scrollPendingBase = null
+        }
+    }
     LaunchedEffect(current.id, current.layoutRevision, transitionMode) {
-        if (transitionMode == ReaderTransitionMode.SCROLL) {
-            scrollOffset = ReaderScrollPolicy.offsetAfterPageChange(
-                scrollOffset, scrollCarryTarget, current.id,
-            )
-        } else scrollOffset = 0f
-        scrollCarryTarget = null
+        val ownCrossing = scrollOwnCrossing
+        scrollOwnCrossing = false
+        if (transitionMode != ReaderTransitionMode.SCROLL || !ownCrossing) {
+            scrollOffset = 0f
+        }
         if (pendingTurnOrigin != null && pendingTurnOrigin != current.id) {
             pageMotionJob?.cancel()
             pendingTurn = null
@@ -519,7 +574,7 @@ fun ReaderCanvasSurface(
             val elapsedMs = (frame - previousFrame) / 1_000_000f
             previousFrame = frame
             if (transitionMode == ReaderTransitionMode.SCROLL) {
-                val window = latestPages
+                val window = currentPageWindow()
                 val page = window.current ?: continue
                 val viewport = page.scrollViewportExtentPx()
                 val delta = viewport /
@@ -784,7 +839,7 @@ fun ReaderCanvasSurface(
                         displayOffset = transition.offsetPx
                         change.consume()
                     } else if (scrollDrag) {
-                        val window = latestPages
+                        val window = currentPageWindow()
                         val page = window.current
                         if (page != null) {
                             val result = ReaderScrollPolicy.apply(scrollOffset, change.positionChange().y, window.previous?.scrollExtentPx ?: 0f, page.scrollExtentPx, page.scrollViewportExtentPx(), window.previous != null, window.next != null)
@@ -839,23 +894,19 @@ fun ReaderCanvasSurface(
                         Animatable(0f).animateDecay(velocity, scrollDecay) {
                             val delta = value - lastValue
                             lastValue = value
-                            // A page crossing publishes a new window asynchronously. Do not send
-                            // the same crossing more than once while that window is arriving.
-                            if (scrollCarryTarget == null) {
-                                val window = latestPages
-                                val page = window.current ?: return@animateDecay
-                                val result = ReaderScrollPolicy.apply(
-                                    scrollOffset,
-                                    delta,
-                                    window.previous?.scrollExtentPx ?: 0f,
-                                    page.scrollExtentPx,
-                                    page.scrollViewportExtentPx(),
-                                    window.previous != null,
-                                    window.next != null,
-                                )
-                                applyScrollResult(result, window)
-                                if (result.hitBoundary) throw ReaderScrollBoundaryReached()
-                            }
+                            val window = currentPageWindow()
+                            val page = window.current ?: return@animateDecay
+                            val result = ReaderScrollPolicy.apply(
+                                scrollOffset,
+                                delta,
+                                window.previous?.scrollExtentPx ?: 0f,
+                                page.scrollExtentPx,
+                                page.scrollViewportExtentPx(),
+                                window.previous != null,
+                                window.next != null,
+                            )
+                            applyScrollResult(result, window)
+                            if (result.hitBoundary) throw ReaderScrollBoundaryReached()
                         }
                     } catch (_: ReaderScrollBoundaryReached) {
                         // Reaching the first/last content boundary ends the fling immediately.
@@ -901,7 +952,15 @@ fun ReaderCanvasSurface(
                     this@drawWithContent.drawContent()
                 }
             }) {
-                ScrollPageStack(pages, scrollOffset, backgroundColor, pageBackgroundImage, backgroundImageAlpha, selectionColor, textAccentColor, textSelection, cachedImage, loadImage)
+                ScrollPageStack(
+                    windowProvider = { currentPageWindow() },
+                    offsetYState = scrollOffsetState,
+                    selection = selectionColor,
+                    readAloud = textAccentColor,
+                    selectionProvider = { textSelection },
+                    cachedImage = cachedImage,
+                    loadImage = loadImage,
+                )
             }
         } else if (transitionMode == ReaderTransitionMode.SIMULATION && transition.direction != null) {
             SimulationPageStack(pages, transition.direction!!, curlTouchX, curlTouchY, curlCornerY, backgroundColor, pageBackgroundImage, backgroundImageAlpha, selectionColor, textAccentColor, textSelection, cachedImage, loadImage)
@@ -1040,29 +1099,154 @@ private fun ReaderPage.scrollViewportExtentPx(): Float =
 
 @Composable
 private fun ScrollPageStack(
-    pages: ReaderPageWindow,
-    offsetY: Float,
-    background: Color,
-    backgroundImage: Drawable?,
-    backgroundImageAlpha: Float,
+    windowProvider: () -> ReaderPageWindow,
+    offsetYState: androidx.compose.runtime.MutableFloatState,
     selection: Color,
     readAloud: Color,
-    activeSelection: ReaderSelection?,
+    selectionProvider: () -> ReaderSelection?,
     cachedImage: (ReaderElement.Image) -> Bitmap?,
     loadImage: suspend (ReaderElement.Image) -> Bitmap?,
 ) {
-    val current = pages.current ?: return
-    val density = LocalDensity.current
-    fun Modifier.pageExtent(page: ReaderPage) = fillMaxWidth().height(with(density) {
-        maxOf(page.heightPx.toFloat(), page.contentTopPx + page.scrollExtentPx).toDp()
-    })
-    pages.previous?.let { previous ->
-        ReaderPageCanvas(previous, background, backgroundImage, backgroundImageAlpha, selection, readAloud, Modifier.pageExtent(previous).offset { IntOffset(0, (offsetY - previous.scrollExtentPx).roundToInt()) }.graphicsLayer { }, activeSelection, cachedImage, loadImage, drawBackground = false, drawDecoration = false)
+    val cache = remember { ScrollPageDrawCache() }
+    // 窗口变化(含跨页同步换窗):effect 期为三页构建绘制数据并加载位图,正常情况下
+    // 跨页时新进入窗口的页在此处预热;draw 期 miss 时同步兜底,保正确性不缺字
+    // (对照 shutiao 的组合期 ensureTextLayoutCache + 绘制期兜底)。
+    LaunchedEffect(windowProvider()) {
+        val pageWindow = windowProvider()
+        // 预热含下下页:跨页后新进入窗口的 next 页就是上一窗口的 nextPlus,绘制数据
+        // 早已就绪。数据构建放 Default 线程——跨页帧主线程对页数据零构建,只有重绘
+        // (对照 shutiao 的四页流预热;miss 时的 draw 期同步构建仍是兜底)。
+        listOfNotNull(
+            pageWindow.previous,
+            pageWindow.current,
+            pageWindow.next,
+            pageWindow.nextPlus,
+        ).forEach { page ->
+            val data = cache.peek(page)
+                ?: withContext(Dispatchers.Default) { ScrollPageDrawData(page) }
+                    .also { cache.put(page, it) }
+            val sources = data.textBackgrounds.map { it.image.source }.distinct()
+            val cachedSources = sources.mapNotNull { source ->
+                ReaderTextBackgroundLoader.cached(source)?.let { source to it }
+            }.toMap()
+            if (cachedSources.isNotEmpty()) data.textBackgroundBitmaps.value = cachedSources
+            val loadedSources = withContext(Dispatchers.IO) {
+                sources.mapNotNull { source ->
+                    ReaderTextBackgroundLoader.load(source)?.let { source to it }
+                }.toMap()
+            }
+            if (loadedSources.isNotEmpty()) data.textBackgroundBitmaps.value = loadedSources
+            page.elements.filterIsInstance<ReaderElement.Image>().forEach { element ->
+                val bitmap = (cachedImage(element) ?: loadImage(element))
+                    ?.takeUnless(Bitmap::isRecycled)
+                    ?: return@forEach
+                if (data.images.value[element] !== bitmap) {
+                    data.images.value = data.images.value + (element to bitmap)
+                }
+            }
+        }
+    }
+    Canvas(
+        modifier = Modifier
+            .fillMaxSize()
+            .graphicsLayer { translationY = offsetYState.floatValue },
+    ) {
+        // draw 期快照读:页窗口(同步换窗的 pending)变化只重绘不重组;拖拽平移只更新
+        // graphicsLayer 变换,draw 块不执行。三页连排的 y 是栈坐标,整画布随层平移偏移。
+        val window = windowProvider()
+        val current = window.current ?: return@Canvas
+        val activeSelection = selectionProvider()
+        fun drawOne(page: ReaderPage, stackOffsetY: Float) {
+            val data = cache.ensure(page)
+            val selectedBounds = if (activeSelection != null || data.hasSelectedElements) {
+                data.textElements
+                    .filter { it.selected || activeSelection?.contains(it) == true }
+                    .map(ReaderElement.Text::bounds)
+                    .mergeSelectionBounds()
+            } else emptyList()
+            withTransform({ translate(0f, stackOffsetY) }) {
+                drawScrollPageContent(page, data, selection, readAloud, activeSelection, selectedBounds)
+            }
+        }
+        window.previous?.let { drawOne(it, -it.scrollExtentPx) }
+        drawOne(current, 0f)
+        window.next?.let { drawOne(it, current.scrollExtentPx) }
     }
-    ReaderPageCanvas(current, background, backgroundImage, backgroundImageAlpha, selection, readAloud, Modifier.pageExtent(current).offset { IntOffset(0, offsetY.roundToInt()) }.graphicsLayer { }, activeSelection, cachedImage, loadImage, drawBackground = false, drawDecoration = false)
-    pages.next?.let { next ->
-        ReaderPageCanvas(next, background, backgroundImage, backgroundImageAlpha, selection, readAloud, Modifier.pageExtent(next).offset { IntOffset(0, (offsetY + current.scrollExtentPx).roundToInt()) }.graphicsLayer { }, activeSelection, cachedImage, loadImage, drawBackground = false, drawDecoration = false)
+}
+
+private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawScrollPageContent(
+    page: ReaderPage,
+    data: ScrollPageDrawData,
+    selection: Color,
+    readAloud: Color,
+    activeSelection: ReaderSelection?,
+    selectedBounds: List<ReaderRect>,
+) {
+    val native = drawContext.canvas.nativeCanvas
+    val bitmaps = data.textBackgroundBitmaps.value
+    data.textBackgrounds.forEach { run ->
+        bitmaps[run.image.source]?.let { bitmap ->
+            drawTextBackground(native, bitmap, run, data.textBackgroundPaint)
+        }
     }
+    data.textElements.mergeBackgroundBounds().forEach { band ->
+        drawRect(
+            Color(band.colorArgb),
+            Offset(band.bounds.left, band.bounds.top),
+            Size(band.bounds.width, band.bounds.height),
+        )
+    }
+    selectedBounds.forEach { rect ->
+        drawRect(selection, Offset(rect.left, rect.top), Size(rect.width, rect.height))
+    }
+    val images = data.images.value
+    page.elements.forEach { e -> when (e) {
+        is ReaderElement.Text -> {
+            val paint = data.paints.getValue(e.style)
+            paint.color = e.resolvedColorArgb(readAloud.toArgb())
+            paint.isUnderlineText = e.style.nativeUnderline || e.drawsLinkUnderline
+            native.drawText(e.value, e.bounds.left, e.baselinePx, paint)
+        }
+        is ReaderElement.Image -> images[e]?.let { bitmap ->
+            ReaderImageDrawLayout.fitCenter(e.bounds, bitmap.width, bitmap.height)?.let { layout ->
+                drawImage(
+                    image = bitmap.asImageBitmap(),
+                    dstOffset = IntOffset(layout.leftPx.roundToInt(), layout.topPx.roundToInt()),
+                    dstSize = IntSize(
+                        layout.widthPx.roundToInt().coerceAtLeast(1),
+                        layout.heightPx.roundToInt().coerceAtLeast(1),
+                    ),
+                )
+            }
+        } ?: drawRect(Color.Gray.copy(alpha = .18f), Offset(e.bounds.left, e.bounds.top), Size(e.bounds.width, e.bounds.height))
+        is ReaderElement.Review -> if (e.count > 0) drawReview(native, e, data.paints.values.firstOrNull()?.color ?: android.graphics.Color.GRAY)
+        is ReaderElement.Action -> Unit
+        is ReaderElement.Spacer -> Unit
+        is ReaderElement.ParagraphMarker -> {
+            if (e.circular) {
+                drawCircle(Color(e.colorArgb), e.strokeWidthPx / 2f, Offset(e.bounds.left, e.bounds.top))
+            } else {
+                drawLine(
+                    Color(e.colorArgb),
+                    Offset(e.bounds.left, e.bounds.top),
+                    Offset(e.bounds.right, e.bounds.bottom),
+                    e.strokeWidthPx,
+                )
+            }
+        }
+        is ReaderElement.Rule -> Unit
+    } }
+    data.emphasisUnderlines.forEach { run ->
+        drawLine(
+            color = Color(run.style.colorArgb),
+            start = Offset(run.startPx, run.yPx),
+            end = Offset(run.endPx, run.yPx),
+            strokeWidth = run.style.widthPx,
+        )
+    }
+    data.decorationDrawCache.contentRules.forEach { it.draw(native) }
+    data.decorationDrawCache.styledUnderlines.forEach { it.draw(native) }
+    data.decorationDrawCache.overlayRules.forEach { it.draw(native) }
 }
 
 @Composable
@@ -1327,14 +1511,12 @@ private fun ReaderPageCanvas(
                 drawTextBackground(native, bitmap, run, textBackgroundPaint)
             }
         }
-        textElements.forEach { element ->
-            element.style.backgroundArgb?.let { color ->
-                drawRect(
-                    Color(color),
-                    Offset(element.bounds.left, element.bounds.top),
-                    Size(element.bounds.width, element.bounds.height),
-                )
-            }
+        textElements.mergeBackgroundBounds().forEach { band ->
+            drawRect(
+                Color(band.colorArgb),
+                Offset(band.bounds.left, band.bounds.top),
+                Size(band.bounds.width, band.bounds.height),
+            )
         }
         selectedTextBounds.forEach { rect ->
             drawRect(selection, Offset(rect.left, rect.top), Size(rect.width, rect.height))
@@ -1401,7 +1583,7 @@ fun ReaderBackgroundSurface(
     val targetAlpha = if (isolatedBackgroundImage == null) 0f else backgroundImageAlpha
     val animatedAlpha by animateFloatAsState(
         targetValue = targetAlpha,
-        animationSpec = if (animateAppearance) tween(450) else snap(),
+        animationSpec = if (animateAppearance) tween(400) else snap(),
         label = "readerBackgroundAlpha",
     )
     Canvas(modifier) {

+ 72 - 0
app/src/main/java/io/legado/app/feature/reader/ScrollPageDrawCache.kt

@@ -0,0 +1,72 @@
+package io.legado.app.feature.reader
+
+import android.graphics.Bitmap
+import android.graphics.Paint
+import androidx.compose.runtime.mutableStateOf
+import io.legado.app.feature.reader.core.model.ReaderElement
+import io.legado.app.feature.reader.core.model.ReaderPage
+import io.legado.app.feature.reader.core.model.ReaderTextStyle
+import io.legado.app.feature.reader.core.model.emphasisUnderlineRuns
+import io.legado.app.feature.reader.core.model.textBackgroundRuns
+import io.legado.app.feature.reader.platform.ReaderAndroidPaintFactory
+import io.legado.app.feature.reader.platform.ReaderPageDecorationDrawCache
+
+/**
+ * 滚动模式单画布的每页绘制数据(对照 shutiao 的 page.textLayoutCache)。
+ *
+ * 普通类、组合外构建:窗口发布后由 effect 期预热,draw 期直读;位图类字段用
+ * snapshot state 承载,加载完成只触发重绘不重组。
+ */
+internal class ScrollPageDrawData(val page: ReaderPage) {
+    val textElements: List<ReaderElement.Text> = page.elements.filterIsInstance<ReaderElement.Text>()
+    val paints: Map<ReaderTextStyle, Paint> =
+        textElements.map { it.style }.distinct().associateWith(ReaderAndroidPaintFactory::create)
+    val textBackgrounds = page.textBackgroundRuns()
+    val textBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
+    val decorationDrawCache = ReaderPageDecorationDrawCache.create(page)
+    val emphasisUnderlines = page.emphasisUnderlineRuns()
+
+    /** 任一正文元素被宿主标为选中(搜索结果/朗读段落)时为真,供 draw 期裁剪选中背景计算。 */
+    val hasSelectedElements = textElements.any { it.selected }
+
+    /** 正文内联图片位图;加载完成写回以触发重绘。 */
+    val images = mutableStateOf<Map<ReaderElement.Image, Bitmap>>(emptyMap())
+
+    /** 文字背景位图,按 source 键;加载完成写回以触发重绘。 */
+    val textBackgroundBitmaps = mutableStateOf<Map<String, Bitmap>>(emptyMap())
+}
+
+/**
+ * 页 → 绘制数据缓存。key 取 elements 的**引用身份**:跨页换窗、分钟级页眉时间刷新
+ * 都会生成新页实例但复用同一 elements 列表,缓存持续命中;只有真重排(新列表)才
+ * 重建。超容量按 LRU 淘汰。
+ */
+internal class ScrollPageDrawCache(capacity: Int = 8) {
+    /**
+     * 按 elements 的引用而非内容比较。每次查询都会创建新的 key 包装对象,故必须
+     * 显式实现引用相等;Object 默认实现会把包装对象本身作为身份,令缓存永远 miss。
+     */
+    private class ElementsKey(private val elements: List<ReaderElement>) {
+        override fun equals(other: Any?): Boolean =
+            other is ElementsKey && elements === other.elements
+
+        override fun hashCode(): Int = System.identityHashCode(elements)
+    }
+
+    private val entries = object : LinkedHashMap<ElementsKey, ScrollPageDrawData>(16, 0.75f, true) {
+        override fun removeEldestEntry(eldest: MutableMap.MutableEntry<ElementsKey, ScrollPageDrawData>): Boolean =
+            size > capacity
+    }
+
+    fun put(page: ReaderPage, data: ScrollPageDrawData) {
+        synchronized(this) { entries[ElementsKey(page.elements)] = data }
+    }
+
+    fun peek(page: ReaderPage): ScrollPageDrawData? = synchronized(this) {
+        entries[ElementsKey(page.elements)]
+    }
+
+    /** draw 期兜底:miss 时主线程同步构建,保正确性不缺字;正常路径由预热先行。 */
+    fun ensure(page: ReaderPage): ScrollPageDrawData =
+        peek(page) ?: ScrollPageDrawData(page).also { put(page, it) }
+}

+ 4 - 0
app/src/main/java/io/legado/app/feature/reader/core/model/ReaderPage.kt

@@ -162,6 +162,8 @@ data class ReaderPage(
     val decoration: ReaderPageDecoration = ReaderPageDecoration(),
     val inlineImagesPreserveScrollLine: Boolean = true,
     val emphasisUnderlineStyle: ReaderEmphasisUnderline? = null,
+    /** 邻章未装载时预置的"加载中"占位页,分页批次落地后被同 id 真实页替换。 */
+    val isPlaceholder: Boolean = false,
 ) {
     fun elementAt(x: Float, y: Float): ReaderElement? =
         elements.firstOrNull { it.bounds.contains(x, y) }
@@ -182,6 +184,8 @@ data class ReaderPageWindow(
     val previous: ReaderPage? = null,
     val current: ReaderPage? = null,
     val next: ReaderPage? = null,
+    /** 下下页:不参与绘制,供滚动渲染层提前预热绘制数据(对照 shutiao 的四页流)。 */
+    val nextPlus: ReaderPage? = null,
 )
 
 enum class ReaderTipAlignment { START, CENTER, END }

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

@@ -43,9 +43,27 @@ object ReaderPageNavigator {
             previous = pages.getOrNull(index - 1),
             current = pages[index],
             next = pages.getOrNull(index + 1),
+            nextPlus = pages.getOrNull(index + 2),
         )
     }
 
+    /**
+     * 章节边界占位语义(对照 shutiao 的"正在加载中"页):当前真实页的邻章在书中
+     * 存在但尚未分页进 [pages] 时,需要预置占位页,让手势层在邻章未装载时也能把
+     * 页面拖/滚进"加载中"。占位页本身不再扩展(装载完成前是死端)。
+     */
+    fun missingAdjacentChapters(
+        pages: List<ReaderPage>,
+        pageIndex: Int,
+        chapterCount: Int,
+    ): List<Int> {
+        val page = pages.getOrNull(pageIndex) ?: return emptyList()
+        if (page.isPlaceholder) return emptyList()
+        return listOf(page.id.chapterIndex - 1, page.id.chapterIndex + 1).filter { chapter ->
+            chapter in 0 until chapterCount && pages.none { it.id.chapterIndex == chapter }
+        }
+    }
+
     fun move(pages: List<ReaderPage>, pageIndex: Int, delta: Int): ReaderNavigationResult {
         if (pages.isEmpty()) return ReaderNavigationResult(0, ReaderPageWindow(), true)
         val target = (pageIndex + delta).coerceIn(pages.indices)

+ 51 - 0
app/src/main/java/io/legado/app/feature/reader/core/style/ReaderBackgroundBounds.kt

@@ -0,0 +1,51 @@
+package io.legado.app.feature.reader.core.style
+
+import io.legado.app.feature.reader.core.model.ReaderElement
+import io.legado.app.feature.reader.core.model.ReaderRect
+
+data class ReaderBackgroundBand(
+    val colorArgb: Int,
+    val bounds: ReaderRect,
+)
+
+/**
+ * Joins sequential same-color background boxes on the same visual line into one continuous
+ * band — the background-color counterpart of selection's mergeSelectionBounds.
+ *
+ * 书源 HTML 与替换规则可能逐字声明背景色(BackgroundColorSpan),排版按样式段切分元素后
+ * 逐元素绘制会出现字间缝隙;合并按元素顺序进行,几何判定(同行重叠 + 间隙容差)与
+ * mergeSelectionBounds 一致,仅多一个颜色键。
+ */
+fun List<ReaderElement.Text>.mergeBackgroundBounds(
+    minimumLineOverlap: Float = 0.5f,
+): List<ReaderBackgroundBand> {
+    if (isEmpty()) return emptyList()
+    val bands = ArrayList<ReaderBackgroundBand>(size)
+    forEach { element ->
+        val colorArgb = element.style.backgroundArgb ?: return@forEach
+        val rect = element.bounds
+        val previous = bands.lastOrNull()
+        val verticalOverlap = previous?.let {
+            (minOf(it.bounds.bottom, rect.bottom) - maxOf(it.bounds.top, rect.top)).coerceAtLeast(0f)
+        } ?: 0f
+        val sameLine = previous != null && verticalOverlap >=
+            minOf(previous.bounds.height, rect.height) * minimumLineOverlap.coerceIn(0f, 1f)
+        val joinGapPx = previous?.let { minOf(it.bounds.height, rect.height) * 0.5f } ?: 0f
+        val horizontalGap = previous?.let {
+            maxOf(rect.left - it.bounds.right, it.bounds.left - rect.right, 0f)
+        } ?: Float.POSITIVE_INFINITY
+        if (sameLine && previous.colorArgb == colorArgb && horizontalGap <= joinGapPx) {
+            bands[bands.lastIndex] = previous.copy(
+                bounds = ReaderRect(
+                    left = minOf(previous.bounds.left, rect.left),
+                    top = minOf(previous.bounds.top, rect.top),
+                    right = maxOf(previous.bounds.right, rect.right),
+                    bottom = maxOf(previous.bounds.bottom, rect.bottom),
+                ),
+            )
+        } else {
+            bands += ReaderBackgroundBand(colorArgb, rect)
+        }
+    }
+    return bands
+}

+ 15 - 4
app/src/main/java/io/legado/app/feature/reader/core/transition/PageCurlGeometry.kt

@@ -38,10 +38,15 @@ data class PageCurlFrame(
     ).all(CurlPoint::isFinite)
 }
 
+/**
+ * 折页三道阴影的峰值颜色。渐变结构与色相沿用原版 GradientDrawable
+ * (0xFF111111 / 0x80111111 / 0xB0333333),峰值 alpha 整体按约六成调轻:
+ * 全不透明的折缝黑边是"阴影偏重"观感的主要来源。
+ */
 object ReaderCurlVisualPolicy {
-    val backShadowDarkArgb: Int = 0xFF111111.toInt()
-    val frontShadowDarkArgb: Int = 0x80111111.toInt()
-    val folderShadowDarkArgb: Int = 0xB0333333.toInt()
+    val backShadowDarkArgb: Int = 0x99111111.toInt()
+    val frontShadowDarkArgb: Int = 0x4D111111.toInt()
+    val folderShadowDarkArgb: Int = 0x73333333.toInt()
 }
 
 object ReaderCurlTouchPolicy {
@@ -123,7 +128,13 @@ object PageCurlGeometry {
             if (inputX <= width / 2f) 0f else width,
             if (inputY <= height / 2f) 0f else height,
         )
-        var touch = CurlPoint(inputX.coerceIn(.1f, width - .1f), inputY.coerceIn(.1f, height - .1f))
+        // 收尾动画把触点送出页外让折页连同阴影滑出屏幕;页外触点保持原值走原始
+        // 贝塞尔计算(对照原版 calcPoints 仅在触点页内时做收拢修正),页内仍收敛
+        // 到 .1f 边界避免 0 值退化。
+        var touch = CurlPoint(
+            if (inputX < 0f || inputX > width) inputX else inputX.coerceIn(.1f, width - .1f),
+            inputY.coerceIn(.1f, height - .1f),
+        )
         fun controls(point: CurlPoint): Pair<CurlPoint, CurlPoint> {
             val middleX = (point.x + corner.x) / 2f
             val middleY = (point.y + corner.y) / 2f

+ 28 - 17
app/src/main/java/io/legado/app/feature/reader/core/transition/ReaderScrollState.kt

@@ -1,6 +1,5 @@
 package io.legado.app.feature.reader.core.transition
 
-import io.legado.app.feature.reader.core.model.ReaderPageId
 import io.legado.app.feature.reader.core.model.ReaderElement
 import io.legado.app.feature.reader.core.model.ReaderPage
 
@@ -12,46 +11,58 @@ data class ReaderScrollResult(
     val hitBoundary: Boolean = false,
 )
 
-/** One-frame continuous-scroll reducer. Page replacement is performed by the host after crossing. */
+/**
+ * One-frame continuous-scroll reducer. A crossing is resolved synchronously by the
+ * host (the turn callback returns the replacement window in the same frame), so no
+ * input delta is ever dropped between pages.
+ */
 object ReaderScrollPolicy {
-    /** Do not reduce another frame against the old page after a crossing requested replacement. */
-    fun canApplyDelta(pendingPageTarget: ReaderPageId?): Boolean = pendingPageTarget == null
 
     /**
      * Click paging keeps one visible text row for context, matching ScrollPageDelegate.
      * Non-inline image pages and empty pages move by one full viewport.
+     *
+     * "保留一行"的基准是三页合成可视内容(对照旧 getCurVisiblePage):页底已露出下一页
+     * 行、或页顶已露出上一页行时,目标行在邻页里,步距自然跨过页边界(跨页折算由
+     * [apply] 在动画帧内完成),而不是停在当前页边缘。
      */
     fun pageStep(
         page: ReaderPage,
         offsetPx: Float,
         direction: ReaderTurnDirection,
+        previous: ReaderPage? = null,
+        next: ReaderPage? = null,
     ): Float {
         val viewport = (page.contentBottomPx - page.contentTopPx).coerceAtLeast(1f)
-        val visible = page.elements.filter { element ->
-            element.bounds.bottom + offsetPx > page.contentTopPx &&
-                element.bounds.top + offsetPx < page.contentBottomPx
+        data class Row(val element: ReaderElement, val stackOffset: Float)
+        val visible = buildList {
+            fun collect(source: ReaderPage, shift: Float) {
+                val stackOffset = offsetPx + shift
+                for (element in source.elements) {
+                    if (element.bounds.bottom + stackOffset > page.contentTopPx &&
+                        element.bounds.top + stackOffset < page.contentBottomPx
+                    ) add(Row(element, stackOffset))
+                }
+            }
+            previous?.let { collect(it, -it.scrollExtentPx) }
+            collect(page, 0f)
+            next?.let { collect(it, page.scrollExtentPx) }
         }
-        val text = visible.filterIsInstance<ReaderElement.Text>()
+        val text = visible.filter { it.element is ReaderElement.Text }
         if (text.isEmpty() ||
-            (!page.inlineImagesPreserveScrollLine && visible.any { it is ReaderElement.Image })
+            (!page.inlineImagesPreserveScrollLine && visible.any { it.element is ReaderElement.Image })
         ) return if (direction == ReaderTurnDirection.PREVIOUS) viewport else -viewport
 
         val distance = when (direction) {
             ReaderTurnDirection.NEXT ->
-                text.maxOf { it.bounds.top } + offsetPx - page.contentTopPx
+                text.maxOf { it.element.bounds.top + it.stackOffset } - page.contentTopPx
             ReaderTurnDirection.PREVIOUS ->
-                viewport - (text.minOf { it.bounds.bottom } + offsetPx - page.contentTopPx)
+                viewport - (text.minOf { it.element.bounds.bottom + it.stackOffset } - page.contentTopPx)
         }.coerceIn(0f, viewport)
         val effective = distance.takeIf { it > 0f } ?: viewport
         return if (direction == ReaderTurnDirection.PREVIOUS) effective else -effective
     }
 
-    fun offsetAfterPageChange(
-        offsetPx: Float,
-        carriedTarget: ReaderPageId?,
-        currentPage: ReaderPageId,
-    ): Float = if (carriedTarget == currentPage) offsetPx else 0f
-
     fun apply(
         offsetPx: Float,
         deltaPx: Float,

+ 17 - 9
app/src/main/java/io/legado/app/feature/reader/platform/AndroidReaderTextShaper.kt

@@ -15,6 +15,9 @@ import java.io.File
 import splitties.init.appCtx
 
 object ReaderAndroidPaintFactory {
+    /** 进程级字体缓存:同一 path/weight/italic/family 只做一次磁盘读取与解析。 */
+    private val typefaceCache = java.util.concurrent.ConcurrentHashMap<String, Typeface>()
+
     fun create(style: ReaderTextStyle): Paint =
         Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG).apply {
             color = style.colorArgb
@@ -37,6 +40,8 @@ object ReaderAndroidPaintFactory {
     fun baselineOffset(paint: Paint): Float = paint.fontMetrics.let { it.leading - it.ascent }
 
     fun loadTypeface(path: String, weight: Int, italic: Boolean, family: String = "sans-serif"): Typeface {
+        val key = "$path|$weight|$italic|$family"
+        typefaceCache[key]?.let { return it }
         val base = runCatching {
             when {
                 path.startsWith("content://", ignoreCase = true) ->
@@ -47,16 +52,19 @@ object ReaderAndroidPaintFactory {
                 else -> null
             }
         }.getOrNull() ?: Typeface.create(family, Typeface.NORMAL)
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
-            return Typeface.create(base, weight.coerceIn(1, 1000), italic)
-        }
-        val typefaceStyle = when {
-            weight >= 600 && italic -> Typeface.BOLD_ITALIC
-            weight >= 600 -> Typeface.BOLD
-            italic -> Typeface.ITALIC
-            else -> Typeface.NORMAL
+        val typeface = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+            Typeface.create(base, weight.coerceIn(1, 1000), italic)
+        } else {
+            val typefaceStyle = when {
+                weight >= 600 && italic -> Typeface.BOLD_ITALIC
+                weight >= 600 -> Typeface.BOLD
+                italic -> Typeface.ITALIC
+                else -> Typeface.NORMAL
+            }
+            Typeface.create(base, typefaceStyle)
         }
-        return Typeface.create(base, typefaceStyle)
+        typefaceCache[key] = typeface
+        return typeface
     }
 }
 

+ 178 - 42
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -36,9 +36,13 @@ import io.legado.app.feature.reader.legacy.paginateLegacyReaderChapterSafely
 import io.legado.app.feature.reader.legacy.LegacyReaderPageDecorationFactory
 import io.legado.app.feature.reader.legacy.LegacyReaderPaginationStyleFactory
 import io.legado.app.feature.reader.platform.ReaderAndroidPaginationStyle
+import io.legado.app.feature.reader.core.navigation.ReaderPageContext
 import io.legado.app.feature.reader.core.navigation.ReaderPageNavigator
 import io.legado.app.feature.reader.core.model.ReaderElement
+import io.legado.app.feature.reader.core.model.ReaderPage
+import io.legado.app.feature.reader.core.model.ReaderRect
 import io.legado.app.feature.reader.core.model.ReaderImageCachePolicy
+import io.legado.app.feature.reader.core.model.ReaderPageId
 import io.legado.app.feature.reader.core.model.ReaderThemeColorChange
 import io.legado.app.feature.reader.core.model.remapThemeColors
 import io.legado.app.feature.reader.core.selection.ReaderSelection
@@ -258,6 +262,20 @@ class ReadBookController(
     private var directReaderLayoutJob: Job? = null
     private var directReaderLayoutKey: String? = null
     private var directReaderPages = emptyList<io.legado.app.feature.reader.core.model.ReaderPage>()
+
+    /**
+     * 页上下文缓存:跨页热路径(书签检查×3 + 进度上报 + 进度提交)每次跨页要取
+     * 5 次 pageContext,每次 O(元素数) 遍历 + groupBy + anchorText 拼接,落在拖拽
+     * 跨页帧上就是掉帧。按页 id 记忆,重排后失效(重排会重算位置与 endPosition)。
+     */
+    private val directReaderPageContexts = HashMap<ReaderPageId, ReaderPageContext>()
+
+    private fun directReaderPageContext(index: Int): ReaderPageContext? {
+        val page = directReaderPages.getOrNull(index) ?: return null
+        return directReaderPageContexts.getOrPut(page.id) {
+            ReaderPageNavigator.pageContext(directReaderPages, index) ?: return null
+        }
+    }
     private var directReaderChapterPageCounts = emptyMap<Int, Int>()
     private var directReaderPageIndex: Int? = null
     private val menuMutex = Mutex()
@@ -346,9 +364,10 @@ class ReadBookController(
         ))
     }
 
-    private fun updateReaderPageWindow(value: ReaderPageWindow) {
+    private fun updateReaderPageWindow(value: ReaderPageWindow): ReaderPageWindow {
         _readerPageWindow.value = value
         readerSessionViewModel.submitPageWindow(value)
+        return value
     }
 
     private fun updateReaderPaginationError(value: String?) {
@@ -608,6 +627,7 @@ class ReadBookController(
             previous = highlight(window.previous, index - 1),
             current = highlight(window.current, index),
             next = highlight(window.next, index + 1),
+            nextPlus = highlight(window.nextPlus, index + 2),
         )
     }
 
@@ -615,7 +635,7 @@ class ReadBookController(
 
     private fun hasBookmarkOnComposePage(index: Int): Boolean {
         val book = ReadBook.book ?: return false
-        val page = ReaderPageNavigator.pageContext(directReaderPages, index) ?: return false
+        val page = directReaderPageContext(index) ?: return false
         return io.legado.app.model.ReaderBookmarkState.hasBookmarkInRange(
             bookName = book.name,
             bookAuthor = book.author,
@@ -625,23 +645,54 @@ class ReadBookController(
         )
     }
 
-    private fun publishDirectReaderWindow(index: Int) {
-        if (directReaderPages.isEmpty()) return
-        val boundedIndex = index.coerceIn(directReaderPages.indices)
+    /** 同步平移页窗口并发布;返回发布的窗口,供滚动渲染层当帧折算使用。 */
+    private fun publishDirectReaderWindow(index: Int): ReaderPageWindow? {
+        if (directReaderPages.isEmpty()) return null
+        val boundedIndex = ensureBoundaryPlaceholderPages(index.coerceIn(directReaderPages.indices))
         directReaderPageIndex = boundedIndex
         viewModel.updateComposeReaderPage(
             position = ReaderPageNavigator.chapterPosition(directReaderPages, boundedIndex),
-            pageContext = ReaderPageNavigator.pageContext(directReaderPages, boundedIndex),
+            pageContext = directReaderPageContext(boundedIndex),
+        )
+        return updateReaderPageWindow(directReaderWindow(boundedIndex))
+    }
+
+    /**
+     * 邻章未分页时预置"加载中"占位页(对照 shutiao 的占位页滚动继续语义):
+     * 预置后手势层的 window.next/previous 不再为空,拖拽、点按、滚动都能自然
+     * 越过章节边界,装载完成后分页批次以同 id 真实页替换。返回当前页在插入后
+     * 的列表中的新下标(前侧插入会使既有下标整体后移)。
+     */
+    private fun ensureBoundaryPlaceholderPages(index: Int): Int {
+        val pages = directReaderPages
+        val page = pages.getOrNull(index) ?: return index
+        val missingChapters = ReaderPageNavigator.missingAdjacentChapters(
+            pages,
+            index,
+            chapterCount = ReadBook.simulatedChapterSize,
         )
-        updateReaderPageWindow(directReaderWindow(boundedIndex))
+        if (missingChapters.isEmpty()) return index
+        val updated = pages.toMutableList()
+        var added = 0
+        missingChapters.forEach { chapterIndex ->
+            placeholderReaderPage(chapterIndex)?.let {
+                updated.add(it)
+                added++
+            }
+        }
+        if (added == 0) return index
+        updated.sortWith(compareBy({ it.id.chapterIndex }, { it.id.pageIndex }))
+        directReaderPages = updated
+        return updated.indexOfFirst { it === page }.coerceAtLeast(0)
     }
 
     private fun commitManualReaderPage(index: Int) {
         val page = directReaderPages.getOrNull(index) ?: return
-        val chapterPosition = ReaderPageNavigator.pageContext(directReaderPages, index)
+        val chapterPosition = directReaderPageContext(index)
             ?.contentStartPosition
             ?: ReaderPageNavigator.pageStart(page)
-        ReadBook.updateReadingPosition(chapterPosition)
+        // 热路径安静更新:不发布快照(否则每次跨页触发一次全量 UiState 重建落在动画帧上)。
+        ReadBook.updateReadingPosition(chapterPosition, publish = false)
         if (BaseReadAloudService.isRun) {
             readAloudPosition = page.id.chapterIndex to chapterPosition
             ReadBook.readAloud(
@@ -885,6 +936,8 @@ class ReadBookController(
                 page.copy(layoutRevision = previous.layoutRevision)
             } ?: page
         }.sortedWith(compareBy({ it.id.chapterIndex }, { it.id.pageIndex }))
+        // 重排可能改变元素位置与页 endPosition,页上下文缓存全部失效。
+        directReaderPageContexts.clear()
         directReaderChapterPageCounts = directReaderPages.groupingBy { it.id.chapterIndex }.eachCount()
         directReaderPageIndex = directReaderPages.takeIf { it.isNotEmpty() }?.let {
             ReaderPageNavigator.locate(
@@ -1874,40 +1927,123 @@ class ReadBookController(
         }
     }
 
-    /** Completes a transition already animated by the Compose renderer. */
-    fun completeComposePageTurn(direction: PageDirection): Boolean {
-        if (directReaderPages.isNotEmpty()) {
-            val currentIndex = directReaderPageIndex ?: 0
-            val delta = when (direction) {
-                PageDirection.PREV -> -1
-                PageDirection.NEXT -> 1
-                PageDirection.NONE -> 0
-            }
-            val navigation = ReaderPageNavigator.move(directReaderPages, currentIndex, delta)
-            if (!navigation.hitBoundary && delta != 0) {
-                val oldChapterIndex = directReaderPages[currentIndex].id.chapterIndex
-                val newChapterIndex = navigation.window.current?.id?.chapterIndex ?: oldChapterIndex
-                val chapterChanged = when {
-                    newChapterIndex > oldChapterIndex -> ReadBook.moveToNextChapter(
-                        upContent = false,
-                        upContentInPlace = false,
-                    )
-                    newChapterIndex < oldChapterIndex -> ReadBook.moveToPrevChapter(
-                        upContent = false,
-                        toLast = true,
-                        upContentInPlace = false,
-                    )
-                    else -> true
-                }
-                if (!chapterChanged) return false
-                commitManualReaderPage(navigation.pageIndex)
-                publishDirectReaderWindow(navigation.pageIndex)
-                pageChanged = true
-                viewModel.startBackupJob()
-                return true
-            }
+    /**
+     * Completes a transition already animated by the Compose renderer.
+     * 返回翻页后发布的页窗口(同步);null 表示翻页被拒绝(边界/无内容)。
+     * 滚动模式依赖这个同步返回在跨页帧内完成窗口替换与偏移折算,不走 StateFlow 往返。
+     */
+    fun completeComposePageTurn(direction: PageDirection): ReaderPageWindow? {
+        if (directReaderPages.isEmpty()) return null
+        val currentIndex = directReaderPageIndex ?: 0
+        val delta = when (direction) {
+            PageDirection.PREV -> -1
+            PageDirection.NEXT -> 1
+            PageDirection.NONE -> 0
+        }
+        val navigation = ReaderPageNavigator.move(directReaderPages, currentIndex, delta)
+        if (delta == 0) return null
+        if (navigation.hitBoundary) {
+            return crossComposeChapterBoundary(currentIndex, delta)
+        }
+        val oldChapterIndex = directReaderPages[currentIndex].id.chapterIndex
+        val newChapterIndex = navigation.window.current?.id?.chapterIndex ?: oldChapterIndex
+        val chapterChanged = when {
+            newChapterIndex > oldChapterIndex -> ReadBook.moveToNextChapter(
+                upContent = false,
+                upContentInPlace = false,
+            )
+            newChapterIndex < oldChapterIndex -> ReadBook.moveToPrevChapter(
+                upContent = false,
+                toLast = true,
+                upContentInPlace = false,
+            )
+            else -> true
         }
-        return false
+        if (!chapterChanged) return null
+        // 占位页没有真实章内位置:moveToNextChapter/PrevChapter 已把 durChapterPos
+        // 设为目标章落点(首页 0 / 上一章末页 lastPageStart),提交会把它覆盖成 0。
+        if (!directReaderPages[navigation.pageIndex].isPlaceholder) {
+            commitManualReaderPage(navigation.pageIndex)
+        }
+        val window = publishDirectReaderWindow(navigation.pageIndex)
+        pageChanged = true
+        viewModel.startBackupJob()
+        return window
+    }
+
+    /**
+     * 章节边界而邻章未分页:滚入"加载中"占位页并触发装载(对照 shutiao 的占位页
+     * 滚动继续语义)。占位页插入 directReaderPages 并正常发布——装载期间页码、
+     * 反向跨页、进度保持一致;分页批次落地时同 id 真实页替换,重建后占位页自然
+     * 消失,locate 按 durChapterPos 落到目标章(下一章首页/上一章末页)。
+     */
+    private fun crossComposeChapterBoundary(currentIndex: Int, delta: Int): ReaderPageWindow? {
+        val fromChapterIndex = directReaderPages[currentIndex].id.chapterIndex
+        val targetChapterIndex = fromChapterIndex + delta
+        // 已处于该章占位/装载状态:不重复切章。
+        if (ReadBook.durChapterIndex == targetChapterIndex) return null
+        // 占位页是死端:邻章装载完成前不允许从占位页继续向更远处串章
+        // (正常路径下占位页已由 ensureBoundaryPlaceholderPages 预置,不会走到这里)。
+        if (directReaderPages[currentIndex].isPlaceholder) return null
+        val moved = if (delta > 0) {
+            ReadBook.moveToNextChapter(upContent = false, upContentInPlace = false)
+        } else {
+            ReadBook.moveToPrevChapter(upContent = false, toLast = true, upContentInPlace = false)
+        }
+        if (!moved) return null
+        val placeholder = placeholderReaderPage(targetChapterIndex) ?: return null
+        val pages = directReaderPages.toMutableList()
+        pages.add(placeholder)
+        pages.sortWith(compareBy({ it.id.chapterIndex }, { it.id.pageIndex }))
+        directReaderPages = pages
+        // 不提交进度:moveToNextChapter/PrevChapter 已把 durChapterPos 设为
+        // 目标章的落点(首页 0 / 末页 lastPageStart),commit 会覆盖上一章的取值。
+        val placeholderIndex = pages.indexOfFirst { it === placeholder }
+        directReaderPageIndex = placeholderIndex
+        val window = publishDirectReaderWindow(placeholderIndex)
+        pageChanged = true
+        viewModel.startBackupJob()
+        return window
+    }
+
+    /** 未装载章节的占位页:一屏居中的"加载中"文字,几何与普通页一致以保持滚动连续。 */
+    private fun placeholderReaderPage(chapterIndex: Int): ReaderPage? {
+        val viewport = layoutController.viewport.value ?: return null
+        val paginationStyle = LegacyReaderPaginationStyleFactory.create()
+        val contentTop = viewport.contentPadding.top.toFloat()
+        val contentBottom = (viewport.heightPx - viewport.contentPadding.bottom).toFloat()
+        if (contentBottom - contentTop <= 0f) return null
+        val paint = paginationStyle.bodyPaint
+        val text = activity.getString(R.string.loading)
+        val textWidth = paint.measureText(text)
+        val left = viewport.contentPadding.left.toFloat()
+        val width = viewport.contentWidthPx.toFloat()
+        val x = left + ((width - textWidth) / 2f).coerceAtLeast(0f)
+        val y = contentTop + ((contentBottom - contentTop - paginationStyle.bodyTextHeightPx) / 2f)
+            .coerceAtLeast(0f)
+        val element = ReaderElement.Text(
+            bounds = ReaderRect(x, y, x + textWidth, y + paginationStyle.bodyTextHeightPx),
+            baselinePx = y + paginationStyle.bodyBaselineOffsetPx,
+            value = text,
+            style = paginationStyle.bodyStyle,
+            selected = false,
+            emphasized = true,
+            chapterPosition = 0,
+            paragraphIndex = -1,
+        )
+        return ReaderPage(
+            id = ReaderPageId(chapterIndex, 0),
+            chapterTitle = "",
+            text = text,
+            widthPx = viewport.widthPx,
+            heightPx = viewport.heightPx,
+            contentTopPx = contentTop,
+            contentBottomPx = contentBottom,
+            elements = listOf(element),
+            revision = 1L,
+            scrollExtentPx = contentBottom - contentTop,
+            isPlaceholder = true,
+        )
     }
 
     private fun keyPageDebounce(

+ 103 - 14
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -9,6 +9,7 @@ import android.hardware.SensorManager
 import android.os.SystemClock
 import android.view.KeyEvent
 import android.view.View
+import android.view.ViewTreeObserver
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.compose.BackHandler
 import androidx.activity.result.contract.ActivityResultContracts
@@ -18,6 +19,7 @@ import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.EnterExitState
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.animation.core.animateFloat
 import androidx.compose.animation.core.tween
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
@@ -25,10 +27,9 @@ import androidx.compose.animation.scaleIn
 import androidx.compose.animation.scaleOut
 import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.WindowInsets
 import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.safeDrawing
 import androidx.compose.foundation.clickable
+import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
@@ -42,12 +43,17 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.LocalView
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleOwner
 import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -78,12 +84,14 @@ import io.legado.app.ui.book.read.sheet.TextSelectMenuConfigSheet
 import io.legado.app.ui.book.searchContent.SearchContentResult
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.login.SourceLoginType
+import io.legado.app.ui.main.AndroidPlatformCapabilities
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.ui.replace.ReplaceEditRoute
 import io.legado.app.ui.replace.ReplaceRuleActivity
 import io.legado.app.ui.theme.LocalAppUiConfiguration
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.ui.widget.components.image.cover.sharedCoverSourceRadius
 import io.legado.app.utils.StartActivityContract
 import io.legado.app.utils.takePersistablePermissionSafely
 import io.legado.app.utils.toastOnUi
@@ -154,13 +162,15 @@ fun ReadBookRouteScreen(
     val readerBackground = readerRenderState.background
     val localDensity = LocalDensity.current
     val density = localDensity.density
-    val layoutDirection = LocalLayoutDirection.current
-    val safeDrawingInsets = WindowInsets.safeDrawing
-    val readerContentPadding = ReaderPadding(
-        left = safeDrawingInsets.getLeft(localDensity, layoutDirection),
-        top = safeDrawingInsets.getTop(localDensity),
-        right = safeDrawingInsets.getRight(localDensity, layoutDirection),
-        bottom = safeDrawingInsets.getBottom(localDensity),
+    // 正文避让是配置驱动的(ReaderContentAvoidancePolicy,对照原版 PageView 占位 View):
+    // 菜单开关翻转系统栏可见性时,本 padding 保持恒定,正文不随 overlay 重排。
+    val readerSystemBarInsets = rememberReaderSystemBarInsets()
+    val readerContentPadding = ReaderContentAvoidancePolicy.padding(
+        insets = readerSystemBarInsets,
+        hideStatusBar = readPreferences.hideStatusBar,
+        hideNavigationBar = readPreferences.hideNavigationBar,
+        paddingDisplayCutouts = readPreferences.paddingDisplayCutouts,
+        inMultiWindow = controller.isInMultiWindowModeCompat,
     )
     val context = LocalContext.current
     val lifecycleOwner = LocalLifecycleOwner.current
@@ -601,10 +611,21 @@ fun ReadBookRouteScreen(
     }
     LaunchedEffect(sharedCoverKey, animatedVisibilityScope) {
         if (!readerContentRevealAllowed) {
-            delay(300)
+            delay(240)
             readerContentRevealAllowed = true
         }
     }
+    // 阅读页 sharedBounds 的裁剪圆角动画:从封面源圆角渐变到设备屏幕圆角,
+    // 转场收尾时正文页与物理圆角贴合(Compose 不会自动插值两端 clip,需自行驱动)。
+    val platformCapabilities = remember(controller) { AndroidPlatformCapabilities(controller.activity) }
+    val displayConfiguration = LocalConfiguration.current
+    val displayCornerRadiusPx = remember(displayConfiguration) { platformCapabilities.displayCornerRadiusPx }
+    val readerClipRadiusDp = rememberReaderSharedClipRadiusDp(
+        sharedCoverKey = sharedCoverKey,
+        animatedVisibilityScope = animatedVisibilityScope,
+        targetRadiusPx = displayCornerRadiusPx,
+        density = density,
+    )
     Box(
         Modifier
             .fillMaxSize()
@@ -612,13 +633,17 @@ fun ReadBookRouteScreen(
                 with(sharedTransitionScope) {
                     if (this != null &&
                         animatedVisibilityScope != null &&
-                        sharedCoverKey != null
+                        sharedCoverKey != null &&
+                        readerClipRadiusDp != null
                     ) {
                         Modifier.sharedBounds(
                             sharedContentState = rememberSharedContentState(sharedCoverKey),
                             animatedVisibilityScope = animatedVisibilityScope,
                             enter = fadeIn(animationSpec = tween(600)),
                             exit = fadeOut(animationSpec = tween(600)),
+                            clipInOverlayDuringTransition = OverlayClip(
+                                RoundedCornerShape(readerClipRadiusDp)
+                            ),
                         )
                     } else {
                         Modifier
@@ -647,11 +672,11 @@ fun ReadBookRouteScreen(
             )
             AnimatedVisibility(
                 visible = readerContentRevealAllowed && hasReadablePage,
-                enter = fadeIn(animationSpec = tween(450)),
+                enter = fadeIn(animationSpec = tween(400)),
                 exit = fadeOut(animationSpec = tween(450)),
             ) {
                 ReaderCanvasSurface(
-                pages = readerPageWindow,
+                hostPages = readerPageWindow,
                 transitionMode = ReaderTransitionMode.fromPageAnim(controller.pageAnim),
                 backgroundColor = readerSurfaceColor,
                 backgroundImage = readerBackground.drawable,
@@ -891,6 +916,70 @@ fun ReadBookRouteScreen(
     }
 }
 
+/**
+ * 采样系统栏与刘海的原始尺寸(px),供 [ReaderContentAvoidancePolicy] 做配置驱动避让。
+ * 状态栏/导航栏走 getInsetsIgnoringVisibility——系统栏隐藏或显隐动画期间同样返回真实
+ * 高度(对照 shutiao 的事件化采样),菜单开关不改变采样值;刘海取当前 dispatch 值。
+ * GlobalLayout 采样 + data class 去重:稳态零重组,仅屏幕形状/配置变化写回。
+ */
+@Composable
+private fun rememberReaderSystemBarInsets(): ReaderContentAvoidancePolicy.SystemBarInsets {
+    val view = LocalView.current
+    val configuration = LocalConfiguration.current
+    var barInsets by remember(configuration) {
+        mutableStateOf(sampleReaderSystemBarInsets(view))
+    }
+    DisposableEffect(view, configuration) {
+        val observer = ViewTreeObserver.OnGlobalLayoutListener {
+            barInsets = sampleReaderSystemBarInsets(view)
+        }
+        view.viewTreeObserver.addOnGlobalLayoutListener(observer)
+        onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(observer) }
+    }
+    return barInsets
+}
+
+private fun sampleReaderSystemBarInsets(
+    view: View,
+): ReaderContentAvoidancePolicy.SystemBarInsets {
+    val rootInsets = ViewCompat.getRootWindowInsets(view)
+    val cutout = rootInsets?.getInsets(WindowInsetsCompat.Type.displayCutout())
+    return ReaderContentAvoidancePolicy.SystemBarInsets(
+        statusBarTopPx = rootInsets
+            ?.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.statusBars())?.top ?: 0,
+        navigationBarBottomPx = rootInsets
+            ?.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0,
+        cutoutLeftPx = cutout?.left ?: 0,
+        cutoutTopPx = cutout?.top ?: 0,
+        cutoutRightPx = cutout?.right ?: 0,
+        cutoutBottomPx = cutout?.bottom ?: 0,
+    )
+}
+
+/**
+ * 阅读页 sharedBounds 转场期的裁剪圆角:起点 = 封面在源页面的圆角
+ * (sharedCoverSourceRadius,与封面端动画同源),终点 = 设备屏幕圆角;
+ * 非转场返回 null,不参与裁剪。镜像 CoilBookCover.rememberSharedCoverTransitionRadius。
+ */
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Composable
+private fun rememberReaderSharedClipRadiusDp(
+    sharedCoverKey: String?,
+    animatedVisibilityScope: AnimatedVisibilityScope?,
+    targetRadiusPx: Float,
+    density: Float,
+): Dp? {
+    if (sharedCoverKey == null || animatedVisibilityScope == null) return null
+    val targetRadius = (targetRadiusPx / density).dp
+    val startRadius = sharedCoverSourceRadius(sharedCoverKey) ?: targetRadius
+    val animatedRadius by animatedVisibilityScope.transition.animateFloat(
+        label = "reader-clip-corner-radius",
+    ) { state ->
+        if (state == EnterExitState.Visible) targetRadius.value else startRadius.value
+    }
+    return animatedRadius.dp
+}
+
 @Composable
 private fun ReaderTextSelectionOverlay(
     controller: ReadBookController,

+ 31 - 4
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -104,6 +104,8 @@ import kotlinx.coroutines.flow.takeWhile
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 
+private const val READER_SYNC_MIN_INTERVAL_MS = 250L
+
 /**
  * 阅读界面 ViewModel — MVI/UDF 架构
  *
@@ -158,6 +160,8 @@ class ReadBookViewModel(
     val effects = _effects.asSharedFlow()
     private var composePagePosition: ReaderChapterPagePosition? = null
     @Volatile private var composePageContext: ReaderPageContext? = null
+    private var composeProgressJob: Job? = null
+    private var readBookSyncJob: Job? = null
     private val _readAloudProgress = MutableStateFlow(
         activeReadAloudProgress(
             isPlaying = BaseReadAloudService.isPlay(),
@@ -671,10 +675,20 @@ class ReadBookViewModel(
      * 收集在 mutator 返回之后异步触发,故 syncFromReadBook 读到的 ReadBook 字段已是最终态。
      */
     private fun collectReaderSession() {
+        // 快照同步一律尾随合并:syncFromReadBook 全量重建 UiState(含 ReadMenuConfig
+        // 等 50+ 字段)并整屏重组,不能落在滚动/翻页动画的帧上。热路径(跨页进度)
+        // 已不发布快照,剩余发布主要是切章/换书等结构变化,延迟一个间隔无感。
         viewModelScope.launch {
-            readerSession.state.collect {
-                _uiState.update { state -> syncFromReadBook(state) }
-            }
+            readerSession.state.collect { scheduleReadBookSync() }
+        }
+    }
+
+    /** 合并高频快照/事件刷新:连续发布只保留最后一次,同步永远离帧 250ms。 */
+    private fun scheduleReadBookSync() {
+        readBookSyncJob?.cancel()
+        readBookSyncJob = viewModelScope.launch {
+            delay(READER_SYNC_MIN_INTERVAL_MS)
+            _uiState.update { syncFromReadBook(it) }
         }
     }
 
@@ -690,7 +704,9 @@ class ReadBookViewModel(
             readerSession.events.collect { event ->
                 when (event) {
                     is ReaderSessionEvent.StateInvalidated -> {
-                        _uiState.update { syncFromReadBook(it) }
+                        // 切章等结构性变化也走尾随合并:发布点多在翻页帧上
+                        // (如 moveToNextChapter 的 upMenuView),立即重组会掉帧。
+                        scheduleReadBookSync()
                     }
 
                     is ReaderSessionEvent.ChapterListRequested -> loadChapterList(event.book)
@@ -2431,6 +2447,17 @@ class ReadBookViewModel(
         composePagePosition = position
         composePageContext = pageContext
         if (position == null || position.chapterIndex != ReadBook.durChapterIndex) return
+        // 滚动热路径每次跨页都会调用;进度 UI(_uiState) 是整屏重组源,延迟到节拍
+        // 间隙发布。composePagePosition/Context 已同步更新,直读字段的路径不受影响。
+        composeProgressJob?.cancel()
+        composeProgressJob = viewModelScope.launch {
+            delay(250L)
+            publishComposeProgress()
+        }
+    }
+
+    private fun publishComposeProgress() {
+        val position = composePagePosition?.takeIf { it.chapterIndex == ReadBook.durChapterIndex } ?: return
         _uiState.update { state ->
             state.copy(
                 durPageIndex = position.pageIndex,

+ 49 - 0
app/src/main/java/io/legado/app/ui/book/read/ReaderContentAvoidancePolicy.kt

@@ -0,0 +1,49 @@
+package io.legado.app.ui.book.read
+
+/**
+ * 正文/页眉/页脚对系统栏的避让策略,对照原版 PageView 占位 View 语义(配置驱动):
+ *
+ * - 状态栏占位 = vwStatusBar:高度恒为状态栏高,`isGone = hideStatusBar || 多窗口`;
+ *   导航栏占位 = vwNavigationBar:`isGone = hideNavigationBar`。两者都不跟随系统栏
+ *   瞬时可见性——菜单打开时系统栏只是 overlay,正文不重排(原版 LAYOUT_HIDE_NAVIGATION
+ *   的布局稳定语义)。
+ * - 刘海避让 = paddingDisplayCutouts(填充刘海区域):开启时按 displayCutout insets
+ *   避让左右/下,顶部只在状态栏占位消失时避让(占位本身已盖住刘海)。
+ * - IME 不参与正文避让(原版 vwNavigationBar 的 insets 监听显式跳过 ime)。
+ *
+ * 高度入参必须是"忽略可见性"的平台采样(getInsetsIgnoringVisibility),隐藏/显隐动画
+ * 期间恒定——本策略因此天然事件化:只有配置、多窗口或屏幕形状变化才改写结果,
+ * 系统栏显隐动画与菜单开关不再触发整章重排。
+ */
+object ReaderContentAvoidancePolicy {
+
+    /** 平台侧采样的系统栏与刘海原始尺寸(px)。 */
+    data class SystemBarInsets(
+        val statusBarTopPx: Int = 0,
+        val navigationBarBottomPx: Int = 0,
+        val cutoutLeftPx: Int = 0,
+        val cutoutTopPx: Int = 0,
+        val cutoutRightPx: Int = 0,
+        val cutoutBottomPx: Int = 0,
+    )
+
+    fun padding(
+        insets: SystemBarInsets,
+        hideStatusBar: Boolean,
+        hideNavigationBar: Boolean,
+        paddingDisplayCutouts: Boolean,
+        inMultiWindow: Boolean,
+    ): ReaderPadding {
+        val statusPlaceholderGone = hideStatusBar || inMultiWindow
+        val navPlaceholderGone = hideNavigationBar || inMultiWindow
+        val cutout = if (paddingDisplayCutouts) insets else SystemBarInsets()
+        return ReaderPadding(
+            left = cutout.cutoutLeftPx,
+            top = (if (statusPlaceholderGone) 0 else insets.statusBarTopPx) +
+                (if (statusPlaceholderGone) cutout.cutoutTopPx else 0),
+            right = cutout.cutoutRightPx,
+            bottom = (if (navPlaceholderGone) 0 else insets.navigationBarBottomPx) +
+                cutout.cutoutBottomPx,
+        )
+    }
+}

+ 43 - 0
app/src/main/java/io/legado/app/ui/main/AndroidPlatformCapabilities.kt

@@ -0,0 +1,43 @@
+package io.legado.app.ui.main
+
+import android.os.Build
+import android.view.RoundedCorner
+import androidx.appcompat.app.AppCompatActivity
+
+/**
+ * 平台能力采样(对照 shutiao 版 AndroidPlatformCapabilities,按需增量补充,不预先铺接口)。
+ */
+class AndroidPlatformCapabilities(private val activity: AppCompatActivity) {
+
+    /**
+     * 屏幕圆角半径 px:四角中非零者的最小值(忽略报告为 0 的角),
+     * 取不到或全 0 时回退 [DEFAULT_DISPLAY_CORNER_RADIUS_PX]
+     * (对照参考实现 AppTransitionInjector.initDisplayRoundCorner 语义)。
+     * 半径可能返回 null(设备未报告圆角、部分多窗口模式),null ≠ 无圆角。
+     * 不缓存:折叠屏切换屏幕后半径会变;取值来自 DisplayInfo,很廉价。
+     */
+    val displayCornerRadiusPx: Float
+        get() {
+            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+                return DEFAULT_DISPLAY_CORNER_RADIUS_PX
+            }
+            val display = activity.display ?: return DEFAULT_DISPLAY_CORNER_RADIUS_PX
+            val radii = listOf(
+                RoundedCorner.POSITION_TOP_LEFT,
+                RoundedCorner.POSITION_TOP_RIGHT,
+                RoundedCorner.POSITION_BOTTOM_RIGHT,
+                RoundedCorner.POSITION_BOTTOM_LEFT,
+            ).map { display.getRoundedCorner(it)?.radius?.toFloat() ?: 0f }
+            return selectCornerRadiusPx(radii)
+        }
+
+    companion object {
+        const val DEFAULT_DISPLAY_CORNER_RADIUS_PX = 60f
+
+        /** 纯选择逻辑:非零角取最小,全零回退(采样与单测共用)。 */
+        fun selectCornerRadiusPx(
+            radii: List<Float>,
+            fallback: Float = DEFAULT_DISPLAY_CORNER_RADIUS_PX,
+        ): Float = radii.filter { it > 0f }.minOrNull() ?: fallback
+    }
+}

+ 7 - 0
app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt

@@ -54,6 +54,13 @@ private const val SharedCoverRadiusCacheMaxSize = 256
 private const val DefaultCoverPath = "use_default_cover"
 private val sharedCoverRadiusCache = mutableStateMapOf<String, Dp>()
 
+/**
+ * 封面在源页面的圆角缓存读取入口:封面离开源页面(Visible→Visible 定格)时写入,
+ * 阅读端 sharedBounds 的起始圆角由它提供,保证转场两端圆角衔接连续。
+ */
+internal fun sharedCoverSourceRadius(sharedCoverKey: String?): Dp? =
+    sharedCoverKey?.let { sharedCoverRadiusCache[it] }
+
 @Composable
 internal fun usesDefaultBookCover(path: String?): Boolean {
     return LocalAppUiConfiguration.current.cover.useDefaultCover ||

+ 66 - 0
app/src/test/java/io/legado/app/feature/reader/ScrollPageDrawCacheTest.kt

@@ -0,0 +1,66 @@
+package io.legado.app.feature.reader
+
+import android.app.Application
+import io.legado.app.feature.reader.core.model.ReaderElement
+import io.legado.app.feature.reader.core.model.ReaderPage
+import io.legado.app.feature.reader.core.model.ReaderPageId
+import io.legado.app.feature.reader.core.model.ReaderRect
+import io.legado.app.feature.reader.core.model.ReaderTextStyle
+import org.junit.Assert.assertNotSame
+import org.junit.Assert.assertSame
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+class ScrollPageDrawCacheTest {
+
+    @Test
+    fun `visual page refresh sharing elements reuses draw data`() {
+        val elements = listOf(text())
+        val original = page(elements)
+        val refreshed = original.copy(revision = 2L)
+        val cache = ScrollPageDrawCache()
+
+        val originalData = cache.ensure(original)
+
+        assertSame(originalData, cache.ensure(refreshed))
+    }
+
+    @Test
+    fun `repaginated page with a new elements list rebuilds draw data`() {
+        val original = page(listOf(text()))
+        val repaginated = original.copy(elements = listOf(text()))
+        val cache = ScrollPageDrawCache()
+
+        val originalData = cache.ensure(original)
+
+        assertNotSame(originalData, cache.ensure(repaginated))
+    }
+
+    private fun page(elements: List<ReaderElement>) = ReaderPage(
+        id = ReaderPageId(0, 0),
+        chapterTitle = "",
+        text = "text",
+        widthPx = 100,
+        heightPx = 200,
+        contentTopPx = 0f,
+        contentBottomPx = 200f,
+        elements = elements,
+        revision = 1L,
+    )
+
+    private fun text() = ReaderElement.Text(
+        bounds = ReaderRect(0f, 0f, 20f, 20f),
+        baselinePx = 16f,
+        value = "text",
+        style = ReaderTextStyle(colorArgb = 0xff000000.toInt(), fontSizePx = 16f),
+        selected = false,
+        emphasized = false,
+        chapterPosition = 0,
+    )
+}

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

@@ -143,4 +143,46 @@ class ReaderPageNavigatorTest {
         assertNull(ReaderPageNavigator.pageContext(listOf(titleOnly), 0)?.contentStartPosition)
         assertNull(ReaderPageNavigator.bodyParagraphAt(listOf(titleOnly), 4, 50))
     }
+
+    @Test
+    fun placeholderIsRequestedOnlyForExistingUnloadedAdjacentChapters() {
+        // 邻章在书中存在但未分页: 上一章 3、下一章 5 都缺
+        val pages = listOf(page(0, 0), page(1, 20))
+        assertEquals(
+            listOf(3, 5),
+            ReaderPageNavigator.missingAdjacentChapters(pages, pageIndex = 0, chapterCount = 6),
+        )
+        // 邻章已分页或不存在: 不需要占位
+        assertEquals(
+            listOf(3),
+            ReaderPageNavigator.missingAdjacentChapters(pages, pageIndex = 0, chapterCount = 5),
+        )
+        // 上一章已分页、下一章超出书末: 不需要占位
+        val withPrevChapter = pages + page(index = 0, start = 0, chapterIndex = 3)
+        assertEquals(
+            emptyList<Int>(),
+            ReaderPageNavigator.missingAdjacentChapters(
+                withPrevChapter, pageIndex = 0, chapterCount = 5,
+            ),
+        )
+        val withNextChapter = pages + page(index = 0, start = 0, chapterIndex = 5)
+        assertEquals(
+            listOf(3),
+            ReaderPageNavigator.missingAdjacentChapters(
+                withNextChapter, pageIndex = 0, chapterCount = 6,
+            ),
+        )
+    }
+
+    @Test
+    fun placeholderPageNeverExtendsFurtherPlaceholders() {
+        // 占位页是死端: 装载完成前不再向更远处串章
+        val placeholder = page(0, 0, chapterIndex = 5).copy(isPlaceholder = true)
+        assertEquals(
+            emptyList<Int>(),
+            ReaderPageNavigator.missingAdjacentChapters(
+                listOf(placeholder), pageIndex = 0, chapterCount = 8,
+            ),
+        )
+    }
 }

+ 83 - 0
app/src/test/java/io/legado/app/feature/reader/core/style/ReaderBackgroundBoundsTest.kt

@@ -0,0 +1,83 @@
+package io.legado.app.feature.reader.core.style
+
+import io.legado.app.feature.reader.core.model.ReaderElement
+import io.legado.app.feature.reader.core.model.ReaderRect
+import io.legado.app.feature.reader.core.model.ReaderTextStyle
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ReaderBackgroundBoundsTest {
+
+    private val baseStyle = ReaderTextStyle(colorArgb = 0xFF000000.toInt(), fontSizePx = 40f)
+
+    private fun textElement(
+        left: Float,
+        right: Float,
+        top: Float,
+        bottom: Float,
+        backgroundArgb: Int?,
+    ): ReaderElement.Text = ReaderElement.Text(
+        bounds = ReaderRect(left, top, right, bottom),
+        baselinePx = bottom - 8f,
+        value = "字",
+        style = baseStyle.copy(backgroundArgb = backgroundArgb),
+        selected = false,
+        emphasized = false,
+        chapterPosition = 0,
+    )
+
+    @Test fun mergesAdjacentSameColorBoxesOnTheSameLine() {
+        val red = 0xFFAA0000.toInt()
+        val bands = listOf(
+            textElement(0f, 40f, 0f, 50f, red),
+            textElement(42f, 82f, 0f, 50f, red),
+            textElement(84f, 124f, 0f, 50f, red),
+        ).mergeBackgroundBounds()
+        assertEquals(1, bands.size)
+        assertEquals(red, bands[0].colorArgb)
+        assertEquals(ReaderRect(0f, 0f, 124f, 50f), bands[0].bounds)
+    }
+
+    @Test fun differentColorStartsANewBand() {
+        val red = 0xFFAA0000.toInt()
+        val blue = 0xFF0000AA.toInt()
+        val bands = listOf(
+            textElement(0f, 40f, 0f, 50f, red),
+            textElement(42f, 82f, 0f, 50f, blue),
+            textElement(84f, 124f, 0f, 50f, red),
+        ).mergeBackgroundBounds()
+        assertEquals(listOf(red, blue, red), bands.map { it.colorArgb })
+    }
+
+    @Test fun differentLineStartsANewBand() {
+        val red = 0xFFAA0000.toInt()
+        val bands = listOf(
+            textElement(0f, 40f, 0f, 50f, red),
+            textElement(0f, 40f, 60f, 110f, red),
+        ).mergeBackgroundBounds()
+        assertEquals(2, bands.size)
+    }
+
+    @Test fun gapBeyondToleranceStartsANewBand() {
+        val red = 0xFFAA0000.toInt()
+        val bands = listOf(
+            textElement(0f, 40f, 0f, 50f, red),
+            textElement(200f, 240f, 0f, 50f, red),
+        ).mergeBackgroundBounds()
+        assertEquals(2, bands.size)
+    }
+
+    @Test fun unstyledElementsAreSkipped() {
+        val red = 0xFFAA0000.toInt()
+        val bands = listOf(
+            textElement(0f, 40f, 0f, 50f, null),
+            textElement(42f, 82f, 0f, 50f, red),
+        ).mergeBackgroundBounds()
+        assertEquals(1, bands.size)
+        assertEquals(ReaderRect(42f, 0f, 82f, 50f), bands[0].bounds)
+    }
+
+    @Test fun emptyInputYieldsNoBands() {
+        assertEquals(emptyList<ReaderBackgroundBand>(), emptyList<ReaderElement.Text>().mergeBackgroundBounds())
+    }
+}

+ 25 - 4
app/src/test/java/io/legado/app/feature/reader/core/transition/PageCurlGeometryTest.kt

@@ -6,10 +6,12 @@ import org.junit.Assert.assertTrue
 import org.junit.Test
 
 class PageCurlGeometryTest {
-    @Test fun curlShadowColorsMatchLegacyGradientDrawables() {
-        assertEquals(0xFF111111.toInt(), ReaderCurlVisualPolicy.backShadowDarkArgb)
-        assertEquals(0x80111111.toInt(), ReaderCurlVisualPolicy.frontShadowDarkArgb)
-        assertEquals(0xB0333333.toInt(), ReaderCurlVisualPolicy.folderShadowDarkArgb)
+    @Test fun curlShadowColorsSoftenTheLegacyGradientDrawables() {
+        // 刻意偏离原版峰值(0xFF/0x80/0xB0):折缝全不透明黑边观感偏重,
+        // 峰值 alpha 按约六成调轻,色相与渐变结构不变。
+        assertEquals(0x99111111.toInt(), ReaderCurlVisualPolicy.backShadowDarkArgb)
+        assertEquals(0x4D111111.toInt(), ReaderCurlVisualPolicy.frontShadowDarkArgb)
+        assertEquals(0x73333333.toInt(), ReaderCurlVisualPolicy.folderShadowDarkArgb)
     }
 
     @Test fun choosesTouchedCornerAndProducesFiniteBezierFrame() {
@@ -59,6 +61,25 @@ class PageCurlGeometryTest {
         assertEquals(1000f, ReaderCurlTouchPolicy.settledX(ReaderTurnDirection.NEXT, false, 1000f))
     }
 
+    @Test fun curlGeometryFollowsTouchBeyondTheLeftEdgeUntilTheFoldLeavesThePage() {
+        val corner = CurlPoint(1080f, 1920f)
+        val near = PageCurlGeometry.calculate(1080f, 1920f, -270f, 1919f, corner)!!
+        val beyond = PageCurlGeometry.calculate(1080f, 1920f, -540f, 1919f, corner)!!
+        // 收尾动画把触点送出页外后,几何必须继续跟随原始触点滑动,
+        // 折页边缘随之滑出左缘,而不是收敛在页内冻结到结束帧。
+        assertEquals(-540f, beyond.touch.x, .001f)
+        assertTrue(beyond.isValid)
+        assertTrue(beyond.start1.x < near.start1.x)
+        assertTrue(beyond.start1.x < 0f)
+    }
+
+    @Test fun curlGeometryKeepsTheInPageTouchGuardWhileReleasingOffPageTouches() {
+        assertEquals(.1f, PageCurlGeometry.calculate(1080f, 1920f, 0f, 1919f, CurlPoint(1080f, 1920f))!!.touch.x, .001f)
+        val topExit = PageCurlGeometry.calculate(1080f, 1920f, -540f, 1f, CurlPoint(1080f, 0f))!!
+        assertTrue(topExit.isValid)
+        assertTrue(topExit.start1.x < 0f)
+    }
+
     @Test fun simulationSettleDurationUsesItsFullLegacyCurlTravel() {
         assertEquals(570, ReaderCurlTouchPolicy.settleDurationMillis(900f, -1000f, 1000f))
         assertEquals(300, ReaderCurlTouchPolicy.settleDurationMillis(0f, 1000f, 1000f))

+ 67 - 11
app/src/test/java/io/legado/app/feature/reader/core/transition/ReaderScrollStateTest.kt

@@ -14,11 +14,6 @@ import org.junit.Test
 class ReaderScrollStateTest {
     private val style = ReaderTextStyle(0, 16f)
 
-    @Test fun pageCrossingFreezesOldWindowUntilTargetPageArrives() {
-        assertTrue(ReaderScrollPolicy.canApplyDelta(pendingPageTarget = null))
-        assertFalse(ReaderScrollPolicy.canApplyDelta(ReaderPageId(0, 1)))
-    }
-
     @Test fun crossingNextCarriesRemainder() {
         val result = ReaderScrollPolicy.apply(-900f, -250f, 1000f, 1000f, 800f, true, true)
         assertEquals(ReaderScrollCrossing.NEXT, result.crossing)
@@ -37,11 +32,30 @@ class ReaderScrollStateTest {
         assertEquals(-400f, result.offsetPx)
     }
 
-    @Test fun pageChangeKeepsOnlyTheCrossingRemainderForTheExpectedTarget() {
-        val target = ReaderPageId(2, 4)
-        assertEquals(-35f, ReaderScrollPolicy.offsetAfterPageChange(-35f, target, target), 0f)
-        assertEquals(0f, ReaderScrollPolicy.offsetAfterPageChange(-35f, target, ReaderPageId(2, 8)), 0f)
-        assertEquals(0f, ReaderScrollPolicy.offsetAfterPageChange(-35f, null, target), 0f)
+    @Test fun crossingRemainderStaysWithinTheReplacementPageExtent() {
+        // 跨页后携带的余量必须落在新当前页的滚动范围内:同步换窗后渲染层直接以
+        // 该余量继续滚动,不允许出现越界空档或增量丢弃。单帧位移以一个视口为上限。
+        repeat(50) { seed ->
+            val currentExtent = 800f + seed * 7f
+            val nextExtent = 700f + seed * 3f
+            val overshoot = 1f + (seed % 5) * 150f
+            val delta = -(currentExtent - 300f + overshoot)
+            val result = ReaderScrollPolicy.apply(
+                -300f, delta, 900f, currentExtent, 800f, true, true,
+            )
+            assertEquals(ReaderScrollCrossing.NEXT, result.crossing)
+            assertTrue(result.offsetPx < 0f)
+            assertTrue(result.offsetPx > -nextExtent)
+        }
+    }
+
+    @Test fun oversizedDeltaCrossesAgainOnTheNextFrame() {
+        // 超过两页的极端步长(仅程序化滚动可能触达):余量超出下一页范围时,
+        // 下一帧按同一策略继续折算跨页,输入不丢失也不冻结。
+        val first = ReaderScrollPolicy.apply(-300f, -2500f, 900f, 1000f, 800f, true, true)
+        assertEquals(ReaderScrollCrossing.NEXT, first.crossing)
+        val second = ReaderScrollPolicy.apply(first.offsetPx, 0f, 800f, 900f, 800f, true, true)
+        assertEquals(ReaderScrollCrossing.NEXT, second.crossing)
     }
 
     @Test fun textPageStepsKeepOneVisibleRowInBothDirections() {
@@ -82,9 +96,51 @@ class ReaderScrollStateTest {
         assertEquals(-80f, ReaderScrollPolicy.pageStep(emptyPage, 0f, ReaderTurnDirection.NEXT), 0f)
     }
 
+    @Test fun tapStepAtPageBottomContinuesIntoTheNextPage() {
+        // 滚到页尾(只剩当前页末行可见、下一页顶部已露出)时,合成可视页的末行
+        // 在下一页里,点按下一页应跨过页边界继续滚,而不是停在页尾只挪几像素。
+        val rows = listOf(
+            text(0f, 20f, 0),
+            text(20f, 40f, 1),
+            text(40f, 60f, 2),
+            text(60f, 80f, 3),
+        )
+        val current = scrollPage(rows, extent = 80f)
+        val next = scrollPage(rows, extent = 80f)
+
+        val withNext = ReaderScrollPolicy.pageStep(current, -70f, ReaderTurnDirection.NEXT, next = next)
+        assertEquals(-70f, withNext, 0f)
+
+        // 对照:不提供邻页时保持旧行为(只在当前页内折算,页尾步距退化为整视口钳制)。
+        val withoutNext = ReaderScrollPolicy.pageStep(current, -70f, ReaderTurnDirection.NEXT)
+        assertEquals(-80f, withoutNext, 0f)
+    }
+
+    @Test fun tapStepAtPageTopContinuesIntoThePreviousPage() {
+        // 跨页余量为正(页顶露出上一页末行)时,合成可视页的首行在上一页里,
+        // 点按上一页的步距把该行对齐到视口底。
+        val rows = listOf(
+            text(0f, 20f, 0),
+            text(20f, 40f, 1),
+            text(40f, 60f, 2),
+            text(60f, 80f, 3),
+        )
+        val current = scrollPage(rows, extent = 80f)
+        val previous = scrollPage(rows, extent = 80f)
+
+        val withPrevious = ReaderScrollPolicy.pageStep(
+            current, 10f, ReaderTurnDirection.PREVIOUS, previous = previous,
+        )
+        assertEquals(70f, withPrevious, 0f)
+
+        val withoutPrevious = ReaderScrollPolicy.pageStep(current, 10f, ReaderTurnDirection.PREVIOUS)
+        assertEquals(50f, withoutPrevious, 0f)
+    }
+
     private fun scrollPage(
         elements: List<ReaderElement>,
         inlineImages: Boolean = true,
+        extent: Float = 200f,
     ) = ReaderPage(
         id = ReaderPageId(0, 0),
         chapterTitle = "",
@@ -95,7 +151,7 @@ class ReaderScrollStateTest {
         contentBottomPx = 90f,
         elements = elements,
         revision = 1,
-        scrollExtentPx = 200f,
+        scrollExtentPx = extent,
         inlineImagesPreserveScrollLine = inlineImages,
     )
 

+ 80 - 0
app/src/test/java/io/legado/app/ui/book/read/ReaderContentAvoidancePolicyTest.kt

@@ -0,0 +1,80 @@
+package io.legado.app.ui.book.read
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ReaderContentAvoidancePolicyTest {
+
+    private val insets = ReaderContentAvoidancePolicy.SystemBarInsets(
+        statusBarTopPx = 100,
+        navigationBarBottomPx = 140,
+        cutoutLeftPx = 10,
+        cutoutTopPx = 120,
+        cutoutRightPx = 12,
+        cutoutBottomPx = 4,
+    )
+
+    @Test fun defaultConfigAvoidsBothBarsWithoutCutout() {
+        val padding = ReaderContentAvoidancePolicy.padding(
+            insets, hideStatusBar = false, hideNavigationBar = false,
+            paddingDisplayCutouts = false, inMultiWindow = false,
+        )
+        assertEquals(ReaderPadding(top = 100, bottom = 140), padding)
+    }
+
+    @Test fun hideNavigationBarRemovesBottomAvoidanceOnly() {
+        val padding = ReaderContentAvoidancePolicy.padding(
+            insets, hideStatusBar = false, hideNavigationBar = true,
+            paddingDisplayCutouts = false, inMultiWindow = false,
+        )
+        assertEquals(ReaderPadding(top = 100), padding)
+    }
+
+    @Test fun hideStatusBarRemovesTopAvoidanceOnly() {
+        val padding = ReaderContentAvoidancePolicy.padding(
+            insets, hideStatusBar = true, hideNavigationBar = false,
+            paddingDisplayCutouts = false, inMultiWindow = false,
+        )
+        assertEquals(ReaderPadding(bottom = 140), padding)
+    }
+
+    @Test fun multiWindowForcesNoBarAvoidanceRegardlessOfConfig() {
+        val padding = ReaderContentAvoidancePolicy.padding(
+            insets, hideStatusBar = false, hideNavigationBar = false,
+            paddingDisplayCutouts = false, inMultiWindow = true,
+        )
+        assertEquals(ReaderPadding(), padding)
+    }
+
+    @Test fun paddingDisplayCutoutsAddsCutoutInsetsOnAllEdges() {
+        val padding = ReaderContentAvoidancePolicy.padding(
+            insets, hideStatusBar = true, hideNavigationBar = true,
+            paddingDisplayCutouts = true, inMultiWindow = false,
+        )
+        assertEquals(ReaderPadding(left = 10, top = 120, right = 12, bottom = 4), padding)
+    }
+
+    @Test fun cutoutTopIsSkippedWhileTheStatusBarPlaceholderCoversIt() {
+        val padding = ReaderContentAvoidancePolicy.padding(
+            insets, hideStatusBar = false, hideNavigationBar = true,
+            paddingDisplayCutouts = true, inMultiWindow = false,
+        )
+        // 状态栏占位可见(100 >= 刘海顶 120 的原版假设由占位高度覆盖),顶部不再叠加刘海
+        assertEquals(ReaderPadding(left = 10, top = 100, right = 12, bottom = 4), padding)
+    }
+
+    @Test fun policyOutputIsIndependentOfBarVisibilityTransitions() {
+        // 系统栏显隐只改变平台 dispatch,不改变"忽略可见性"的采样值;
+        // 同一采样 + 同一配置必须得到同一 padding(菜单开关零重排的依据)
+        val before = ReaderContentAvoidancePolicy.padding(
+            insets, hideStatusBar = true, hideNavigationBar = true,
+            paddingDisplayCutouts = false, inMultiWindow = false,
+        )
+        val after = ReaderContentAvoidancePolicy.padding(
+            insets, hideStatusBar = true, hideNavigationBar = true,
+            paddingDisplayCutouts = false, inMultiWindow = false,
+        )
+        assertEquals(before, after)
+        assertEquals(ReaderPadding(), before)
+    }
+}

+ 40 - 0
app/src/test/java/io/legado/app/ui/main/AndroidPlatformCapabilitiesTest.kt

@@ -0,0 +1,40 @@
+package io.legado.app.ui.main
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class AndroidPlatformCapabilitiesTest {
+
+    @Test fun selectCornerRadiusTakesMinOfNonZeroCorners() {
+        assertEquals(
+            80f,
+            AndroidPlatformCapabilities.selectCornerRadiusPx(listOf(0f, 100f, 80f, 120f)),
+            .001f,
+        )
+    }
+
+    @Test fun selectCornerRadiusIgnoresReportedZeroCorners() {
+        assertEquals(
+            100f,
+            AndroidPlatformCapabilities.selectCornerRadiusPx(listOf(100f, 0f, 0f)),
+            .001f,
+        )
+    }
+
+    @Test fun selectCornerRadiusFallsBackWhenNoCornerIsReported() {
+        assertEquals(
+            AndroidPlatformCapabilities.DEFAULT_DISPLAY_CORNER_RADIUS_PX,
+            AndroidPlatformCapabilities.selectCornerRadiusPx(emptyList()),
+            .001f,
+        )
+        assertEquals(
+            AndroidPlatformCapabilities.DEFAULT_DISPLAY_CORNER_RADIUS_PX,
+            AndroidPlatformCapabilities.selectCornerRadiusPx(listOf(0f, 0f, 0f, 0f)),
+            .001f,
+        )
+    }
+
+    @Test fun selectCornerRadiusHonorsCustomFallback() {
+        assertEquals(24f, AndroidPlatformCapabilities.selectCornerRadiusPx(emptyList(), fallback = 24f), .001f)
+    }
+}