Selaa lähdekoodia

refactor(R2.1): ReadBookViewModel 33 处 DAO 直连溶解进 BookRepository

R2.1 验收:`legacyDaoInjectionBaseline` 里 ReadBookViewModel 的基线 33 → 0。

计划书原文写「随 ReaderSession 溶解」,实际改走 BookRepository:ReaderSession 的
既有契约明写「调用方只拿到只读快照,拿不到可变领域对象(Book/TextChapter)」,
往里加 `getChapter(): BookChapter?` 正好破掉这条不变式。而 BookRepository 已经
有 getChapter/getChapters/getBook/getLastReadBook/getMinOrder/insert/update/
replace/deleteChaptersByBook/replaceChaptersAndUpdateBook——33 处里 32 处是现成的,
只补了一个按区间取目录的 getChapters(bookUrl, start, end)。

收敛:9 处重复的 `getChapter(book.bookUrl, ReadBook.durChapterIndex)` 收进
私有 `currentChapter()`。

线程语义变化(真机回归重点)——原先这 4 个入口在主线程同步查库,
现在查询挪到 IO,后续动作晚一个派发:
- `initReadBookConfig` 改 suspend;MainNavGraph 的调用点本就在 LaunchedEffect 里,
  仍然先于 initData 完成,顺序不变。
- `openChapterUrl` / `runSourceCustomButton` / `showPayDialog` 包进 viewModelScope。
- `ReadAiDelegate.openAiTextRewrite` 改 suspend(Host.findChapter 已是挂起);
  关菜单和开 sheet 随之晚一个派发。
- 本地书目录落库改用 replaceChaptersAndUpdateBook,多了一层事务(原来三条独立写)。

护栏:`ReadBookDomainSplitBoundaryTest` 新增「ReadBookViewModel 不再直连 DAO」,
反证已做——把 currentChapter() 改回 appDb.bookChapterDao 后,gradle 棘轮和这条
单测都变红。全量单测 426 → 427。

回归清单:开书(含从书架/最近阅读两条入口)、换源、换章源、目录刷新、
刷新当前章/后续章、正文编辑保存、反转正文、AI 划词重写、书源自定义按钮、
章节付费弹窗、打开章节链接、加入书架、退出时加入书架、重新翻译当前章。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5151561 3 kuukautta sitten
vanhempi
commit
d942cfefb4

+ 6 - 0
app/src/main/java/io/legado/app/data/repository/BookRepository.kt

@@ -106,6 +106,12 @@ class BookRepository(
         }
     }
 
+    suspend fun getChapters(bookUrl: String, start: Int, end: Int): List<BookChapter> {
+        return withContext(Dispatchers.IO) {
+            bookChapterDao.getChapterList(bookUrl, start, end)
+        }
+    }
+
     suspend fun update(vararg book: Book) {
         withContext(Dispatchers.IO) {
             bookDao.update(*book)

+ 1 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -684,6 +684,7 @@ val appModule = module {
             httpTtsRepository = get(),
             bookSourceRepository = get(),
             bookmarkRepository = get(),
+            bookRepository = get(),
             readerSession = get(),
         )
     }

+ 7 - 3
app/src/main/java/io/legado/app/ui/book/read/ReadAiDelegate.kt

@@ -73,9 +73,9 @@ class ReadAiDelegate(
 
         fun reloadChapterAfterContentProcessChanged(bookUrl: String, chapterIndex: Int)
 
-        fun findChapter(bookUrl: String, chapterIndex: Int): BookChapter?
+        suspend fun findChapter(bookUrl: String, chapterIndex: Int): BookChapter?
 
-        fun listChapters(bookUrl: String): List<BookChapter>
+        suspend fun listChapters(bookUrl: String): List<BookChapter>
     }
 
     private val _uiState = MutableStateFlow(ReadAiUiState())
@@ -514,7 +514,11 @@ class ReadAiDelegate(
         }
     }
 
-    fun openAiTextRewrite(
+    /**
+     * R2.1:章节标题查询改走 `Host` 的挂起方法(背后是 `BookRepository`,在 IO 上执行),
+     * 因此整个打开流程移进 [scope]——原先是在主线程上同步查库。
+     */
+    suspend fun openAiTextRewrite(
         text: String,
         chapterIndex: Int,
         chapterPosition: Int,

+ 86 - 75
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -16,7 +16,6 @@ import io.legado.app.constant.BookType
 import io.legado.app.constant.EventBus
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.ReadMenuBlurMode
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookContentProcess
@@ -24,6 +23,7 @@ import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.entities.HttpTTS
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
+import io.legado.app.data.repository.BookRepository
 import io.legado.app.data.repository.BookSourceRepository
 import io.legado.app.data.repository.BookmarkRepository
 import io.legado.app.data.repository.HighlightRuleRepository
@@ -186,6 +186,7 @@ class ReadBookViewModel(
     private val httpTtsRepository: HttpTtsRepository,
     private val bookSourceRepository: BookSourceRepository,
     private val bookmarkRepository: BookmarkRepository,
+    private val bookRepository: BookRepository,
     private val readerSession: ReaderSession,
 ) : BaseViewModel(application), ReadBook.CallBack {
 
@@ -240,11 +241,11 @@ class ReadBookViewModel(
             reloadCurrentChapterAfterContentProcessChanged(bookUrl, chapterIndex)
         }
 
-        override fun findChapter(bookUrl: String, chapterIndex: Int): BookChapter? =
-            appDb.bookChapterDao.getChapter(bookUrl, chapterIndex)
+        override suspend fun findChapter(bookUrl: String, chapterIndex: Int): BookChapter? =
+            bookRepository.getChapter(bookUrl, chapterIndex)
 
-        override fun listChapters(bookUrl: String): List<BookChapter> =
-            appDb.bookChapterDao.getChapterList(bookUrl)
+        override suspend fun listChapters(bookUrl: String): List<BookChapter> =
+            bookRepository.getChapters(bookUrl)
     }
 
     private val aiDelegate = ReadAiDelegate(
@@ -297,8 +298,8 @@ class ReadBookViewModel(
                 _uiState.update { it.copy(activeSheet = sheet) }
             }
 
-            override fun findChapter(bookUrl: String, chapterIndex: Int): BookChapter? =
-                appDb.bookChapterDao.getChapter(bookUrl, chapterIndex)
+            override suspend fun findChapter(bookUrl: String, chapterIndex: Int): BookChapter? =
+                bookRepository.getChapter(bookUrl, chapterIndex)
         },
         readSettingsRepository = readSettingsRepository,
     )
@@ -458,7 +459,9 @@ class ReadBookViewModel(
                 initData(intent.intent)
                 justInitData = true
             }
-            is ReadBookIntent.InitReadBookConfig -> initReadBookConfig(intent.intent)
+            is ReadBookIntent.InitReadBookConfig -> viewModelScope.launch {
+                initReadBookConfig(intent.intent)
+            }
             is ReadBookIntent.CheckSwitchDayNight -> checkSwitchDayNight(intent.lux)
             is ReadBookIntent.DismissReminder -> dismissReminder()
             is ReadBookIntent.NextPage -> ReadBook.moveToNextPage()
@@ -1489,11 +1492,13 @@ class ReadBookViewModel(
             is ReadBookIntent.ConfirmAiTextClean -> aiDelegate.confirmAiTextClean()
 
             is ReadBookIntent.OpenAiTextRewrite -> {
-                aiDelegate.openAiTextRewrite(
-                    text = intent.text,
-                    chapterIndex = intent.chapterIndex,
-                    chapterPosition = intent.chapterPosition,
-                )
+                viewModelScope.launch {
+                    aiDelegate.openAiTextRewrite(
+                        text = intent.text,
+                        chapterIndex = intent.chapterIndex,
+                        chapterPosition = intent.chapterPosition,
+                    )
+                }
             }
 
             is ReadBookIntent.ApplySimulatedReading -> {
@@ -1679,9 +1684,7 @@ class ReadBookViewModel(
             if (readSettingsRepository.currentSettings.defaultSourceChangeAll) {
                 _uiState.update { it.copy(activeSheet = ReadBookSheet.ChangeBookSource) }
             } else {
-                val book = ReadBook.book ?: return@launch
-                val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
-                    ?: return@launch
+                val chapter = currentChapter() ?: return@launch
                 _uiState.update {
                     it.copy(
                         activeSheet = ReadBookSheet.ChangeChapterSource(
@@ -1695,9 +1698,7 @@ class ReadBookViewModel(
 
     private fun handleChapterChangeSource() {
         viewModelScope.launch {
-            val book = ReadBook.book ?: return@launch
-            val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
-                ?: return@launch
+            val chapter = currentChapter() ?: return@launch
             _uiState.update {
                 it.copy(
                     activeSheet = ReadBookSheet.ChangeChapterSource(
@@ -2379,11 +2380,22 @@ class ReadBookViewModel(
 
     // --- Business Logic (migrated from Activity / kept from old ViewModel) ---
 
-    fun initReadBookConfig(intent: Intent) {
+    /**
+     * 当前会话书籍的当前章节。
+     *
+     * R2.1:VM 不再直连 Room DAO,书籍/目录读写一律经 [BookRepository]。
+     * 原来散在十余处的 `getChapter(book.bookUrl, ReadBook.durChapterIndex)` 收敛到这里。
+     */
+    private suspend fun currentChapter(): BookChapter? {
+        val book = ReadBook.book ?: return null
+        return bookRepository.getChapter(book.bookUrl, ReadBook.durChapterIndex)
+    }
+
+    suspend fun initReadBookConfig(intent: Intent) {
         val bookUrl = intent.getStringExtra("bookUrl")
         val book = when {
-            bookUrl.isNullOrEmpty() -> appDb.bookDao.lastReadBook
-            else -> appDb.bookDao.getBook(bookUrl)
+            bookUrl.isNullOrEmpty() -> bookRepository.getLastReadBook()
+            else -> bookRepository.getBook(bookUrl)
         } ?: return
         ReadBook.upReadBookConfig(book)
     }
@@ -2395,8 +2407,8 @@ class ReadBookViewModel(
             ReadBook.chapterChanged = intent.getBooleanExtra("chapterChanged", false)
             val bookUrl = intent.getStringExtra("bookUrl")
             val book = when {
-                bookUrl.isNullOrEmpty() -> appDb.bookDao.lastReadBook
-                else -> appDb.bookDao.getBook(bookUrl)
+                bookUrl.isNullOrEmpty() -> bookRepository.getLastReadBook()
+                else -> bookRepository.getBook(bookUrl)
             } ?: ReadBook.book
             when {
                 book != null -> initBook(book)
@@ -2520,9 +2532,7 @@ class ReadBookViewModel(
         if (book.isLocal) {
             kotlin.runCatching {
                 LocalBook.getChapterList(book).let {
-                    appDb.bookChapterDao.delByBook(book.bookUrl)
-                    appDb.bookChapterDao.insert(*it.toTypedArray())
-                    appDb.bookDao.update(book)
+                    bookRepository.replaceChaptersAndUpdateBook(book, it)
                     ReadBook.onChapterListUpdated(book)
                 }
                 return true
@@ -2544,13 +2554,13 @@ class ReadBookViewModel(
                 WebBook.getChapterListAwait(it, book, true)
                     .onSuccess { cList ->
                         if (oldBook.bookUrl == book.bookUrl) {
-                            appDb.bookDao.update(book)
+                            bookRepository.update(book)
                         } else {
-                            appDb.bookDao.replace(oldBook, book)
+                            bookRepository.replace(oldBook, book)
                             BookHelp.updateCacheFolder(oldBook, book)
                         }
-                        appDb.bookChapterDao.delByBook(oldBook.bookUrl)
-                        appDb.bookChapterDao.insert(*cList.toTypedArray())
+                        bookRepository.deleteChaptersByBook(oldBook.bookUrl)
+                        bookRepository.insertChapters(*cList.toTypedArray())
                         ReadBook.onChapterListUpdated(book)
                         return true
                     }.onFailure {
@@ -2593,7 +2603,7 @@ class ReadBookViewModel(
     suspend fun uploadBookProgress(book: Book) {
         uploadReadingProgressUseCase.execute(book.toReadingProgress())?.let { uploadTime ->
             book.syncTime = uploadTime
-            appDb.bookDao.update(book)
+            bookRepository.update(book)
         }
     }
 
@@ -2748,14 +2758,14 @@ class ReadBookViewModel(
     private fun addCurrentBookToBookshelfAndFinish() {
         val book = ReadBook.book ?: return removeCurrentNotShelfBookAndFinish()
         execute {
-            val toc = appDb.bookChapterDao.getChapterList(book.bookUrl)
+            val toc = bookRepository.getChapters(book.bookUrl)
             book.removeType(BookType.notShelf)
             if (book.order == 0) {
-                book.order = appDb.bookDao.minOrder - 1
+                book.order = bookRepository.getMinOrder() - 1
             }
-            appDb.bookDao.insert(book)
+            bookRepository.insert(book)
             if (toc.isNotEmpty()) {
-                appDb.bookChapterDao.insert(*toc.toTypedArray())
+                bookRepository.insertChapters(*toc.toTypedArray())
             }
             ReadBook.inBookshelf = true
         }.onSuccess {
@@ -2789,11 +2799,10 @@ class ReadBookViewModel(
     private fun refreshCurrentChapter() {
         execute {
             ReadBook.book?.let { book ->
-                appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
-                    ?.let { chapter ->
-                        BookHelp.delContent(book, chapter)
-                        ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false)
-                    }
+                currentChapter()?.let { chapter ->
+                    BookHelp.delContent(book, chapter)
+                    ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false)
+                }
             }
         }
     }
@@ -2806,7 +2815,7 @@ class ReadBookViewModel(
     private fun refreshContentAfter() {
         execute {
             ReadBook.book?.let { book ->
-                appDb.bookChapterDao.getChapterList(
+                bookRepository.getChapters(
                     book.bookUrl,
                     ReadBook.durChapterIndex,
                     book.totalChapterNum
@@ -2839,7 +2848,7 @@ class ReadBookViewModel(
 
     fun saveContent(book: Book, content: String, chapterIndex: Int = ReadBook.durChapterIndex) {
         execute {
-            appDb.bookChapterDao.getChapter(book.bookUrl, chapterIndex)
+            bookRepository.getChapter(book.bookUrl, chapterIndex)
                 ?.let { chapter ->
                     BookHelp.saveText(book, chapter, content)
                     ReadBook.loadContent(chapterIndex, resetPageOffset = false)
@@ -2850,8 +2859,7 @@ class ReadBookViewModel(
     fun reverseContent() {
         execute {
             val book = ReadBook.book ?: return@execute
-            val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
-                ?: return@execute
+            val chapter = currentChapter() ?: return@execute
             val content = BookHelp.getContent(book, chapter) ?: return@execute
             val stringBuilder = StringBuilder()
             content.toStringArray().forEach {
@@ -3444,9 +3452,7 @@ class ReadBookViewModel(
     private fun retranslateCurrentChapter() {
         val book = ReadBook.book ?: return
         viewModelScope.launch {
-            val chapter = appDb.bookChapterDao.getChapter(
-                book.bookUrl, ReadBook.durChapterIndex
-            ) ?: return@launch
+            val chapter = currentChapter() ?: return@launch
             io.legado.app.model.translation.TranslationManager.deleteTranslationCache(book, chapter)
             book.setTranslationMode(true)
             book.save()
@@ -3465,13 +3471,12 @@ class ReadBookViewModel(
 
     private fun openChapterUrl() {
         if (ReadBook.isLocalBook) return
-        val book = ReadBook.book ?: return
-        val chapter = ReadBook.curTextChapter?.chapter
-            ?: appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
-            ?: return
-        val url = chapter.getAbsoluteURL()
-        if (url.isBlank()) return
         viewModelScope.launch {
+            val chapter = ReadBook.curTextChapter?.chapter
+                ?: currentChapter()
+                ?: return@launch
+            val url = chapter.getAbsoluteURL()
+            if (url.isBlank()) return@launch
             val useBrowser = localPreferencesRepository
                 .getPreference(LocalPreferencesKeys.READ_URL_IN_BROWSER, false)
                 .first()
@@ -3495,19 +3500,21 @@ class ReadBookViewModel(
     private fun runSourceCustomButton(longClick: Boolean) {
         val source = ReadBook.bookSource?.takeIf { it.customButton } ?: return
         val book = ReadBook.book ?: return
-        val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
-        _effects.tryEmit(
-            ReadBookEffect.RunSourceCustomButton(
-                event = if (longClick) {
-                    SourceCallBack.LONG_CLICK_CUSTOM_BUTTON
-                } else {
-                    SourceCallBack.CLICK_CUSTOM_BUTTON
-                },
-                source = source,
-                book = book,
-                chapter = chapter,
+        viewModelScope.launch {
+            val chapter = currentChapter()
+            _effects.tryEmit(
+                ReadBookEffect.RunSourceCustomButton(
+                    event = if (longClick) {
+                        SourceCallBack.LONG_CLICK_CUSTOM_BUTTON
+                    } else {
+                        SourceCallBack.CLICK_CUSTOM_BUTTON
+                    },
+                    source = source,
+                    book = book,
+                    chapter = chapter,
+                )
             )
-        )
+        }
     }
 
     private fun toggleReadUrlInBrowser() {
@@ -3532,12 +3539,16 @@ class ReadBookViewModel(
     private fun showPayDialog() {
         val book = ReadBook.book ?: return
         if (book.isLocal) return
-        val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
-        if (chapter == null) {
-            _effects.tryEmit(ReadBookEffect.ShowToast(context.getString(R.string.no_chapter)))
-            return
+        viewModelScope.launch {
+            val chapter = currentChapter()
+            if (chapter == null) {
+                _effects.tryEmit(ReadBookEffect.ShowToast(context.getString(R.string.no_chapter)))
+                return@launch
+            }
+            _uiState.update {
+                it.copy(activeDialog = ReadBookDialog.ConfirmChapterPay(chapter.title))
+            }
         }
-        _uiState.update { it.copy(activeDialog = ReadBookDialog.ConfirmChapterPay(chapter.title)) }
     }
 
     private fun confirmPayAction() {
@@ -3545,7 +3556,7 @@ class ReadBookViewModel(
         if (book.isLocal) return
         execute {
             val source = ReadBook.bookSource ?: throw NoStackTraceException("no book source")
-            val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
+            val chapter = currentChapter()
                 ?: throw NoStackTraceException(context.getString(R.string.no_chapter))
             val payAction = source.getContentRule().payAction
             if (payAction.isNullOrBlank()) {
@@ -3635,10 +3646,10 @@ class ReadBookViewModel(
         execute {
             book.removeType(BookType.notShelf)
             if (book.order == 0) {
-                book.order = appDb.bookDao.minOrder - 1
+                book.order = bookRepository.getMinOrder() - 1
             }
-            appDb.bookDao.insert(book)
-            appDb.bookChapterDao.insert(*toc.toTypedArray())
+            bookRepository.insert(book)
+            bookRepository.insertChapters(*toc.toTypedArray())
         }.onSuccess {
             success?.invoke()
         }.onError {

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

@@ -34,7 +34,7 @@ class ReadContentEditDelegate(
     interface Host {
         fun setActiveSheet(sheet: ReadBookSheet?)
 
-        fun findChapter(bookUrl: String, chapterIndex: Int): BookChapter?
+        suspend fun findChapter(bookUrl: String, chapterIndex: Int): BookChapter?
     }
 
     private val _uiState = MutableStateFlow(ContentEditUiState())

+ 17 - 1
app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

@@ -17,7 +17,7 @@ import kotlin.reflect.full.primaryConstructor
  * 3. delegate 自己拿 DAO——`build.gradle.kts` 的 `legacyDaoInjectionBaseline` 只认
  *    **文件名含 `ViewModel`** 的文件,delegate 里的 DAO 直连会掉进宽松的
  *    `legacyUiDaoAccessBaseline`,等于把 VM 棘轮上的债洗白。章节等数据读取必须继续
- *    走各 delegate 的 `Host`,随 R2.1 的 `ReaderSession` 溶解一并清理。
+ *    走各 delegate 的 `Host`——R2.1 之后 Host 背后是 `BookRepository`。
  */
 class ReadBookDomainSplitBoundaryTest {
 
@@ -77,6 +77,22 @@ class ReadBookDomainSplitBoundaryTest {
         }
     }
 
+    @Test
+    fun `ReadBookViewModel 不再直连 DAO`() {
+        val source = mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt").readText()
+        val violations = buildList {
+            APP_DB_DAO.findAll(source).forEach { add(it.value) }
+            DAO_IMPORT.findAll(source).forEach { add(it.value) }
+        }
+        assertTrue(
+            "ReadBookViewModel 又出现了 DAO 直连:${violations.joinToString()}。\n" +
+                "R2.1 已把书籍/目录读写全部收进 BookRepository," +
+                "`legacyDaoInjectionBaseline` 里这个文件的基线是 0——" +
+                "章节读取请用 currentChapter() 或 bookRepository 的方法。",
+            violations.isEmpty(),
+        )
+    }
+
     private fun constructorParameterNames(type: KClass<*>): Set<String> =
         type.primaryConstructor?.parameters?.mapNotNull { it.name }?.toSet().orEmpty()
 

+ 3 - 1
build.gradle.kts

@@ -219,7 +219,9 @@ val verifyConfigArchitecture = tasks.register<VerifyConfigArchitectureTask>(
     )
     legacyDaoInjectionBaseline.set(
         mapOf(
-            "io/legado/app/ui/book/read/ReadBookViewModel.kt" to 33,
+            // R2.1 已清零:ReadBookViewModel 的书籍/目录读写全部经 BookRepository。
+            // 保留 0 值条目让棘轮继续盯着这个文件——新增一处直连就报红。
+            "io/legado/app/ui/book/read/ReadBookViewModel.kt" to 0,
             // 护栏缺席期间(MAD-3 未合并窗口)main 新增的直连,随合并冻结,清理归 Track A/F2
             "io/legado/app/ui/book/readaloud/cloudtts/CloudTtsViewModel.kt" to 13,
         )