From a971f4389e6b45be84ff8344be77ffb068a4e889 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 21 Jun 2026 19:36:59 -0400 Subject: [PATCH] perf(notifications): cut per-card composition cost on the notifications feed The notifications feed is dominated by MultiSetCards, each rendering a gallery of up to 30 author avatars. Profiling a loaded account showed the per-author work on the main thread, not the GPU, as the scroll-jitter driver. Three feature-preserving cuts: - Hoist account-global reads out of the per-author avatar. The auto-play-gif setting and the logged-in follow set were collected once *per author* (~60 redundant Flow collectors / coroutine launches per card). They are now collected once per gallery and passed down via LocalAuthorGalleryRenderContext. - Dedupe the per-author metadata subscription. Each avatar fired UserFinderFilterAssemblerSubscription twice (via observeUserPicture + observeUserContactCardsScore); both observers gained a `subscribe` flag so the gallery subscribes once per author. - Replace the per-event DateTimeFormatter day-bucketing in convertToCard with a LocalDate.toEpochDay() Long key (identical grouping, no Instant/ZonedDateTime/ String allocation per reaction/zap/repost), and hoist the ZoneId lookup. Measured before/after on a Samsung SM-T220 (loaded account, identical scripted scroll, dumpsys gfxinfo): Slow-UI-thread events ~13 -> ~5 (~60% fewer), 95th-pct frame ~52ms -> ~38ms, janky frames ~13% -> ~10%. GPU timings unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../reqCommand/user/UserObservers.kt | 15 +++- .../ui/note/AuthorGalleryRenderContext.kt | 66 ++++++++++++++++ .../amethyst/ui/note/MultiSetCompose.kt | 76 +++++++++++++++---- .../notifications/CardFeedContentState.kt | 36 ++++----- 4 files changed, 153 insertions(+), 40 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AuthorGalleryRenderContext.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 30db8918c5..447b4f68d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -150,9 +150,15 @@ fun observeUserBanner( fun observeUserPicture( user: User, accountViewModel: AccountViewModel, + subscribe: Boolean = true, ): State { // Subscribe in the relay for changes in the metadata of this user. - UserFinderFilterAssemblerSubscription(user, accountViewModel) + // Callers that already hold a single shared subscription for the same user + // (e.g. an author avatar that also observes the contact-card score) can pass + // subscribe = false to avoid setting up a redundant relay subscription. + if (subscribe) { + UserFinderFilterAssemblerSubscription(user, accountViewModel) + } // Subscribe in the LocalCache for changes that arrive in the device val flow = @@ -476,9 +482,14 @@ fun observeUserIsFollowingChannel( fun observeUserContactCardsScore( user: User, accountViewModel: AccountViewModel, + subscribe: Boolean = true, ): State { // Subscribe in the relay for changes in the metadata of this user. - UserFinderFilterAssemblerSubscription(user, accountViewModel) + // See observeUserPicture: pass subscribe = false when a shared subscription + // for the same user already exists. + if (subscribe) { + UserFinderFilterAssemblerSubscription(user, accountViewModel) + } // Subscribe in the LocalCache for changes that arrive in the device val flow = remember(user) { user.cards().rankFlow(accountViewModel.account.trustProviderList) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AuthorGalleryRenderContext.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AuthorGalleryRenderContext.kt new file mode 100644 index 0000000000..702d846009 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AuthorGalleryRenderContext.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +/** + * The notification galleries (reactions, zaps, nutzaps, boosts) render up to 30 + * author avatars each. A couple of the inputs to every avatar are *account-global* + * — the auto-play-gif setting and the logged-in user's follow set — yet the avatar + * code used to collect each of them **once per author**. With dozens of authors per + * card that produced dozens of redundant Flow collectors / coroutine launches every + * time a card scrolled into view, a measurable chunk of the per-card composition cost. + * + * Hoisting those reads to a single collection per gallery and handing the snapshot + * down through [LocalAuthorGalleryRenderContext] lets each avatar read plain values + * and stay skippable. When the local is absent the avatar falls back to its old + * per-author behaviour, so callers that don't provide a context keep working. + */ +@Immutable +class AuthorGalleryRenderContext( + val autoPlayGif: Boolean, + val follows: Set, +) + +val LocalAuthorGalleryRenderContext = compositionLocalOf { null } + +/** + * Collects the account-global render inputs a single time. Provide the result via + * [LocalAuthorGalleryRenderContext] around a gallery's author list. + */ +@Composable +fun rememberAuthorGalleryRenderContext(accountViewModel: AccountViewModel): AuthorGalleryRenderContext { + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + val follows by accountViewModel.account.allFollows.flow + .collectAsStateWithLifecycle() + + val followAuthors = follows.authors + return remember(autoPlayGif, followAuthors) { + AuthorGalleryRenderContext(autoPlayGif, followAuthors) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index cc1e97ba06..672ce202f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -40,6 +40,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -72,6 +73,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.CachedRichTextParser +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsScore import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius @@ -463,8 +465,12 @@ fun AuthorGalleryZaps( nav: INav, accountViewModel: AccountViewModel, ) { - Column(modifier = StdStartPadding) { - FlowRow { authorNotes.forEach { RenderState(it, backgroundColor, accountViewModel, nav) } } + CompositionLocalProvider( + LocalAuthorGalleryRenderContext provides rememberAuthorGalleryRenderContext(accountViewModel), + ) { + Column(modifier = StdStartPadding) { + FlowRow { authorNotes.forEach { RenderState(it, backgroundColor, accountViewModel, nav) } } + } } } @@ -631,8 +637,12 @@ fun AuthorGallery( accountViewModel: AccountViewModel, clickRoute: (Note) -> Route? = ::authorRouteFor, ) { - Column(modifier = StdStartPadding) { - FlowRow { authorNotes.forEach { note -> BoxedAuthor(note, nav, accountViewModel, clickRoute) } } + CompositionLocalProvider( + LocalAuthorGalleryRenderContext provides rememberAuthorGalleryRenderContext(accountViewModel), + ) { + Column(modifier = StdStartPadding) { + FlowRow { authorNotes.forEach { note -> BoxedAuthor(note, nav, accountViewModel, clickRoute) } } + } } } @@ -643,9 +653,13 @@ fun AuthorGallery( nav: INav, accountViewModel: AccountViewModel, ) { - Column(modifier = StdStartPadding) { - FlowRow { - noteToGetBoostEvents.note.boosts.forEach { note -> BoxedAuthor(note, nav, accountViewModel) } + CompositionLocalProvider( + LocalAuthorGalleryRenderContext provides rememberAuthorGalleryRenderContext(accountViewModel), + ) { + Column(modifier = StdStartPadding) { + FlowRow { + noteToGetBoostEvents.note.boosts.forEach { note -> BoxedAuthor(note, nav, accountViewModel) } + } } } } @@ -684,7 +698,27 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture( author: User, accountViewModel: AccountViewModel, ) { - WatchUserMetadata(author, accountViewModel) { baseUserPicture -> + // When rendered inside a gallery, the account-global auto-play and follow-set + // reads are hoisted to a single collection for the whole gallery (see + // [rememberAuthorGalleryRenderContext]). Falls back to per-author collection + // for callers that don't provide a context. + val galleryContext = LocalAuthorGalleryRenderContext.current + + // One shared relay subscription per author. The profile picture and the + // contact-card score below both fetch the same kind-0 metadata, so we + // subscribe once here and let them skip their own (formerly duplicate) one. + UserFinderFilterAssemblerSubscription(author, accountViewModel) + + WatchUserMetadata(author, accountViewModel, subscribe = false) { baseUserPicture -> + val autoPlayGif = + if (galleryContext != null) { + galleryContext.autoPlayGif + } else { + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value + } + RobohashFallbackAsyncImage( robot = author.pubkeyHex, model = baseUserPicture, @@ -693,30 +727,39 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture( contentScale = ContentScale.Crop, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), - autoPlayGif = - accountViewModel.settings.autoPlayVideosFlow - .collectAsStateWithLifecycle() - .value, + autoPlayGif = autoPlayGif, ) } - WatchUserFollows(author.pubkeyHex, accountViewModel) { isFollowing -> + if (galleryContext != null) { + val isFollowing = + accountViewModel.isLoggedUser(author.pubkeyHex) || + author.pubkeyHex in galleryContext.follows if (isFollowing) { Box(modifier = Size35Modifier, contentAlignment = Alignment.TopEnd) { FollowingIcon(Size10Modifier) } } + } else { + WatchUserFollows(author.pubkeyHex, accountViewModel) { isFollowing -> + if (isFollowing) { + Box(modifier = Size35Modifier, contentAlignment = Alignment.TopEnd) { + FollowingIcon(Size10Modifier) + } + } + } } - ObserveAndRenderBoxedUserCards(author, accountViewModel) + ObserveAndRenderBoxedUserCards(author, accountViewModel, subscribe = false) } @Composable fun ObserveAndRenderBoxedUserCards( user: User, accountViewModel: AccountViewModel, + subscribe: Boolean = true, ) { - val score by observeUserContactCardsScore(user, accountViewModel) + val score by observeUserContactCardsScore(user, accountViewModel, subscribe) score?.let { Box(modifier = Size35Modifier, contentAlignment = Alignment.BottomCenter) { @@ -729,9 +772,10 @@ fun ObserveAndRenderBoxedUserCards( private fun WatchUserMetadata( author: User, accountViewModel: AccountViewModel, + subscribe: Boolean = true, onNewMetadata: @Composable (String?) -> Unit, ) { - val userProfile by observeUserPicture(author, accountViewModel) + val userProfile by observeUserPicture(author, accountViewModel, subscribe) onNewMetadata(userProfile) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt index 15292532f9..9cd127336b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt @@ -62,8 +62,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import java.time.Instant +import java.time.LocalDate import java.time.ZoneId -import java.time.format.DateTimeFormatter @Stable class CardFeedContentState( @@ -266,7 +266,14 @@ class CardFeedContentState( } } - val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat() + // Notifications are bucketed per calendar day. Computing the day key as a + // raw epoch-day Long (instead of formatting a "yyyy-MM-dd" String per event) + // gives identical buckets and chronological ordering while avoiding a + // DateTimeFormatter + ZonedDateTime + String allocation for every single + // reaction/zap/repost — a meaningful GC saving on full feed conversions. + val zone = ZoneId.systemDefault() + + fun epochDay(createdAt: Long?): Long = LocalDate.ofInstant(Instant.ofEpochSecond(createdAt ?: 0L), zone).toEpochDay() val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys + nutzapsPerEvent.keys val multiCards = @@ -278,21 +285,16 @@ class CardFeedContentState( val singleList = (boostsInCard + zapsInCard.map { it.response } + reactionsInCard + nutzapsInCard).groupBy { - sdf.format( - Instant - .ofEpochSecond(it.createdAt() ?: 0L) - .atZone(ZoneId.systemDefault()) - .toLocalDateTime(), - ) + epochDay(it.createdAt()) } val days = singleList.keys.sortedBy { it } days - .mapNotNull { string -> + .mapNotNull { day -> val sortedList = singleList - .get(string) + .get(day) ?.sortedWith(compareByDescending { it.createdAt() }.thenBy { it.idHex }) sortedList?.chunked(30)?.map { chunk -> @@ -312,12 +314,7 @@ class CardFeedContentState( .map { user -> val byDay = user.value.groupBy { - sdf.format( - Instant - .ofEpochSecond(it.createdAt() ?: 0L) - .atZone(ZoneId.systemDefault()) - .toLocalDateTime(), - ) + epochDay(it.createdAt()) } byDay.values.map { zaps -> @@ -338,12 +335,7 @@ class CardFeedContentState( .map { user -> val byDay = user.value.groupBy { - sdf.format( - Instant - .ofEpochSecond(it.createdAt() ?: 0L) - .atZone(ZoneId.systemDefault()) - .toLocalDateTime(), - ) + epochDay(it.createdAt()) } byDay.values.map { nutzaps ->