[优化] 优化书架的性能与稳定性

This commit is contained in:
HapeLee
2026-03-22 03:00:50 +08:00
parent 6a99e1023f
commit 5cd55758a1
5 changed files with 201 additions and 75 deletions
@@ -115,15 +115,18 @@ object BookCover {
}.getOrDefault(appCtx.resources.getDrawable(R.drawable.image_cover_default, null))
}
fun getRandomDefaultDrawable(seed: Any? = null): Drawable {
fun getRandomDefaultPath(seed: Any? = null): String? {
val isNightTheme = AppConfig.isNightTheme
val key = if (isNightTheme) PreferKey.defaultCoverDark else PreferKey.defaultCover
val paths = appCtx.getPrefString(key)?.split(",")?.filter { it.isNotBlank() }
if (paths.isNullOrEmpty()) {
return appCtx.resources.getDrawable(R.drawable.image_cover_default, null)
}
if (paths.isNullOrEmpty()) return null
val random = if (seed != null) Random(seed.hashCode()) else Random
val randomPath = paths[random.nextInt(paths.size)]
return paths[random.nextInt(paths.size)]
}
fun getRandomDefaultDrawable(seed: Any? = null): Drawable {
val randomPath = getRandomDefaultPath(seed)
?: return appCtx.resources.getDrawable(R.drawable.image_cover_default, null)
return kotlin.runCatching {
BitmapUtils.decodeBitmap(randomPath, 600, 900)!!.toDrawable(appCtx.resources)
}.getOrDefault(appCtx.resources.getDrawable(R.drawable.image_cover_default, null))
@@ -1,6 +1,7 @@
package io.legado.app.ui.main.bookshelf
import android.content.res.Configuration
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
@@ -48,6 +49,7 @@ fun BookshelfConfigSheet(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.animateContentSize()
.padding(horizontal = 16.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
@@ -1,8 +1,10 @@
package io.legado.app.ui.main.bookshelf
import android.content.ClipData
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -12,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
@@ -32,6 +35,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
@@ -49,6 +54,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -56,6 +63,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import io.legado.app.R
import io.legado.app.base.BaseRuleEvent
import io.legado.app.data.entities.Book
import io.legado.app.ui.about.AppLogSheet
import io.legado.app.ui.book.cache.CacheActivity
@@ -64,7 +72,6 @@ import io.legado.app.ui.book.import.remote.RemoteBookActivity
import io.legado.app.ui.book.manage.BookshelfManageActivity
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
import io.legado.app.ui.file.HandleFileContract
import io.legado.app.ui.widget.components.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
@@ -75,6 +82,7 @@ import io.legado.app.utils.readText
import io.legado.app.utils.startActivity
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@@ -94,38 +102,79 @@ fun BookshelfScreen(
var showAddUrlDialog by remember { mutableStateOf(false) }
var showImportSheet by remember { mutableStateOf(false) }
var showExportSheet by remember { mutableStateOf(false) }
var showConfigSheet by remember { mutableStateOf(false) }
var showGroupManageSheet by remember { mutableStateOf(false) }
var showLogSheet by remember { mutableStateOf(false) }
val importLauncher = rememberLauncherForActivityResult(HandleFileContract()) {
runCatching {
it.uri?.readText(context)?.let { text ->
val groupId = uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId ?: -1L
viewModel.importBookshelf(text, groupId)
val clipboardManager = LocalClipboard.current
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is BaseRuleEvent.ShowSnackbar -> {
val result = snackbarHostState.showSnackbar(
message = event.message,
actionLabel = event.actionLabel,
withDismissAction = true
)
if (result == SnackbarResult.ActionPerformed && event.url != null) {
clipboardManager.setClipEntry(
ClipEntry(
ClipData.newPlainText(
"url",
event.url
)
)
)
}
}
}
}.onFailure {
context.toastOnUi(it.localizedMessage ?: "ERROR")
}
}
val importLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri ->
uri?.let {
runCatching {
val text = it.readText(context)
val groupId =
uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId ?: -1L
viewModel.importBookshelf(text, groupId)
}.onFailure {
context.toastOnUi(it.localizedMessage ?: "ERROR")
}
}
}
)
val exportLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json"),
onResult = { uri ->
uri?.let { viewModel.exportToUri(it, uiState.items) }
}
)
val pagerState = rememberPagerState(
initialPage = uiState.selectedGroupIndex,
pageCount = { uiState.groups.size }
)
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
if (uiState.groups.isNotEmpty() && page < uiState.groups.size) {
viewModel.changeGroup(uiState.groups[page].groupId)
snapshotFlow { pagerState.currentPage }
.distinctUntilChanged()
.collect { page ->
if (uiState.groups.isNotEmpty() && page in uiState.groups.indices) {
val targetGroupId = uiState.groups[page].groupId
val currentGroupId =
uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId
if (currentGroupId != targetGroupId) {
viewModel.changeGroup(targetGroupId)
}
}
}
}
}
LaunchedEffect(uiState.selectedGroupIndex) {
if (uiState.selectedGroupIndex != pagerState.currentPage && uiState.selectedGroupIndex < pagerState.pageCount) {
pagerState.scrollToPage(uiState.selectedGroupIndex)
}
}
val bookGroupStyle = BookshelfConfig.bookGroupStyle
@@ -223,6 +272,7 @@ fun BookshelfScreen(
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.export_bookshelf)) },
onClick = {
showExportSheet = true
dismiss()
},
leadingIcon = { Icon(Icons.Default.ImportExport, null) }
@@ -241,12 +291,12 @@ fun BookshelfScreen(
leadingIcon = { Icon(Icons.Default.History, null) }
)
},
snackbarHostState = snackbarHostState,
bottomContent = if (bookGroupStyle == 0) {
{
if (uiState.groups.isNotEmpty()) {
val selectedTabIndex = remember(pagerState.currentPage, uiState.groups.size) {
val selectedTabIndex =
pagerState.currentPage.coerceIn(0, uiState.groups.size - 1)
}
PrimaryScrollableTabRow(
selectedTabIndex = selectedTabIndex,
edgePadding = 0.dp,
@@ -312,7 +362,7 @@ fun BookshelfScreen(
horizontalArrangement = Arrangement.spacedBy(if (bookshelfLayoutMode != 0) 8.dp else 0.dp),
showFastScroll = BookshelfConfig.showBookshelfFastScroller
) {
items(uiState.groups) { group ->
itemsIndexed(uiState.groups, key = { _, it -> it.groupId }) { index, group ->
if (bookshelfLayoutMode == 0) {
BookGroupItemList(
group = group,
@@ -322,11 +372,8 @@ fun BookshelfScreen(
titleCenter = BookshelfConfig.bookshelfTitleCenter,
titleMaxLines = BookshelfConfig.bookshelfTitleMaxLines,
onClick = {
val index = uiState.groups.indexOf(group)
if (index != -1) {
scope.launch { pagerState.scrollToPage(index) }
isInFolderRoot = false
}
scope.launch { pagerState.scrollToPage(index) }
isInFolderRoot = false
},
onLongClick = { showGroupManageSheet = true }
)
@@ -340,11 +387,8 @@ fun BookshelfScreen(
titleMaxLines = BookshelfConfig.bookshelfTitleMaxLines,
coverShadow = BookshelfConfig.bookshelfCoverShadow,
onClick = {
val index = uiState.groups.indexOf(group)
if (index != -1) {
scope.launch { pagerState.scrollToPage(index) }
isInFolderRoot = false
}
scope.launch { pagerState.scrollToPage(index) }
isInFolderRoot = false
},
onLongClick = { showGroupManageSheet = true }
)
@@ -360,8 +404,10 @@ fun BookshelfScreen(
) { pageIndex ->
val group = uiState.groups.getOrNull(pageIndex)
if (group != null) {
val books by viewModel.getBooksFlow(group.groupId)
.collectAsState(emptyList())
val booksFlow = remember(group.groupId) {
viewModel.getBooksFlow(group.groupId)
}
val books by booksFlow.collectAsState(emptyList())
BookshelfPage(
paddingValues = paddingValues,
books = books,
@@ -409,15 +455,28 @@ fun BookshelfScreen(
onDismissRequest = { showImportSheet = false },
title = stringResource(R.string.import_bookshelf),
onSelectSysFile = { types ->
importLauncher.launch {
mode = HandleFileContract.FILE
allowExtensions = types
}
importLauncher.launch(types)
showImportSheet = false
},
onManualInput = {
showAddUrlDialog = true
showImportSheet = false
},
allowExtensions = arrayOf("json", "txt")
)
}
if (showExportSheet) {
FilePickerSheet(
onDismissRequest = { showExportSheet = false },
title = stringResource(R.string.export_bookshelf),
onSelectSysDir = {
showExportSheet = false
exportLauncher.launch("bookshelf.json")
},
onUpload = {
showExportSheet = false
viewModel.uploadBookshelf(uiState.items)
}
)
}
@@ -1,10 +1,12 @@
package io.legado.app.ui.main.bookshelf
import android.app.Application
import android.net.Uri
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.viewModelScope
import com.google.gson.stream.JsonWriter
import io.legado.app.R
import io.legado.app.base.BaseRuleEvent
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog
@@ -16,6 +18,7 @@ import io.legado.app.data.entities.BookGroup
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.repository.BookGroupRepository
import io.legado.app.data.repository.UploadRepository
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.addType
@@ -51,6 +54,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
@@ -69,11 +73,13 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStreamWriter
@@ -85,7 +91,8 @@ import kotlin.math.min
class BookshelfViewModel(
application: Application,
private val bookGroupRepository: BookGroupRepository
private val bookGroupRepository: BookGroupRepository,
private val uploadRepository: UploadRepository
) : BaseViewModel(application) {
var addBookJob: Coroutine<*>? = null
@@ -107,6 +114,9 @@ class BookshelfViewModel(
val scrollTrigger = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
protected val _eventChannel = Channel<BaseRuleEvent>()
val events = _eventChannel.receiveAsFlow()
val groupsFlow: StateFlow<List<BookGroup>> = bookGroupRepository.flowShow()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
@@ -512,6 +522,63 @@ class BookshelfViewModel(
}
}
fun exportToUri(uri: Uri, books: List<Book>) {
execute {
context.contentResolver.openOutputStream(uri)?.use { out ->
val writer = JsonWriter(OutputStreamWriter(out, "UTF-8"))
writer.setIndent(" ")
writer.beginArray()
books.forEach {
val bookMap = hashMapOf<String, String?>()
bookMap["name"] = it.name
bookMap["author"] = it.author
bookMap["intro"] = it.getDisplayIntro()
GSON.toJson(bookMap, bookMap::class.java, writer)
}
writer.endArray()
writer.close()
}
}.onSuccess {
_eventChannel.trySend(BaseRuleEvent.ShowSnackbar("导出成功"))
}.onError {
_eventChannel.trySend(BaseRuleEvent.ShowSnackbar("导出失败\n${it.localizedMessage}"))
}
}
fun uploadBookshelf(books: List<Book>) {
execute {
val json = withContext(Dispatchers.Default) {
val list = books.map {
val bookMap = hashMapOf<String, String?>()
bookMap["name"] = it.name
bookMap["author"] = it.author
bookMap["intro"] = it.getDisplayIntro()
bookMap
}
GSON.toJson(list)
}
uploadRepository.upload(
fileName = "bookshelf.json",
file = json,
contentType = "application/json"
)
}.onSuccess { url ->
_eventChannel.trySend(
BaseRuleEvent.ShowSnackbar(
message = "上传成功: $url",
actionLabel = "复制链接",
url = url
)
)
}.onError {
_eventChannel.trySend(
BaseRuleEvent.ShowSnackbar(
message = "上传失败: ${it.localizedMessage}"
)
)
}
}
fun exportBookshelf(books: List<Book>?, success: (file: File) -> Unit) {
execute {
books?.let {
@@ -13,10 +13,13 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Book
import androidx.compose.material.icons.filled.Update
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -59,12 +62,14 @@ fun BookCover(
val useDefault = CoverConfig.useDefaultCover
val finalPath = if (useDefault) null else path
// 为每个 BookCover 组件实例基于其书名或路径生成一个随机封面
// 这样书架上的每个默认封面都会不同,但对于同一本书又是固定的
val randomDefault = remember(name, author, path) {
BookCover.getRandomDefaultDrawable(seed = name ?: author ?: path ?: "")
// 获取随机封面路径(字符串),而非直接获取 Drawable,避免主线程解码大图
val randomPath = remember(name, author, path, isNight) {
BookCover.getRandomDefaultPath(seed = name ?: author ?: path ?: "")
}
// 检查是否有自定义随机封面设置
val hasCustomDefault = !randomPath.isNullOrBlank()
var isOnlineCoverLoaded by remember(path) { mutableStateOf(false) }
Box(
@@ -76,25 +81,26 @@ fun BookCover(
} else Modifier
)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceContainerLow)
/*
.then(
if (CoverConfig.coverShowStroke) {
Modifier.border(
0.5.dp,
MaterialTheme.colorScheme.outlineVariant,
RoundedCornerShape(4.dp)
)
} else Modifier
)
*/
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
// 1. 封面底图
// 如果没有自定义随机封面,显示书本图标作为底图
if (!hasCustomDefault) {
Icon(
Icons.Default.Book,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier
.size(32.dp)
.align(Alignment.Center)
)
}
// 1. 封面图层
AsyncImage(
model = ImageRequest.Builder(context)
.data(finalPath ?: randomDefault)
.placeholder(randomDefault)
.error(randomDefault)
.data(finalPath ?: randomPath)
// 限制解码分辨率
.size(400, 560)
.crossfade(true)
.setParameter("sourceOrigin", sourceOrigin)
.setParameter("loadOnlyWifi", CoverConfig.loadCoverOnlyWifi)
@@ -115,7 +121,7 @@ fun BookCover(
}
)
// 2. 文字叠加层当没有在线封面或者加载失败时显示)
// 2. 文字叠加层当没有书籍自身封面(或加载失败)时,在随机底图上绘制书名/作者
if (!isOnlineCoverLoaded) {
CoverTextOverlay(
name = name,
@@ -152,7 +158,6 @@ private fun CoverTextOverlay(
val showAuthor =
(if (isNight) CoverConfig.coverShowAuthorN else CoverConfig.coverShowAuthor) && showName
// 如果都不显示,直接返回
if (!showName && !showAuthor) return
val secondaryColor = MaterialTheme.colorScheme.secondary.toArgb()
@@ -171,7 +176,6 @@ private fun CoverTextOverlay(
drawIntoCanvas { canvas ->
val nativeCanvas = canvas.nativeCanvas
// 绘制书名
if (showName && !name.isNullOrBlank()) {
val paint = Paint().apply {
isAntiAlias = true
@@ -200,9 +204,7 @@ private fun CoverTextOverlay(
nativeCanvas.withSave {
val textX = (viewWidth - maxWidth) / 2f
val textY = viewHeight * 0.08f
translate(textX, textY)
if (CoverConfig.coverShowStroke) {
textPaint.style = Paint.Style.STROKE
textPaint.strokeWidth = textPaint.textSize / 12
@@ -210,8 +212,6 @@ private fun CoverTextOverlay(
textPaint.color = Color.White.toArgb()
textPaint.clearShadowLayer()
layout.draw(this)
// 还原
textPaint.style = Paint.Style.FILL
textPaint.color = originalColor
if (CoverConfig.coverShowShadow) {
@@ -225,7 +225,6 @@ private fun CoverTextOverlay(
var startY = viewHeight * 0.16f
val fm = paint.fontMetrics
val charHeight = fm.bottom - fm.top
name.forEach { char ->
if (CoverConfig.coverShowStroke) {
val strokePaint = Paint(paint).apply {
@@ -246,7 +245,6 @@ private fun CoverTextOverlay(
}
}
// 绘制作者
if (showAuthor && !author.isNullOrBlank()) {
val paint = Paint().apply {
isAntiAlias = true
@@ -257,7 +255,6 @@ private fun CoverTextOverlay(
setShadowLayer(4f, 1f, 1f, shadowColor)
}
}
if (isHorizontal) {
val authorText = TextUtils.ellipsize(
author,
@@ -265,7 +262,6 @@ private fun CoverTextOverlay(
viewWidth * 0.9f,
TextUtils.TruncateAt.END
)
if (CoverConfig.coverShowStroke) {
val strokePaint = Paint(paint).apply {
color = Color.White.toArgb()
@@ -292,7 +288,6 @@ private fun CoverTextOverlay(
val charHeight = fm.bottom - fm.top
var startY = viewHeight * 0.16f - (author.length * charHeight)
startY = startY.coerceAtLeast(viewHeight * 0.2f)
author.forEach { char ->
nativeCanvas.drawText(char.toString(), startX, startY, paint)
startY += charHeight