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

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