vc=81 — perf-audit app-side batch (search debounce + feed-merge memoize)
Search: the reactive cache-preview filter no longer runs on the main thread on every keystroke. It walked the whole cached-results pool (thousands of items on a heavy user) inline; now each keystroke debounces ~150ms and the scan runs on Dispatchers.Default. A submit cancels the pending preview so a late scan can't clobber live results. Feed: mergeFromCache memoizes the relative-upload-date parse by string, so the recency regex runs once per distinct "N days ago" value instead of once per item (~3000 per merge on a 200-sub feed) — across hydration, every refresh, and each background-enrichment emit. No behavior change.
This commit is contained in:
parent
0e7f0b4781
commit
1730ed3dc8
3 changed files with 79 additions and 22 deletions
|
|
@ -9,6 +9,19 @@ const val STRAW_SDK_TARGET = 35
|
|||
|
||||
// Sulkta fork — Straw
|
||||
//
|
||||
// vc=81 / 0.1.0-CO — perf-audit app-side batch (no behavior change):
|
||||
// * Search: the reactive cache-preview filter no longer runs on the
|
||||
// main thread on every keystroke. It walked the entire cached-
|
||||
// results pool (thousands of items on a heavy user) inline; now each
|
||||
// keystroke debounces ~150ms and the scan runs on Dispatchers.Default.
|
||||
// A submit cancels the pending preview so it can't clobber live
|
||||
// results.
|
||||
// * Subscription feed: the merge memoizes the relative-upload-date
|
||||
// parse by string, so the recency regex runs once per distinct
|
||||
// "N days ago" value instead of once per item (~3000 per merge on a
|
||||
// 200-sub feed) — across hydration, every refresh, and each
|
||||
// background-enrichment emit.
|
||||
//
|
||||
// vc=80 / 0.1.0-CN — strawcore extraction perf (Rust batch):
|
||||
// * The extractor borrows the streamingData subtree out of the Android
|
||||
// + iOS player responses instead of deep-cloning the largest part of
|
||||
|
|
@ -121,6 +134,6 @@ const val STRAW_SDK_TARGET = 35
|
|||
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
|
||||
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
|
||||
// NewPipeExtractor in the runtime path.
|
||||
const val STRAW_VERSION_CODE = 80
|
||||
const val STRAW_VERSION_NAME = "0.1.0-CN"
|
||||
const val STRAW_VERSION_CODE = 81
|
||||
const val STRAW_VERSION_NAME = "0.1.0-CO"
|
||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||
|
|
|
|||
|
|
@ -329,9 +329,18 @@ class SubscriptionFeedViewModel : ViewModel() {
|
|||
// Pure read of the enrichment store; the enrichment write
|
||||
// path triggers a fresh _ui emit.
|
||||
val enrichments = FeedEnrichment.get().entries.value
|
||||
// Memoize recencyScore by its only input — the relative-date
|
||||
// string — so the regex parse runs once per *distinct* string
|
||||
// instead of once per item. audit 2.3: a 200-sub feed merges
|
||||
// ~3000 items but carries only a few dozen distinct "N days ago"
|
||||
// strings, and this merge runs on hydration, every refresh, and
|
||||
// each enrichment emit. recencyScore() reads only
|
||||
// uploadDateRelative, and withEnrichment never touches it, so the
|
||||
// memo is exact.
|
||||
val recencyMemo = HashMap<String, Long>()
|
||||
return channels.flatMap { ch -> channelCache[ch.url]?.items.orEmpty() }
|
||||
.map { it.withEnrichment(enrichments) }
|
||||
.map { it to it.recencyScore() }
|
||||
.map { item -> item to recencyMemo.getOrPut(item.uploadDateRelative) { item.recencyScore() } }
|
||||
.sortedWith(
|
||||
compareByDescending<Pair<StreamItem, Long>> { it.second }
|
||||
.thenByDescending { it.first.viewCount },
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import com.sulkta.straw.data.Settings
|
|||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
|
@ -119,31 +120,52 @@ class SearchViewModel : ViewModel() {
|
|||
// swaps in) so a late page can't append to stale results.
|
||||
private var loadMoreJob: Job? = null
|
||||
|
||||
// Debounced reactive-filter pass. Each keystroke cancels the prior
|
||||
// one; the survivor waits FILTER_DEBOUNCE_MS then scans the pool off
|
||||
// the main thread. A submit cancels it so a late preview can't
|
||||
// clobber a fresh live result set.
|
||||
private var filterJob: Job? = null
|
||||
|
||||
fun onQueryChange(q: String) {
|
||||
// Clear any prior error state when the user resumes typing —
|
||||
// a failed submit's banner used to persist into the next
|
||||
// reactive preview, looking like the new query had failed.
|
||||
// audit Q3.
|
||||
_ui.update { it.copy(query = q, error = null) }
|
||||
if (Settings.get().cacheEnabled.value && q.trim().length >= 2) {
|
||||
val matches = reactiveFilter(q.trim())
|
||||
if (matches.isNotEmpty()) {
|
||||
// Cache preview replaces the live set → kill its pagination
|
||||
// and drop the token (cache views don't paginate).
|
||||
loadMoreJob?.cancel()
|
||||
_ui.update {
|
||||
it.copy(
|
||||
results = matches,
|
||||
fromCache = true,
|
||||
loading = false,
|
||||
continuation = null,
|
||||
loadingMore = false,
|
||||
)
|
||||
}
|
||||
} else if (_ui.value.fromCache) {
|
||||
loadMoreJob?.cancel()
|
||||
_ui.update {
|
||||
it.copy(results = emptyList(), fromCache = false, continuation = null)
|
||||
val trimmed = q.trim()
|
||||
// Restart the debounce on every keystroke. audit 3.2 —
|
||||
// reactiveFilter walks the whole cached-results pool (thousands
|
||||
// of items on a heavy user) and used to run synchronously on the
|
||||
// main thread per keystroke. Now: debounce, then scan off-Main.
|
||||
filterJob?.cancel()
|
||||
if (Settings.get().cacheEnabled.value && trimmed.length >= 2) {
|
||||
filterJob = viewModelScope.launch {
|
||||
delay(FILTER_DEBOUNCE_MS)
|
||||
val matches = withContext(Dispatchers.Default) { reactiveFilter(trimmed) }
|
||||
// A submit (or a newer keystroke) may have moved on while
|
||||
// we debounced/scanned — only touch the result set if this
|
||||
// is still the query the user is looking at.
|
||||
ensureActive()
|
||||
if (_ui.value.query.trim() != trimmed) return@launch
|
||||
if (matches.isNotEmpty()) {
|
||||
// Cache preview replaces the live set → kill its
|
||||
// pagination and drop the token (cache views don't
|
||||
// paginate).
|
||||
loadMoreJob?.cancel()
|
||||
_ui.update {
|
||||
it.copy(
|
||||
results = matches,
|
||||
fromCache = true,
|
||||
loading = false,
|
||||
continuation = null,
|
||||
loadingMore = false,
|
||||
)
|
||||
}
|
||||
} else if (_ui.value.fromCache) {
|
||||
loadMoreJob?.cancel()
|
||||
_ui.update {
|
||||
it.copy(results = emptyList(), fromCache = false, continuation = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (q.isBlank()) {
|
||||
|
|
@ -178,6 +200,10 @@ class SearchViewModel : ViewModel() {
|
|||
// A fresh submit invalidates any in-flight pagination of the
|
||||
// previous result set; drop its token until page 1 lands.
|
||||
loadMoreJob?.cancel()
|
||||
// Kill any debounced reactive-filter preview too — its query is
|
||||
// (or is about to be) this same string, so without this it would
|
||||
// fire ~150ms later and clobber the live submit's result set.
|
||||
filterJob?.cancel()
|
||||
|
||||
if (cached != null && cached.isNotEmpty()) {
|
||||
_ui.update {
|
||||
|
|
@ -348,4 +374,13 @@ class SearchViewModel : ViewModel() {
|
|||
.take(60)
|
||||
.toList()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Debounce window before scanning the cached-results pool on a
|
||||
* keystroke. Long enough to skip the intermediate states of a
|
||||
* fast typist, short enough to feel instant. audit 3.2.
|
||||
*/
|
||||
const val FILTER_DEBOUNCE_MS = 150L
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue