mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
perf(polls): make the results screen scroll like the rest of the app
Scrolling the results page cost 250ms per frame against 42ms on the home feed —
same debug build, same device, same swipe — with twelve rows and barely a
screenful of content. Every janky frame was Slow UI thread; the GPU never went
above 7ms, and the page renders one frame in eight seconds when left alone, so
all of it was composition work provoked by scrolling.
Three causes, measured one at a time.
The summary was list content. Header, option bars and chips each sat in their
own `item {}`, so scrolling disposed and rebuilt the whole block — nine
user-observing composables and two bar animations restarted — every time it left
and re-entered the viewport. It is one static block above a list, so it now sits
above the list, and the LazyColumn holds only voters.
The first `build()` ran on the composition thread. `uiState` is `flowOn(Default)`,
but `stateIn`'s initialValue was an eager build: it sorted every voter and
materialised every row synchronously wherever the ViewModel was constructed. Fine
at twelve voters, not fine on the thousand-voter poll this screen exists for. The
initial value is now a placeholder and `hasTally` keeps the footer from reading it
as a poll with no votes.
The voter row carried a NIP-05 line. `UserLine` is the app's short-list row — the
mention picker, follow import — and its supporting slot opens a third per-row
observer and a verified NIP-05 lookup for a list of people the reader has mostly
never met. The row is now the same `SlimListItem` primitive with avatar, name and
the vote, which is what the screen is for.
scrolling, median frame / 90th before after
poll results 250ms 38ms (550 -> 77)
home feed, for scale 42ms 42ms (73)
The NIP-05 line is the one thing lost here, and it was worth 60ms a frame on its
own (97ms with it, 38ms without, after the other two fixes). Putting it back is
one call site if identity disambiguation matters more than the scroll.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c937d42254
commit
9c38240e3e
+49
-42
@@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollResultsViewM
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollVoterRow
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
@@ -98,6 +99,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.results.datasources.P
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
@@ -181,51 +183,49 @@ private fun PollResults(
|
||||
val selected by viewModel.selectedOption.collectAsStateWithLifecycle()
|
||||
|
||||
PollResultsScaffold(nav) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
item("header") {
|
||||
PollHeader(note, state, accountViewModel, nav)
|
||||
}
|
||||
// The summary is not list content — it is one static block that happens to sit above a list.
|
||||
// Inside the LazyColumn it was disposed and rebuilt every time it left and re-entered the
|
||||
// viewport, re-subscribing an avatar per option and restarting both bar animations on every
|
||||
// scroll. Pinned here it composes once and the scroll only pays for voter rows.
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
PollHeader(note, state, accountViewModel, nav)
|
||||
|
||||
item("options") {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
state.options.forEach { option ->
|
||||
key(option.code) {
|
||||
OptionBar(
|
||||
option = option,
|
||||
isSelected = option.code == selected,
|
||||
isMyPick = option.code in state.myVote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onClick = { viewModel.selectOption(option.code) },
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
state.options.forEach { option ->
|
||||
key(option.code) {
|
||||
OptionBar(
|
||||
option = option,
|
||||
isSelected = option.code == selected,
|
||||
isMyPick = option.code in state.myVote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onClick = { viewModel.selectOption(option.code) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.options.size > 1) {
|
||||
item("chips") {
|
||||
OptionFilterRow(state, selected, onSelect = viewModel::selectOption)
|
||||
OptionFilterRow(state, selected, onSelect = viewModel::selectOption)
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
items(state.voters, key = { it.user.pubkeyHex }) { voter ->
|
||||
VoterRow(voter, accountViewModel, nav)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
|
||||
item("voters-divider") {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
}
|
||||
|
||||
items(state.voters, key = { it.user.pubkeyHex }) { voter ->
|
||||
VoterRow(voter, accountViewModel, nav)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
|
||||
item("footer") {
|
||||
ResultsFooter(state)
|
||||
item("footer") {
|
||||
ResultsFooter(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -569,11 +569,18 @@ private fun VoterRow(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
UserLine(
|
||||
baseUser = voter.user,
|
||||
accountViewModel = accountViewModel,
|
||||
// `UserLine` with its NIP-05 line dropped, built from the same `SlimListItem` primitive it uses.
|
||||
//
|
||||
// That line is not free: it opens a third per-row observer and a verified NIP-05 lookup for a
|
||||
// list of people the reader has mostly never seen. Measured on a tablet, scrolling this screen,
|
||||
// it was the single largest cost left after the summary was hoisted — 97ms to 38ms per frame
|
||||
// without it, which is the difference between this list feeling heavy and feeling like the rest
|
||||
// of the app. The vote is what a reader is here for, and it already occupies the trailing slot.
|
||||
SlimListItem(
|
||||
modifier = Modifier.fillMaxWidth().clickable { nav.nav(routeFor(voter.user)) },
|
||||
leadingContent = { UserPicture(voter.user, Size55dp, accountViewModel = accountViewModel, nav = nav) },
|
||||
headlineContent = { UsernameDisplay(voter.user, accountViewModel = accountViewModel) },
|
||||
trailingContent = { VoteChoice(voter) },
|
||||
onClick = { nav.nav(routeFor(voter.user)) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -611,7 +618,7 @@ private fun ResultsFooter(state: PollResultsUiState) {
|
||||
}
|
||||
}
|
||||
|
||||
if (state.totalVoters == 0 && !state.isCheckingCompleteness) {
|
||||
if (state.hasTally && state.totalVoters == 0 && !state.isCheckingCompleteness) {
|
||||
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = stringRes(R.string.poll_results_no_votes),
|
||||
|
||||
+12
-5
@@ -94,6 +94,11 @@ class PollResultsUiState(
|
||||
val relaysAnswered: Int = 0,
|
||||
/** True while we are still asking the relays how many votes exist. */
|
||||
val isCheckingCompleteness: Boolean = false,
|
||||
/**
|
||||
* False only for the placeholder state handed out before the first tally is folded. Lets the UI
|
||||
* tell "this poll has no votes" apart from "we have not looked yet" without flashing the former.
|
||||
*/
|
||||
val hasTally: Boolean = false,
|
||||
) {
|
||||
/** True only when we can actually show that responses are missing. */
|
||||
val isIncomplete get() = reportedResponses != null && reportedResponses > loadedResponses
|
||||
@@ -176,11 +181,12 @@ class PollResultsViewModel(
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
// Built eagerly so the first frame already has the tally instead of flashing an
|
||||
// empty poll. Only this one pass is on the caller's thread; every recomputation
|
||||
// after it — including the flood of them as votes stream in, one per event —
|
||||
// runs on Default via the flowOn above.
|
||||
initialValue = build(pollNote.pollState().responses.value, emptySet(), null, CompletenessState()),
|
||||
// A placeholder, not a tally: building the real one here would sort every voter on
|
||||
// whatever thread constructs the ViewModel — the composition thread — which is the
|
||||
// one poll where that hurts, the thousand-voter one this screen exists for. The
|
||||
// combine above delivers the real state from Default, and `hasTally` keeps the UI
|
||||
// from reading this placeholder as an empty poll.
|
||||
initialValue = PollResultsUiState(),
|
||||
)
|
||||
|
||||
fun selectOption(code: String?) {
|
||||
@@ -276,6 +282,7 @@ class PollResultsViewModel(
|
||||
reportIsApproximate = load.report?.approximate ?: false,
|
||||
relaysAnswered = load.report?.relaysAnswered ?: 0,
|
||||
isCheckingCompleteness = load.running,
|
||||
hasTally = true,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user