Просмотр исходного кода

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

HapeLee 4 месяцев назад
Родитель
Сommit
70c88cf3f4

+ 33 - 4
app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt

@@ -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(

+ 45 - 23
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -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,
         )
     }

+ 109 - 40
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -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

+ 5 - 1
app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt

@@ -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(),