perf(NoteCompose): cut per-item allocations during feed scroll
Three targeted fixes for the jitter that shows up as soon as a feed contains nested NoteCompose (reposts, quotes, BechLink previews): * `calculateBackgroundColor`: only schedule the 5s "new item" fade LaunchedEffect when the item is actually new and tracks read state. Inner notes pass `routeForLastRead = null` and were parking a coroutine for 5s per item, every scroll. Also drop a per-call `Color.copy(alpha = 0f)` allocation in favor of `Color.Transparent`. * `EventObservers` + `WatchBlockAndReport`: wrap the `StateFlow` lookups in `remember(note)` so each recomposition of the same note doesn't re-resolve `note.flow().…stateFlow` (and, in `WatchBlockAndReport`, hit the synchronized LRU lookup) on every pass. Affects observeNote / Replies / Reactions / Zaps / Reposts / Ots / Edits and the per-item hidden-flow check. * `produceCachedState` / `produceCachedStateAsync`: short-circuit on cache hits. The previous `produceState` body always launched a coroutine even when the LRU already had the value; this is the common path for BechLink previews and draft notes during scroll. Now we read the cache synchronously inside `remember`, and only launch a `LaunchedEffect` for the actual miss. No behavior change.
This commit is contained in:
+14
-30
@@ -52,10 +52,8 @@ fun observeNote(
|
||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return note
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val flow = remember(note) { note.flow().metadata.stateFlow }
|
||||
return flow.collectAsStateWithLifecycle()
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@@ -187,10 +185,8 @@ fun observeNoteReplies(
|
||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return note
|
||||
.flow()
|
||||
.replies.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val flow = remember(note) { note.flow().replies.stateFlow }
|
||||
return flow.collectAsStateWithLifecycle()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
@@ -225,10 +221,8 @@ fun observeNoteReactions(
|
||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return note
|
||||
.flow()
|
||||
.reactions.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val flow = remember(note) { note.flow().reactions.stateFlow }
|
||||
return flow.collectAsStateWithLifecycle()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
@@ -265,10 +259,8 @@ fun observeNoteZaps(
|
||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return note
|
||||
.flow()
|
||||
.zaps.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val flow = remember(note) { note.flow().zaps.stateFlow }
|
||||
return flow.collectAsStateWithLifecycle()
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -280,10 +272,8 @@ fun observeNoteReposts(
|
||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return note
|
||||
.flow()
|
||||
.boosts.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val flow = remember(note) { note.flow().boosts.stateFlow }
|
||||
return flow.collectAsStateWithLifecycle()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -365,11 +355,8 @@ fun observeNoteOts(
|
||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return note
|
||||
.flow()
|
||||
.ots
|
||||
.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val flow = remember(note) { note.flow().ots.stateFlow }
|
||||
return flow.collectAsStateWithLifecycle()
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -381,11 +368,8 @@ fun observeNoteEdits(
|
||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return note
|
||||
.flow()
|
||||
.edits
|
||||
.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val flow = remember(note) { note.flow().edits.stateFlow }
|
||||
return flow.collectAsStateWithLifecycle()
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -59,7 +59,8 @@ fun WatchBlockAndReport(
|
||||
nav: INav,
|
||||
normalNote: @Composable (canPreview: Boolean) -> Unit,
|
||||
) {
|
||||
val isHidden by accountViewModel.createIsHiddenFlow(note).collectAsStateWithLifecycle()
|
||||
val isHidden by remember(note) { accountViewModel.createIsHiddenFlow(note) }
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val showAnyway =
|
||||
remember {
|
||||
|
||||
@@ -489,30 +489,31 @@ fun calculateBackgroundColor(
|
||||
): MutableState<Color> {
|
||||
val defaultBackgroundColor = MaterialTheme.colorScheme.background
|
||||
val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor
|
||||
|
||||
// Only fade in/out the "new item" highlight for items that track read state.
|
||||
// Inner notes (reposts/quotes) pass routeForLastRead = null and reuse the parent color directly,
|
||||
// so the LaunchedEffect would just park a coroutine for 5s per item during scroll.
|
||||
val isNew =
|
||||
remember(createdAt, routeForLastRead) {
|
||||
routeForLastRead != null && accountViewModel.loadAndMarkAsRead(routeForLastRead, createdAt)
|
||||
}
|
||||
|
||||
val bgColor =
|
||||
remember(createdAt) {
|
||||
mutableStateOf(
|
||||
if (routeForLastRead != null) {
|
||||
val isNew = accountViewModel.loadAndMarkAsRead(routeForLastRead, createdAt)
|
||||
|
||||
if (isNew) {
|
||||
if (parentBackgroundColor != null) {
|
||||
newItemColor.compositeOver(parentBackgroundColor.value)
|
||||
} else {
|
||||
newItemColor.compositeOver(defaultBackgroundColor)
|
||||
}
|
||||
} else {
|
||||
parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f)
|
||||
}
|
||||
if (isNew) {
|
||||
newItemColor.compositeOver(parentBackgroundColor?.value ?: defaultBackgroundColor)
|
||||
} else {
|
||||
parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f)
|
||||
parentBackgroundColor?.value ?: Color.Transparent
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(createdAt) {
|
||||
delay(5000)
|
||||
bgColor.value = parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f)
|
||||
if (isNew) {
|
||||
LaunchedEffect(createdAt) {
|
||||
delay(5000)
|
||||
bgColor.value = parentBackgroundColor?.value ?: Color.Transparent
|
||||
}
|
||||
}
|
||||
|
||||
return bgColor
|
||||
|
||||
+25
-13
@@ -22,35 +22,47 @@ package com.vitorpamplona.amethyst.commons.compose
|
||||
|
||||
import androidx.collection.LruCache
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
// On a cache hit, short-circuit with a remembered State<V?> and skip the produceState coroutine.
|
||||
// Only on a miss do we launch the suspending update.
|
||||
@Composable
|
||||
fun <K : Any, V : Any> produceCachedStateAsync(
|
||||
cache: AsyncCachedState<K, V>,
|
||||
key: K,
|
||||
): State<V?> =
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
produceState(initialValue = cache.cached(key), key1 = key) {
|
||||
val newValue = cache.update(key)
|
||||
if (newValue != value) {
|
||||
value = newValue
|
||||
): State<V?> {
|
||||
val state = remember(key) { mutableStateOf(cache.cached(key)) }
|
||||
if (state.value == null) {
|
||||
LaunchedEffect(key) {
|
||||
val newValue = cache.update(key)
|
||||
if (state.value != newValue) {
|
||||
state.value = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <K : Any, V : Any> produceCachedStateAsync(
|
||||
cache: AsyncCachedState<K, V>,
|
||||
key: String,
|
||||
updateValue: K,
|
||||
): State<V?> =
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
produceState(initialValue = cache.cached(updateValue), key1 = key) {
|
||||
val newValue = cache.update(updateValue)
|
||||
if (newValue != value) {
|
||||
value = newValue
|
||||
): State<V?> {
|
||||
val state = remember(key) { mutableStateOf(cache.cached(updateValue)) }
|
||||
if (state.value == null) {
|
||||
LaunchedEffect(key) {
|
||||
val newValue = cache.update(updateValue)
|
||||
if (state.value != newValue) {
|
||||
state.value = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
interface AsyncCachedState<K : Any, V : Any> {
|
||||
fun cached(k: K): V?
|
||||
|
||||
+26
-13
@@ -22,35 +22,48 @@ package com.vitorpamplona.amethyst.commons.compose
|
||||
|
||||
import androidx.collection.LruCache
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
// On a cache hit (the common case during scroll for things like Bech32 link previews),
|
||||
// short-circuit with a remembered State<V?> and skip the produceState coroutine entirely.
|
||||
// Only on a miss do we launch the suspending update.
|
||||
@Composable
|
||||
fun <K : Any, V : Any> produceCachedState(
|
||||
cache: CachedState<K, V>,
|
||||
key: K,
|
||||
): State<V?> =
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
produceState(initialValue = cache.cached(key), key1 = key) {
|
||||
val newValue = cache.update(key)
|
||||
if (value != newValue) {
|
||||
value = newValue
|
||||
): State<V?> {
|
||||
val state = remember(key) { mutableStateOf(cache.cached(key)) }
|
||||
if (state.value == null) {
|
||||
LaunchedEffect(key) {
|
||||
val newValue = cache.update(key)
|
||||
if (state.value != newValue) {
|
||||
state.value = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <K : Any, V : Any> produceCachedState(
|
||||
cache: CachedState<K, V>,
|
||||
key: String,
|
||||
updateValue: K,
|
||||
): State<V?> =
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
produceState(initialValue = cache.cached(updateValue), key1 = key) {
|
||||
val newValue = cache.update(updateValue)
|
||||
if (value != newValue) {
|
||||
value = newValue
|
||||
): State<V?> {
|
||||
val state = remember(key) { mutableStateOf(cache.cached(updateValue)) }
|
||||
if (state.value == null) {
|
||||
LaunchedEffect(key) {
|
||||
val newValue = cache.update(updateValue)
|
||||
if (state.value != newValue) {
|
||||
state.value = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
interface CachedState<K : Any, V : Any> {
|
||||
fun cached(k: K): V?
|
||||
|
||||
Reference in New Issue
Block a user