[优化] 重写搜索核心

This commit is contained in:
HapeLee
2026-04-28 16:15:34 +08:00
parent fb31b55125
commit e1070c745e
8 changed files with 494 additions and 450 deletions
@@ -4,18 +4,13 @@ import io.legado.app.data.AppDatabase
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.entities.SearchKeyword
import io.legado.app.domain.gateway.BookSearchGateway
import io.legado.app.domain.model.BookSearchScope
import io.legado.app.domain.usecase.BookShelfKey
import io.legado.app.help.book.isNotShelf
import io.legado.app.model.webBook.SearchModel
import io.legado.app.ui.book.search.SearchScope
import io.legado.app.ui.main.bookshelf.BookShelfItem
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
@@ -31,41 +26,11 @@ interface SearchRepository {
suspend fun saveSearchKeyword(keyword: String)
suspend fun deleteSearchKeyword(item: SearchKeyword)
suspend fun clearSearchKeywords()
fun createSearchSession(scopeProvider: () -> SearchScope): SearchSession
}
sealed interface SearchSessionEvent {
data object Started : SearchSessionEvent
data class Progress(
val upsertBooks: List<SearchBook>,
val removedBookUrls: List<String>,
val resultCount: Int,
val processedSources: Int,
val totalSources: Int,
) : SearchSessionEvent
data class Finished(
val isEmpty: Boolean,
val hasMore: Boolean,
) : SearchSessionEvent
data class Canceled(val throwable: Throwable? = null) : SearchSessionEvent
}
interface SearchSession {
val events: Flow<SearchSessionEvent>
fun search(searchId: Long, keyword: String)
fun stop()
fun pause()
fun resume()
fun close()
}
class SearchRepositoryImpl(
private val appDb: AppDatabase,
) : SearchRepository {
) : SearchRepository, BookSearchGateway {
override val enabledGroups: Flow<List<String>> = appDb.bookSourceDao.flowEnabledGroups()
override val enabledSources: Flow<List<BookSourcePart>> = appDb.bookSourceDao.flowEnabled()
@@ -113,71 +78,34 @@ class SearchRepositoryImpl(
appDb.searchKeywordDao.deleteAll()
}
override fun createSearchSession(scopeProvider: () -> SearchScope): SearchSession {
return SearchSessionImpl(scopeProvider)
override suspend fun getBookSourceParts(scope: BookSearchScope): List<BookSourcePart> =
withContext(Dispatchers.IO) {
val selectedSources = linkedSetOf<BookSourcePart>()
when {
scope.isAll -> selectedSources.addAll(appDb.bookSourceDao.allEnabledPart)
scope.isSource -> scope.sourceUrls.forEach { sourceUrl ->
appDb.bookSourceDao.getBookSourcePart(sourceUrl)?.let { selectedSources.add(it) }
}
else -> scope.groupNames.forEach { groupName ->
selectedSources.addAll(appDb.bookSourceDao.getEnabledPartByGroup(groupName))
}
}
if (selectedSources.isEmpty()) {
appDb.bookSourceDao.allEnabledPart
} else {
selectedSources.toList().sortedBy { it.customOrder }
}
}
override suspend fun getBookSource(sourceUrl: String) = withContext(Dispatchers.IO) {
appDb.bookSourceDao.getBookSource(sourceUrl)
}
private class SearchSessionImpl(
scopeProvider: () -> SearchScope,
) : SearchSession {
private val sessionScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _events = MutableSharedFlow<SearchSessionEvent>(extraBufferCapacity = 8)
override val events: Flow<SearchSessionEvent> = _events.asSharedFlow()
private val searchModel = SearchModel(sessionScope, object : SearchModel.CallBack {
override fun getSearchScope(): SearchScope = scopeProvider()
override suspend fun onSearchStart() {
_events.emit(SearchSessionEvent.Started)
}
override suspend fun onSearchSuccess(
upsertBooks: List<SearchBook>,
removedBookUrls: List<String>,
resultCount: Int,
processedSources: Int,
totalSources: Int,
) {
_events.emit(
SearchSessionEvent.Progress(
upsertBooks = upsertBooks,
removedBookUrls = removedBookUrls,
resultCount = resultCount,
processedSources = processedSources,
totalSources = totalSources,
)
)
}
override suspend fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) {
_events.emit(SearchSessionEvent.Finished(isEmpty, hasMore))
}
override fun onSearchCancel(exception: Throwable?) {
_events.tryEmit(SearchSessionEvent.Canceled(exception))
}
})
override fun search(searchId: Long, keyword: String) {
searchModel.search(searchId, keyword)
}
override fun stop() {
searchModel.cancelSearch()
}
override fun pause() {
searchModel.pause()
}
override fun resume() {
searchModel.resume()
}
override fun close() {
searchModel.close()
sessionScope.cancel()
override suspend fun saveSearchBooks(books: List<SearchBook>) = withContext(Dispatchers.IO) {
if (books.isNotEmpty()) {
appDb.searchBookDao.insert(books)
}
}
}
@@ -0,0 +1,12 @@
package io.legado.app.domain.gateway
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook
import io.legado.app.domain.model.BookSearchScope
interface BookSearchGateway {
suspend fun getBookSourceParts(scope: BookSearchScope): List<BookSourcePart>
suspend fun getBookSource(sourceUrl: String): BookSource?
suspend fun saveSearchBooks(books: List<SearchBook>)
}
@@ -0,0 +1,40 @@
package io.legado.app.domain.model
import io.legado.app.utils.splitNotBlank
data class BookSearchScope(val raw: String) {
val items: List<String>
get() = raw.splitNotBlank(",").toList()
val isAll: Boolean
get() = raw.isEmpty()
val isSource: Boolean
get() = items.isNotEmpty() && parseSourceItems().size == items.size
val groupNames: List<String>
get() = if (isSource) emptyList() else items
val sourceUrls: List<String>
get() = parseSourceItems().map { it.url }
private fun parseSourceItems(): List<ScopeSourceItem> {
return items.mapNotNull { item ->
val splitIndex = item.indexOf("::")
if (splitIndex <= 0 || splitIndex >= item.lastIndex) {
null
} else {
ScopeSourceItem(
name = item.substring(0, splitIndex),
url = item.substring(splitIndex + 2)
)
}
}
}
private data class ScopeSourceItem(
val name: String,
val url: String,
)
}
@@ -0,0 +1,255 @@
package io.legado.app.domain.usecase
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook
import io.legado.app.domain.gateway.BookSearchGateway
import io.legado.app.domain.model.BookSearchScope
import io.legado.app.exception.NoStackTraceException
import io.legado.app.model.webBook.WebBook
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withTimeout
import kotlin.coroutines.coroutineContext
import kotlin.math.min
data class BookSearchRequest(
val keyword: String,
val page: Int,
val scope: BookSearchScope,
val precision: Boolean,
val concurrency: Int,
)
sealed interface SearchRunEvent {
data object Started : SearchRunEvent
data class Progress(
val upsertBooks: List<SearchBook>,
val removedBookUrls: List<String>,
val resultCount: Int,
val processedSources: Int,
val totalSources: Int,
) : SearchRunEvent
data class Finished(
val isEmpty: Boolean,
val hasMore: Boolean,
) : SearchRunEvent
}
class BookSearchControl {
private val isResumed = MutableStateFlow(true)
fun pause() {
isResumed.value = false
}
fun resume() {
isResumed.value = true
}
suspend fun awaitResumed() {
isResumed.first { it }
}
}
class SearchBooksUseCase(
private val gateway: BookSearchGateway,
) {
@OptIn(ExperimentalCoroutinesApi::class)
fun execute(
request: BookSearchRequest,
control: BookSearchControl,
): Flow<SearchRunEvent> = flow {
val keyword = request.keyword.trim()
if (keyword.isBlank()) return@flow
val sourceParts = gateway.getBookSourceParts(request.scope)
if (sourceParts.isEmpty()) {
throw NoStackTraceException("启用书源为空")
}
val merger = SearchResultMerger(keyword, request.precision)
val concurrency = min(request.concurrency.coerceAtLeast(1), AppConst.MAX_THREAD)
var hasMore = false
var processedSources = 0
emit(SearchRunEvent.Started)
sourceParts.asFlow()
.flatMapMerge(concurrency) { sourcePart ->
flow {
control.awaitResumed()
emit(searchSource(sourcePart, keyword, request.page, request.precision))
}.flowOn(Dispatchers.IO)
}
.collect { result ->
currentCoroutineContext().ensureActive()
processedSources++
when (result) {
is SourceSearchResult.Found -> {
result.books.forEach { it.releaseHtmlData() }
hasMore = hasMore || result.books.isNotEmpty()
if (result.books.isNotEmpty()) {
gateway.saveSearchBooks(result.books)
}
val change = merger.merge(result.books)
emit(
SearchRunEvent.Progress(
upsertBooks = change.upsertBooks,
removedBookUrls = change.removedBookUrls,
resultCount = merger.count,
processedSources = processedSources,
totalSources = sourceParts.size,
)
)
}
is SourceSearchResult.Failed -> {
AppLog.put("书源搜索出错\n${result.throwable.localizedMessage}", result.throwable)
emit(
SearchRunEvent.Progress(
upsertBooks = emptyList(),
removedBookUrls = emptyList(),
resultCount = merger.count,
processedSources = processedSources,
totalSources = sourceParts.size,
)
)
}
}
}
emit(
SearchRunEvent.Finished(
isEmpty = merger.count == 0,
hasMore = hasMore && !merger.resultLimitReached,
)
)
}.flowOn(Dispatchers.IO)
private suspend fun searchSource(
sourcePart: BookSourcePart,
keyword: String,
page: Int,
precision: Boolean,
): SourceSearchResult {
return try {
val source = gateway.getBookSource(sourcePart.bookSourceUrl)
?: return SourceSearchResult.Found(emptyList())
val books = withTimeout(30000L) {
WebBook.searchBookAwait(
source,
keyword,
page,
filter = { name, author ->
!precision || name.contains(keyword) || author.contains(keyword)
}
)
}
SourceSearchResult.Found(books)
} catch (exception: Throwable) {
coroutineContext.ensureActive()
if (exception is CancellationException) throw exception
SourceSearchResult.Failed(exception)
}
}
private sealed interface SourceSearchResult {
data class Found(val books: List<SearchBook>) : SourceSearchResult
data class Failed(val throwable: Throwable) : SourceSearchResult
}
private class SearchResultMerger(
private val keyword: String,
private val precision: Boolean,
) {
private companion object {
const val MAX_RETAINED_SEARCH_RESULTS = 1000
}
private val equalBooks = 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
suspend fun merge(newBooks: List<SearchBook>): SearchBookChange {
if (newBooks.isEmpty()) return SearchBookChange()
val upsertBooks = arrayListOf<SearchBook>()
val removedBookUrls = linkedSetOf<String>()
newBooks.forEach { newBook ->
coroutineContext.ensureActive()
val bucket = classifyBucket(newBook) ?: return@forEach
val key = SearchBookKey(newBook.name, newBook.author)
val currentBook = bucket[key]
if (currentBook == null) {
bucket[key] = newBook
upsertBooks.add(newBook)
} else {
currentBook.addOrigin(newBook.origin)
upsertBooks.add(currentBook)
}
trimSearchBooks()?.let { removed ->
removedBookUrls.add(removed.bookUrl)
upsertBooks.removeAll { it.bookUrl == removed.bookUrl }
}
}
return SearchBookChange(upsertBooks, removedBookUrls.toList())
}
private fun classifyBucket(book: SearchBook): LinkedHashMap<SearchBookKey, SearchBook>? {
return when {
book.name == keyword || book.author == keyword -> equalBooks
book.name.contains(keyword) || book.author.contains(keyword) -> containsBooks
!precision -> otherBooks
else -> null
}
}
private fun trimSearchBooks(): SearchBook? {
if (count <= MAX_RETAINED_SEARCH_RESULTS) return null
resultLimitReached = true
return removeLast(otherBooks)
?: removeLowestOrigin(containsBooks)
?: removeLowestOrigin(equalBooks)
}
private fun removeLast(bucket: LinkedHashMap<SearchBookKey, SearchBook>): SearchBook? {
val key = bucket.keys.lastOrNull() ?: return null
return bucket.remove(key)
}
private fun removeLowestOrigin(bucket: LinkedHashMap<SearchBookKey, SearchBook>): SearchBook? {
val key = bucket.entries.minByOrNull { it.value.origins.size }?.key ?: return null
return bucket.remove(key)
}
}
private data class SearchBookKey(
val name: String,
val author: String,
)
private data class SearchBookChange(
val upsertBooks: List<SearchBook> = emptyList(),
val removedBookUrls: List<String> = emptyList(),
)
}
@@ -1,252 +0,0 @@
package io.legado.app.model.webBook
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog
import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook
import io.legado.app.exception.NoStackTraceException
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.ui.book.search.SearchScope
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.mapParallelSafe
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExecutorCoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import splitties.init.appCtx
import java.util.concurrent.Executors
import kotlin.coroutines.coroutineContext
import kotlin.math.min
class SearchModel(private val scope: CoroutineScope, private val callBack: CallBack) {
private companion object {
const val MAX_RETAINED_SEARCH_RESULTS = 1000
}
val threadCount = OtherConfig.threadCount
private var searchPool: ExecutorCoroutineDispatcher? = null
private var mSearchId = 0L
private var searchPage = 1
private var searchKey: String = ""
private var bookSourceParts = emptyList<BookSourcePart>()
private val equalBooks = LinkedHashMap<SearchBookKey, SearchBook>()
private val containsBooks = LinkedHashMap<SearchBookKey, SearchBook>()
private val otherBooks = LinkedHashMap<SearchBookKey, SearchBook>()
private var searchJob: Job? = null
private var workingState = MutableStateFlow(true)
private var resultLimitReached = false
private fun initSearchPool() {
searchPool?.close()
searchPool = Executors
.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
}
fun search(searchId: Long, key: String) {
if (searchId != mSearchId) {
if (key.isEmpty()) {
return
}
searchKey = key
if (mSearchId != 0L) {
close()
}
clearSearchBooks()
bookSourceParts = callBack.getSearchScope().getBookSourceParts()
if (bookSourceParts.isEmpty()) {
callBack.onSearchCancel(NoStackTraceException("启用书源为空"))
return
}
mSearchId = searchId
searchPage = 1
initSearchPool()
} else {
searchPage++
}
startSearch()
}
private fun startSearch() {
val precision = appCtx.getPrefBoolean(PreferKey.precisionSearch)
var hasMore = false
val totalParts = bookSourceParts.size
var processedParts = 0
searchJob = scope.launch(searchPool!!) {
flow {
for (bs in bookSourceParts) {
bs.getBookSource()?.let {
emit(it)
}
workingState.first { it }
}
}.onStart {
callBack.onSearchStart()
}.mapParallelSafe(threadCount) {
withTimeout(30000L) {
WebBook.searchBookAwait(
it, searchKey, searchPage,
filter = { name, author ->
!precision || name.contains(searchKey) ||
author.contains(searchKey)
})
}
}.onEach { items ->
for (book in items) {
book.releaseHtmlData()
}
hasMore = hasMore || items.isNotEmpty()
if (items.isNotEmpty()) {
appDb.searchBookDao.insert(items)
}
val change = mergeItems(items, precision)
currentCoroutineContext().ensureActive()
processedParts++
callBack.onSearchSuccess(
upsertBooks = change.upsertBooks,
removedBookUrls = change.removedBookUrls,
resultCount = searchBookCount(),
processedSources = processedParts,
totalSources = totalParts,
)
}.onCompletion {
if (it == null) {
callBack.onSearchFinish(
isEmpty = searchBookCount() == 0,
hasMore = hasMore && !resultLimitReached,
)
}
}.catch {
AppLog.put("书源搜索出错\n${it.localizedMessage}", it)
}.collect()
}
}
private suspend fun mergeItems(newDataS: List<SearchBook>, precision: Boolean): SearchBookChange {
if (newDataS.isEmpty()) {
return SearchBookChange()
}
val upsertBooks = arrayListOf<SearchBook>()
val removedBookUrls = linkedSetOf<String>()
newDataS.forEach { nBook ->
coroutineContext.ensureActive()
val bucket = classifyBucket(nBook, precision) ?: return@forEach
val key = SearchBookKey(nBook.name, nBook.author)
val currentBook = bucket[key]
if (currentBook == null) {
bucket[key] = nBook
upsertBooks.add(nBook)
} else {
currentBook.addOrigin(nBook.origin)
upsertBooks.add(currentBook)
}
trimSearchBooks()?.let { removed ->
removedBookUrls.add(removed.bookUrl)
upsertBooks.removeAll { it.bookUrl == removed.bookUrl }
}
}
return SearchBookChange(upsertBooks, removedBookUrls.toList())
}
private fun classifyBucket(
book: SearchBook,
precision: Boolean,
): LinkedHashMap<SearchBookKey, SearchBook>? {
return when {
book.name == searchKey || book.author == searchKey -> equalBooks
book.name.contains(searchKey) || book.author.contains(searchKey) -> containsBooks
!precision -> otherBooks
else -> null
}
}
private fun trimSearchBooks(): SearchBook? {
if (searchBookCount() <= MAX_RETAINED_SEARCH_RESULTS) {
return null
}
resultLimitReached = true
return removeLast(otherBooks)
?: removeLowestOrigin(containsBooks)
?: removeLowestOrigin(equalBooks)
}
private fun removeLast(bucket: LinkedHashMap<SearchBookKey, SearchBook>): SearchBook? {
val key = bucket.keys.lastOrNull() ?: return null
return bucket.remove(key)
}
private fun removeLowestOrigin(bucket: LinkedHashMap<SearchBookKey, SearchBook>): SearchBook? {
val key = bucket.entries.minByOrNull { it.value.origins.size }?.key ?: return null
return bucket.remove(key)
}
private fun searchBookCount(): Int {
return equalBooks.size + containsBooks.size + otherBooks.size
}
private fun clearSearchBooks() {
equalBooks.clear()
containsBooks.clear()
otherBooks.clear()
resultLimitReached = false
}
fun cancelSearch() {
close()
callBack.onSearchCancel()
}
fun close() {
searchJob?.cancel()
searchPool?.close()
searchPool = null
mSearchId = 0L
}
fun pause() {
workingState.value = false
}
fun resume() {
workingState.value = true
}
interface CallBack {
fun getSearchScope(): SearchScope
suspend fun onSearchStart()
suspend fun onSearchSuccess(
upsertBooks: List<SearchBook>,
removedBookUrls: List<String>,
resultCount: Int,
processedSources: Int,
totalSources: Int,
)
suspend fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean)
fun onSearchCancel(exception: Throwable? = null)
}
private data class SearchBookKey(
val name: String,
val author: String,
)
private data class SearchBookChange(
val upsertBooks: List<SearchBook> = emptyList(),
val removedBookUrls: List<String> = emptyList(),
)
}
@@ -44,6 +44,7 @@ import io.legado.app.ui.widget.components.topbar.M3GlassScrollBehavior
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -178,6 +179,12 @@ fun SearchScreen(
}
}
DisposableEffect(viewModel) {
onDispose {
viewModel.onIntent(SearchIntent.StopSearch)
}
}
val submitSearch: (String) -> Unit = { rawQuery ->
val normalized = rawQuery.trim()
if (normalized.isNotBlank()) {
@@ -6,14 +6,21 @@ import io.legado.app.constant.PreferKey
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.repository.SearchRepository
import io.legado.app.data.repository.SearchSessionEvent
import io.legado.app.domain.model.BookSearchScope
import io.legado.app.domain.usecase.BookSearchControl
import io.legado.app.domain.usecase.BookSearchRequest
import io.legado.app.domain.usecase.BookShelfKey
import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
import io.legado.app.domain.usecase.SearchBooksUseCase
import io.legado.app.domain.usecase.SearchRunEvent
import io.legado.app.help.config.AppConfig
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.ui.main.bookshelf.BookShelfItem
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.putPrefBoolean
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -30,6 +37,7 @@ import splitties.init.appCtx
class SearchViewModel(
private val repository: SearchRepository,
private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
private val searchBooksUseCase: SearchBooksUseCase,
) : ViewModel() {
private val _uiState = MutableStateFlow(
@@ -49,10 +57,11 @@ class SearchViewModel(
private val queryFlow = MutableStateFlow("")
private val bookshelfKeys = MutableStateFlow<Set<BookShelfKey>>(emptySet())
private val searchScope = SearchScope(AppConfig.searchScope)
private val searchSession = repository.createSearchSession { searchScope }
private val searchControl = BookSearchControl()
private val searchResultBooks = LinkedHashMap<String, SearchBook>()
private var currentSearchId = 0L
private var searchJob: Job? = null
private var currentSearchPage = 1
init {
syncScopeState()
@@ -61,7 +70,6 @@ class SearchViewModel(
observeBookshelf()
observeQueryHistory()
observeQueryBookshelfHints()
observeSearchSession()
}
fun onIntent(intent: SearchIntent) {
@@ -71,8 +79,8 @@ class SearchViewModel(
SearchIntent.SubmitSearch -> submitSearch()
SearchIntent.LoadMore -> loadMore()
SearchIntent.StopSearch -> stopSearch()
SearchIntent.PauseEngine -> searchSession.pause()
SearchIntent.ResumeEngine -> searchSession.resume()
SearchIntent.PauseEngine -> searchControl.pause()
SearchIntent.ResumeEngine -> searchControl.resume()
is SearchIntent.UseHistoryKeyword -> {
updateQuery(intent.keyword, showSuggestions = false)
submitSearch(intent.keyword)
@@ -145,8 +153,8 @@ class SearchViewModel(
}
override fun onCleared() {
stopSearch(manualStop = false)
super.onCleared()
searchSession.close()
}
private fun initialize(key: String?, scopeRaw: String?) {
@@ -223,62 +231,9 @@ class SearchViewModel(
}
}
private fun observeSearchSession() {
viewModelScope.launch {
searchSession.events.collect { event ->
when (event) {
SearchSessionEvent.Started -> {
_uiState.update { it.copy(isSearching = true) }
}
is SearchSessionEvent.Progress -> {
event.removedBookUrls.forEach { searchResultBooks.remove(it) }
event.upsertBooks.forEach { book ->
searchResultBooks[book.bookUrl] = book
}
_uiState.update {
it.copy(
results = buildSearchResultItems(
shelf = bookshelfKeys.value,
),
processedSources = event.processedSources,
totalSources = event.totalSources,
)
}
}
is SearchSessionEvent.Finished -> {
_uiState.update { state ->
val emptyAction = if (event.isEmpty && !searchScope.isAll()) {
SearchEmptyScopeAction(
scopeDisplay = searchScope.display,
wasPrecisionSearch = state.isPrecisionSearch,
)
} else {
null
}
state.copy(
isSearching = false,
hasMore = event.hasMore,
emptyScopeAction = emptyAction,
)
}
}
is SearchSessionEvent.Canceled -> {
_uiState.update { it.copy(isSearching = false) }
event.throwable?.localizedMessage
?.takeIf { it.isNotBlank() }
?.let { emitEffect(SearchEffect.ShowMessage(it)) }
}
}
}
}
}
private fun updateQuery(query: String, showSuggestions: Boolean) {
if (showSuggestions && _uiState.value.isSearching) {
searchSession.stop()
stopSearch(manualStop = false)
}
queryFlow.value = query
_uiState.update {
@@ -297,7 +252,7 @@ class SearchViewModel(
updateQuery(keyword, showSuggestions = false)
currentSearchId = System.currentTimeMillis()
currentSearchPage = 1
searchResultBooks.clear()
_uiState.update {
it.copy(
@@ -314,7 +269,7 @@ class SearchViewModel(
viewModelScope.launch {
repository.saveSearchKeyword(keyword)
}
searchSession.search(currentSearchId, keyword)
startSearch(keyword, currentSearchPage)
}
private fun loadMore() {
@@ -323,18 +278,95 @@ class SearchViewModel(
if (state.committedQuery.isBlank()) return
if (!state.hasMore) return
currentSearchPage += 1
_uiState.update {
it.copy(
isManualStop = false,
showSuggestions = false,
)
}
searchSession.search(currentSearchId, state.committedQuery)
startSearch(state.committedQuery, currentSearchPage)
}
private fun stopSearch() {
_uiState.update { it.copy(isManualStop = true) }
searchSession.stop()
private fun startSearch(keyword: String, page: Int) {
searchJob?.cancel()
searchControl.resume()
searchJob = viewModelScope.launch {
try {
searchBooksUseCase
.execute(
BookSearchRequest(
keyword = keyword,
page = page,
scope = BookSearchScope(searchScope.toString()),
precision = _uiState.value.isPrecisionSearch,
concurrency = OtherConfig.threadCount,
),
searchControl
)
.collect { event -> handleSearchEvent(event) }
} catch (exception: CancellationException) {
throw exception
} catch (exception: Throwable) {
_uiState.update { it.copy(isSearching = false) }
exception.localizedMessage
?.takeIf { it.isNotBlank() }
?.let { emitEffect(SearchEffect.ShowMessage(it)) }
}
}
}
private fun handleSearchEvent(event: SearchRunEvent) {
when (event) {
SearchRunEvent.Started -> {
_uiState.update { it.copy(isSearching = true) }
}
is SearchRunEvent.Progress -> {
event.removedBookUrls.forEach { searchResultBooks.remove(it) }
event.upsertBooks.forEach { book ->
searchResultBooks[book.bookUrl] = book
}
_uiState.update {
it.copy(
results = buildSearchResultItems(
shelf = bookshelfKeys.value,
),
processedSources = event.processedSources,
totalSources = event.totalSources,
)
}
}
is SearchRunEvent.Finished -> {
_uiState.update { state ->
val emptyAction = if (searchResultBooks.isEmpty() && event.isEmpty && !searchScope.isAll()) {
SearchEmptyScopeAction(
scopeDisplay = searchScope.display,
wasPrecisionSearch = state.isPrecisionSearch,
)
} else {
null
}
state.copy(
isSearching = false,
hasMore = event.hasMore,
emptyScopeAction = emptyAction,
)
}
}
}
}
private fun stopSearch(manualStop: Boolean = true) {
searchJob?.cancel()
searchJob = null
_uiState.update {
it.copy(
isSearching = false,
isManualStop = manualStop || it.isManualStop,
)
}
}
private fun toggleScopeGroup(groupName: String) {
@@ -3,30 +3,39 @@ package io.legado.app.web.socket
import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoWSD
import io.legado.app.R
import io.legado.app.data.entities.SearchBook
import io.legado.app.constant.PreferKey
import io.legado.app.domain.model.BookSearchScope
import io.legado.app.domain.usecase.BookSearchControl
import io.legado.app.domain.usecase.BookSearchRequest
import io.legado.app.domain.usecase.SearchBooksUseCase
import io.legado.app.domain.usecase.SearchRunEvent
import io.legado.app.help.config.AppConfig
import io.legado.app.model.webBook.SearchModel
import io.legado.app.ui.book.search.SearchScope
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.isJson
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.koin.core.context.GlobalContext
import splitties.init.appCtx
import java.io.IOException
class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
NanoWSD.WebSocket(handshakeRequest),
CoroutineScope by MainScope(),
SearchModel.CallBack {
CoroutineScope by MainScope() {
private val normalClosure = NanoWSD.WebSocketFrame.CloseCode.NormalClosure
private val searchModel = SearchModel(this, this)
private val searchBooksUseCase: SearchBooksUseCase by lazy { GlobalContext.get().get() }
private val searchControl = BookSearchControl()
private val sentBookUrls = linkedSetOf<String>()
private var searchJob: Job? = null
private val SEARCH_FINISH = "Search finish"
@@ -46,8 +55,8 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
reason: String,
initiatedByRemote: Boolean
) {
searchJob?.cancel()
cancel()
searchModel.close()
}
override fun onMessage(message: NanoWSD.WebSocketFrame) {
@@ -61,14 +70,13 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
val searchMap =
GSON.fromJsonObject<Map<String, String>>(message.textPayload).getOrNull()
if (searchMap != null) {
val key = searchMap["key"]
val key = searchMap["key"]?.trim()
if (key.isNullOrBlank()) {
send(appCtx.getString(R.string.cannot_empty))
close(normalClosure, SEARCH_FINISH, false)
return@launch
}
sentBookUrls.clear()
searchModel.search(System.currentTimeMillis(), key)
startSearch(key)
}
}
}
@@ -82,27 +90,41 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
}
override fun getSearchScope(): SearchScope = SearchScope(AppConfig.searchScope)
private fun startSearch(key: String) {
searchJob?.cancel()
sentBookUrls.clear()
searchControl.resume()
searchJob = launch(IO) {
try {
searchBooksUseCase
.execute(
BookSearchRequest(
keyword = key,
page = 1,
scope = BookSearchScope(AppConfig.searchScope),
precision = appCtx.getPrefBoolean(PreferKey.precisionSearch),
concurrency = OtherConfig.threadCount,
),
searchControl
)
.collect { event ->
when (event) {
SearchRunEvent.Started -> Unit
is SearchRunEvent.Progress -> {
val newBooks = event.upsertBooks.filter { sentBookUrls.add(it.bookUrl) }
if (newBooks.isNotEmpty()) {
send(GSON.toJson(newBooks))
}
}
override suspend fun onSearchStart() {
}
override suspend fun onSearchSuccess(
upsertBooks: List<SearchBook>,
removedBookUrls: List<String>,
resultCount: Int,
processedSources: Int,
totalSources: Int
) {
val newBooks = upsertBooks.filter { sentBookUrls.add(it.bookUrl) }
if (newBooks.isNotEmpty()) {
send(GSON.toJson(newBooks))
is SearchRunEvent.Finished -> close(normalClosure, SEARCH_FINISH, false)
}
}
} catch (exception: CancellationException) {
throw exception
} catch (exception: Throwable) {
close(normalClosure, exception.toString(), false)
}
}
}
override suspend fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) = close(normalClosure, SEARCH_FINISH, false)
override fun onSearchCancel(exception: Throwable?) = close(normalClosure, exception?.toString() ?: SEARCH_FINISH, false)
}