fix: 优化书籍详情内存占用

This commit is contained in:
HapeLee
2026-06-20 00:14:40 +08:00
parent c45e4355ff
commit 70c88cf3f4
4 changed files with 192 additions and 68 deletions
@@ -1,6 +1,7 @@
package io.legado.app.ui.book.info
import android.net.Uri
import androidx.compose.runtime.Stable
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource
@@ -13,8 +14,8 @@ import kotlinx.collections.immutable.persistentListOf
const val READER_RESULT_DELETED = 100
data class BookInfoUiState(
val book: Book? = null,
val chapterList: List<BookChapter> = emptyList(),
val book: BookInfoBookUi? = null,
val hasChapters: Boolean = false,
val webFiles: List<BookInfoWebFile> = emptyList(),
val kindLabels: List<String> = emptyList(),
val groupNames: String? = null,
@@ -22,7 +23,7 @@ data class BookInfoUiState(
val readRecordTotalTime: Long = 0L,
val readRecordTimelineDays: List<ReadRecordTimelineDay> = emptyList(),
val inBookshelf: Boolean = false,
val bookSource: BookSource? = null,
val bookSource: BookInfoSourceUi? = null,
val relatedBooks: ImmutableList<RelatedBooksUi> = persistentListOf(),
val isTocLoading: Boolean = true,
val isBusy: Boolean = false,
@@ -33,11 +34,39 @@ data class BookInfoUiState(
val dialog: BookInfoDialog? = null,
)
@Stable
data class BookInfoBookUi(
val bookUrl: String,
val name: String,
val author: String,
val realAuthor: String,
val origin: String,
val originName: String,
val coverPath: String?,
val group: Long,
val isLocal: Boolean,
val type: Int,
val canUpdate: Boolean,
val splitLongChapter: Boolean,
val durChapterTitle: String?,
val latestChapterTitle: String?,
val totalChapterNum: Int,
val durChapterIndex: Int,
val remark: String?,
val displayIntro: String?,
)
@Stable
data class BookInfoSourceUi(
val sourceUrl: String,
val hasLogin: Boolean,
)
sealed interface BookInfoSheet {
data object None : BookInfoSheet
data object CoverPicker : BookInfoSheet
data object GroupPicker : BookInfoSheet
data object SourcePicker : BookInfoSheet
data class SourcePicker(val oldBook: Book) : BookInfoSheet
data object ReadRecord : BookInfoSheet
data class WebFiles(val openAfterImport: Boolean) : BookInfoSheet
data class ArchiveEntries(
@@ -64,12 +64,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.ImageLoader
import coil.size.Size
import io.legado.app.R
import io.legado.app.constant.BookType
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.SearchBook
import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig
import io.legado.app.ui.config.coverConfig.CoverConfig
import io.legado.app.ui.main.homepage.modules.BannerModule
@@ -252,7 +250,7 @@ private fun BookInfoScreenContent(
}
BookInfoSummary(
book = book,
chapterList = state.chapterList,
hasChapters = state.hasChapters,
onRemarkClick = { onIntent(BookInfoIntent.RemarkClick) },
)
}
@@ -294,10 +292,10 @@ private fun BookInfoScreenContent(
onConfirm = { onIntent(BookInfoIntent.SelectGroup(it)) },
)
}
BookInfoSheet.SourcePicker -> state.book?.let { book ->
is BookInfoSheet.SourcePicker -> {
ChangeSourceSheet(
show = currentSheet == BookInfoSheet.SourcePicker,
oldBook = book,
show = currentSheet is BookInfoSheet.SourcePicker,
oldBook = sheet.oldBook,
onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) },
onReplace = { source, newBook, toc, options ->
onIntent(BookInfoIntent.ReplaceWithSource(source, newBook, toc, options))
@@ -419,12 +417,12 @@ private fun BookInfoTransparentTopAppBar(
}
@Composable
private fun rememberBookInfoColorTheme(book: Book?): ThemeOverrideState? {
val useDefaultCover = AppConfig.useDefaultCover || book?.customCoverUrl == "use_default_cover"
private fun rememberBookInfoColorTheme(book: BookInfoBookUi?): ThemeOverrideState? {
val useDefaultCover = AppConfig.useDefaultCover || book?.coverPath == "use_default_cover"
if (useDefaultCover) return null
val imageLoader = koinInject<ImageLoader>()
val coverPath = book?.getDisplayCover()
val coverPath = book?.coverPath
val sourceOrigin = book?.origin
val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi
val requestKey = remember(coverPath, sourceOrigin, loadOnlyWifi) {
@@ -480,8 +478,21 @@ private fun BookInfoTopBarActions(
@Composable
private fun BookInfoBackdrop(
book: Book,
book: BookInfoBookUi,
) {
val backdropState = remember(
book.name,
book.author,
book.coverPath,
book.origin,
) {
BookInfoBackdropState(
name = book.name,
author = book.author,
coverPath = book.coverPath,
sourceOrigin = book.origin,
)
}
val seedOverlay = lerp(
LegadoTheme.colorScheme.secondaryContainer,
LegadoTheme.seedColor,
@@ -489,21 +500,25 @@ private fun BookInfoBackdrop(
)
Box(modifier = Modifier.fillMaxSize()) {
Crossfade(
targetState = book,
targetState = backdropState,
animationSpec = tween(800),
label = "BackdropCrossfade"
) { currentBook ->
BookCoverImage(
name = currentBook.name,
author = currentBook.author,
path = currentBook.getDisplayCover(),
sourceOrigin = currentBook.origin,
path = currentBook.coverPath,
sourceOrigin = currentBook.sourceOrigin,
memoryCacheKey = currentBook.coverPath?.let { "$it#book-info-backdrop" },
modifier = Modifier
.fillMaxWidth()
.height(480.dp)
.blur(24.dp),
contentScale = ContentScale.Crop,
showLoadingPlaceholder = false,
requestBuilder = {
size(Size(384, 384))
}
)
}
Box(
@@ -530,6 +545,13 @@ private fun BookInfoBackdrop(
}
}
private data class BookInfoBackdropState(
val name: String,
val author: String,
val coverPath: String?,
val sourceOrigin: String?,
)
@Composable
private fun BookInfoOverflowMenu(
expanded: Boolean,
@@ -563,7 +585,7 @@ private fun BookInfoOverflowMenu(
onClick = { onMenuAction(BookInfoMenuAction.Upload) }
)
}
if (!state.bookSource?.loginUrl.isNullOrBlank()) {
if (state.bookSource?.hasLogin == true) {
RoundDropdownMenuItem(
text = stringResource(R.string.login),
onClick = { onMenuAction(BookInfoMenuAction.Login) }
@@ -602,7 +624,7 @@ private fun BookInfoOverflowMenu(
RoundDropdownMenuItem(
text = stringResource(R.string.split_long_chapter),
onClick = { onMenuAction(BookInfoMenuAction.ToggleSplitLongChapter) },
isSelected = book.getSplitLongChapter()
isSelected = book.splitLongChapter
)
}
RoundDropdownMenuItem(
@@ -623,7 +645,7 @@ private fun BookInfoOverflowMenu(
@Composable
private fun BookInfoHeader(
book: Book,
book: BookInfoBookUi,
kindLabels: List<String>,
groupNames: String?,
onCoverClick: () -> Unit,
@@ -668,7 +690,7 @@ private fun BookInfoHeader(
CoilBookCover(
name = book.name,
author = book.author,
path = book.getDisplayCover(),
path = book.coverPath,
sourceOrigin = book.origin,
modifier = Modifier
.width(112.dp)
@@ -720,7 +742,7 @@ private fun BookInfoHeader(
}
}
AnimatedTextLine(
text = stringResource(R.string.author_show, book.getRealAuthor()),
text = stringResource(R.string.author_show, book.realAuthor),
style = LegadoTheme.typography.bodyLarge,
color = LegadoTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.combinedClickable(
@@ -886,8 +908,8 @@ private fun BookInfoActionCard(
@Composable
private fun BookInfoSummary(
book: Book,
chapterList: List<BookChapter>,
book: BookInfoBookUi,
hasChapters: Boolean,
onRemarkClick: () -> Unit,
) {
Column(
@@ -928,7 +950,7 @@ private fun BookInfoSummary(
color = LegadoTheme.colorScheme.secondary,
)
}
if (chapterList.isEmpty()) {
if (!hasChapters) {
AnimatedTextLine(
text = stringResource(R.string.error_load_toc),
style = LegadoTheme.typography.bodySmall,
@@ -952,7 +974,7 @@ private fun BookInfoSummary(
}
Spacer(modifier = Modifier.height(4.dp))
AnimatedTextLine(
text = book.getDisplayIntro().orEmpty().ifBlank { stringResource(R.string.intro_show_null) },
text = book.displayIntro.orEmpty().ifBlank { stringResource(R.string.intro_show_null) },
style = LegadoTheme.typography.bodyMedium,
)
}
@@ -62,12 +62,14 @@ import io.legado.app.utils.postEvent
import io.legado.app.utils.splitNotBlank
import io.legado.app.utils.toastOnUi
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -76,6 +78,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
class BookInfoViewModel(
@@ -119,6 +122,7 @@ class BookInfoViewModel(
private var changeSourceCoroutine: Coroutine<*>? = null
private var readRecordObserveJob: Job? = null
private var relatedBooksLoadJob: Job? = null
fun initData(intent: Intent) {
initData(
@@ -162,6 +166,7 @@ class BookInfoViewModel(
bookSource = null
chapterChanged = false
clearReadRecordObserve()
relatedBooksLoadJob?.cancel()
syncUiState()
execute {
val dbBook = appDb.bookDao.getBook(bookUrl)
@@ -214,7 +219,8 @@ class BookInfoViewModel(
?.let { showDialog(BookInfoDialog.PhotoPreview(it)) }
BookInfoIntent.GroupClick -> setSheet(BookInfoSheet.GroupPicker)
BookInfoIntent.ChangeSourceClick -> setSheet(BookInfoSheet.SourcePicker)
BookInfoIntent.ChangeSourceClick -> currentBook?.uiCopy()
?.let { setSheet(BookInfoSheet.SourcePicker(it)) }
BookInfoIntent.ReadRecordClick -> setSheet(BookInfoSheet.ReadRecord)
BookInfoIntent.RemarkClick -> showDialog(BookInfoDialog.EditRemark(currentBook?.remark))
is BookInfoIntent.SaveCover -> {
@@ -733,7 +739,7 @@ class BookInfoViewModel(
} else {
loadChapter(loadedBook, runPreUpdateJs)
}
loadRelatedBooks(loadedBook, source)
scheduleRelatedBooksLoad(loadedBook, source)
}.onError {
AppLog.put("获取书籍信息失败\n${it.localizedMessage}", it)
context.toastOnUi(R.string.error_get_book_info)
@@ -792,7 +798,7 @@ class BookInfoViewModel(
if (chapters.isNotEmpty()) {
currentChapterList = chapters
syncUiState(isTocLoading = false)
source?.let { loadRelatedBooks(book, it) }
source?.let { scheduleRelatedBooksLoad(book, it) }
} else {
loadChapter(book)
}
@@ -1291,8 +1297,8 @@ class BookInfoViewModel(
private fun syncUiState(isTocLoading: Boolean = _uiState.value.isTocLoading) {
_uiState.update {
it.copy(
book = currentBook?.uiCopy(),
chapterList = currentChapterList,
book = currentBook?.toBookInfoBookUi(),
hasChapters = currentChapterList.isNotEmpty(),
webFiles = currentWebFiles,
relatedBooks = currentRelatedBooks.toImmutableList(),
kindLabels = currentKindLabels,
@@ -1301,7 +1307,7 @@ class BookInfoViewModel(
readRecordTotalTime = currentReadRecordTotalTime,
readRecordTimelineDays = currentReadRecordTimelineDays,
inBookshelf = inBookshelf,
bookSource = bookSource,
bookSource = bookSource?.toBookInfoSourceUi(),
isTocLoading = isTocLoading,
deleteAlertEnabled = LocalConfig.bookInfoDeleteAlert,
deleteOriginal = LocalConfig.deleteBookOriginal,
@@ -1332,14 +1338,50 @@ class BookInfoViewModel(
)
}
private fun loadRelatedBooks(book: Book, source: BookSource) {
private fun scheduleRelatedBooksLoad(
book: Book,
source: BookSource,
delayMillis: Long = 350L,
) {
relatedBooksLoadJob?.cancel()
relatedBooksLoadJob = viewModelScope.launch {
delay(delayMillis)
if (!isCurrentBookSource(book, source)) return@launch
val modules = parseRelatedBookModules(source)
if (modules.isEmpty()) {
currentRelatedBooks = emptyList()
syncUiState()
return@launch
}
try {
val result = withContext(IO) {
loadRelatedBooks(book, source, modules)
}
if (!isCurrentBookSource(book, source)) return@launch
currentRelatedBooks = result
syncUiState()
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
if (!isCurrentBookSource(book, source)) return@launch
currentRelatedBooks = emptyList()
syncUiState()
}
}
}
private fun isCurrentBookSource(book: Book, source: BookSource): Boolean {
return currentBook?.bookUrl == book.bookUrl && bookSource?.bookSourceUrl == source.bookSourceUrl
}
private fun parseRelatedBookModules(source: BookSource): List<RelatedBooksDef> {
val modulesJson = source.ruleBookInfo?.relatedBooks
if (modulesJson.isNullOrBlank()) {
currentRelatedBooks = emptyList()
syncUiState()
return
return emptyList()
}
val modules = try {
return try {
GSON.fromJsonArray<RelatedBooksDef>(modulesJson)
.getOrNull()
?.filter { !it.url.isNullOrBlank() }
@@ -1348,36 +1390,33 @@ class BookInfoViewModel(
} catch (e: Exception) {
emptyList()
}
if (modules.isEmpty()) {
currentRelatedBooks = emptyList()
syncUiState()
return
}
execute {
coroutineScope {
modules.map { def ->
async {
val (resolvedUrl, books) = try {
resolveAndExplore(source, def.url!!, book)
} catch (e: Exception) {
def.url!! to emptyList()
}
RelatedBooksUi(
key = def.key ?: def.title.orEmpty(),
title = def.title.orEmpty(),
url = def.url!!,
resolvedUrl = resolvedUrl,
books = books.filter { it.bookUrl != book.bookUrl }.toImmutableList(),
)
}
private suspend fun loadRelatedBooks(
book: Book,
source: BookSource,
modules: List<RelatedBooksDef>,
): List<RelatedBooksUi> {
return coroutineScope {
modules.map { def ->
async {
val url = def.url.orEmpty()
val (resolvedUrl, books) = try {
resolveAndExplore(source, url, book)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
url to emptyList()
}
}.awaitAll().filter { it.books.isNotEmpty() }
}
}.onSuccess { result ->
currentRelatedBooks = result
syncUiState()
}.onError {
currentRelatedBooks = emptyList()
syncUiState()
RelatedBooksUi(
key = def.key ?: def.title.orEmpty(),
title = def.title.orEmpty(),
url = url,
resolvedUrl = resolvedUrl,
books = books.filter { it.bookUrl != book.bookUrl }.toImmutableList(),
)
}
}.awaitAll().filter { it.books.isNotEmpty() }
}
}
@@ -1393,6 +1432,36 @@ class BookInfoViewModel(
_effects.tryEmit(effect)
}
private fun Book.toBookInfoBookUi(): BookInfoBookUi {
return BookInfoBookUi(
bookUrl = bookUrl,
name = name,
author = author,
realAuthor = getRealAuthor(),
origin = origin,
originName = originName,
coverPath = getDisplayCover(),
group = group,
isLocal = isLocal,
type = type,
canUpdate = canUpdate,
splitLongChapter = getSplitLongChapter(),
durChapterTitle = durChapterTitle,
latestChapterTitle = latestChapterTitle,
totalChapterNum = totalChapterNum,
durChapterIndex = durChapterIndex,
remark = remark,
displayIntro = getDisplayIntro(),
)
}
private fun BookSource.toBookInfoSourceUi(): BookInfoSourceUi {
return BookInfoSourceUi(
sourceUrl = bookSourceUrl,
hasLogin = !loginUrl.isNullOrBlank(),
)
}
private fun Book.uiCopy(): Book {
return copy().also { snapshot ->
snapshot.infoHtml = infoHtml
@@ -43,6 +43,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.graphics.withSave
import coil.compose.AsyncImage
import coil.request.ImageRequest
import io.legado.app.ui.config.coverConfig.CoverConfig
import io.legado.app.ui.theme.LegadoTheme
import org.koin.compose.koinInject
@@ -58,6 +59,7 @@ fun BookCoverImage(
path: String?,
modifier: Modifier = Modifier,
sourceOrigin: String? = null,
memoryCacheKey: String? = null,
ignoreUseDefaultCover: Boolean = false,
showLoadingPlaceholder: Boolean = true,
contentScale: ContentScale = ContentScale.Crop,
@@ -65,6 +67,7 @@ fun BookCoverImage(
onSuccess: (() -> Unit)? = null,
onError: (() -> Unit)? = null,
sharedCoverKey: String? = null,
requestBuilder: ImageRequest.Builder.() -> Unit = {},
) {
val context = LocalContext.current
val isNight = LegadoTheme.isDark
@@ -116,7 +119,8 @@ fun BookCoverImage(
sourceOrigin = sourceOrigin,
loadOnlyWifi = CoverConfig.loadCoverOnlyWifi,
crossfade = showLoadingPlaceholder,
memoryCacheKey = finalPath,
memoryCacheKey = memoryCacheKey ?: finalPath,
configure = requestBuilder,
),
contentDescription = null,
imageLoader = koinInject(),