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
d9e5addb0b
commit
f51b4fbb77
3 changed files with 79 additions and 22 deletions
|
|
@ -9,6 +9,19 @@ const val STRAW_SDK_TARGET = 35
|
||||||
|
|
||||||
// Sulkta fork — Straw
|
// 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):
|
// vc=80 / 0.1.0-CN — strawcore extraction perf (Rust batch):
|
||||||
// * The extractor borrows the streamingData subtree out of the Android
|
// * The extractor borrows the streamingData subtree out of the Android
|
||||||
// + iOS player responses instead of deep-cloning the largest part of
|
// + 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
|
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
|
||||||
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
|
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
|
||||||
// NewPipeExtractor in the runtime path.
|
// NewPipeExtractor in the runtime path.
|
||||||
const val STRAW_VERSION_CODE = 80
|
const val STRAW_VERSION_CODE = 81
|
||||||
const val STRAW_VERSION_NAME = "0.1.0-CN"
|
const val STRAW_VERSION_NAME = "0.1.0-CO"
|
||||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||||
|
|
|
||||||
|
|
@ -329,9 +329,18 @@ class SubscriptionFeedViewModel : ViewModel() {
|
||||||
// Pure read of the enrichment store; the enrichment write
|
// Pure read of the enrichment store; the enrichment write
|
||||||
// path triggers a fresh _ui emit.
|
// path triggers a fresh _ui emit.
|
||||||
val enrichments = FeedEnrichment.get().entries.value
|
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() }
|
return channels.flatMap { ch -> channelCache[ch.url]?.items.orEmpty() }
|
||||||
.map { it.withEnrichment(enrichments) }
|
.map { it.withEnrichment(enrichments) }
|
||||||
.map { it to it.recencyScore() }
|
.map { item -> item to recencyMemo.getOrPut(item.uploadDateRelative) { item.recencyScore() } }
|
||||||
.sortedWith(
|
.sortedWith(
|
||||||
compareByDescending<Pair<StreamItem, Long>> { it.second }
|
compareByDescending<Pair<StreamItem, Long>> { it.second }
|
||||||
.thenByDescending { it.first.viewCount },
|
.thenByDescending { it.first.viewCount },
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import com.sulkta.straw.data.Settings
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -119,31 +120,52 @@ class SearchViewModel : ViewModel() {
|
||||||
// swaps in) so a late page can't append to stale results.
|
// swaps in) so a late page can't append to stale results.
|
||||||
private var loadMoreJob: Job? = null
|
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) {
|
fun onQueryChange(q: String) {
|
||||||
// Clear any prior error state when the user resumes typing —
|
// Clear any prior error state when the user resumes typing —
|
||||||
// a failed submit's banner used to persist into the next
|
// a failed submit's banner used to persist into the next
|
||||||
// reactive preview, looking like the new query had failed.
|
// reactive preview, looking like the new query had failed.
|
||||||
// audit Q3.
|
// audit Q3.
|
||||||
_ui.update { it.copy(query = q, error = null) }
|
_ui.update { it.copy(query = q, error = null) }
|
||||||
if (Settings.get().cacheEnabled.value && q.trim().length >= 2) {
|
val trimmed = q.trim()
|
||||||
val matches = reactiveFilter(q.trim())
|
// Restart the debounce on every keystroke. audit 3.2 —
|
||||||
if (matches.isNotEmpty()) {
|
// reactiveFilter walks the whole cached-results pool (thousands
|
||||||
// Cache preview replaces the live set → kill its pagination
|
// of items on a heavy user) and used to run synchronously on the
|
||||||
// and drop the token (cache views don't paginate).
|
// main thread per keystroke. Now: debounce, then scan off-Main.
|
||||||
loadMoreJob?.cancel()
|
filterJob?.cancel()
|
||||||
_ui.update {
|
if (Settings.get().cacheEnabled.value && trimmed.length >= 2) {
|
||||||
it.copy(
|
filterJob = viewModelScope.launch {
|
||||||
results = matches,
|
delay(FILTER_DEBOUNCE_MS)
|
||||||
fromCache = true,
|
val matches = withContext(Dispatchers.Default) { reactiveFilter(trimmed) }
|
||||||
loading = false,
|
// A submit (or a newer keystroke) may have moved on while
|
||||||
continuation = null,
|
// we debounced/scanned — only touch the result set if this
|
||||||
loadingMore = false,
|
// is still the query the user is looking at.
|
||||||
)
|
ensureActive()
|
||||||
}
|
if (_ui.value.query.trim() != trimmed) return@launch
|
||||||
} else if (_ui.value.fromCache) {
|
if (matches.isNotEmpty()) {
|
||||||
loadMoreJob?.cancel()
|
// Cache preview replaces the live set → kill its
|
||||||
_ui.update {
|
// pagination and drop the token (cache views don't
|
||||||
it.copy(results = emptyList(), fromCache = false, continuation = null)
|
// 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()) {
|
} else if (q.isBlank()) {
|
||||||
|
|
@ -178,6 +200,10 @@ class SearchViewModel : ViewModel() {
|
||||||
// A fresh submit invalidates any in-flight pagination of the
|
// A fresh submit invalidates any in-flight pagination of the
|
||||||
// previous result set; drop its token until page 1 lands.
|
// previous result set; drop its token until page 1 lands.
|
||||||
loadMoreJob?.cancel()
|
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()) {
|
if (cached != null && cached.isNotEmpty()) {
|
||||||
_ui.update {
|
_ui.update {
|
||||||
|
|
@ -348,4 +374,13 @@ class SearchViewModel : ViewModel() {
|
||||||
.take(60)
|
.take(60)
|
||||||
.toList()
|
.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