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

fix: 按审查意见修复下滑书签手势与架构门禁

1. 下滑手势不再在越过 touch slop 那一帧永久认领本次触摸:改为每帧
   MOVE 按当前累计位移重新判定,方向翻转为横向占优时交还 pageDelegate,
   不再吞掉斜向翻页;交还后用 bookmarkSwipeReleased latch 住,避免中途
   抢回导致翻页动画停在半路。抬手时再按最终位移复核一次。

2. 书签收集下沉到 ReadBookmarkDelegate:flowByBook 订阅、
   ReaderBookmarkState 快照的写入与清理全部归该 delegate,
   VM 只投影 bookKey 并转发 intent。快照清理改为随 scope 结束
   在 finally 里做,不再占用 onCleared。

3. 按审查意见拆出无关改动:RssScreen 的 ellipsizeSourceName 与其测试、
   gradle.properties 的行尾改动全部还原到 origin/main,本 PR 只留
   下滑书签手势本身。

架构门禁 2520 → 2523 并说明理由:新增的不是域,留在 VM 的 8 行是
无法再摘的接线。同时把 flowByBook / ReaderBookmarkState 加进书签域的
stateTypes,守住这部分逻辑不回流 VM。
Wuying Created Local Users 3 месяцев назад
Родитель
Сommit
ab49ca97ac

+ 2 - 33
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -62,7 +62,6 @@ import io.legado.app.model.ImageProvider
 import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadAloudSessionStore
 import io.legado.app.model.ReadBook
-import io.legado.app.model.ReaderBookmarkState
 import io.legado.app.model.ReaderSession
 import io.legado.app.model.ReaderSessionEvent
 import io.legado.app.model.SourceCallBack
@@ -86,7 +85,6 @@ import io.legado.app.utils.toStringArray
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.collections.immutable.toImmutableMap
 import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.channels.awaitClose
 import kotlinx.coroutines.delay
@@ -96,10 +94,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.callbackFlow
-import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.flatMapLatest
-import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.takeWhile
 import kotlinx.coroutines.flow.update
@@ -352,6 +347,7 @@ class ReadBookViewModel(
             }
         },
         bookmarkRepository = bookmarkRepository,
+        bookKey = _uiState.map { it.book?.let { book -> book.name to book.author } },
     )
 
     // --- 开书 / 目录 / 换源 / 进度同步域(无自持状态,isInitFinish 仍在 UiState)---
@@ -575,7 +571,7 @@ class ReadBookViewModel(
         collectEventBus()
         collectReaderSession()
         collectReadStyle()
-        collectBookmarks()
+        bookmarkDelegate.start()
         replaceRuleDelegate.start()
         execute { readAloudDelegate.syncConfiguredTtsVoices() }
     }
@@ -611,32 +607,6 @@ class ReadBookViewModel(
         }
     }
 
-    /**
-     * 维护 [ReaderBookmarkState]:渲染层要在主线程热路径上同步判定「本页是否有书签」画角标,
-     * 不能起协程查库,所以把当前书的书签位置整份缓存进会话快照。
-     *
-     * 书名/作者是 `bookmarks` 表的关联键(无 bookUrl 列),故以它们为 flow 的切换键。
-     */
-    @OptIn(ExperimentalCoroutinesApi::class)
-    private fun collectBookmarks() {
-        viewModelScope.launch {
-            uiState
-                .map { it.book?.let { book -> book.name to book.author } }
-                .distinctUntilChanged()
-                .flatMapLatest { key ->
-                    if (key == null) {
-                        flowOf(emptyList())
-                    } else {
-                        bookmarkRepository.flowByBook(key.first, key.second)
-                    }
-                }
-                .collect { bookmarks ->
-                    ReaderBookmarkState.update(bookmarks)
-                    _effects.tryEmit(ReadBookEffect.UpBookmarkBadge)
-                }
-        }
-    }
-
     /**
      * 消费 [ReaderSession.state](Track A A5):会话快照在任意受控 mutator 完成后发射,
      * 据此驱动 UiState 刷新。与遗留 CallBack 刷新路径叠加、幂等(相同结果 StateFlow 不再发),
@@ -2517,7 +2487,6 @@ class ReadBookViewModel(
 
     override fun onCleared() {
         translationStatusJob?.cancel()
-        ReaderBookmarkState.clear()
         super.onCleared()
         if (BaseReadAloudService.isRun && BaseReadAloudService.pause) {
             ReadAloud.stop(context)

+ 40 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookmarkDelegate.kt

@@ -4,9 +4,15 @@ import io.legado.app.R
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.repository.BookmarkRepository
 import io.legado.app.model.ReadBook
+import io.legado.app.model.ReaderBookmarkState
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.Main
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import splitties.init.appCtx
@@ -21,6 +27,11 @@ class ReadBookmarkDelegate(
     private val scope: CoroutineScope,
     private val host: Host,
     private val bookmarkRepository: BookmarkRepository,
+    /**
+     * 当前书的 `书名 to 作者`(未开书时为 null)——`bookmarks` 表没有 bookUrl 列,
+     * 这两个字段就是关联键。[start] 用它当切换键重订书签流。
+     */
+    private val bookKey: Flow<Pair<String, String>?>,
 ) {
 
     interface Host {
@@ -30,6 +41,35 @@ class ReadBookmarkDelegate(
         fun emitEffect(effect: ReadBookEffect)
     }
 
+    /**
+     * 维护 [ReaderBookmarkState]:渲染层要在主线程热路径上同步判定「本页是否有书签」画角标,
+     * 不能起协程查库,所以把当前书的书签位置整份缓存进会话快照。VM 构造时调一次。
+     *
+     * 收集随 [scope] 结束,`finally` 里清掉快照——否则下一本书会读到上一本的书签位置。
+     */
+    @OptIn(ExperimentalCoroutinesApi::class)
+    fun start() {
+        scope.launch {
+            try {
+                bookKey
+                    .distinctUntilChanged()
+                    .flatMapLatest { key ->
+                        if (key == null) {
+                            flowOf(emptyList())
+                        } else {
+                            bookmarkRepository.flowByBook(key.first, key.second)
+                        }
+                    }
+                    .collect { bookmarks ->
+                        ReaderBookmarkState.update(bookmarks)
+                        host.emitEffect(ReadBookEffect.UpBookmarkBadge)
+                    }
+            } finally {
+                ReaderBookmarkState.clear()
+            }
+        }
+    }
+
     /**
      * 下滑手势:本页无书签则直接存一条(不弹编辑器),已有则删掉本页范围内的全部书签。
      *

+ 37 - 12
app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt

@@ -107,12 +107,20 @@ class ReadView(
     private val initialTextPos = TextPos(0, 0, 0)
 
     /**
-     * 本次手势已被判定为「下滑切换书签」。
+     * 本次手势**仍可能**是「下滑切换书签」。
      *
-     * 归属在 [isMove] 刚成立那一帧就定下来(此时首次拿到方向),之后的 MOVE 一律不再喂给
-     * [pageDelegate],否则横向委托会同时启动翻页动画。
+     * 只是候选而非认领:每帧 MOVE 都按当前位移重新判定,因此中途转为横向占优的斜划会
+     * 交还 [pageDelegate] 正常翻页,不会被吞掉。候选期间不喂 [pageDelegate],
+     * 否则横向委托会同时启动翻页动画。
      */
     private var isBookmarkSwipe = false
+
+    /**
+     * 手势已交还 [pageDelegate],本次触摸不再收回。
+     *
+     * 交还那一刻委托就可能起了翻页动画,中途再抢回来会留下一个停在半路的动画。
+     */
+    private var bookmarkSwipeReleased = false
     private val bookmarkSwipeMinDistance by lazy { BOOKMARK_SWIPE_MIN_DISTANCE_DP.dpToPx() }
 
     private val slopSquare by lazy { ViewConfiguration.get(context).scaledTouchSlop }
@@ -243,7 +251,7 @@ class ReadView(
                 postDelayed(longPressRunnable, longPressTimeout)
                 pressDown = true
                 isMove = false
-                isBookmarkSwipe = false
+                resetBookmarkSwipe()
                 pageDelegate?.onTouch(event)
                 pageDelegate?.onDown()
                 setStartPoint(event.x, event.y, false)
@@ -255,15 +263,15 @@ class ReadView(
                 val absY = abs(startY - event.y)
                 if (!isMove) {
                     isMove = absX > slopSquare || absY > slopSquare
-                    if (isMove) {
-                        // 手势归属只在这一帧判定:竖直向下且竖直位移占主导 → 归书签手势。
-                        isBookmarkSwipe = !isScroll && !isTextSelected &&
-                                event.y - startY > 0 && absY > absX * VERTICAL_DOMINANCE_RATIO
-                    }
                 }
                 if (isMove) {
                     longPressed = false
                     removeCallbacks(longPressRunnable)
+                    // 每帧按当前累计位移重新判定,方向翻转时才能交还 pageDelegate。
+                    if (!bookmarkSwipeReleased) {
+                        isBookmarkSwipe = isBookmarkSwipeCandidate(event.y - startY, absX, absY)
+                        if (!isBookmarkSwipe) bookmarkSwipeReleased = true
+                    }
                     if (isTextSelected) {
                         selectText(event.x, event.y)
                     } else if (!isBookmarkSwipe) {
@@ -288,10 +296,14 @@ class ReadView(
                     }
                 }
                 if (isBookmarkSwipe) {
-                    if (event.y - startY >= bookmarkSwipeMinDistance) {
+                    // 以抬手时的最终位移复核:MOVE 之后手指还可能拐弯或回抽。
+                    val dy = event.y - startY
+                    if (dy >= bookmarkSwipeMinDistance &&
+                        isBookmarkSwipeCandidate(dy, abs(startX - event.x), abs(dy))
+                    ) {
                         eventListener.onEvent(ReaderEvent.ToggleBookmark)
                     }
-                    isBookmarkSwipe = false
+                    resetBookmarkSwipe()
                 } else if (isTextSelected) {
                     callBack.showTextActionMenu()
                 } else if (pageDelegate!!.isMoved) {
@@ -304,7 +316,7 @@ class ReadView(
                 removeCallbacks(longPressRunnable)
                 if (!pressDown) return true
                 pressDown = false
-                isBookmarkSwipe = false
+                resetBookmarkSwipe()
                 if (isTextSelected) {
                     callBack.showTextActionMenu()
                 } else if (pageDelegate!!.isMoved) {
@@ -317,6 +329,19 @@ class ReadView(
         return true
     }
 
+    /**
+     * 当前位移是否仍算「下滑切换书签」:向下、且竖直位移压过水平位移。
+     *
+     * @param dy 相对按下点的竖直位移,向下为正
+     */
+    private fun isBookmarkSwipeCandidate(dy: Float, absX: Float, absY: Float): Boolean =
+        !isScroll && !isTextSelected && dy > 0 && absY > absX * VERTICAL_DOMINANCE_RATIO
+
+    private fun resetBookmarkSwipe() {
+        isBookmarkSwipe = false
+        bookmarkSwipeReleased = false
+    }
+
     override fun performClick(): Boolean {
         super.performClick()
         return true

+ 18 - 4
app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

@@ -78,16 +78,23 @@ class ReadBookDomainSplitBoundaryTest {
      * 再加两个对齐项,一个字段就是一行,纯派生、没有逻辑可摘。上游同批带来的
      * `useNewTocSheet` 分支(书籍信息/目录改开 Sheet)本来是两处复制粘贴,
      * 已合并成 `openBookNavigation()`,那部分没占额度。
+     *
+     * 2520 → 2523:下滑手势切换书签。新增的不是域,书签域早已是
+     * `ReadBookmarkDelegate`——切换判定、页范围计算、`ReaderBookmarkState` 快照的
+     * 订阅与清理全在该 delegate 里。留在 VM 的是 8 行纯接线,逐行都摘不掉:
+     * `bookKey` 的投影(`bookmarks` 表以书名+作者为关联键,只有 VM 持有 `_uiState`)、
+     * `Host.emitEffect` 的实现(`_effects` 只有 VM 能碰)、`start()` 与
+     * `ToggleBookmark` 的转发各一行,以及一个 `map` import。
      */
     @Test
-    fun `ReadBookViewModel 不超过 R2 验收的 2520 行`() {
+    fun `ReadBookViewModel 不超过 R2 验收的 2523 行`() {
         val lineCount = mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt")
             .readLines().size
         assertTrue(
-            "ReadBookViewModel 涨到了 $lineCount 行,超过 R2 验收线 2520。\n" +
+            "ReadBookViewModel 涨到了 $lineCount 行,超过 R2 验收线 2523。\n" +
                 "新功能请摘成 io/legado/app/ui/book/read/ 下的 XxxDelegate," +
                 "并在本测试的 DOMAINS 里加一条边界。",
-            lineCount <= 2520,
+            lineCount <= 2523,
         )
     }
 
@@ -208,7 +215,14 @@ class ReadBookDomainSplitBoundaryTest {
                 name = "书签",
                 delegateFile = "io/legado/app/ui/book/read/ReadBookmarkDelegate.kt",
                 stateFields = emptySet(),
-                stateTypes = listOf("bookmarkRepository.save", "bookmarkRepository.delete"),
+                // ReaderBookmarkState 是渲染层同步查角标用的快照:订阅 flowByBook 与
+                // 退出时清理都归本域,VM 只投影 bookKey。
+                stateTypes = listOf(
+                    "bookmarkRepository.save",
+                    "bookmarkRepository.delete",
+                    "bookmarkRepository.flowByBook",
+                    "ReaderBookmarkState",
+                ),
             ),
             // 样式域无自持状态:styleConfig 的重建由 VM 的 collectReadStyle() 统一驱动,
             // activeReminder / eyeProtection 被菜单栏直读;靠 stateTypes 守

+ 1 - 1
gradle.properties

@@ -48,4 +48,4 @@ android.r8.strictFullModeForKeepRules=false
 org.gradle.configuration-cache=true
 android.newDsl=false
 org.gradle.java.installations.auto-detect=true
-org.gradle.java.installations.auto-download=false
+org.gradle.java.installations.auto-download=false