fix: 优化目录替换处理体验

This commit is contained in:
HapeLee
2026-07-01 01:02:56 +08:00
parent 13685b3364
commit 715e22681d
2 changed files with 111 additions and 12 deletions
@@ -106,6 +106,7 @@ import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.progressIndicator.AppContainedLoadingIndicator
import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
import io.legado.app.ui.widget.components.tabRow.AppTabRow
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.DynamicTopAppBar
@@ -603,6 +604,20 @@ fun TocScreen(
}
}
AnimatedVisibility(
visible = isOnTocPage && state.titleReplaceProgress != null,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding())
.fillMaxWidth()
.zIndex(2f)
) {
AppLinearProgressIndicator(
progress = state.titleReplaceProgress ?: 0f,
modifier = Modifier.fillMaxWidth()
)
}
TopFloatingStickyItem(
item = stickyVolume,
modifier = Modifier
@@ -35,12 +35,15 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.scan
@@ -83,6 +86,7 @@ data class TocActionState(
val downloadSummary: String = "",
val useReplace: Boolean = false,
val showWordCount: Boolean = true,
val titleReplaceProgress: Float? = null,
) : ListUiState<TocItemUi>
data class TocDomainItem(
@@ -116,6 +120,14 @@ private data class TitleCacheKey(
val chapterCount: Int
)
private data class TitleReplaceState(
val cacheKey: TitleCacheKey? = null,
val titles: Map<Int, String> = emptyMap(),
val completed: Int = 0,
val total: Int = 0,
val isRunning: Boolean = false
)
@OptIn(ExperimentalCoroutinesApi::class)
class TocViewModel(
application: Application,
@@ -240,7 +252,7 @@ class TocViewModel(
TocUiConfig(collapsed, tocPreferences.useReplace, tocPreferences.showWordCount, isReverse)
}
private val titleReplaceCache = MutableStateFlow<Map<Int, String>>(emptyMap())
private val titleReplaceState = MutableStateFlow(TitleReplaceState())
private var titleCacheJob: Job? = null
private var lastTitleCacheKey: TitleCacheKey? = null
@@ -249,8 +261,8 @@ class TocViewModel(
.flatMapLatest { appDb.bookChapterDao.getChapterListFlow(it) },
downloadContextFlow,
uiConfigFlow,
titleReplaceCache
) { originalChapters, downloadCtx, config, cachedTitles ->
titleReplaceState
) { originalChapters, downloadCtx, config, titleState ->
val book = bookState.value ?: return@combine emptyList()
val processedChapters = if (config.isReverse) {
@@ -275,7 +287,7 @@ class TocViewModel(
val baseTitle = chapter.getDisplayTitle(useReplace = false)
TocDomainItem(
chapter = chapter,
displayTitle = cachedTitles[chapter.index] ?: baseTitle,
displayTitle = titleState.titles[chapter.index] ?: baseTitle,
downloadState = DownloadState.LOCAL
)
}
@@ -296,7 +308,7 @@ class TocViewModel(
val baseTitle = chapter.getDisplayTitle(useReplace = false)
TocDomainItem(
chapter,
cachedTitles[chapter.index] ?: baseTitle,
titleState.titles[chapter.index] ?: baseTitle,
downloadState
)
}
@@ -352,6 +364,9 @@ class TocViewModel(
downloadSummary = downloadSummary.value,
useReplace = tocPreferences.value.useReplace,
showWordCount = tocPreferences.value.showWordCount,
titleReplaceProgress = titleReplaceState.value
.takeIf { it.isRunning && it.total > 0 }
?.let { it.completed.toFloat() / it.total },
)
}
@@ -622,8 +637,8 @@ class TocViewModel(
titleCacheJob?.cancel()
titleCacheJob = null
lastTitleCacheKey = null
if (titleReplaceCache.value.isNotEmpty()) {
titleReplaceCache.value = emptyMap()
if (titleReplaceState.value != TitleReplaceState()) {
titleReplaceState.value = TitleReplaceState()
}
return
}
@@ -646,20 +661,89 @@ class TocViewModel(
chapterCount = chapters.size
)
val currentTitleState = titleReplaceState.value
val isJobActive = titleCacheJob?.isActive == true
if (key == lastTitleCacheKey && (isJobActive || titleReplaceCache.value.isNotEmpty())) {
val isCurrentCacheReady =
currentTitleState.cacheKey == key &&
currentTitleState.completed == chapters.size
if (key == lastTitleCacheKey &&
(isJobActive || currentTitleState.isRunning || isCurrentCacheReady)
) {
return
}
lastTitleCacheKey = key
titleCacheJob?.cancel()
titleReplaceCache.value = emptyMap()
titleReplaceState.value = TitleReplaceState(
cacheKey = key,
total = chapters.size,
isRunning = chapters.isNotEmpty()
)
if (chapters.isEmpty()) {
titleCacheJob = null
return
}
titleCacheJob = viewModelScope.launch(Dispatchers.Default) {
val newCache = HashMap<Int, String>(chapters.size)
chapters.forEach { chapter ->
newCache[chapter.index] = chapter.getDisplayTitle(replaceRules, true)
val workerCount = minOf(TITLE_REPLACE_WORKER_COUNT, chapters.size)
val publishBatchSize =
(chapters.size / MAX_TITLE_REPLACE_PROGRESS_UPDATES).coerceAtLeast(1)
var completed = 0
var lastPublishedCompleted = 0
var lastPublishedAt = System.nanoTime()
chapters.asFlow()
.flatMapMerge(concurrency = workerCount) { chapter ->
flow {
emit(chapter.index to chapter.getDisplayTitle(replaceRules, true))
}
}
.collect { (chapterIndex, displayTitle) ->
newCache[chapterIndex] = displayTitle
completed++
val now = System.nanoTime()
val shouldPublish = completed < chapters.size &&
(completed - lastPublishedCompleted >= publishBatchSize ||
now - lastPublishedAt >= TITLE_REPLACE_UPDATE_INTERVAL_NANOS)
if (shouldPublish) {
titleReplaceState.update { current ->
if (current.cacheKey != key) {
current
} else {
TitleReplaceState(
cacheKey = key,
titles = HashMap(newCache),
completed = completed,
total = chapters.size,
isRunning = true
)
}
}
lastPublishedCompleted = completed
lastPublishedAt = now
}
}
titleReplaceState.update { current ->
if (current.cacheKey != key) {
current
} else {
TitleReplaceState(
cacheKey = key,
titles = newCache,
completed = chapters.size,
total = chapters.size,
isRunning = false
)
}
}
titleReplaceCache.value = newCache
}
}
private companion object {
const val TITLE_REPLACE_WORKER_COUNT = 4
const val MAX_TITLE_REPLACE_PROGRESS_UPDATES = 100
const val TITLE_REPLACE_UPDATE_INTERVAL_NANOS = 100_000_000L
}
}