[优化] 优化书架性能

This commit is contained in:
HapeLee
2026-03-25 01:35:40 +08:00
parent 32806e2e26
commit 25abbdaf1e
7 changed files with 208 additions and 62 deletions
@@ -12,6 +12,7 @@ import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.data.entities.BookSource
import io.legado.app.help.book.isNotShelf
import io.legado.app.ui.main.bookshelf.BookShelfItem
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -51,6 +52,31 @@ interface BookDao {
@Query("SELECT * FROM books order by durChapterTime desc")
fun flowAll(): Flow<List<Book>>
@Query(
"""
SELECT
bookUrl,
name,
author,
coverUrl,
customCoverUrl,
durChapterTitle,
durChapterTime,
durChapterPos,
latestChapterTitle,
latestChapterTime,
totalChapterNum,
durChapterIndex,
type,
`group`,
`order`,
canUpdate
FROM books
ORDER BY durChapterTime DESC
"""
)
fun flowBookShelf(): Flow<List<BookShelfItem>>
@Query("SELECT * FROM books WHERE type & ${BookType.audio} > 0")
fun flowAudio(): Flow<List<Book>>
@@ -32,9 +32,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.help.book.isLocal
import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
import io.legado.app.ui.widget.components.cover.BookCover
import io.legado.app.ui.widget.components.cover.BookCoverWithProgress
@@ -197,7 +195,7 @@ fun BookshelfItem(
@Composable
fun BookGroupCover(
books: List<Book>,
books: List<BookShelfItem>,
coverPath: String? = null,
modifier: Modifier = Modifier
) {
@@ -289,7 +287,7 @@ fun BookGroupCover(
@Composable
fun BookGroupItemGrid(
group: BookGroup,
previewBooks: List<Book>,
previewBooks: List<BookShelfItem>,
gridStyle: Int = 0,
titleSmallFont: Boolean = false,
titleCenter: Boolean = true,
@@ -318,7 +316,7 @@ fun BookGroupItemGrid(
@Composable
fun BookGroupItemList(
group: BookGroup,
previewBooks: List<Book>,
previewBooks: List<BookShelfItem>,
isCompact: Boolean = false,
titleSmallFont: Boolean = false,
titleCenter: Boolean = true,
@@ -348,7 +346,7 @@ fun BookGroupItemList(
@Composable
fun BookItem(
book: Book,
book: BookShelfItem,
layoutMode: Int,
gridStyle: Int = 0,
isCompact: Boolean = false,
@@ -374,7 +372,7 @@ fun BookItem(
isUpdating = isUpdating,
modifier = modifier,
badgeText = if (BookshelfConfig.showUnread && unreadCount > 0) unreadCount.toString() else null,
showBadgeDot = !BookshelfConfig.showUnread && BookshelfConfig.showUnreadNew && unreadCount > 0 && book.lastCheckCount > 0
showBadgeDot = !BookshelfConfig.showUnread && BookshelfConfig.showUnreadNew && unreadCount > 0
)
},
title = book.name,
@@ -0,0 +1,55 @@
package io.legado.app.ui.main.bookshelf
import io.legado.app.constant.BookType
import io.legado.app.data.entities.Book
import kotlin.math.max
data class BookShelfItem(
val bookUrl: String,
val name: String,
val author: String,
val coverUrl: String?,
val customCoverUrl: String?,
val durChapterTitle: String?,
val durChapterTime: Long,
val durChapterPos: Int,
val latestChapterTitle: String?,
val latestChapterTime: Long,
val totalChapterNum: Int,
val durChapterIndex: Int,
val type: Int,
val group: Long,
val order: Int,
val canUpdate: Boolean = true
) {
fun getDisplayCover() = if (customCoverUrl.isNullOrEmpty()) coverUrl else customCoverUrl
val isLocal: Boolean get() = (type and BookType.local) > 0
val isAudio: Boolean get() = (type and BookType.audio) > 0
val isImage: Boolean get() = (type and BookType.image) > 0
fun getUnreadChapterNum() = max(totalChapterNum - durChapterIndex - 1, 0)
}
fun Book.toBookShelfItem(): BookShelfItem {
return BookShelfItem(
bookUrl = bookUrl,
name = name,
author = author,
coverUrl = coverUrl,
customCoverUrl = customCoverUrl,
durChapterTitle = durChapterTitle,
durChapterTime = durChapterTime,
durChapterPos = durChapterPos,
latestChapterTitle = latestChapterTitle,
latestChapterTime = latestChapterTime,
totalChapterNum = totalChapterNum,
durChapterIndex = durChapterIndex,
type = type,
group = group,
order = order,
canUpdate = canUpdate
)
}
@@ -11,14 +11,19 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FileOpen
@@ -65,7 +70,6 @@ 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
import io.legado.app.ui.book.import.local.ImportBookActivity
@@ -74,10 +78,12 @@ 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.widget.components.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton
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.lazylist.FastScrollLazyVerticalGrid
import io.legado.app.ui.widget.components.list.ListScaffold
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.utils.readText
import io.legado.app.utils.startActivity
@@ -94,8 +100,8 @@ import org.koin.androidx.compose.koinViewModel
@Composable
fun BookshelfScreen(
viewModel: BookshelfViewModel = koinViewModel(),
onBookClick: (Book) -> Unit,
onBookLongClick: (Book) -> Unit
onBookClick: (BookShelfItem) -> Unit,
onBookLongClick: (BookShelfItem) -> Unit
) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState()
@@ -107,6 +113,7 @@ fun BookshelfScreen(
var showConfigSheet by remember { mutableStateOf(false) }
var showGroupManageSheet by remember { mutableStateOf(false) }
var showLogSheet by remember { mutableStateOf(false) }
var showGroupMenu by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboard.current
val snackbarHostState = remember { SnackbarHostState() }
@@ -309,29 +316,69 @@ fun BookshelfScreen(
if (uiState.groups.isNotEmpty()) {
val selectedTabIndex =
pagerState.currentPage.coerceIn(0, uiState.groups.size - 1)
PrimaryScrollableTabRow(
selectedTabIndex = selectedTabIndex,
edgePadding = 0.dp,
divider = { },
containerColor = GlassTopAppBarDefaults.containerColor(),
minTabWidth = 0.dp
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
uiState.groups.forEachIndexed { index, group ->
Tab(
selected = selectedTabIndex == index,
onClick = {
scope.launch { pagerState.animateScrollToPage(index) }
},
text = {
Text(
text = group.groupName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
PrimaryScrollableTabRow(
selectedTabIndex = selectedTabIndex,
edgePadding = 0.dp,
divider = { },
containerColor = GlassTopAppBarDefaults.containerColor(),
minTabWidth = 0.dp,
modifier = Modifier.weight(1f)
) {
uiState.groups.forEachIndexed { index, group ->
Tab(
selected = selectedTabIndex == index,
onClick = {
scope.launch { pagerState.animateScrollToPage(index) }
},
text = {
Text(
text = group.groupName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
}
if (BookshelfConfig.shouldShowExpandButton) {
Box(modifier = Modifier.padding(end = 16.dp)) {
SmallOutlinedIconToggleButton(
checked = showGroupMenu,
onCheckedChange = { showGroupMenu = it },
icon = Icons.AutoMirrored.Filled.FormatListBulleted,
contentDescription = stringResource(R.string.group_manage)
)
RoundDropdownMenu(
expanded = showGroupMenu,
onDismissRequest = { showGroupMenu = false }
) { dismiss ->
uiState.groups.forEachIndexed { index, group ->
RoundDropdownMenuItem(
text = { Text(group.groupName) },
onClick = {
scope.launch { pagerState.animateScrollToPage(index) }
dismiss()
},
trailingIcon = {
if (selectedTabIndex == index) {
Icon(
Icons.Default.Check,
null,
modifier = Modifier.size(18.dp)
)
}
}
)
}
}
)
}
}
}
}
@@ -533,12 +580,12 @@ fun BookshelfScreen(
@Composable
fun BookshelfPage(
paddingValues: PaddingValues,
books: List<Book>,
books: List<BookShelfItem>,
uiState: BookshelfUiState,
bookshelfLayoutMode: Int,
bookshelfLayoutGrid: Int,
onBookClick: (Book) -> Unit,
onBookLongClick: (Book) -> Unit
onBookClick: (BookShelfItem) -> Unit,
onBookLongClick: (BookShelfItem) -> Unit
) {
FastScrollLazyVerticalGrid(
columns = GridCells.Fixed(bookshelfLayoutGrid.coerceAtLeast(1)),
@@ -1,18 +1,17 @@
package io.legado.app.ui.main.bookshelf
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.ui.widget.components.list.ListUiState
data class BookshelfUiState(
override val items: List<Book> = emptyList(),
override val items: List<BookShelfItem> = emptyList(),
override val selectedIds: Set<Any> = emptySet(),
override val searchKey: String = "",
override val isSearch: Boolean = false,
override val isLoading: Boolean = false,
val groups: List<BookGroup> = emptyList(),
val groupPreviews: Map<Long, List<Book>> = emptyMap(),
val groupPreviews: Map<Long, List<BookShelfItem>> = emptyMap(),
val selectedGroupIndex: Int = 0,
val loadingText: String? = null,
val updatingBooks: Set<String> = emptySet()
) : ListUiState<Book>
) : ListUiState<BookShelfItem>
@@ -22,7 +22,6 @@ 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
import io.legado.app.help.book.isLocal
import io.legado.app.help.book.isUpError
import io.legado.app.help.book.removeType
import io.legado.app.help.book.sync
@@ -123,13 +122,15 @@ class BookshelfViewModel(
val allGroupsFlow: StateFlow<List<BookGroup>> = bookGroupRepository.flowAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
private val allBooksFlow = appDb.bookDao.flowAll().flowOn(Dispatchers.Default)
private val allBooksFlow = appDb.bookDao.flowBookShelf().flowOn(Dispatchers.Default)
@OptIn(ExperimentalCoroutinesApi::class)
val booksFlow = combine(groupIdFlow, refreshTrigger) { groupId, _ -> groupId }
.flatMapLatest { groupId ->
appDb.bookDao.flowByGroup(groupId).map { list ->
sortBooks(list, groupsFlow.value.find { it.groupId == groupId })
sortBooks(
list.map { it.toBookShelfItem() },
groupsFlow.value.find { it.groupId == groupId })
}
}.flowOn(Dispatchers.Default)
@@ -269,7 +270,7 @@ class BookshelfViewModel(
upTocPool.close()
}
private fun sortBooks(list: List<Book>, group: BookGroup?): List<Book> {
private fun sortBooks(list: List<BookShelfItem>, group: BookGroup?): List<BookShelfItem> {
val bookSort = group?.getRealBookSort() ?: BookshelfConfig.bookshelfSort
val isDescending = BookshelfConfig.bookshelfSortOrder == 1
@@ -304,7 +305,7 @@ class BookshelfViewModel(
}
@OptIn(ExperimentalCoroutinesApi::class)
fun getBooksFlow(groupId: Long): Flow<List<Book>> {
fun getBooksFlow(groupId: Long): Flow<List<BookShelfItem>> {
return combine(
appDb.bookDao.flowByGroup(groupId),
searchKeyFlow,
@@ -312,10 +313,11 @@ class BookshelfViewModel(
refreshTrigger
) { books, searchKey, groups, _ ->
val group = groups.find { it.groupId == groupId }
val bookshelfItems = books.map { it.toBookShelfItem() }
val filtered = if (searchKey.isEmpty()) {
books
bookshelfItems
} else {
books.filter {
bookshelfItems.filter {
it.name.contains(searchKey, true) || it.author.contains(searchKey, true)
}
}
@@ -349,13 +351,11 @@ class BookshelfViewModel(
}
}
fun upToc(books: List<Book>) {
fun upToc(books: List<BookShelfItem>) {
execute(context = upTocPool) {
books.filter {
!it.isLocal && it.canUpdate
}.let {
addToWaitUp(it)
}
val bookUrls = books.filter { !it.isLocal && it.canUpdate }.map { it.bookUrl }
val fullBooks = bookUrls.mapNotNull { appDb.bookDao.getBook(it) }
addToWaitUp(fullBooks)
}
}
@@ -562,17 +562,20 @@ class BookshelfViewModel(
}
}
fun exportToUri(uri: Uri, books: List<Book>) {
fun exportToUri(uri: Uri, items: List<BookShelfItem>) {
execute {
context.contentResolver.openOutputStream(uri)?.use { out ->
val writer = JsonWriter(OutputStreamWriter(out, "UTF-8"))
writer.setIndent(" ")
writer.beginArray()
books.forEach {
items.forEach {
val bookMap = hashMapOf<String, String?>()
bookMap["name"] = it.name
bookMap["author"] = it.author
bookMap["intro"] = it.getDisplayIntro()
// intro is not in BookShelfItem, fetch from DB if needed or skip
// For now, let's keep it simple and skip intro or fetch it
val fullBook = appDb.bookDao.getBook(it.bookUrl)
bookMap["intro"] = fullBook?.getDisplayIntro()
GSON.toJson(bookMap, bookMap::class.java, writer)
}
writer.endArray()
@@ -585,14 +588,15 @@ class BookshelfViewModel(
}
}
fun uploadBookshelf(books: List<Book>) {
fun uploadBookshelf(items: List<BookShelfItem>) {
execute {
val json = withContext(Dispatchers.Default) {
val list = books.map {
val list = items.map {
val bookMap = hashMapOf<String, String?>()
bookMap["name"] = it.name
bookMap["author"] = it.author
bookMap["intro"] = it.getDisplayIntro()
val fullBook = appDb.bookDao.getBook(it.bookUrl)
bookMap["intro"] = fullBook?.getDisplayIntro()
bookMap
}
GSON.toJson(list)
@@ -619,9 +623,9 @@ class BookshelfViewModel(
}
}
fun exportBookshelf(books: List<Book>?, success: (file: File) -> Unit) {
fun exportBookshelf(items: List<BookShelfItem>?, success: (file: File) -> Unit) {
execute {
books?.let {
items?.let {
val path = "${context.filesDir}/books.json"
FileUtils.delete(path)
val file = FileUtils.createFileWithReplace(path)
@@ -629,11 +633,12 @@ class BookshelfViewModel(
val writer = JsonWriter(OutputStreamWriter(out, "UTF-8"))
writer.setIndent(" ")
writer.beginArray()
books.forEach {
items.forEach {
val bookMap = hashMapOf<String, String?>()
bookMap["name"] = it.name
bookMap["author"] = it.author
bookMap["intro"] = it.getDisplayIntro()
val fullBook = appDb.bookDao.getBook(it.bookUrl)
bookMap["intro"] = fullBook?.getDisplayIntro()
GSON.toJson(bookMap, bookMap::class.java, writer)
}
writer.endArray()
@@ -50,6 +50,7 @@ import io.legado.app.help.config.AppConfig
import io.legado.app.ui.book.audio.AudioPlayActivity
import io.legado.app.ui.book.manga.ReadMangaActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.main.bookshelf.BookShelfItem
import splitties.systemservices.clipboardManager
import splitties.systemservices.connectivityManager
import splitties.systemservices.uiModeManager
@@ -80,6 +81,22 @@ fun Context.startActivityForBook(
startActivity(intent)
}
fun Context.startActivityForBook(
book: BookShelfItem,
configIntent: Intent.() -> Unit = {},
) {
val cls = when {
book.isAudio -> AudioPlayActivity::class.java
!book.isLocal && book.isImage && AppConfig.showMangaUi -> ReadMangaActivity::class.java
else -> ReadBookActivity::class.java
}
val intent = Intent(this, cls)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.putExtra("bookUrl", book.bookUrl)
intent.apply(configIntent)
startActivity(intent)
}
inline fun <reified T : Service> Context.startService(configIntent: Intent.() -> Unit = {}) {
startService(Intent(this, T::class.java).apply(configIntent))
}
@@ -464,4 +481,3 @@ fun Context.themeColor(attr: Int): Int {
typedValue.data
}
}