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)
|
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||||
|
|
||||||
// Subscribe in the LocalCache for changes that arrive in the device
|
// Subscribe in the LocalCache for changes that arrive in the device
|
||||||
return note
|
val flow = remember(note) { note.flow().metadata.stateFlow }
|
||||||
.flow()
|
return flow.collectAsStateWithLifecycle()
|
||||||
.metadata.stateFlow
|
|
||||||
.collectAsStateWithLifecycle()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
@@ -187,10 +185,8 @@ fun observeNoteReplies(
|
|||||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||||
|
|
||||||
// Subscribe in the LocalCache for changes that arrive in the device
|
// Subscribe in the LocalCache for changes that arrive in the device
|
||||||
return note
|
val flow = remember(note) { note.flow().replies.stateFlow }
|
||||||
.flow()
|
return flow.collectAsStateWithLifecycle()
|
||||||
.replies.stateFlow
|
|
||||||
.collectAsStateWithLifecycle()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||||
@@ -225,10 +221,8 @@ fun observeNoteReactions(
|
|||||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||||
|
|
||||||
// Subscribe in the LocalCache for changes that arrive in the device
|
// Subscribe in the LocalCache for changes that arrive in the device
|
||||||
return note
|
val flow = remember(note) { note.flow().reactions.stateFlow }
|
||||||
.flow()
|
return flow.collectAsStateWithLifecycle()
|
||||||
.reactions.stateFlow
|
|
||||||
.collectAsStateWithLifecycle()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||||
@@ -265,10 +259,8 @@ fun observeNoteZaps(
|
|||||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||||
|
|
||||||
// Subscribe in the LocalCache for changes that arrive in the device
|
// Subscribe in the LocalCache for changes that arrive in the device
|
||||||
return note
|
val flow = remember(note) { note.flow().zaps.stateFlow }
|
||||||
.flow()
|
return flow.collectAsStateWithLifecycle()
|
||||||
.zaps.stateFlow
|
|
||||||
.collectAsStateWithLifecycle()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -280,10 +272,8 @@ fun observeNoteReposts(
|
|||||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||||
|
|
||||||
// Subscribe in the LocalCache for changes that arrive in the device
|
// Subscribe in the LocalCache for changes that arrive in the device
|
||||||
return note
|
val flow = remember(note) { note.flow().boosts.stateFlow }
|
||||||
.flow()
|
return flow.collectAsStateWithLifecycle()
|
||||||
.boosts.stateFlow
|
|
||||||
.collectAsStateWithLifecycle()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@@ -365,11 +355,8 @@ fun observeNoteOts(
|
|||||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||||
|
|
||||||
// Subscribe in the LocalCache for changes that arrive in the device
|
// Subscribe in the LocalCache for changes that arrive in the device
|
||||||
return note
|
val flow = remember(note) { note.flow().ots.stateFlow }
|
||||||
.flow()
|
return flow.collectAsStateWithLifecycle()
|
||||||
.ots
|
|
||||||
.stateFlow
|
|
||||||
.collectAsStateWithLifecycle()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -381,11 +368,8 @@ fun observeNoteEdits(
|
|||||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||||
|
|
||||||
// Subscribe in the LocalCache for changes that arrive in the device
|
// Subscribe in the LocalCache for changes that arrive in the device
|
||||||
return note
|
val flow = remember(note) { note.flow().edits.stateFlow }
|
||||||
.flow()
|
return flow.collectAsStateWithLifecycle()
|
||||||
.edits
|
|
||||||
.stateFlow
|
|
||||||
.collectAsStateWithLifecycle()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ fun WatchBlockAndReport(
|
|||||||
nav: INav,
|
nav: INav,
|
||||||
normalNote: @Composable (canPreview: Boolean) -> Unit,
|
normalNote: @Composable (canPreview: Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val isHidden by accountViewModel.createIsHiddenFlow(note).collectAsStateWithLifecycle()
|
val isHidden by remember(note) { accountViewModel.createIsHiddenFlow(note) }
|
||||||
|
.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val showAnyway =
|
val showAnyway =
|
||||||
remember {
|
remember {
|
||||||
|
|||||||
@@ -489,30 +489,31 @@ fun calculateBackgroundColor(
|
|||||||
): MutableState<Color> {
|
): MutableState<Color> {
|
||||||
val defaultBackgroundColor = MaterialTheme.colorScheme.background
|
val defaultBackgroundColor = MaterialTheme.colorScheme.background
|
||||||
val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor
|
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 =
|
val bgColor =
|
||||||
remember(createdAt) {
|
remember(createdAt) {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
if (routeForLastRead != null) {
|
if (isNew) {
|
||||||
val isNew = accountViewModel.loadAndMarkAsRead(routeForLastRead, createdAt)
|
newItemColor.compositeOver(parentBackgroundColor?.value ?: defaultBackgroundColor)
|
||||||
|
|
||||||
if (isNew) {
|
|
||||||
if (parentBackgroundColor != null) {
|
|
||||||
newItemColor.compositeOver(parentBackgroundColor.value)
|
|
||||||
} else {
|
|
||||||
newItemColor.compositeOver(defaultBackgroundColor)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f)
|
parentBackgroundColor?.value ?: Color.Transparent
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(createdAt) {
|
if (isNew) {
|
||||||
delay(5000)
|
LaunchedEffect(createdAt) {
|
||||||
bgColor.value = parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f)
|
delay(5000)
|
||||||
|
bgColor.value = parentBackgroundColor?.value ?: Color.Transparent
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return bgColor
|
return bgColor
|
||||||
|
|||||||
+25
-13
@@ -22,35 +22,47 @@ package com.vitorpamplona.amethyst.commons.compose
|
|||||||
|
|
||||||
import androidx.collection.LruCache
|
import androidx.collection.LruCache
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.State
|
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
|
@Composable
|
||||||
fun <K : Any, V : Any> produceCachedStateAsync(
|
fun <K : Any, V : Any> produceCachedStateAsync(
|
||||||
cache: AsyncCachedState<K, V>,
|
cache: AsyncCachedState<K, V>,
|
||||||
key: K,
|
key: K,
|
||||||
): State<V?> =
|
): State<V?> {
|
||||||
@Suppress("ProduceStateDoesNotAssignValue")
|
val state = remember(key) { mutableStateOf(cache.cached(key)) }
|
||||||
produceState(initialValue = cache.cached(key), key1 = key) {
|
if (state.value == null) {
|
||||||
val newValue = cache.update(key)
|
LaunchedEffect(key) {
|
||||||
if (newValue != value) {
|
val newValue = cache.update(key)
|
||||||
value = newValue
|
if (state.value != newValue) {
|
||||||
|
state.value = newValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun <K : Any, V : Any> produceCachedStateAsync(
|
fun <K : Any, V : Any> produceCachedStateAsync(
|
||||||
cache: AsyncCachedState<K, V>,
|
cache: AsyncCachedState<K, V>,
|
||||||
key: String,
|
key: String,
|
||||||
updateValue: K,
|
updateValue: K,
|
||||||
): State<V?> =
|
): State<V?> {
|
||||||
@Suppress("ProduceStateDoesNotAssignValue")
|
val state = remember(key) { mutableStateOf(cache.cached(updateValue)) }
|
||||||
produceState(initialValue = cache.cached(updateValue), key1 = key) {
|
if (state.value == null) {
|
||||||
val newValue = cache.update(updateValue)
|
LaunchedEffect(key) {
|
||||||
if (newValue != value) {
|
val newValue = cache.update(updateValue)
|
||||||
value = newValue
|
if (state.value != newValue) {
|
||||||
|
state.value = newValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
interface AsyncCachedState<K : Any, V : Any> {
|
interface AsyncCachedState<K : Any, V : Any> {
|
||||||
fun cached(k: K): V?
|
fun cached(k: K): V?
|
||||||
|
|||||||
+26
-13
@@ -22,35 +22,48 @@ package com.vitorpamplona.amethyst.commons.compose
|
|||||||
|
|
||||||
import androidx.collection.LruCache
|
import androidx.collection.LruCache
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.State
|
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
|
@Composable
|
||||||
fun <K : Any, V : Any> produceCachedState(
|
fun <K : Any, V : Any> produceCachedState(
|
||||||
cache: CachedState<K, V>,
|
cache: CachedState<K, V>,
|
||||||
key: K,
|
key: K,
|
||||||
): State<V?> =
|
): State<V?> {
|
||||||
@Suppress("ProduceStateDoesNotAssignValue")
|
val state = remember(key) { mutableStateOf(cache.cached(key)) }
|
||||||
produceState(initialValue = cache.cached(key), key1 = key) {
|
if (state.value == null) {
|
||||||
val newValue = cache.update(key)
|
LaunchedEffect(key) {
|
||||||
if (value != newValue) {
|
val newValue = cache.update(key)
|
||||||
value = newValue
|
if (state.value != newValue) {
|
||||||
|
state.value = newValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun <K : Any, V : Any> produceCachedState(
|
fun <K : Any, V : Any> produceCachedState(
|
||||||
cache: CachedState<K, V>,
|
cache: CachedState<K, V>,
|
||||||
key: String,
|
key: String,
|
||||||
updateValue: K,
|
updateValue: K,
|
||||||
): State<V?> =
|
): State<V?> {
|
||||||
@Suppress("ProduceStateDoesNotAssignValue")
|
val state = remember(key) { mutableStateOf(cache.cached(updateValue)) }
|
||||||
produceState(initialValue = cache.cached(updateValue), key1 = key) {
|
if (state.value == null) {
|
||||||
val newValue = cache.update(updateValue)
|
LaunchedEffect(key) {
|
||||||
if (value != newValue) {
|
val newValue = cache.update(updateValue)
|
||||||
value = newValue
|
if (state.value != newValue) {
|
||||||
|
state.value = newValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
interface CachedState<K : Any, V : Any> {
|
interface CachedState<K : Any, V : Any> {
|
||||||
fun cached(k: K): V?
|
fun cached(k: K): V?
|
||||||
|
|||||||
Reference in New Issue
Block a user