下载代码优化

This commit is contained in:
HapeLee committed 2026-05-15 13:04:15 +08:00
1 parent 7fda288dee
commit af84848a58
3 files changed
+89 -26

No files matched your search

@@ -128,6 +128,10 @@ object CacheBook {
@Volatile @Volatile
private var lastQueueStats = QueueStats(0, 0) private var lastQueueStats = QueueStats(0, 0)
@Volatile
private var lastSummaryUpdateTime = 0L
private const val SUMMARY_UPDATE_THROTTLE_MS = 100L
private val successDownloadCount = AtomicInteger(0) private val successDownloadCount = AtomicInteger(0)
val cacheBookMap: ConcurrentHashMap<String, CacheBookModel> val cacheBookMap: ConcurrentHashMap<String, CacheBookModel>
@@ -177,6 +181,17 @@ object CacheBook {
} }
private fun updateSummary() { private fun updateSummary() {
val now = System.currentTimeMillis()
if (now - lastSummaryUpdateTime < SUMMARY_UPDATE_THROTTLE_MS) {
return
}
lastSummaryUpdateTime = now
val stats = collectQueueStats()
lastQueueStats = stats
_downloadSummaryFlow.value = buildSummary(stats)
}
private fun updateSummaryImmediate() {
val stats = collectQueueStats() val stats = collectQueueStats()
lastQueueStats = stats lastQueueStats = stats
_downloadSummaryFlow.value = buildSummary(stats) _downloadSummaryFlow.value = buildSummary(stats)
@@ -482,7 +497,7 @@ object CacheBook {
} else { } else {
stateStore.clearRuntimeState() stateStore.clearRuntimeState()
} }
updateSummary() updateSummaryImmediate()
} }
fun shutdownPreservingPaused() { fun shutdownPreservingPaused() {
@@ -502,7 +517,7 @@ object CacheBook {
pendingRemoveRequests.clear() pendingRemoveRequests.clear()
clearPendingAdmissions() clearPendingAdmissions()
stateStore.clearRuntimeState() stateStore.clearRuntimeState()
updateSummary() updateSummaryImmediate()
} }
fun setWorkingState(value: Boolean) { fun setWorkingState(value: Boolean) {
@@ -29,6 +29,11 @@ class CacheBookModel(
private val host: Host, private val host: Host,
) { ) {
private companion object {
/** Download timeout: 2 minutes */
const val DOWNLOAD_TIMEOUT_MS = 120_000L
}
interface Host { interface Host {
val stateStore: CacheDownloadStateStore val stateStore: CacheDownloadStateStore
val cacheBookMap: ConcurrentHashMap<String, CacheBookModel> val cacheBookMap: ConcurrentHashMap<String, CacheBookModel>
@@ -58,9 +63,13 @@ class CacheBookModel(
private val tasks = CompositeCoroutine() private val tasks = CompositeCoroutine()
private val repository = CacheDownloadRepository() private val repository = CacheDownloadRepository()
private val retryCountMap = hashMapOf<Int, Int>() private val retryCountMap = hashMapOf<Int, Int>()
@Volatile
private var isStopped = false private var isStopped = false
@Volatile
private var waitingRetry = false private var waitingRetry = false
@Volatile
private var isLoading = false private var isLoading = false
@Volatile
private var isPaused = false private var isPaused = false
@Synchronized @Synchronized
@@ -91,7 +100,6 @@ class CacheBookModel(
@Synchronized @Synchronized
fun isDownloading(index: Int): Boolean = onDownloadSet.contains(index) fun isDownloading(index: Int): Boolean = onDownloadSet.contains(index)
@Synchronized
fun isPaused(): Boolean = isPaused fun isPaused(): Boolean = isPaused
@Synchronized @Synchronized
@@ -120,12 +128,10 @@ class CacheBookModel(
return queue.waitingCount() > 0 || onDownloadSet.isNotEmpty() || isLoading || chapterTasks.isNotEmpty() return queue.waitingCount() > 0 || onDownloadSet.isNotEmpty() || isLoading || chapterTasks.isNotEmpty()
} }
@Synchronized
fun isStop(): Boolean { fun isStop(): Boolean {
return isStopped || (!isRun() && !waitingRetry) return isStopped || (!isRun() && !waitingRetry)
} }
@Synchronized
fun isLoading(): Boolean = isLoading fun isLoading(): Boolean = isLoading
@Synchronized @Synchronized
@@ -498,20 +504,23 @@ class CacheBookModel(
chapter: BookChapter, chapter: BookChapter,
chapterIndex: Int, chapterIndex: Int,
) { ) {
task.onSuccess { task.onSuccess(IO) {
onSuccess(chapter) onSuccess(chapter)
(it as? String)?.let { content -> (it as? String)?.let { content ->
emitPendingReadContent(chapter, content) emitPendingReadContent(chapter, content)
} }
}.onError { }.onError(IO) {
onPreError(chapter, it) onPreError(chapter, it)
try {
delay(1000) delay(1000)
} finally {
onPostError(chapter, it) onPostError(chapter, it)
}
emitPendingReadError(chapter, it) emitPendingReadError(chapter, it)
}.onCancel { }.onCancel(IO) {
onCancel(chapterIndex) onCancel(chapterIndex)
emitPendingReadCanceled(chapter) emitPendingReadCanceled(chapter)
}.onFinally { }.onFinally(IO) {
chapterTasks.remove(chapterIndex)?.let { tasks.delete(it) } chapterTasks.remove(chapterIndex)?.let { tasks.delete(it) }
onFinally() onFinally()
} }
@@ -548,10 +557,27 @@ class CacheBookModel(
chapter: BookChapter, chapter: BookChapter,
semaphore: Semaphore?, semaphore: Semaphore?,
resetPageOffset: Boolean = false resetPageOffset: Boolean = false
) { ): Boolean {
if (!markChapterDownloadStarted(chapter.index)) {
// Chapter is already in onDownloadSet. Check if the task is actually alive.
val hasLiveTask = synchronized(this) {
chapterTasks.containsKey(chapter.index)
}
if (!hasLiveTask) {
// Stale entry: onDownloadSet has the index but no live task.
// Clean up and retry.
synchronized(this) {
onDownloadSet.remove(chapter.index)
}
notifyDownloadSetChanged()
if (!markChapterDownloadStarted(chapter.index)) { if (!markChapterDownloadStarted(chapter.index)) {
markPendingReadRequest(chapter.index, resetPageOffset) markPendingReadRequest(chapter.index, resetPageOffset)
return return false
}
} else {
markPendingReadRequest(chapter.index, resetPageOffset)
return true
}
} }
repository.downloadContentTask( repository.downloadContentTask(
scope = scope, scope = scope,
@@ -562,7 +588,7 @@ class CacheBookModel(
context = IO, context = IO,
executeContext = IO, executeContext = IO,
semaphore = semaphore semaphore = semaphore
).onSuccess { content -> ).timeout(DOWNLOAD_TIMEOUT_MS).onSuccess { content ->
onSuccess(chapter) onSuccess(chapter)
ReadBook.downloadedChapters.add(chapter.index) ReadBook.downloadedChapters.add(chapter.index)
ReadBook.downloadFailChapters.remove(chapter.index) ReadBook.downloadFailChapters.remove(chapter.index)
@@ -580,6 +606,7 @@ class CacheBookModel(
}.onFinally { }.onFinally {
host.onTaskQueuesChanged(book.bookUrl) host.onTaskQueuesChanged(book.bookUrl)
}.start() }.start()
return true
} }
@Synchronized @Synchronized
@@ -30,6 +30,7 @@ import io.legado.app.model.webBook.WebBook
import io.legado.app.service.BaseReadAloudService import io.legado.app.service.BaseReadAloudService
import io.legado.app.service.CacheBookService import io.legado.app.service.CacheBookService
import io.legado.app.ui.book.read.page.entities.TextChapter import io.legado.app.ui.book.read.page.entities.TextChapter
import io.legado.app.ui.book.read.page.entities.TextPage
import io.legado.app.ui.book.read.page.provider.ChapterProvider import io.legado.app.ui.book.read.page.provider.ChapterProvider
import io.legado.app.ui.book.read.page.provider.LayoutProgressListener import io.legado.app.ui.book.read.page.provider.LayoutProgressListener
import io.legado.app.utils.postEvent import io.legado.app.utils.postEvent
@@ -42,17 +43,17 @@ import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import splitties.init.appCtx import splitties.init.appCtx
@@ -750,7 +751,11 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
val book = book ?: return removeLoading(chapter.index) val book = book ?: return removeLoading(chapter.index)
val bookSource = bookSource val bookSource = bookSource
if (bookSource != null) { if (bookSource != null) {
val started =
CacheBook.getOrCreate(bookSource, book).download(scope, chapter, semaphore) CacheBook.getOrCreate(bookSource, book).download(scope, chapter, semaphore)
if (!started) {
removeLoading(chapter.index)
}
} else { } else {
val msg = if (book.isLocal) "无内容" else "没有书源" val msg = if (book.isLocal) "无内容" else "没有书源"
contentLoadFinish( contentLoadFinish(
@@ -824,7 +829,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
} }
callBack?.upMenuView() callBack?.upMenuView()
var available = false var available = false
for (page in textChapter.layoutChannel) { collectLayoutPages(textChapter) { page ->
val index = page.index val index = page.index
if (!available && page.containPos(durChapterPos)) { if (!available && page.containPos(durChapterPos)) {
if (upContent) { if (upContent) {
@@ -849,7 +854,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
ensureActive() ensureActive()
prevTextChapter = textChapter prevTextChapter = textChapter
} }
textChapter.layoutChannel.receiveAsFlow().collect() collectLayoutPages(textChapter) {}
if (upContent) callBack?.upContent(offset, resetPageOffset) if (upContent) callBack?.upContent(offset, resetPageOffset)
} }
@@ -858,10 +863,8 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
ensureActive() ensureActive()
nextTextChapter = textChapter nextTextChapter = textChapter
} }
for (page in textChapter.layoutChannel) { collectLayoutPages(textChapter) { page ->
if (page.index > 1) { if (page.index > 1) return@collectLayoutPages
continue
}
if (upContent) callBack?.upContent(offset, resetPageOffset) if (upContent) callBack?.upContent(offset, resetPageOffset)
} }
} }
@@ -881,6 +884,26 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
job.start() job.start()
} }
/**
* Safely collect pages from a TextChapterLayout's channel with timeout protection.
* Prevents indefinite blocking if the layout job hangs without closing the channel.
*/
private suspend fun collectLayoutPages(
textChapter: TextChapter,
onPage: (TextPage) -> Unit,
) {
try {
withTimeout(30_000L) {
for (page in textChapter.layoutChannel) {
ensureActive()
onPage(page)
}
}
} catch (_: TimeoutCancellationException) {
AppLog.put("Layout channel timeout for chapter ${textChapter.chapter.index}")
}
}
suspend fun contentLoadFinishAwait( suspend fun contentLoadFinishAwait(
book: Book, book: Book,
chapter: BookChapter, chapter: BookChapter,
@@ -911,7 +934,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
} }
callBack?.upMenuView() callBack?.upMenuView()
var available = false var available = false
for (page in textChapter.layoutChannel) { collectLayoutPages(textChapter) { page ->
val index = page.index val index = page.index
if (!available && page.containPos(durChapterPos)) { if (!available && page.containPos(durChapterPos)) {
if (upContent) { if (upContent) {
@@ -936,7 +959,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
withContext(Main) { withContext(Main) {
prevTextChapter = textChapter prevTextChapter = textChapter
} }
textChapter.layoutChannel.receiveAsFlow().collect() collectLayoutPages(textChapter) {}
if (upContent) callBack?.upContent(offset, resetPageOffset) if (upContent) callBack?.upContent(offset, resetPageOffset)
} }
@@ -945,10 +968,8 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
withContext(Main) { withContext(Main) {
nextTextChapter = textChapter nextTextChapter = textChapter
} }
for (page in textChapter.layoutChannel) { collectLayoutPages(textChapter) { page ->
if (page.index > 1) { if (page.index > 1) return@collectLayoutPages
continue
}
if (upContent) callBack?.upContent(offset, resetPageOffset) if (upContent) callBack?.upContent(offset, resetPageOffset)
} }
} }