fix: 优化搜素逻辑

This commit is contained in:
HapeLee
2026-06-07 01:09:24 +08:00
parent adc7738627
commit 116762f826
11 changed files with 119 additions and 48 deletions
@@ -191,10 +191,11 @@ class SearchBooksUseCase(
source,
keyword,
page,
filter = { name, author ->
filter = { name, author, kind ->
matchMode == MatchMode.DEFAULT ||
name.contains(keyword, ignoreCase = true) ||
author.contains(keyword, ignoreCase = true)
author.contains(keyword, ignoreCase = true) ||
kind?.contains(keyword, ignoreCase = true) == true
}
)
}
@@ -230,19 +231,21 @@ class SearchBooksUseCase(
}
private val equalBooks = LinkedHashMap<SearchBookKey, SearchBook>()
private val tagsBooks = LinkedHashMap<SearchBookKey, SearchBook>()
private val containsBooks = LinkedHashMap<SearchBookKey, SearchBook>()
private val otherBooks = LinkedHashMap<SearchBookKey, SearchBook>()
var resultLimitReached = false
private set
val count: Int
get() = equalBooks.size + containsBooks.size + otherBooks.size
get() = equalBooks.size + tagsBooks.size + containsBooks.size + otherBooks.size
suspend fun merge(newBooks: List<SearchBook>): SearchBookChange {
if (newBooks.isEmpty()) return SearchBookChange()
val upsertBooks = arrayListOf<SearchBook>()
val removedBookUrls = linkedSetOf<String>()
val touchedBuckets = linkedSetOf<LinkedHashMap<SearchBookKey, SearchBook>>()
newBooks.forEach { newBook ->
coroutineContext.ensureActive()
val bucket = classifyBucket(newBook) ?: return@forEach
@@ -255,33 +258,71 @@ class SearchBooksUseCase(
currentBook.addOrigin(newBook.origin)
upsertBooks.add(currentBook)
}
touchedBuckets.add(bucket)
trimSearchBooks()?.let { removed ->
removedBookUrls.add(removed.bookUrl)
upsertBooks.removeAll { it.bookUrl == removed.bookUrl }
}
}
// Re-sort touched buckets by origins.size descending
touchedBuckets.forEach { bucket ->
sortBucket(bucket)
}
return SearchBookChange(upsertBooks, removedBookUrls.toList())
}
/**
* 将书籍分类到对应的优先级桶中:
* - equalBooks: 书名或作者完全等于搜索词
* - tagsBooks: 分类标签包含搜索词
* - containsBooks: 书名或作者包含搜索词(非精确匹配)
* - otherBooks: 其他结果(仅 DEFAULT 模式保留)
*/
private fun classifyBucket(book: SearchBook): LinkedHashMap<SearchBookKey, SearchBook>? {
return when {
book.name.equals(keyword, ignoreCase = true) ||
book.author.equals(keyword, ignoreCase = true) -> equalBooks
book.kind?.contains(keyword, ignoreCase = true) == true -> {
if (matchMode != MatchMode.DEFAULT) null else tagsBooks
}
book.name.contains(keyword, ignoreCase = true) ||
book.author.contains(
keyword,
ignoreCase = true
) -> if (matchMode == MatchMode.EXACT) null else containsBooks
book.author.contains(keyword, ignoreCase = true) -> {
if (matchMode == MatchMode.EXACT) null else containsBooks
}
matchMode != MatchMode.DEFAULT -> null
else -> otherBooks
}
}
/**
* 按 origins.size 降序重新排列桶内元素。
* 使用 sortedEntries 重建 LinkedHashMap 以保持排序后的迭代顺序。
*/
private fun sortBucket(bucket: LinkedHashMap<SearchBookKey, SearchBook>) {
if (bucket.size <= 1) return
val sorted = bucket.entries.sortedByDescending { it.value.origins.size }
bucket.clear()
sorted.forEach { (k, v) -> bucket[k] = v }
}
/**
* 获取排序后的最终结果列表。
* 每个桶内按来源数量降序排列(多源 = 更可靠),桶间按优先级拼接。
*/
fun getSortedList(): List<SearchBook> {
val sorted = ArrayList<SearchBook>(count)
sorted.addAll(equalBooks.values.sortedByDescending { it.origins.size })
sorted.addAll(tagsBooks.values.sortedByDescending { it.origins.size })
sorted.addAll(containsBooks.values.sortedByDescending { it.origins.size })
sorted.addAll(otherBooks.values)
return sorted
}
private fun trimSearchBooks(): SearchBook? {
if (count <= MAX_RETAINED_SEARCH_RESULTS) return null
resultLimitReached = true
return removeLast(otherBooks)
?: removeLast(tagsBooks)
?: removeLowestOrigin(containsBooks)
?: removeLowestOrigin(equalBooks)
}
@@ -40,7 +40,7 @@ object BookList {
body: String?,
isSearch: Boolean = true,
isRedirect: Boolean = false,
filter: ((name: String, author: String) -> Boolean)? = null,
filter: ((name: String, author: String, kind: String?) -> Boolean)? = null,
shouldBreak: ((size: Int) -> Boolean)? = null
): ArrayList<SearchBook> {
body ?: throw NoStackTraceException(
@@ -159,7 +159,7 @@ object BookList {
baseUrl: String,
variable: String?,
isRedirect: Boolean,
filter: ((name: String, author: String) -> Boolean)?
filter: ((name: String, author: String, kind: String?) -> Boolean)?
): SearchBook? {
val book = Book(variable = variable)
book.bookUrl = if (isRedirect) {
@@ -181,7 +181,7 @@ object BookList {
baseUrl,
false
)
if (filter?.invoke(book.name, book.author) == false) {
if (filter?.invoke(book.name, book.author, book.kind) == false) {
return null
}
if (book.name.isNotBlank()) {
@@ -198,7 +198,7 @@ object BookList {
baseUrl: String,
variable: String?,
log: Boolean,
filter: ((name: String, author: String) -> Boolean)?,
filter: ((name: String, author: String, kind: String?) -> Boolean)?,
ruleName: List<AnalyzeRule.SourceRule>,
ruleBookUrl: List<AnalyzeRule.SourceRule>,
ruleAuthor: List<AnalyzeRule.SourceRule>,
@@ -224,9 +224,6 @@ object BookList {
Debug.log(bookSource.bookSourceUrl, "┌获取作者", log)
searchBook.author = BookHelp.formatBookAuthor(analyzeRule.getString(ruleAuthor))
Debug.log(bookSource.bookSourceUrl, "${searchBook.author}", log)
if (filter?.invoke(searchBook.name, searchBook.author) == false) {
return null
}
coroutineContext.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取分类", log)
try {
@@ -236,6 +233,9 @@ object BookList {
coroutineContext.ensureActive()
Debug.log(bookSource.bookSourceUrl, "${e.localizedMessage}", log)
}
if (filter?.invoke(searchBook.name, searchBook.author, searchBook.kind) == false) {
return null
}
coroutineContext.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取字数", log)
try {
@@ -51,7 +51,7 @@ object WebBook {
bookSource: BookSource,
key: String,
page: Int? = 1,
filter: ((name: String, author: String) -> Boolean)? = null,
filter: ((name: String, author: String, kind: String?) -> Boolean)? = null,
shouldBreak: ((size: Int) -> Boolean)? = null
): ArrayList<SearchBook> {
val searchUrl = bookSource.searchUrl
@@ -481,7 +481,7 @@ object WebBook {
coroutineContext.ensureActive()
searchBookAwait(
bookSource, name,
filter = { fName, fAuthor -> fName == name && fAuthor == author },
filter = { fName, fAuthor, _ -> fName == name && fAuthor == author },
shouldBreak = { it > 0 }
).firstOrNull()?.let { searchBook ->
coroutineContext.ensureActive()
@@ -275,7 +275,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
val loadWordCount = AppConfig.changeSourceLoadWordCount
val resultBooks = WebBook.searchBookAwait(
source, name,
filter = { fName, fAuthor ->
filter = { fName, fAuthor, _ ->
fName == name && (!checkAuthor || fAuthor.contains(author))
})
resultBooks.forEach { searchBook ->
@@ -16,7 +16,11 @@ class SearchActivity : BaseComposeActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
dispatchInit(intent)
// Only dispatch init on fresh launch, not on config change
// (ViewModel is retained by Koin and already has correct state).
if (savedInstanceState == null) {
dispatchInit(intent)
}
}
override fun onNewIntent(intent: Intent) {
@@ -25,16 +29,6 @@ class SearchActivity : BaseComposeActivity() {
dispatchInit(intent)
}
override fun onResume() {
super.onResume()
viewModel.onIntent(SearchIntent.ResumeEngine)
}
override fun onPause() {
viewModel.onIntent(SearchIntent.PauseEngine)
super.onPause()
}
@Composable
override fun Content() {
SearchScreen(
@@ -8,7 +8,9 @@ import io.legado.app.domain.model.BookShelfState
import io.legado.app.domain.model.MatchMode
import io.legado.app.ui.main.bookshelf.BookShelfItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
@Stable
data class SearchResultItemUi(
@@ -27,7 +29,7 @@ data class SearchUiState(
val enabledSources: ImmutableList<BookSourcePart> = persistentListOf(),
val scopeDisplay: String = "",
val scopeDisplayNames: ImmutableList<String> = persistentListOf(),
val selectedScopeSourceUrls: Set<String> = emptySet(),
val selectedScopeSourceUrls: ImmutableSet<String> = persistentSetOf(),
val isAllScope: Boolean = true,
val isSourceScope: Boolean = false,
val matchMode: MatchMode = MatchMode.DEFAULT,
@@ -36,7 +38,7 @@ data class SearchUiState(
val hasMore: Boolean = true,
val processedSources: Int = 0,
val totalSources: Int = 0,
val selectedSourceTypes: Set<Int> = emptySet(),
val selectedSourceTypes: ImmutableSet<Int> = persistentSetOf(),
val showScopeSheet: Boolean = false,
val showSettingsSheet: Boolean = false,
val showClearHistoryDialog: Boolean = false,
@@ -63,7 +65,7 @@ data class SearchEmptyScopeAction(
sealed interface SearchIntent {
data class Initialize(val key: String?, val scopeRaw: String?) : SearchIntent
data class UpdateQuery(val query: String) : SearchIntent
data class UpdateQuery(val query: String, val showSuggestions: Boolean = true) : SearchIntent
data object SubmitSearch : SearchIntent
data object LoadMore : SearchIntent
data object StopSearch : SearchIntent
@@ -84,6 +86,7 @@ sealed interface SearchIntent {
data class SetScopeSheetVisible(val visible: Boolean) : SearchIntent
data class SetSettingsSheetVisible(val visible: Boolean) : SearchIntent
data class ToggleSourceType(val type: Int) : SearchIntent
data object ClearAllSourceTypes : SearchIntent
data object SelectAllScope : SearchIntent
data class ToggleScopeGroup(val groupName: String) : SearchIntent
data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent
@@ -120,6 +120,7 @@ data class SearchScope(private var scope: String) {
this.scope = stringBuilder.toString()
}
stateLiveData.postValue(this.scope)
save()
}
/**
@@ -108,6 +108,7 @@ fun SearchScreen(
var previewBook by remember { mutableStateOf<SearchBook?>(null) }
var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
val listState = rememberLazyListState()
val groupedListState = rememberLazyListState()
val lifecycleOwner = LocalLifecycleOwner.current
var queryInput by rememberSaveable { mutableStateOf(state.query) }
var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf<String?>(null) }
@@ -127,6 +128,14 @@ fun SearchScreen(
}
}
val shouldLoadMoreGrouped by remember {
derivedStateOf {
val totalCount = groupedListState.layoutInfo.totalItemsCount
val lastVisible = groupedListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
totalCount > 0 && lastVisible >= totalCount - 3
}
}
LaunchedEffect(state.query) {
if (state.query != queryInput) {
queryInput = state.query
@@ -150,20 +159,19 @@ fun SearchScreen(
LaunchedEffect(
shouldLoadMore,
shouldLoadMoreGrouped,
state.isSearching,
state.hasMore,
state.isManualStop,
state.showSuggestions,
isSourceGroupedMode,
) {
if (
shouldLoadMore &&
!state.isSearching &&
val readyToLoad = !state.isSearching &&
state.hasMore &&
!state.isManualStop &&
!state.showSuggestions &&
!isSourceGroupedMode
) {
!state.showSuggestions
val nearEnd = if (isSourceGroupedMode) shouldLoadMoreGrouped else shouldLoadMore
if (readyToLoad && nearEnd) {
viewModel.onIntent(SearchIntent.LoadMore)
}
}
@@ -416,6 +424,7 @@ fun SearchScreen(
) { isSourceGrouped ->
if (isSourceGrouped) {
LazyColumn(
state = groupedListState,
modifier = Modifier.fillMaxSize(),
contentPadding = adaptiveContentPaddingOnlyVertical(
top = 48.dp,
@@ -477,11 +486,11 @@ fun SearchScreen(
) {
itemsIndexed(
items = state.results,
key = { index, item -> "${item.book.origin}:${item.book.bookUrl}:$index" }
key = { _, item -> "${item.book.origin}:${item.book.bookUrl}" }
) { index, item ->
val sharedCoverKey = bookCoverSharedElementKey(
item.book.bookUrl,
"search:${item.book.origin}:$index"
"search:${item.book.origin}"
)
SearchBookListItem(
book = item.book,
@@ -636,7 +645,7 @@ fun SearchScreen(
AppIcon(Icons.Default.Layers, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
AppText(
text = "搜索类型",
text = stringResource(R.string.search_type),
style = LegadoTheme.typography.titleSmall
)
}
@@ -679,9 +688,11 @@ fun SearchScreen(
}
}
val resultsByBookUrl = remember(state.results) {
state.results.associateBy { it.book.bookUrl }
}
val previewShelfState = previewBook?.let { book ->
state.results.find { it.book.bookUrl == book.bookUrl }?.shelfState
?: BookShelfState.NOT_IN_SHELF
resultsByBookUrl[book.bookUrl]?.shelfState ?: BookShelfState.NOT_IN_SHELF
}
SearchBookPreviewSheet(
data = previewBook,
@@ -864,7 +875,7 @@ private fun SearchResultFooter(
hasMore -> {
Text(
text = stringResource(R.string.search_empty),
text = stringResource(R.string.search_has_more),
color = LegadoTheme.colorScheme.onSurfaceVariant,
)
}
@@ -20,7 +20,9 @@ import io.legado.app.domain.usecase.SearchRunEvent
import io.legado.app.help.config.AppConfig
import io.legado.app.ui.config.otherConfig.OtherConfig
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
@@ -107,7 +109,7 @@ class SearchViewModel(
fun onIntent(intent: SearchIntent) {
when (intent) {
is SearchIntent.Initialize -> initialize(intent.key, intent.scopeRaw)
is SearchIntent.UpdateQuery -> updateQuery(intent.query, showSuggestions = true)
is SearchIntent.UpdateQuery -> updateQuery(intent.query, intent.showSuggestions)
SearchIntent.SubmitSearch -> submitSearch()
SearchIntent.LoadMore -> loadMore()
SearchIntent.StopSearch -> stopSearch()
@@ -185,15 +187,20 @@ class SearchViewModel(
_uiState.update { state ->
val current = state.selectedSourceTypes
val next = if (current.contains(intent.type)) {
current - intent.type
(current - intent.type).toImmutableSet()
} else {
current + intent.type
(current + intent.type).toImmutableSet()
}
state.copy(selectedSourceTypes = next)
}
restartCommittedSearchIfNeeded()
}
SearchIntent.ClearAllSourceTypes -> {
_uiState.update { it.copy(selectedSourceTypes = persistentSetOf()) }
restartCommittedSearchIfNeeded()
}
SearchIntent.SelectAllScope -> {
val oldScope = searchScope.toString()
searchScope.update("")
@@ -310,6 +317,10 @@ class SearchViewModel(
}
syncScopeState()
// Always reset search state so the screen starts clean,
// regardless of whether the ViewModel was retained by navigation.
clearSearchResults()
val initKey = key?.trim().orEmpty()
if (initKey.isNotEmpty()) {
updateQuery(initKey, showSuggestions = false)
@@ -413,6 +424,11 @@ class SearchViewModel(
updateQuery(keyword, showSuggestions = false)
// Cancel the old search job BEFORE clearing results to prevent
// stale Progress events from re-inserting books into the map.
searchJob?.cancel()
searchJob = null
currentSearchPage = 1
searchResultBooks.clear()
_uiState.update {
@@ -546,6 +562,7 @@ class SearchViewModel(
private fun clearSearchResults() {
stopSearch(manualStop = true)
searchResultBooks.clear()
queryFlow.value = ""
_uiState.update {
it.copy(
query = "",
@@ -646,7 +663,7 @@ class SearchViewModel(
it.copy(
scopeDisplay = searchScope.display,
scopeDisplayNames = searchScope.displayNames.toImmutableList(),
selectedScopeSourceUrls = searchScope.sourceUrls.toSet(),
selectedScopeSourceUrls = searchScope.sourceUrls.toImmutableSet(),
isAllScope = searchScope.isAll(),
isSourceScope = searchScope.isSource(),
)