Browse Source

[优化] 重写下载核心

HapeLee 6 tháng trước cách đây
mục cha
commit
fb31b55125
19 tập tin đã thay đổi với 941 bổ sung và 181 xóa
  1. 23 2
      app/src/main/java/io/legado/app/data/repository/CacheBookDownloadRepository.kt
  2. 8 1
      app/src/main/java/io/legado/app/di/appModule.kt
  3. 4 0
      app/src/main/java/io/legado/app/domain/gateway/BookCacheDownloadGateway.kt
  4. 13 7
      app/src/main/java/io/legado/app/domain/usecase/BatchCacheDownloadUseCase.kt
  5. 22 1
      app/src/main/java/io/legado/app/domain/usecase/CacheBookChaptersUseCase.kt
  6. 163 71
      app/src/main/java/io/legado/app/model/CacheBook.kt
  7. 22 0
      app/src/main/java/io/legado/app/model/ReadBook.kt
  8. 46 0
      app/src/main/java/io/legado/app/model/cache/CacheDownloadModels.kt
  9. 118 0
      app/src/main/java/io/legado/app/model/cache/CacheDownloadQueue.kt
  10. 72 0
      app/src/main/java/io/legado/app/model/cache/CacheDownloadRepository.kt
  11. 92 0
      app/src/main/java/io/legado/app/model/cache/CacheDownloadStateStore.kt
  12. 25 0
      app/src/main/java/io/legado/app/model/cache/ReadingCacheEvents.kt
  13. 32 16
      app/src/main/java/io/legado/app/service/CacheBookService.kt
  14. 82 39
      app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt
  15. 69 30
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt
  16. 1 2
      app/src/main/java/io/legado/app/ui/book/read/BaseReadBookActivity.kt
  17. 10 12
      app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt
  18. 78 0
      app/src/test/java/io/legado/app/model/cache/CacheDownloadQueueTest.kt
  19. 61 0
      app/src/test/java/io/legado/app/model/cache/CacheDownloadStateStoreTest.kt

+ 23 - 2
app/src/main/java/io/legado/app/data/repository/CacheBookDownloadRepository.kt

@@ -2,15 +2,36 @@ package io.legado.app.data.repository
 
 import io.legado.app.data.dao.BookDao
 import io.legado.app.domain.gateway.BookCacheDownloadGateway
+import io.legado.app.help.book.isLocal
 import io.legado.app.model.CacheBook
+import io.legado.app.model.cache.CacheDownloadRequest
+import io.legado.app.model.cache.ChapterSelection
 import splitties.init.appCtx
 
 class CacheBookDownloadRepository(
     private val bookDao: BookDao
 ) : BookCacheDownloadGateway {
 
+    override suspend fun start(request: CacheDownloadRequest) {
+        val book = bookDao.getBook(request.bookUrl) ?: return
+        CacheBook.start(appCtx, request, isLocal = book.isLocal)
+    }
+
     override suspend fun start(bookUrl: String, chapterIndices: List<Int>) {
-        val book = bookDao.getBook(bookUrl) ?: return
-        CacheBook.start(appCtx, book, chapterIndices)
+        start(
+            CacheDownloadRequest(
+                bookUrl = bookUrl,
+                selection = ChapterSelection.Indices(chapterIndices.toSet()),
+            )
+        )
+    }
+
+    override suspend fun start(bookUrl: String, startIndex: Int, endIndex: Int) {
+        start(
+            CacheDownloadRequest(
+                bookUrl = bookUrl,
+                selection = ChapterSelection.Range(startIndex, endIndex),
+            )
+        )
     }
 }

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

@@ -30,6 +30,7 @@ import io.legado.app.data.repository.WebDavReadingProgressRepository
 import io.legado.app.domain.gateway.BookCacheCleanupGateway
 import io.legado.app.domain.gateway.AppStartupGateway
 import io.legado.app.domain.gateway.BookCacheDownloadGateway
+import io.legado.app.domain.gateway.BookSearchGateway
 import io.legado.app.domain.gateway.BookSourceCallbackGateway
 import io.legado.app.domain.gateway.DatabaseMaintenanceGateway
 import io.legado.app.domain.gateway.LocalBookGateway
@@ -46,6 +47,7 @@ import io.legado.app.domain.usecase.GetReadingProgressUseCase
 import io.legado.app.domain.usecase.RemoveBookGroupAssignmentUseCase
 import io.legado.app.ui.widget.components.explore.ExploreKindUiUseCase
 import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
+import io.legado.app.domain.usecase.SearchBooksUseCase
 import io.legado.app.domain.usecase.ShrinkDatabaseUseCase
 import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
 import io.legado.app.domain.usecase.UploadReadingProgressUseCase
@@ -140,7 +142,12 @@ val appModule = module {
     single<ReadingProgressGateway> { WebDavReadingProgressRepository() }
     single<BookDomainRepository> { BookDomainRepositoryImpl(get(), get()) }
     single<ExploreRepository> { ExploreRepositoryImpl(get()) }
-    single<SearchRepository> { SearchRepositoryImpl(get()) }
+    single {
+        SearchRepositoryImpl(get())
+    }
+    single<SearchRepository> { get<SearchRepositoryImpl>() }
+    single<BookSearchGateway> { get<SearchRepositoryImpl>() }
+    singleOf(::SearchBooksUseCase)
 
     single<ImageLoader> {
         ImageLoader.Builder(get())

+ 4 - 0
app/src/main/java/io/legado/app/domain/gateway/BookCacheDownloadGateway.kt

@@ -1,5 +1,9 @@
 package io.legado.app.domain.gateway
 
+import io.legado.app.model.cache.CacheDownloadRequest
+
 interface BookCacheDownloadGateway {
+    suspend fun start(request: CacheDownloadRequest)
     suspend fun start(bookUrl: String, chapterIndices: List<Int>)
+    suspend fun start(bookUrl: String, startIndex: Int, endIndex: Int)
 }

+ 13 - 7
app/src/main/java/io/legado/app/domain/usecase/BatchCacheDownloadUseCase.kt

@@ -3,6 +3,9 @@ package io.legado.app.domain.usecase
 import io.legado.app.domain.gateway.BookCacheDownloadGateway
 import io.legado.app.domain.model.CacheableBook
 import io.legado.app.domain.repository.BookDomainRepository
+import io.legado.app.model.cache.CacheDownloadRequest
+import io.legado.app.model.cache.CacheDownloadSource
+import io.legado.app.model.cache.ChapterSelection
 
 class BatchCacheDownloadUseCase(
     private val bookRepository: BookDomainRepository,
@@ -31,13 +34,16 @@ class BatchCacheDownloadUseCase(
     ): Boolean {
         if (book.isLocal) return false
         if (skipAudioBooks && book.isAudio) return false
-        val indices = if (downloadAllChapters) {
-            (0..book.lastChapterIndex).toList()
-        } else {
-            (book.durChapterIndex..book.lastChapterIndex).toList()
-        }
-        if (indices.isEmpty()) return false
-        bookCacheDownloadGateway.start(book.bookUrl, indices)
+        val startIndex = if (downloadAllChapters) 0 else book.durChapterIndex
+        val endIndex = book.lastChapterIndex
+        if (endIndex < startIndex) return false
+        bookCacheDownloadGateway.start(
+            CacheDownloadRequest(
+                bookUrl = book.bookUrl,
+                selection = ChapterSelection.Range(startIndex, endIndex),
+                source = CacheDownloadSource.Batch,
+            )
+        )
         return true
     }
 }

+ 22 - 1
app/src/main/java/io/legado/app/domain/usecase/CacheBookChaptersUseCase.kt

@@ -1,6 +1,9 @@
 package io.legado.app.domain.usecase
 
 import io.legado.app.domain.gateway.BookCacheDownloadGateway
+import io.legado.app.model.cache.CacheDownloadRequest
+import io.legado.app.model.cache.CacheDownloadSource
+import io.legado.app.model.cache.ChapterSelection
 
 class CacheBookChaptersUseCase(
     private val bookCacheDownloadGateway: BookCacheDownloadGateway
@@ -9,7 +12,25 @@ class CacheBookChaptersUseCase(
     suspend fun execute(bookUrl: String, chapterIndices: Iterable<Int>): Int {
         val indices = chapterIndices.distinct()
         if (indices.isEmpty()) return 0
-        bookCacheDownloadGateway.start(bookUrl, indices)
+        bookCacheDownloadGateway.start(
+            CacheDownloadRequest(
+                bookUrl = bookUrl,
+                selection = ChapterSelection.Indices(indices.toSet()),
+                source = CacheDownloadSource.Manual,
+            )
+        )
         return indices.size
     }
+
+    suspend fun executeRange(bookUrl: String, startIndex: Int, endIndex: Int): Int {
+        if (endIndex < startIndex) return 0
+        bookCacheDownloadGateway.start(
+            CacheDownloadRequest(
+                bookUrl = bookUrl,
+                selection = ChapterSelection.Range(startIndex, endIndex),
+                source = CacheDownloadSource.Manual,
+            )
+        )
+        return endIndex - startIndex + 1
+    }
 }

+ 163 - 71
app/src/main/java/io/legado/app/model/CacheBook.kt

@@ -2,22 +2,26 @@ package io.legado.app.model
 
 import android.content.Context
 import io.legado.app.constant.AppLog
-import io.legado.app.constant.EventBus
 import io.legado.app.constant.IntentAction
 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.BookSource
 import io.legado.app.exception.ConcurrentException
-import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.coroutine.CompositeCoroutine
 import io.legado.app.help.coroutine.Coroutine
-import io.legado.app.model.webBook.WebBook
+import io.legado.app.model.cache.CacheDownloadQueue
+import io.legado.app.model.cache.CacheDownloadRepository
+import io.legado.app.model.cache.CacheDownloadRequest
+import io.legado.app.model.cache.CacheDownloadSource
+import io.legado.app.model.cache.CacheDownloadStateStore
+import io.legado.app.model.cache.ChapterSelection
+import io.legado.app.model.cache.ReadingCacheEvent
+import io.legado.app.model.cache.ReadingCacheEvents
 import io.legado.app.service.CacheBookService
 import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.utils.onEachParallel
-import io.legado.app.utils.postEvent
 import io.legado.app.utils.startService
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CoroutineScope
@@ -75,20 +79,20 @@ object CacheBook {
                     if (!emitted) delay(800)
                 }
             }.onStart {
-                postEvent(EventBus.UP_DOWNLOAD_STATE, "")
                 updateSummary()
             }.onEachParallel(OtherConfig.cacheBookThreadCount.coerceAtLeast(1)) {
                 coroutineScope {
                     it.download(this, context)
                 }
             }.onCompletion {
-                postEvent(EventBus.UP_DOWNLOAD_STATE, "")
                 updateSummary()
             }.collect()
         }
     }
 
     private val coordinator = CacheBookCoordinator()
+    private val stateStore = CacheDownloadStateStore()
+    val downloadStateFlow = stateStore.stateFlow
 
     private val _cacheSuccessFlow = MutableSharedFlow<BookChapter>(extraBufferCapacity = 64)
     val cacheSuccessFlow = _cacheSuccessFlow.asSharedFlow()
@@ -117,7 +121,7 @@ object CacheBook {
         get() = coordinator.taskMap
 
     fun errorIndices(bookUrl: String): Set<Int> {
-        return errorIndexMap[bookUrl]?.toSet().orEmpty()
+        return stateStore.bookState(bookUrl)?.failedIndices.orEmpty()
     }
 
     private fun collectQueueStats(): QueueStats {
@@ -168,11 +172,54 @@ object CacheBook {
     }
 
     fun start(context: Context, book: Book, selectedIndices: List<Int>) {
-        if (!book.isLocal && selectedIndices.isNotEmpty()) {
-            context.startService<CacheBookService> {
-                action = IntentAction.start
-                putExtra("bookUrl", book.bookUrl)
-                putIntegerArrayListExtra("indices", ArrayList(selectedIndices))
+        start(
+            context = context,
+            request = CacheDownloadRequest(
+                bookUrl = book.bookUrl,
+                selection = ChapterSelection.Indices(selectedIndices.toSet()),
+            ),
+            isLocal = book.isLocal,
+        )
+    }
+
+    fun start(context: Context, book: Book, startIndex: Int, endIndex: Int) {
+        start(
+            context = context,
+            request = CacheDownloadRequest(
+                bookUrl = book.bookUrl,
+                selection = ChapterSelection.Range(startIndex, endIndex),
+            ),
+            isLocal = book.isLocal,
+        )
+    }
+
+    fun start(context: Context, request: CacheDownloadRequest, isLocal: Boolean = false) {
+        if (isLocal) return
+        when (val selection = request.selection) {
+            is ChapterSelection.Range -> {
+                if (selection.end < selection.start) return
+            }
+            is ChapterSelection.Indices -> {
+                if (selection.values.isEmpty()) return
+            }
+            is ChapterSelection.Single -> Unit
+        }
+        context.startService<CacheBookService> {
+            action = IntentAction.start
+            putExtra("bookUrl", request.bookUrl)
+            putExtra("source", request.source.name)
+            when (val selection = request.selection) {
+                is ChapterSelection.Range -> {
+                    putExtra("start", selection.start)
+                    putExtra("end", selection.end)
+                }
+                is ChapterSelection.Indices -> {
+                    putIntegerArrayListExtra("indices", ArrayList(selection.values))
+                }
+                is ChapterSelection.Single -> {
+                    putExtra("start", selection.index)
+                    putExtra("end", selection.index)
+                }
             }
         }
     }
@@ -187,9 +234,9 @@ object CacheBook {
     fun removeBook(bookUrl: String): Boolean {
         val model = cacheBookMap.remove(bookUrl) ?: return false
         model.stop()
+        stateStore.removeBook(bookUrl)
         updateSummary()
         _queueChangedFlow.tryEmit(bookUrl)
-        postEvent(EventBus.UP_DOWNLOAD, bookUrl)
         return true
     }
 
@@ -211,6 +258,7 @@ object CacheBook {
         successDownloadSet.clear()
         errorDownloadMap.clear()
         errorIndexMap.clear()
+        stateStore.clear()
         updateSummary()
     }
 
@@ -241,15 +289,26 @@ object CacheBook {
         get() = lastQueueStats.waitingCount > 0 || lastQueueStats.downloadingCount > 0
 
     private fun onTaskQueuesChanged(bookUrl: String) {
+        cacheBookMap[bookUrl]?.let { model ->
+            stateStore.updateBookQueue(
+                bookUrl = bookUrl,
+                waitingCount = model.queueCounts().first,
+                runningIndices = model.downloadingIndices(),
+            )
+            _downloadingIndicesFlow.tryEmit(bookUrl to model.downloadingIndices())
+            _downloadErrorFlow.tryEmit(bookUrl to errorIndices(bookUrl))
+        }
         updateSummary()
         _queueChangedFlow.tryEmit(bookUrl)
-        postEvent(EventBus.UP_DOWNLOAD, bookUrl)
     }
 
-    private fun onTaskRemoved(bookUrl: String) {
+    private fun onTaskRemoved(bookUrl: String, clearState: Boolean = false) {
         cacheBookMap.remove(bookUrl)
+        if (clearState) {
+            stateStore.removeBook(bookUrl)
+        }
         updateSummary()
-        postEvent(EventBus.UP_DOWNLOAD, bookUrl)
+        _queueChangedFlow.tryEmit(bookUrl)
     }
 
     class CacheBookModel(
@@ -257,40 +316,50 @@ object CacheBook {
         @Volatile var book: Book
     ) {
 
-        private val waitDownloadSet = linkedSetOf<Int>()
+        private val queue = CacheDownloadQueue()
         private val onDownloadSet = linkedSetOf<Int>()
         private val pausedDownloadSet = hashSetOf<Int>()
         private val chapterTasks = hashMapOf<Int, Coroutine<*>>()
         private val tasks = CompositeCoroutine()
+        private val repository = CacheDownloadRepository()
         private var isStopped = false
         private var waitingRetry = false
         private var isLoading = false
 
-        init {
-            postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
-        }
-
         private fun notifyDownloadSetChanged() {
             _downloadingIndicesFlow.tryEmit(book.bookUrl to onDownloadSet.toSet())
+            if (cacheBookMap[book.bookUrl] === this) {
+                stateStore.updateBookQueue(
+                    bookUrl = book.bookUrl,
+                    waitingCount = queue.waitingCount(),
+                    runningIndices = onDownloadSet.toSet(),
+                )
+            }
         }
 
         private fun notifyErrorChanged() {
-            val errors = errorIndexMap[book.bookUrl]?.toSet() ?: emptySet()
+            val errors = errorIndices(book.bookUrl)
             _downloadErrorFlow.tryEmit(book.bookUrl to errors)
         }
 
         @Synchronized
-        fun queueCounts(): Pair<Int, Int> = waitDownloadSet.size to onDownloadSet.size
+        fun queueCounts(): Pair<Int, Int> = queue.waitingCount() to onDownloadSet.size
+
+        @Synchronized
+        fun isWaiting(index: Int): Boolean = queue.isWaiting(index)
 
         @Synchronized
-        fun waitingIndices(): Set<Int> = waitDownloadSet.toSet()
+        fun isDownloading(index: Int): Boolean = onDownloadSet.contains(index)
+
+        @Synchronized
+        fun waitingIndices(): Set<Int> = queue.waitingIndices()
 
         @Synchronized
         fun downloadingIndices(): Set<Int> = onDownloadSet.toSet()
 
         @Synchronized
         fun isRun(): Boolean {
-            return waitDownloadSet.isNotEmpty() || onDownloadSet.isNotEmpty() || isLoading
+            return queue.waitingCount() > 0 || onDownloadSet.isNotEmpty() || isLoading
         }
 
         @Synchronized
@@ -309,7 +378,7 @@ object CacheBook {
 
         @Synchronized
         fun stop() {
-            waitDownloadSet.clear()
+            queue.clear()
             pausedDownloadSet.clear()
             chapterTasks.clear()
             tasks.clear()
@@ -322,18 +391,39 @@ object CacheBook {
 
         @Synchronized
         fun addDownload(start: Int, end: Int) {
-            addDownloads(start..end)
+            addRequest(
+                CacheDownloadRequest(
+                    book.bookUrl,
+                    ChapterSelection.Range(start, end),
+                    CacheDownloadSource.ReadPreload,
+                )
+            )
         }
 
         @Synchronized
         fun addDownloads(indices: Iterable<Int>) {
+            val values = indices.toSet()
+            if (values.isEmpty()) return
+            addRequest(
+                CacheDownloadRequest(
+                    book.bookUrl,
+                    ChapterSelection.Indices(values),
+                    CacheDownloadSource.Manual,
+                )
+            )
+        }
+
+        @Synchronized
+        fun addRequest(request: CacheDownloadRequest) {
             isStopped = false
-            for (i in indices) {
-                pausedDownloadSet.remove(i)
-                if (!onDownloadSet.contains(i)) {
-                    waitDownloadSet.add(i)
+            when (val selection = request.selection) {
+                is ChapterSelection.Range -> {
+                    pausedDownloadSet.removeAll { it in selection.start..selection.end }
                 }
+                is ChapterSelection.Indices -> selection.values.forEach { pausedDownloadSet.remove(it) }
+                is ChapterSelection.Single -> pausedDownloadSet.remove(selection.index)
             }
+            queue.enqueue(request)
             cacheBookMap[book.bookUrl] = this
             isLoading = false
             notifyDownloadSetChanged()
@@ -351,6 +441,7 @@ object CacheBook {
             successDownloadSet.add(chapter.primaryStr())
             errorDownloadMap.remove(chapter.primaryStr())
             errorIndexMap[book.bookUrl]?.remove(chapter.index)
+            stateStore.markSuccess(book.bookUrl, chapter.index)
             notifyDownloadSetChanged()
             notifyErrorChanged()
             _cacheSuccessFlow.tryEmit(chapter)
@@ -363,6 +454,7 @@ object CacheBook {
                 errorDownloadMap.merge(chapter.primaryStr(), 1) { old, inc -> old + inc }
                 errorIndexMap.getOrPut(book.bookUrl) { ConcurrentHashMap.newKeySet() }
                     .add(chapter.index)
+                stateStore.markFailed(book.bookUrl, chapter.index)
             }
             onDownloadSet.remove(chapter.index)
             chapterTasks.remove(chapter.index)
@@ -372,7 +464,7 @@ object CacheBook {
         private fun onPostError(chapter: BookChapter, error: Throwable) {
             val retryCount = errorDownloadMap[chapter.primaryStr()] ?: 0
             if (retryCount < 3 && !isStopped) {
-                waitDownloadSet.add(chapter.index)
+                queue.enqueue(ChapterSelection.Single(chapter.index))
             } else {
                 AppLog.put("下载${book.name}-${chapter.title}失败\n${error.localizedMessage}", error)
             }
@@ -391,14 +483,16 @@ object CacheBook {
         private fun onCancel(index: Int) {
             onDownloadSet.remove(index)
             chapterTasks.remove(index)
-            if (!isStopped && !pausedDownloadSet.remove(index)) waitDownloadSet.add(index)
+            if (!isStopped && !pausedDownloadSet.remove(index)) {
+                queue.enqueue(ChapterSelection.Single(index))
+            }
             notifyDownloadSetChanged()
         }
 
         @Synchronized
         private fun onFinally() {
             val bookUrl = book.bookUrl
-            if (waitDownloadSet.isEmpty() && onDownloadSet.isEmpty()) {
+            if (queue.waitingCount() == 0 && onDownloadSet.isEmpty()) {
                 CacheBook.onTaskRemoved(bookUrl)
             } else {
                 CacheBook.onTaskQueuesChanged(bookUrl)
@@ -408,7 +502,7 @@ object CacheBook {
 
         @Synchronized
         fun removeDownload(index: Int): Boolean {
-            val removedWaiting = waitDownloadSet.remove(index)
+            val removedWaiting = queue.removeChapter(index)
             val task = chapterTasks.remove(index)
             val removedRunning = onDownloadSet.contains(index) || task != null
             if (removedRunning) {
@@ -420,8 +514,8 @@ object CacheBook {
             }
             if (!removedWaiting && !removedRunning) return false
             notifyDownloadSetChanged()
-            if (waitDownloadSet.isEmpty() && onDownloadSet.isEmpty()) {
-                CacheBook.onTaskRemoved(book.bookUrl)
+            if (queue.waitingCount() == 0 && onDownloadSet.isEmpty()) {
+                CacheBook.onTaskRemoved(book.bookUrl, clearState = true)
             } else {
                 CacheBook.onTaskQueuesChanged(book.bookUrl)
             }
@@ -433,42 +527,39 @@ object CacheBook {
          */
         @Synchronized
         fun download(scope: CoroutineScope, context: CoroutineContext) {
-            val chapterIndex = waitDownloadSet.firstOrNull()
-            if (chapterIndex == null) {
+            val candidate = queue.next(book.bookUrl, onDownloadSet)
+            if (candidate == null) {
                 if (!isLoading && onDownloadSet.isEmpty()) {
                     CacheBook.onTaskRemoved(book.bookUrl)
                 }
                 return
             }
+            val chapterIndex = candidate.chapterIndex
             if (onDownloadSet.contains(chapterIndex)) {
-                waitDownloadSet.remove(chapterIndex)
                 return
             }
-            val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, chapterIndex) ?: run {
-                waitDownloadSet.remove(chapterIndex)
+            val chapter = repository.getChapter(book.bookUrl, chapterIndex) ?: run {
                 return
             }
             if (chapter.isVolume) {
-                postEvent(EventBus.SAVE_CONTENT, Pair(book, chapter))
                 _cacheSuccessFlow.tryEmit(chapter)
-                waitDownloadSet.remove(chapterIndex)
                 return
             }
-            if (BookHelp.hasImageContent(book, chapter)) {
-                waitDownloadSet.remove(chapterIndex)
+            if (repository.hasImageContent(book, chapter)) {
                 return
             }
 
-            waitDownloadSet.remove(chapterIndex)
             onDownloadSet.add(chapterIndex)
             notifyDownloadSetChanged()
 
-            if (BookHelp.hasContent(book, chapter)) {
-                val task = Coroutine.async(scope, context, executeContext = context) {
-                    BookHelp.getContent(book, chapter)?.let {
-                        BookHelp.saveImages(bookSource, book, chapter, it, 1)
-                    }
-                }.onSuccess {
+            if (repository.hasContent(book, chapter)) {
+                val task = repository.saveCachedImagesTask(
+                    scope = scope,
+                    context = context,
+                    bookSource = bookSource,
+                    book = book,
+                    chapter = chapter,
+                ).onSuccess {
                     onSuccess(chapter)
                 }.onError {
                     onPreError(chapter, it)
@@ -485,14 +576,14 @@ object CacheBook {
                 return
             }
 
-            val task = WebBook.getContent(
-                scope,
-                bookSource,
-                book,
-                chapter,
+            val task = repository.downloadContentTask(
+                scope = scope,
+                bookSource = bookSource,
+                book = book,
+                chapter = chapter,
                 context = context,
                 start = CoroutineStart.LAZY,
-                executeContext = context
+                executeContext = context,
             ).onSuccess { content ->
                 onSuccess(chapter)
                 downloadFinish(chapter, content)
@@ -515,11 +606,11 @@ object CacheBook {
         suspend fun downloadAwait(chapter: BookChapter): String {
             synchronized(this) {
                 onDownloadSet.add(chapter.index)
-                waitDownloadSet.remove(chapter.index)
+                queue.removeChapter(chapter.index)
                 notifyDownloadSetChanged()
             }
             try {
-                val content = WebBook.getContentAwait(bookSource, book, chapter)
+                val content = repository.downloadContentAwait(bookSource, book, chapter)
                 onSuccess(chapter)
                 ReadBook.downloadedChapters.add(chapter.index)
                 ReadBook.downloadFailChapters.remove(chapter.index)
@@ -544,15 +635,16 @@ object CacheBook {
         ) {
             if (onDownloadSet.contains(chapter.index)) return
             onDownloadSet.add(chapter.index)
-            waitDownloadSet.remove(chapter.index)
+            queue.removeChapter(chapter.index)
             notifyDownloadSetChanged()
 
-            WebBook.getContent(
-                scope,
-                bookSource,
-                book,
-                chapter,
+            repository.downloadContentTask(
+                scope = scope,
+                bookSource = bookSource,
+                book = book,
+                chapter = chapter,
                 start = CoroutineStart.LAZY,
+                context = IO,
                 executeContext = IO,
                 semaphore = semaphore
             ).onSuccess { content ->
@@ -579,15 +671,15 @@ object CacheBook {
             resetPageOffset: Boolean = false,
             canceled: Boolean = false
         ) {
-            if (ReadBook.book?.bookUrl == book.bookUrl) {
-                ReadBook.contentLoadFinish(
+            ReadingCacheEvents.emit(
+                ReadingCacheEvent.ContentReady(
                     book = book,
                     chapter = chapter,
                     content = content,
                     resetPageOffset = resetPageOffset,
-                    canceled = canceled
+                    canceled = canceled,
                 )
-            }
+            )
         }
     }
 }

+ 22 - 0
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -26,6 +26,8 @@ import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.globalExecutor
 import io.legado.app.model.localBook.TextFile
+import io.legado.app.model.cache.ReadingCacheEvent
+import io.legado.app.model.cache.ReadingCacheEvents
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.service.BaseReadAloudService
 import io.legado.app.service.CacheBookService
@@ -88,6 +90,26 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     private val nextChapterLoadingLock = Mutex()
     var readStartTime: Long = System.currentTimeMillis()
 
+    init {
+        launch {
+            ReadingCacheEvents.events.collect { event ->
+                when (event) {
+                    is ReadingCacheEvent.ContentReady -> {
+                        if (book?.bookUrl == event.book.bookUrl) {
+                            contentLoadFinish(
+                                book = event.book,
+                                chapter = event.chapter,
+                                content = event.content,
+                                resetPageOffset = event.resetPageOffset,
+                                canceled = event.canceled,
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+
     /* 跳转进度前进度记录 */
     var lastBookProgress: BookProgress? = null
 

+ 46 - 0
app/src/main/java/io/legado/app/model/cache/CacheDownloadModels.kt

@@ -0,0 +1,46 @@
+package io.legado.app.model.cache
+
+data class CacheDownloadRequest(
+    val bookUrl: String,
+    val selection: ChapterSelection,
+    val source: CacheDownloadSource = CacheDownloadSource.Manual,
+)
+
+sealed interface ChapterSelection {
+    data class Range(val start: Int, val end: Int) : ChapterSelection
+    data class Indices(val values: Set<Int>) : ChapterSelection
+    data class Single(val index: Int) : ChapterSelection
+}
+
+enum class CacheDownloadSource {
+    Manual,
+    Batch,
+    ReadPreload,
+}
+
+data class CacheDownloadCandidate(
+    val bookUrl: String,
+    val chapterIndex: Int,
+)
+
+data class CacheDownloadQueueSnapshot(
+    val waitingCount: Int,
+)
+
+data class CacheDownloadState(
+    val isRunning: Boolean = false,
+    val totalWaiting: Int = 0,
+    val totalRunning: Int = 0,
+    val totalSuccess: Int = 0,
+    val totalFailure: Int = 0,
+    val books: Map<String, CacheBookDownloadState> = emptyMap(),
+)
+
+data class CacheBookDownloadState(
+    val bookUrl: String,
+    val waitingCount: Int = 0,
+    val runningIndices: Set<Int> = emptySet(),
+    val failedIndices: Set<Int> = emptySet(),
+    val successIndices: Set<Int> = emptySet(),
+    val successCount: Int = 0,
+)

+ 118 - 0
app/src/main/java/io/legado/app/model/cache/CacheDownloadQueue.kt

@@ -0,0 +1,118 @@
+package io.legado.app.model.cache
+
+class CacheDownloadQueue {
+
+    private data class RangeCursor(
+        val start: Int,
+        val end: Int,
+        var next: Int = start,
+    ) {
+        fun contains(index: Int): Boolean = index in next..end
+        fun remainingCount(
+            emittedIndices: Set<Int>,
+            removedIndices: Set<Int>,
+        ): Int {
+            if (next > end) return 0
+            val rawCount = end - next + 1
+            val emittedCount = emittedIndices.count { it in next..end }
+            val removedCount = removedIndices.count { it in next..end && it !in emittedIndices }
+            val excludedCount = emittedCount + removedCount
+            return rawCount - excludedCount
+        }
+    }
+
+    private val ranges = ArrayDeque<RangeCursor>()
+    private val indices = linkedSetOf<Int>()
+    private val emittedIndices = hashSetOf<Int>()
+    private val removedIndices = hashSetOf<Int>()
+
+    fun enqueue(request: CacheDownloadRequest) {
+        enqueue(request.selection)
+    }
+
+    fun enqueue(selection: ChapterSelection) {
+        when (selection) {
+            is ChapterSelection.Range -> addRange(selection.start, selection.end)
+            is ChapterSelection.Indices -> addIndices(selection.values)
+            is ChapterSelection.Single -> addIndex(selection.index)
+        }
+    }
+
+    fun next(bookUrl: String, runningIndices: Set<Int>): CacheDownloadCandidate? {
+        while (indices.isNotEmpty()) {
+            val index = indices.first()
+            indices.remove(index)
+            if (index in runningIndices || index in removedIndices) continue
+            emittedIndices.add(index)
+            return CacheDownloadCandidate(bookUrl, index)
+        }
+
+        while (ranges.isNotEmpty()) {
+            val cursor = ranges.first()
+            while (cursor.next <= cursor.end) {
+                val index = cursor.next++
+                if (index in removedIndices || index in runningIndices) continue
+                if (emittedIndices.add(index)) {
+                    return CacheDownloadCandidate(bookUrl, index)
+                }
+            }
+            ranges.removeFirst()
+        }
+        return null
+    }
+
+    fun removeChapter(index: Int): Boolean {
+        val removed = indices.remove(index) || isWaiting(index)
+        removedIndices.add(index)
+        return removed
+    }
+
+    fun clear() {
+        ranges.clear()
+        indices.clear()
+        emittedIndices.clear()
+        removedIndices.clear()
+    }
+
+    fun snapshot(): CacheDownloadQueueSnapshot {
+        return CacheDownloadQueueSnapshot(waitingCount = waitingCount())
+    }
+
+    fun waitingCount(): Int {
+        val indexCount = indices.count { it !in emittedIndices && it !in removedIndices }
+        val rangeCount = ranges.sumOf { it.remainingCount(emittedIndices, removedIndices) }
+        return indexCount + rangeCount
+    }
+
+    fun isWaiting(index: Int): Boolean {
+        if (index in emittedIndices || index in removedIndices) return false
+        return indices.contains(index) || ranges.any { it.contains(index) }
+    }
+
+    fun waitingIndices(): Set<Int> {
+        return buildSet {
+            indices.filterTo(this) { it !in emittedIndices && it !in removedIndices }
+            ranges.forEach { cursor ->
+                for (index in cursor.next..cursor.end) {
+                    if (index !in emittedIndices && index !in removedIndices) add(index)
+                }
+            }
+        }
+    }
+
+    private fun addRange(start: Int, end: Int) {
+        if (end < start) return
+        removedIndices.removeAll { it in start..end }
+        ranges.add(RangeCursor(start, end))
+    }
+
+    private fun addIndices(values: Iterable<Int>) {
+        values.forEach { addIndex(it) }
+    }
+
+    private fun addIndex(index: Int) {
+        emittedIndices.remove(index)
+        removedIndices.remove(index)
+        indices.add(index)
+    }
+}

+ 72 - 0
app/src/main/java/io/legado/app/model/cache/CacheDownloadRepository.kt

@@ -0,0 +1,72 @@
+package io.legado.app.model.cache
+
+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.BookSource
+import io.legado.app.help.book.BookHelp
+import io.legado.app.help.coroutine.Coroutine
+import io.legado.app.model.webBook.WebBook
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.sync.Semaphore
+import kotlin.coroutines.CoroutineContext
+
+class CacheDownloadRepository {
+
+    fun getChapter(bookUrl: String, index: Int): BookChapter? {
+        return appDb.bookChapterDao.getChapter(bookUrl, index)
+    }
+
+    fun hasImageContent(book: Book, chapter: BookChapter): Boolean {
+        return BookHelp.hasImageContent(book, chapter)
+    }
+
+    fun hasContent(book: Book, chapter: BookChapter): Boolean {
+        return BookHelp.hasContent(book, chapter)
+    }
+
+    fun saveCachedImagesTask(
+        scope: CoroutineScope,
+        context: CoroutineContext,
+        bookSource: BookSource,
+        book: Book,
+        chapter: BookChapter,
+    ): Coroutine<Unit> {
+        return Coroutine.async(scope, context, executeContext = context) {
+            BookHelp.getContent(book, chapter)?.let {
+                BookHelp.saveImages(bookSource, book, chapter, it, 1)
+            }
+        }
+    }
+
+    fun downloadContentTask(
+        scope: CoroutineScope,
+        bookSource: BookSource,
+        book: Book,
+        chapter: BookChapter,
+        context: CoroutineContext,
+        start: CoroutineStart = CoroutineStart.LAZY,
+        executeContext: CoroutineContext = context,
+        semaphore: Semaphore? = null,
+    ): Coroutine<String> {
+        return WebBook.getContent(
+            scope = scope,
+            bookSource = bookSource,
+            book = book,
+            bookChapter = chapter,
+            context = context,
+            start = start,
+            executeContext = executeContext,
+            semaphore = semaphore,
+        )
+    }
+
+    suspend fun downloadContentAwait(
+        bookSource: BookSource,
+        book: Book,
+        chapter: BookChapter,
+    ): String {
+        return WebBook.getContentAwait(bookSource, book, chapter)
+    }
+}

+ 92 - 0
app/src/main/java/io/legado/app/model/cache/CacheDownloadStateStore.kt

@@ -0,0 +1,92 @@
+package io.legado.app.model.cache
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+class CacheDownloadStateStore {
+
+    private val _stateFlow = MutableStateFlow(CacheDownloadState())
+    val stateFlow = _stateFlow.asStateFlow()
+
+    val state: CacheDownloadState
+        get() = _stateFlow.value
+
+    fun updateBookQueue(
+        bookUrl: String,
+        waitingCount: Int,
+        runningIndices: Set<Int>,
+    ) {
+        updateBook(bookUrl) { current ->
+            current.copy(
+                waitingCount = waitingCount,
+                runningIndices = runningIndices,
+            )
+        }
+    }
+
+    fun markSuccess(bookUrl: String, chapterIndex: Int) {
+        updateBook(bookUrl) { current ->
+            val successIndices = current.successIndices + chapterIndex
+            current.copy(
+                runningIndices = current.runningIndices - chapterIndex,
+                failedIndices = current.failedIndices - chapterIndex,
+                successIndices = successIndices,
+                successCount = successIndices.size,
+            )
+        }
+    }
+
+    fun markFailed(bookUrl: String, chapterIndex: Int) {
+        updateBook(bookUrl) { current ->
+            current.copy(
+                runningIndices = current.runningIndices - chapterIndex,
+                failedIndices = current.failedIndices + chapterIndex,
+            )
+        }
+    }
+
+    fun clearFailure(bookUrl: String, chapterIndex: Int) {
+        updateBook(bookUrl) { current ->
+            current.copy(failedIndices = current.failedIndices - chapterIndex)
+        }
+    }
+
+    fun removeBook(bookUrl: String) {
+        _stateFlow.update { state ->
+            state.copy(books = state.books - bookUrl).recalculate()
+        }
+    }
+
+    fun clear() {
+        _stateFlow.value = CacheDownloadState()
+    }
+
+    fun bookState(bookUrl: String): CacheBookDownloadState? {
+        return state.books[bookUrl]
+    }
+
+    private fun updateBook(
+        bookUrl: String,
+        transform: (CacheBookDownloadState) -> CacheBookDownloadState,
+    ) {
+        _stateFlow.update { state ->
+            val current = state.books[bookUrl] ?: CacheBookDownloadState(bookUrl)
+            state.copy(books = state.books + (bookUrl to transform(current))).recalculate()
+        }
+    }
+
+    private fun CacheDownloadState.recalculate(): CacheDownloadState {
+        val totalWaiting = books.values.sumOf { it.waitingCount }
+        val totalRunning = books.values.sumOf { it.runningIndices.size }
+        val totalFailure = books.values.sumOf { it.failedIndices.size }
+        val totalSuccess = books.values.sumOf { it.successCount }
+        return copy(
+            isRunning = totalWaiting > 0 || totalRunning > 0,
+            totalWaiting = totalWaiting,
+            totalRunning = totalRunning,
+            totalFailure = totalFailure,
+            totalSuccess = totalSuccess,
+        )
+    }
+}

+ 25 - 0
app/src/main/java/io/legado/app/model/cache/ReadingCacheEvents.kt

@@ -0,0 +1,25 @@
+package io.legado.app.model.cache
+
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+
+sealed interface ReadingCacheEvent {
+    data class ContentReady(
+        val book: Book,
+        val chapter: BookChapter,
+        val content: String,
+        val resetPageOffset: Boolean,
+        val canceled: Boolean,
+    ) : ReadingCacheEvent
+}
+
+object ReadingCacheEvents {
+    private val _events = MutableSharedFlow<ReadingCacheEvent>(extraBufferCapacity = 32)
+    val events = _events.asSharedFlow()
+
+    fun emit(event: ReadingCacheEvent) {
+        _events.tryEmit(event)
+    }
+}

+ 32 - 16
app/src/main/java/io/legado/app/service/CacheBookService.kt

@@ -7,17 +7,18 @@ import io.legado.app.R
 import io.legado.app.base.BaseService
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppLog
-import io.legado.app.constant.EventBus
 import io.legado.app.constant.IntentAction
 import io.legado.app.constant.NotificationId
 import io.legado.app.data.appDb
 import io.legado.app.help.book.update
 import io.legado.app.model.CacheBook
+import io.legado.app.model.cache.CacheDownloadRequest
+import io.legado.app.model.cache.CacheDownloadSource
+import io.legado.app.model.cache.ChapterSelection
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.utils.activityPendingIntent
-import io.legado.app.utils.postEvent
 import io.legado.app.utils.servicePendingIntent
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.asCoroutineDispatcher
@@ -75,7 +76,6 @@ class CacheBookService : BaseService() {
                 delay(1000)
                 notificationContent = CacheBook.downloadSummary
                 upCacheBookNotification()
-                postEvent(EventBus.UP_DOWNLOAD, "")
             }
         }
     }
@@ -111,18 +111,39 @@ class CacheBookService : BaseService() {
         cachePool.close()
         CacheBook.close()
         super.onDestroy()
-        postEvent(EventBus.UP_DOWNLOAD, "")
     }
 
-    private fun addDownloadData(bookUrl: String?, indices: List<Int>) {
+    private fun addDownloadData(bookUrl: String?, indices: Iterable<Int>) {
         bookUrl ?: return
-        if (indices.isEmpty()) return
+        val values = indices.toSet()
+        if (values.isEmpty()) return
+        addDownloadRequest(
+            CacheDownloadRequest(
+                bookUrl = bookUrl,
+                selection = ChapterSelection.Indices(values),
+                source = CacheDownloadSource.Manual,
+            )
+        )
+    }
+
+    private fun addDownloadData(bookUrl: String?, start: Int, end: Int) {
+        bookUrl ?: return
+        if (end < start) return
+        addDownloadRequest(
+            CacheDownloadRequest(
+                bookUrl = bookUrl,
+                selection = ChapterSelection.Range(start, end),
+                source = CacheDownloadSource.Manual,
+            )
+        )
+    }
 
+    private fun addDownloadRequest(request: CacheDownloadRequest) {
         execute {
-            val cacheBook = CacheBook.getOrCreate(bookUrl) ?: return@execute
+            val cacheBook = CacheBook.getOrCreate(request.bookUrl) ?: return@execute
 
             val book = cacheBook.book
-            val chapterCount = appDb.bookChapterDao.getChapterCount(bookUrl)
+            val chapterCount = appDb.bookChapterDao.getChapterCount(request.bookUrl)
 
             if (chapterCount == 0) {
                 cacheBook.setLoading()
@@ -132,7 +153,7 @@ class CacheBookService : BaseService() {
                         kotlin.runCatching {
                             WebBook.getBookInfoAwait(cacheBook.bookSource, book)
                         }.onFailure {
-                            removeDownload(bookUrl)
+                            removeDownload(request.bookUrl)
                             AppLog.put(
                                 "《$name》目录为空且加载详情页失败\n${it.localizedMessage}",
                                 it,
@@ -147,7 +168,7 @@ class CacheBookService : BaseService() {
                             book.totalChapterNum = 0
                             book.update()
                         }
-                        removeDownload(bookUrl)
+                        removeDownload(request.bookUrl)
                         AppLog.put(
                             "《$name》目录为空且加载目录失败\n${it.localizedMessage}",
                             it,
@@ -163,7 +184,7 @@ class CacheBookService : BaseService() {
             }
 
             //添加章节到下载队列
-            cacheBook.addDownloads(indices)
+            cacheBook.addRequest(request)
 
             notificationContent = CacheBook.downloadSummary
             upCacheBookNotification()
@@ -174,14 +195,9 @@ class CacheBookService : BaseService() {
         }
     }
 
-    private fun addDownloadData(bookUrl: String?, start: Int, end: Int) {
-        addDownloadData(bookUrl, (start..end).toList())
-    }
-
 
     private fun removeDownload(bookUrl: String?) {
         CacheBook.cacheBookMap[bookUrl]?.stop()
-        postEvent(EventBus.UP_DOWNLOAD, "")
         if (downloadJob == null && CacheBook.isRun) {
             download()
             return

+ 82 - 39
app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt

@@ -19,6 +19,8 @@ import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.delay
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.isActive
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
@@ -26,6 +28,7 @@ import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
+import java.util.concurrent.ConcurrentHashMap
 import kotlin.math.min
 
 data class BookCacheManageUiState(
@@ -94,6 +97,11 @@ class BookCacheManageViewModel(
     private val clearBookCacheUseCase: ClearBookCacheUseCase,
 ) : BaseViewModel(application) {
 
+    private companion object {
+        const val DOWNLOAD_BATCH_SIZE = 512
+        const val DOWNLOAD_STATUS_REFRESH_INTERVAL_MILLIS = 2_000L
+    }
+
     private val _uiState = MutableStateFlow(BookCacheManageUiState())
     val uiState = _uiState.asStateFlow()
 
@@ -103,6 +111,9 @@ class BookCacheManageViewModel(
     private var observeJob: Job? = null
     private var fullReloadJob: Job? = null
     private val bookReloadJobs = hashMapOf<String, Job>()
+    private val pendingDownloadRefreshBookUrls = ConcurrentHashMap.newKeySet<String>()
+    @Volatile
+    private var pendingDownloadSummaryRefresh = false
 
     fun onIntent(intent: BookCacheManageIntent) {
         when (intent) {
@@ -140,32 +151,24 @@ class BookCacheManageViewModel(
         }
         viewModelScope.launch {
             CacheBook.cacheSuccessFlow.collect { chapter ->
-                scheduleBookReload(chapter.bookUrl)
+                scheduleDownloadStatusRefresh(chapter.bookUrl)
             }
         }
         viewModelScope.launch {
-            CacheBook.downloadingIndicesFlow.collect { state ->
-                scheduleBookReload(state.first)
+            CacheBook.downloadStateFlow.collect { state ->
+                state.books.keys.forEach { scheduleDownloadStatusRefresh(it) }
+                pendingDownloadSummaryRefresh = true
             }
         }
         viewModelScope.launch {
             CacheBook.queueChangedFlow.collect { bookUrl ->
-                scheduleBookReload(bookUrl)
-            }
-        }
-        viewModelScope.launch {
-            CacheBook.downloadErrorFlow.collect { state ->
-                scheduleBookReload(state.first)
+                scheduleDownloadStatusRefresh(bookUrl)
             }
         }
         viewModelScope.launch {
-            CacheBook.downloadSummaryFlow.collect {
-                _uiState.update {
-                    it.copy(
-                        downloadSummary = buildDownloadSummary(it.shelfBooks + it.notShelfBooks),
-                        version = it.version + 1,
-                    )
-                }
+            while (isActive) {
+                delay(DOWNLOAD_STATUS_REFRESH_INTERVAL_MILLIS)
+                flushDownloadStatusRefresh()
             }
         }
     }
@@ -216,6 +219,31 @@ class BookCacheManageViewModel(
         }
     }
 
+    private fun scheduleDownloadStatusRefresh(bookUrl: String) {
+        if (bookUrl.isNotBlank()) {
+            pendingDownloadRefreshBookUrls.add(bookUrl)
+        }
+        pendingDownloadSummaryRefresh = true
+    }
+
+    private suspend fun flushDownloadStatusRefresh() {
+        val bookUrls = pendingDownloadRefreshBookUrls.toList()
+        bookUrls.forEach { pendingDownloadRefreshBookUrls.remove(it) }
+        val shouldRefreshSummary = pendingDownloadSummaryRefresh || bookUrls.isNotEmpty()
+        pendingDownloadSummaryRefresh = false
+        bookUrls.forEach { bookUrl ->
+            reloadBook(bookUrl)
+        }
+        if (shouldRefreshSummary) {
+            _uiState.update {
+                it.copy(
+                    downloadSummary = buildDownloadSummary(it.shelfBooks + it.notShelfBooks),
+                    version = it.version + 1,
+                )
+            }
+        }
+    }
+
     private fun scheduleBookReload(bookUrl: String, debounceMillis: Long = 80) {
         if (bookUrl.isBlank()) return
         bookReloadJobs.remove(bookUrl)?.cancel()
@@ -278,8 +306,7 @@ class BookCacheManageViewModel(
     private fun buildBookItem(book: Book): BookCacheBookItem? {
         val cacheFiles = BookHelp.getChapterFiles(book)
         val model = CacheBook.cacheBookMap[book.bookUrl]
-        val waitingIndices = model?.waitingIndices().orEmpty()
-        val downloadingIndices = model?.downloadingIndices().orEmpty()
+        val (waitingCount, downloadingCount) = model?.queueCounts() ?: (0 to 0)
         val errorIndices = errorIndices(book.bookUrl)
         val totalCount = bookChapterDao.getChapterCount(book.bookUrl)
         val cachedFileCount = cacheFiles.count { it.endsWith(".nb") }
@@ -293,8 +320,8 @@ class BookCacheManageViewModel(
             author = book.getRealAuthor(),
             totalCount = totalCount,
             cachedCount = cachedCount,
-            waitingCount = waitingIndices.size,
-            downloadingCount = downloadingIndices.size,
+            waitingCount = waitingCount,
+            downloadingCount = downloadingCount,
             errorCount = errorIndices.size,
             isNotShelf = book.isNotShelf,
         )
@@ -305,8 +332,6 @@ class BookCacheManageViewModel(
         val chapters = bookChapterDao.getChapterCacheInfoList(bookUrl)
         val cacheFiles = BookHelp.getChapterFiles(book)
         val model = CacheBook.cacheBookMap[bookUrl]
-        val waitingIndices = model?.waitingIndices().orEmpty()
-        val downloadingIndices = model?.downloadingIndices().orEmpty()
         val errorIndices = errorIndices(bookUrl)
         return chapters.map { chapter ->
             BookCacheChapterItem(
@@ -314,8 +339,8 @@ class BookCacheManageViewModel(
                 title = chapter.title,
                 index = chapter.index,
                 isCached = cacheFiles.contains(chapter.getFileName()) || chapter.isVolume,
-                isWaiting = waitingIndices.contains(chapter.index),
-                isDownloading = downloadingIndices.contains(chapter.index),
+                isWaiting = model?.isWaiting(chapter.index) == true,
+                isDownloading = model?.isDownloading(chapter.index) == true,
                 isError = errorIndices.contains(chapter.index),
             )
         }
@@ -377,10 +402,14 @@ class BookCacheManageViewModel(
     private fun startAllDownloads() {
         val items = uiState.value.shelfBooks + uiState.value.notShelfBooks
         execute {
-            items.sumOf { item ->
-                val chapterIndices = downloadableChapterIndices(item.bookUrl)
-                cacheBookChaptersUseCase.execute(item.bookUrl, chapterIndices)
+            var count = 0
+            items.forEach { item ->
+                downloadableChapterIndexBatches(item.bookUrl).forEach { chapterIndices ->
+                    count += cacheBookChaptersUseCase.execute(item.bookUrl, chapterIndices)
+                }
+                currentCoroutineContext().ensureActive()
             }
+            count
         }.onSuccess { count ->
             if (count > 0) {
                 _effects.tryEmit(BookCacheManageEffect.ShowMessage("已加入缓存队列: $count 章"))
@@ -396,7 +425,11 @@ class BookCacheManageViewModel(
 
     private fun startBookDownload(bookUrl: String) {
         execute {
-            cacheBookChaptersUseCase.execute(bookUrl, downloadableChapterIndices(bookUrl))
+            var count = 0
+            downloadableChapterIndexBatches(bookUrl).forEach { chapterIndices ->
+                count += cacheBookChaptersUseCase.execute(bookUrl, chapterIndices)
+            }
+            count
         }.onSuccess { count ->
             if (count > 0) {
                 _effects.tryEmit(BookCacheManageEffect.ShowMessage("已加入缓存队列: $count 章"))
@@ -410,22 +443,32 @@ class BookCacheManageViewModel(
         }
     }
 
-    private fun downloadableChapterIndices(bookUrl: String): List<Int> {
-        val book = bookDao.getBook(bookUrl) ?: return emptyList()
+    private fun downloadableChapterIndexBatches(
+        bookUrl: String,
+        batchSize: Int = DOWNLOAD_BATCH_SIZE,
+    ): Sequence<List<Int>> = sequence {
+        val book = bookDao.getBook(bookUrl) ?: return@sequence
         val cacheFiles = BookHelp.getChapterFiles(book)
         val model = CacheBook.cacheBookMap[bookUrl]
-        val waitingIndices = model?.waitingIndices().orEmpty()
-        val downloadingIndices = model?.downloadingIndices().orEmpty()
-        return bookChapterDao.getChapterCacheInfoList(bookUrl)
-            .asSequence()
-            .filterNot { chapter ->
+        var batch = ArrayList<Int>(batchSize)
+        for (chapter in bookChapterDao.getChapterCacheInfoList(bookUrl)) {
+            if (
                 chapter.isVolume ||
                     cacheFiles.contains(chapter.getFileName()) ||
-                    waitingIndices.contains(chapter.index) ||
-                    downloadingIndices.contains(chapter.index)
+                    model?.isWaiting(chapter.index) == true ||
+                    model?.isDownloading(chapter.index) == true
+            ) {
+                continue
             }
-            .map { it.index }
-            .toList()
+            batch.add(chapter.index)
+            if (batch.size == batchSize) {
+                yield(batch)
+                batch = ArrayList(batchSize)
+            }
+        }
+        if (batch.isNotEmpty()) {
+            yield(batch)
+        }
     }
 
     private fun deleteBookCache(bookUrl: String) {

+ 69 - 30
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt

@@ -37,7 +37,9 @@ import io.legado.app.utils.cnCompare
 import io.legado.app.utils.move
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.isActive
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
@@ -155,6 +157,10 @@ class BookshelfManageScreenViewModel(
     private val updateBooksGroupUseCase: UpdateBooksGroupUseCase
 ) : BaseViewModel(application) {
 
+    private companion object {
+        const val DOWNLOAD_STATUS_REFRESH_INTERVAL_MILLIS = 2_000L
+    }
+
     private val _uiState = MutableStateFlow(BookshelfManageScreenUiState())
     val uiState = _uiState.asStateFlow()
 
@@ -165,8 +171,11 @@ class BookshelfManageScreenViewModel(
     private var booksJob: Job? = null
     private var groupsJob: Job? = null
     private var cacheLoadJob: Job? = null
-    private val cacheCountJobs = ConcurrentHashMap<String, Job>()
     private var observersStarted = false
+    private val pendingDownloadStatusBookUrls = ConcurrentHashMap.newKeySet<String>()
+    private val pendingCacheCountRefreshBookUrls = ConcurrentHashMap.newKeySet<String>()
+    @Volatile
+    private var pendingDownloadRunningRefresh = false
 
     fun dispatch(intent: BookshelfManageScreenIntent) {
         when (intent) {
@@ -367,28 +376,26 @@ class BookshelfManageScreenViewModel(
         observersStarted = true
         viewModelScope.launch {
             CacheBook.cacheSuccessFlow.collect { chapter ->
-                onChapterCached(chapter)
+                scheduleCacheCountRefresh(chapter.bookUrl)
             }
         }
         viewModelScope.launch {
-            CacheBook.downloadingIndicesFlow.collect { (bookUrl, _) ->
-                syncDownloadRunning()
-                if (bookUrl.isNotEmpty()) {
-                    emitBookChanged(bookUrl)
+            CacheBook.downloadStateFlow.collect { state ->
+                state.books.keys.forEach { bookUrl ->
+                    scheduleDownloadStatusRefresh(bookUrl)
                 }
+                scheduleDownloadStatusRefresh()
             }
         }
         viewModelScope.launch {
-            CacheBook.downloadErrorFlow.collect { (bookUrl, _) ->
-                syncDownloadRunning()
-                if (bookUrl.isNotEmpty()) {
-                    emitBookChanged(bookUrl)
-                }
+            CacheBook.queueChangedFlow.collect { bookUrl ->
+                scheduleDownloadStatusRefresh(bookUrl)
             }
         }
-        viewModelScope.launch {
-            CacheBook.downloadSummaryFlow.collect {
-                syncDownloadRunning()
+        viewModelScope.launch(Dispatchers.IO) {
+            while (isActive) {
+                delay(DOWNLOAD_STATUS_REFRESH_INTERVAL_MILLIS)
+                flushDownloadStatusRefresh()
             }
         }
         viewModelScope.launch {
@@ -432,8 +439,6 @@ class BookshelfManageScreenViewModel(
 
     private fun loadCacheCounts(books: List<Book>) {
         cacheLoadJob?.cancel()
-        cacheCountJobs.values.forEach { it.cancel() }
-        cacheCountJobs.clear()
         cacheLoadJob = viewModelScope.launch(Dispatchers.IO) {
             val visibleBookUrls = books.mapTo(hashSetOf()) { it.bookUrl }
             cacheCounts.keys.toList().forEach { bookUrl ->
@@ -451,22 +456,48 @@ class BookshelfManageScreenViewModel(
         }
     }
 
-    private fun onChapterCached(chapter: BookChapter) {
-        val bookUrl = chapter.bookUrl
-        scheduleCacheCountRefresh(bookUrl)
-    }
-
     private fun scheduleCacheCountRefresh(bookUrl: String) {
-        cacheCountJobs.remove(bookUrl)?.cancel()
-        cacheCountJobs[bookUrl] = viewModelScope.launch(Dispatchers.IO) {
-            val book = bookDao.getBook(bookUrl) ?: return@launch
-            if (!uiState.value.books.any { it.bookUrl == bookUrl }) {
-                return@launch
+        if (bookUrl.isNotBlank()) {
+            pendingCacheCountRefreshBookUrls.add(bookUrl)
+        }
+        pendingDownloadRunningRefresh = true
+    }
+
+    private fun scheduleDownloadStatusRefresh(bookUrl: String = "") {
+        if (bookUrl.isNotBlank()) {
+            pendingDownloadStatusBookUrls.add(bookUrl)
+        }
+        pendingDownloadRunningRefresh = true
+    }
+
+    private suspend fun flushDownloadStatusRefresh() {
+        val cacheRefreshBookUrls = pendingCacheCountRefreshBookUrls.toList()
+        cacheRefreshBookUrls.forEach { pendingCacheCountRefreshBookUrls.remove(it) }
+        val statusBookUrls = pendingDownloadStatusBookUrls.toList()
+        statusBookUrls.forEach { pendingDownloadStatusBookUrls.remove(it) }
+        val shouldSyncDownloadRunning = pendingDownloadRunningRefresh ||
+            cacheRefreshBookUrls.isNotEmpty() ||
+            statusBookUrls.isNotEmpty()
+        pendingDownloadRunningRefresh = false
+        val changedBookUrls = linkedSetOf<String>()
+        val visibleBookUrls = uiState.value.books.mapTo(hashSetOf()) { it.bookUrl }
+        cacheRefreshBookUrls.forEach { bookUrl ->
+            if (visibleBookUrls.contains(bookUrl)) {
+                bookDao.getBook(bookUrl)?.let { book ->
+                    cacheCounts[bookUrl] = calculateCacheCount(book)
+                    changedBookUrls.add(bookUrl)
+                }
             }
-            cacheCounts[bookUrl] = calculateCacheCount(book)
-            emitBookChanged(bookUrl)
-            cacheCountJobs.remove(bookUrl)
         }
+        statusBookUrls.forEach { bookUrl ->
+            if (visibleBookUrls.contains(bookUrl)) {
+                changedBookUrls.add(bookUrl)
+            }
+        }
+        if (shouldSyncDownloadRunning) {
+            syncDownloadRunning()
+        }
+        emitBooksChanged(changedBookUrls)
     }
 
     private fun calculateCacheCount(book: Book): Int {
@@ -496,7 +527,7 @@ class BookshelfManageScreenViewModel(
             syncDownloadRunning()
         } else {
             execute {
-                cacheBookChaptersUseCase.execute(book.bookUrl, 0..book.lastChapterIndex)
+                cacheBookChaptersUseCase.executeRange(book.bookUrl, 0, book.lastChapterIndex)
             }.onFinally {
                 syncDownloadRunning()
             }
@@ -873,4 +904,12 @@ class BookshelfManageScreenViewModel(
         _effects.tryEmit(BookshelfManageScreenEffect.NotifyBookChanged(bookUrl))
     }
 
+    private fun emitBooksChanged(bookUrls: Set<String>) {
+        if (bookUrls.isEmpty()) return
+        _uiState.update { it.copy(cacheVersion = it.cacheVersion + 1) }
+        bookUrls.forEach { bookUrl ->
+            _effects.tryEmit(BookshelfManageScreenEffect.NotifyBookChanged(bookUrl))
+        }
+    }
+
 }

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

@@ -303,8 +303,7 @@ abstract class BaseReadBookActivity :
                         val end = editEnd.text!!.toString().let {
                             if (it.isEmpty()) book.totalChapterNum else it.toInt()
                         }
-                        val indices = (start - 1..end - 1).toList()
-                        CacheBook.start(this@BaseReadBookActivity, book, indices)
+                        CacheBook.start(this@BaseReadBookActivity, book, start - 1, end - 1)
                     }
                 }
                 cancelButton()

+ 10 - 12
app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt

@@ -21,6 +21,7 @@ import io.legado.app.help.bookmark.BookmarkExporter
 import io.legado.app.help.config.AppConfig
 import io.legado.app.model.CacheBook
 import io.legado.app.model.ReadBook
+import io.legado.app.model.cache.CacheBookDownloadState
 import io.legado.app.model.localBook.LocalBook
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
@@ -89,8 +90,7 @@ data class TocDomainItem(
 )
 
 private data class DownloadContext(
-    val downloadingPair: Pair<String, Set<Int>>,
-    val errorPair: Pair<String, Set<Int>>,
+    val downloadState: CacheBookDownloadState?,
     val cachedFiles: Set<String>
 )
 
@@ -206,11 +206,11 @@ class TocViewModel(
             .distinctUntilChanged()
 
     private val downloadContextFlow = combine(
-        CacheBook.downloadingIndicesFlow,
-        CacheBook.downloadErrorFlow,
+        bookState.filterNotNull().map { it.bookUrl }.distinctUntilChanged(),
+        CacheBook.downloadStateFlow,
         _cacheFileNames
-    ) { downloading, errors, cached ->
-        DownloadContext(downloading, errors, cached)
+    ) { bookUrl, state, cached ->
+        DownloadContext(state.books[bookUrl], cached)
     }
 
     private val uiConfigFlow = combine(
@@ -263,15 +263,13 @@ class TocViewModel(
             }
         }
 
-        val (downloadingPair, errorPair, cachedFiles) = downloadCtx
-        val downloadingIndices =
-            if (downloadingPair.first == book.bookUrl) downloadingPair.second else emptySet()
-        val errorIndices =
-            if (errorPair.first == book.bookUrl) errorPair.second else emptySet()
+        val runningIndices = downloadCtx.downloadState?.runningIndices.orEmpty()
+        val errorIndices = downloadCtx.downloadState?.failedIndices.orEmpty()
+        val cachedFiles = downloadCtx.cachedFiles
 
         processedChapters.map { chapter ->
             val downloadState = when {
-                chapter.index in downloadingIndices -> DownloadState.DOWNLOADING
+                chapter.index in runningIndices -> DownloadState.DOWNLOADING
                 chapter.index in errorIndices -> DownloadState.ERROR
                 chapter.getFileName() in cachedFiles -> DownloadState.SUCCESS
                 else -> DownloadState.NONE

+ 78 - 0
app/src/test/java/io/legado/app/model/cache/CacheDownloadQueueTest.kt

@@ -0,0 +1,78 @@
+package io.legado.app.model.cache
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CacheDownloadQueueTest {
+
+    @Test
+    fun rangeReturnsChaptersLazily() {
+        val queue = CacheDownloadQueue()
+
+        queue.enqueue(ChapterSelection.Range(2, 4))
+
+        assertEquals(3, queue.waitingCount())
+        assertEquals(2, queue.next("book", emptySet())?.chapterIndex)
+        assertEquals(2, queue.waitingCount())
+        assertEquals(3, queue.next("book", emptySet())?.chapterIndex)
+        assertEquals(4, queue.next("book", emptySet())?.chapterIndex)
+        assertNull(queue.next("book", emptySet()))
+    }
+
+    @Test
+    fun removeChapterSkipsChapterInsideRange() {
+        val queue = CacheDownloadQueue()
+
+        queue.enqueue(ChapterSelection.Range(0, 3))
+        assertTrue(queue.removeChapter(1))
+
+        assertEquals(listOf(0, 2, 3), drain(queue))
+        assertFalse(queue.isWaiting(1))
+    }
+
+    @Test
+    fun nextDoesNotReturnRunningChapter() {
+        val queue = CacheDownloadQueue()
+
+        queue.enqueue(ChapterSelection.Indices(setOf(1, 2)))
+
+        assertEquals(2, queue.next("book", setOf(1))?.chapterIndex)
+        assertNull(queue.next("book", emptySet()))
+    }
+
+    @Test
+    fun explicitRequeueCanRetryConsumedChapter() {
+        val queue = CacheDownloadQueue()
+
+        queue.enqueue(ChapterSelection.Range(1, 1))
+        assertEquals(1, queue.next("book", emptySet())?.chapterIndex)
+        queue.enqueue(ChapterSelection.Single(1))
+
+        assertEquals(1, queue.next("book", emptySet())?.chapterIndex)
+    }
+
+    @Test
+    fun reEnqueueRangeRestoresRemovedChapter() {
+        val queue = CacheDownloadQueue()
+
+        queue.enqueue(ChapterSelection.Range(0, 2))
+        assertTrue(queue.removeChapter(1))
+        assertEquals(0, queue.next("book", emptySet())?.chapterIndex)
+        queue.enqueue(ChapterSelection.Range(1, 1))
+
+        assertEquals(1, queue.next("book", emptySet())?.chapterIndex)
+        assertEquals(2, queue.next("book", emptySet())?.chapterIndex)
+    }
+
+    private fun drain(queue: CacheDownloadQueue): List<Int> {
+        val result = mutableListOf<Int>()
+        while (true) {
+            val next = queue.next("book", emptySet()) ?: break
+            result.add(next.chapterIndex)
+        }
+        return result
+    }
+}

+ 61 - 0
app/src/test/java/io/legado/app/model/cache/CacheDownloadStateStoreTest.kt

@@ -0,0 +1,61 @@
+package io.legado.app.model.cache
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CacheDownloadStateStoreTest {
+
+    @Test
+    fun queueAndRunningCountsAreDerivedFromBooks() {
+        val store = CacheDownloadStateStore()
+
+        store.updateBookQueue("a", waitingCount = 2, runningIndices = setOf(1))
+        store.updateBookQueue("b", waitingCount = 3, runningIndices = setOf(4, 5))
+
+        val state = store.state
+        assertTrue(state.isRunning)
+        assertEquals(5, state.totalWaiting)
+        assertEquals(3, state.totalRunning)
+    }
+
+    @Test
+    fun successAndFailureUpdateBookState() {
+        val store = CacheDownloadStateStore()
+
+        store.updateBookQueue("a", waitingCount = 0, runningIndices = setOf(1, 2))
+        store.markFailed("a", 1)
+        store.markSuccess("a", 2)
+
+        val bookState = store.state.books.getValue("a")
+        assertEquals(setOf(1), bookState.failedIndices)
+        assertEquals(emptySet<Int>(), bookState.runningIndices)
+        assertEquals(1, bookState.successCount)
+        assertEquals(1, store.state.totalFailure)
+        assertEquals(1, store.state.totalSuccess)
+    }
+
+    @Test
+    fun duplicateSuccessDoesNotInflateCount() {
+        val store = CacheDownloadStateStore()
+
+        store.markSuccess("a", 1)
+        store.markSuccess("a", 1)
+
+        assertEquals(1, store.state.books.getValue("a").successCount)
+        assertEquals(1, store.state.totalSuccess)
+    }
+
+    @Test
+    fun removeBookRecalculatesRunningState() {
+        val store = CacheDownloadStateStore()
+
+        store.updateBookQueue("a", waitingCount = 1, runningIndices = emptySet())
+        store.removeBook("a")
+
+        assertFalse(store.state.isRunning)
+        assertEquals(0, store.state.totalWaiting)
+        assertEquals(emptyMap<String, CacheBookDownloadState>(), store.state.books)
+    }
+}