fix: 优化搜素逻辑
This commit is contained in:
@@ -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(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user