Skip to content

Commit d01fbae

Browse files
authored
perf(discovery): baseline profile + hot-path fixes for the token leaderboard (#1028)
Mitigates a low-end-device ANR (Bugsnag 6a46563b) where the main thread stalled composing/measuring the Discover-tab token leaderboard, and wires proper baseline profile generation so that path (and token info + market-cap chart) is AOT-compiled. Perf: - Hoist the LazyColumn item key off the composition hot path: compute each token's base58 address once in the VM (new LeaderboardEntry) instead of in the itemsIndexed { key } lambda (base58 is BigInteger division). Baseline profile (was a hand-written 43-line wildcard file with zero feature coverage; the generator existed but was never wired): - Apply androidx.baselineprofile (producer + consumer) and consume the generated profile via baselineProfile(project(":apps:flipcash:benchmark")). Bump androidx-benchmark-macro 1.4.1 -> 1.5.0-alpha07 for AGP 9 support. - Add a Discover journey to BaselineProfileGenerator (tap token -> token info -> scroll -> scrub market-cap chart -> toggle time windows -> back -> fling list) and a LeaderboardScrollBenchmark (FrameTiming, None vs BaselineProfile). - Replace the hand-written profile with the generated one (59k lines) covering TokenLeaderboard/TokenMetricsRow/TokenInfoScreen/MarketCapSection/MarketCapChart. Testability (no production exposure): - Gate testTagsAsResourceId on BuildConfig.UI_TESTABLE (true for debug + profile-gen/benchmark variants, false for shipping release) instead of DEBUG, so UiAutomator/Maestro/baseline-profile generation can resolve testTags in the nonMinifiedRelease variant while the shipping wallet keeps internal ids private. - Add testTags: discovery_leaderboard, leaderboard_token_row, token_info_screen, market_cap_chart, market_cap_period_*. - Add <profileable android:shell="true"/> for macrobenchmark. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 537aa33 commit d01fbae

15 files changed

Lines changed: 59312 additions & 60 deletions

File tree

apps/flipcash/app/build.gradle.kts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import com.android.build.api.variant.BuildConfigField
12
import com.bugsnag.gradle.dsl.debug
23
import com.bugsnag.gradle.dsl.release
34
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
@@ -15,6 +16,7 @@ plugins {
1516
alias(libs.plugins.secrets)
1617
id("org.jetbrains.kotlin.plugin.compose")
1718
alias(libs.plugins.kover)
19+
alias(libs.plugins.androidx.baselineprofile)
1820
}
1921

2022
fun gitVersionCode(): Int {
@@ -154,6 +156,23 @@ kotlin {
154156
}
155157
}
156158

159+
// Expose Compose testTags as resource-ids (for UiAutomator/Maestro/baseline-profile
160+
// generation) on every variant EXCEPT the shipping `release` — keeps internal ids like
161+
// `seed_input_field` out of the production wallet build. Read in App.kt as
162+
// BuildConfig.UI_TESTABLE. Refs Bugsnag 6a46563b.
163+
androidComponents {
164+
onVariants { variant ->
165+
variant.buildConfigFields?.put(
166+
"UI_TESTABLE",
167+
BuildConfigField(
168+
"boolean",
169+
(variant.name != "release").toString(),
170+
"testTags exposed as resource-ids; false only for shipping release",
171+
),
172+
)
173+
}
174+
}
175+
157176
dependencies {
158177
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
159178
implementation(project(":services:flipcash-compose"))
@@ -294,6 +313,9 @@ dependencies {
294313
implementation(libs.bugsnag)
295314

296315
implementation(libs.androidx.profileinstaller)
316+
// Consumes the generated baseline profile from the benchmark module and packages it
317+
// into the APK (assets/dexopt/baseline.prof). Refs Bugsnag 6a46563b.
318+
baselineProfile(project(":apps:flipcash:benchmark"))
297319

298320
testImplementation(libs.junit)
299321
testImplementation(libs.kotlin.test.junit)

apps/flipcash/app/src/main/AndroidManifest.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@
5858
android:theme="@style/Theme.Code"
5959
tools:targetApi="tiramisu">
6060

61+
<!-- Lets Macrobenchmark/simpleperf attach to a non-debuggable release build. -->
62+
<profileable android:shell="true" />
63+
6164
<meta-data
6265
android:name="com.bugsnag.android.API_KEY"
6366
android:value="${BUGSNAG_API_KEY}" />

apps/flipcash/app/src/main/baseline-prof.txt

Lines changed: 0 additions & 43 deletions
This file was deleted.

apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,9 @@ internal fun App(
133133
onRootReached = { /* handled by activity back press */ },
134134
)
135135

136-
val semanticsModifier = if (BuildConfig.DEBUG) {
136+
// UI_TESTABLE is true for debug + the profile-gen/benchmark variants and
137+
// false for the shipping release, so testTags aren't exposed in production.
138+
val semanticsModifier = if (BuildConfig.UI_TESTABLE) {
137139
Modifier.semantics { testTagsAsResourceId = true }
138140
} else Modifier
139141

apps/flipcash/app/src/release/generated/baselineProfiles/baseline-prof.txt

Lines changed: 59074 additions & 0 deletions
Large diffs are not rendered by default.

apps/flipcash/benchmark/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
plugins {
22
id("com.android.test")
3+
alias(libs.plugins.androidx.baselineprofile)
34
}
45

56
val contributorsSigningConfig = ContributorsSignatory(rootDir)

apps/flipcash/benchmark/src/main/kotlin/com/flipcash/benchmark/BaselineProfileGenerator.kt

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
55
import androidx.test.filters.LargeTest
66
import androidx.test.platform.app.InstrumentationRegistry
77
import androidx.test.uiautomator.By
8+
import androidx.test.uiautomator.Direction
9+
import androidx.test.uiautomator.StaleObjectException
810
import androidx.test.uiautomator.Until
911
import org.junit.Rule
1012
import org.junit.Test
@@ -47,6 +49,7 @@ class BaselineProfileGenerator {
4749
if (onScanner) {
4850
// Already logged in from a prior iteration
4951
scannerJourney()
52+
discoveryJourney()
5053
walletJourney()
5154
giveJourney()
5255
menuJourney()
@@ -103,6 +106,88 @@ class BaselineProfileGenerator {
103106
device.waitForIdle()
104107
}
105108

109+
private fun MacrobenchmarkScope.discoveryJourney() {
110+
// Open the Discover tab from the scanner bottom nav
111+
device.wait(Until.findObject(By.text("Discover")), TIMEOUT)?.click()
112+
113+
device.wait(Until.findObject(By.res("discovery_leaderboard")), LOGIN_TIMEOUT)
114+
device.waitForIdle()
115+
116+
// Open a token's info screen from the FRESH leaderboard first. (Tapping a row
117+
// right after flinging is unreliable — the row is still settling — so do the
118+
// token-info journey before scrolling the list.)
119+
device.wait(Until.findObject(By.res("leaderboard_token_row")), TIMEOUT)?.click()
120+
device.wait(Until.findObject(By.res("token_info_screen")), LOGIN_TIMEOUT)
121+
device.waitForIdle()
122+
123+
// Scroll the token-info screen down until the market-cap chart's period tabs
124+
// (the bottom-most element) are on screen — the chart sits at the very bottom.
125+
scrollUntilVisible("token_info_screen", "market_cap_period_All", Direction.UP)
126+
127+
// Interact with the market-cap chart: scrub across it (highlights points), then
128+
// toggle every time window (each reloads data + redraws the chart).
129+
device.findObject(By.res("market_cap_chart"))?.let { chart ->
130+
val b = chart.visibleBounds
131+
val y = b.centerY()
132+
device.drag(b.left + b.width() / 6, y, b.right - b.width() / 6, y, 40)
133+
}
134+
device.waitForIdle()
135+
listOf("Week", "Month", "Year", "All", "Day").forEach { period ->
136+
device.findObject(By.res("market_cap_period_$period"))?.click()
137+
device.waitForIdle()
138+
}
139+
140+
// Back to the leaderboard, then fling-scroll it so TokenLeaderboard /
141+
// TokenMetricsRow / RankBadge composition + layout get compiled.
142+
device.wait(Until.findObject(By.res("action_back")), TIMEOUT)?.click()
143+
device.wait(Until.findObject(By.res("discovery_leaderboard")), TIMEOUT)
144+
device.waitForIdle()
145+
flingScroll("discovery_leaderboard", Direction.UP, 3) // scroll down through the list
146+
flingScroll("discovery_leaderboard", Direction.DOWN, 2) // and back up
147+
148+
// Close discovery to the scanner
149+
device.wait(Until.findObject(By.res("action_close")), TIMEOUT)?.click()
150+
device.wait(Until.findObject(By.res("scanner_view")), TIMEOUT)
151+
device.waitForIdle()
152+
}
153+
154+
/**
155+
* Fling a scrollable [times], re-finding it each iteration. A Compose LazyColumn
156+
* recomposes on fling, invalidating a held UiObject2 — so we re-query and tolerate
157+
* a StaleObjectException rather than reusing a stale reference.
158+
*/
159+
private fun MacrobenchmarkScope.flingScroll(resId: String, direction: Direction, times: Int) {
160+
repeat(times) {
161+
val scrollable = device.findObject(By.res(resId)) ?: return
162+
try {
163+
scrollable.setGestureMargin(device.displayWidth / 5)
164+
scrollable.fling(direction)
165+
} catch (_: StaleObjectException) {
166+
// View recomposed mid-fling; the next iteration re-finds it.
167+
}
168+
device.waitForIdle()
169+
}
170+
}
171+
172+
/** Fling [scrollableResId] in [direction] (re-finding each time) until [targetResId] appears. */
173+
private fun MacrobenchmarkScope.scrollUntilVisible(
174+
scrollableResId: String,
175+
targetResId: String,
176+
direction: Direction,
177+
maxFlings: Int = 6,
178+
) {
179+
repeat(maxFlings) {
180+
if (device.hasObject(By.res(targetResId))) return
181+
val scrollable = device.findObject(By.res(scrollableResId)) ?: return
182+
try {
183+
scrollable.setGestureMargin(device.displayWidth / 5)
184+
scrollable.fling(direction)
185+
} catch (_: StaleObjectException) {
186+
}
187+
device.waitForIdle()
188+
}
189+
}
190+
106191
private fun MacrobenchmarkScope.walletJourney() {
107192
// Open wallet sheet
108193
device.wait(Until.findObject(By.text("Wallet")), TIMEOUT)?.click()
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package com.flipcash.benchmark
2+
3+
import androidx.benchmark.macro.BaselineProfileMode
4+
import androidx.benchmark.macro.CompilationMode
5+
import androidx.benchmark.macro.FrameTimingMetric
6+
import androidx.benchmark.macro.MacrobenchmarkScope
7+
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
8+
import androidx.test.ext.junit.runners.AndroidJUnit4
9+
import androidx.test.filters.LargeTest
10+
import androidx.test.uiautomator.By
11+
import androidx.test.uiautomator.Direction
12+
import androidx.test.uiautomator.StaleObjectException
13+
import androidx.test.uiautomator.Until
14+
import org.junit.Rule
15+
import org.junit.Test
16+
import org.junit.runner.RunWith
17+
18+
/**
19+
* Frame-timing benchmark for scrolling the Discover-tab token leaderboard — the screen
20+
* that ANR'd on a low-end device (Bugsnag 6a46563b). Compares an unprofiled build against
21+
* one compiled from the baseline profile so the profile's effect on first-scroll jank is
22+
* measurable. Headline metric: P95 frameOverrunMs (negative = on time).
23+
*
24+
* Requires an already-authenticated device (run the BaselineProfileGenerator once, or
25+
* log in, first — the setup navigates from a cold launch to the Discover leaderboard).
26+
* On an emulator, results are suppressed-but-runnable (see the module's suppressErrors);
27+
* report medians from a physical low-end device for real numbers.
28+
*/
29+
@RunWith(AndroidJUnit4::class)
30+
@LargeTest
31+
class LeaderboardScrollBenchmark {
32+
33+
@get:Rule
34+
val rule = MacrobenchmarkRule()
35+
36+
@Test
37+
fun scrollNoCompilation() = scroll(CompilationMode.None())
38+
39+
@Test
40+
fun scrollBaselineProfile() =
41+
scroll(CompilationMode.Partial(baselineProfileMode = BaselineProfileMode.Require))
42+
43+
private fun scroll(compilationMode: CompilationMode) {
44+
rule.measureRepeated(
45+
packageName = PACKAGE_NAME,
46+
metrics = listOf(FrameTimingMetric()),
47+
compilationMode = compilationMode,
48+
iterations = 5,
49+
setupBlock = {
50+
pressHome()
51+
startActivityAndWait()
52+
// Navigate to the Discover leaderboard (device must be authenticated).
53+
device.wait(Until.findObject(By.text("Discover")), TIMEOUT)?.click()
54+
device.wait(Until.findObject(By.res("discovery_leaderboard")), LIST_TIMEOUT)
55+
device.waitForIdle()
56+
},
57+
) {
58+
// Re-find each fling: the LazyColumn recomposes on scroll, which would
59+
// otherwise invalidate a held UiObject2 (StaleObjectException).
60+
repeat(3) { flingLeaderboard(Direction.UP) }
61+
repeat(3) { flingLeaderboard(Direction.DOWN) }
62+
}
63+
}
64+
65+
private fun MacrobenchmarkScope.flingLeaderboard(direction: Direction) {
66+
val list = device.findObject(By.res("discovery_leaderboard")) ?: return
67+
try {
68+
list.setGestureMargin(device.displayWidth / 5)
69+
list.fling(direction)
70+
} catch (_: StaleObjectException) {
71+
// recomposed mid-fling; next call re-finds
72+
}
73+
device.waitForIdle()
74+
}
75+
76+
companion object {
77+
private const val PACKAGE_NAME = "com.flipcash.app.android"
78+
private const val TIMEOUT = 5_000L
79+
private const val LIST_TIMEOUT = 15_000L
80+
}
81+
}

apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/TokenDiscoveryScreenContent.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import com.getcode.manager.BottomBarManager
3535
import com.getcode.opencode.model.financial.HolderMetrics
3636
import com.getcode.opencode.model.financial.MintMetadata
3737
import com.getcode.opencode.model.financial.Token
38+
import com.getcode.solana.keys.base58
3839
import com.getcode.opencode.model.financial.VmMetadata
3940
import com.getcode.opencode.model.ui.DiscoverCategory
4041
import com.getcode.opencode.model.ui.WindowedRange
@@ -99,7 +100,9 @@ private fun LoadedPopular_Preview() {
99100
TokenDiscoveryScreenContent(
100101
state = TokenDiscoveryViewModel.State(
101102
category = DiscoverCategory.Popular,
102-
tokens = Loadable.Loaded(sampleTokens())
103+
tokens = Loadable.Loaded(
104+
sampleTokens().map { LeaderboardEntry(key = it.address.base58(), token = it) }
105+
)
103106
)
104107
) { }
105108
}

apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/TokenDiscoveryViewModel.kt

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.getcode.opencode.controllers.CurrencyController
1111
import com.getcode.opencode.model.financial.Token
1212
import com.getcode.opencode.model.ui.DiscoverCategory
1313
import com.getcode.solana.keys.Mint
14+
import com.getcode.solana.keys.base58
1415
import com.getcode.util.resources.ResourceHelper
1516
import com.flipcash.libs.coroutines.DispatcherProvider
1617
import com.getcode.manager.BottomBarManager
@@ -26,6 +27,17 @@ import kotlinx.coroutines.flow.mapNotNull
2627
import kotlinx.coroutines.flow.onEach
2728
import javax.inject.Inject
2829

30+
/**
31+
* A leaderboard row's token paired with its precomputed LazyColumn key. The key is
32+
* the token's base58 address (BigInteger encoding); computing it once here at load
33+
* keeps it out of the `itemsIndexed { key = … }` hot path, which Compose re-runs
34+
* over the viewport's nearby range on scroll. Refs Bugsnag 6a46563b.
35+
*/
36+
internal data class LeaderboardEntry(
37+
val key: String,
38+
val token: Token,
39+
)
40+
2941
@HiltViewModel
3042
internal class TokenDiscoveryViewModel @Inject constructor(
3143
private val currencyController: CurrencyController,
@@ -42,7 +54,7 @@ internal class TokenDiscoveryViewModel @Inject constructor(
4254
data class State(
4355
val createEnabled: Boolean = false,
4456
val category: DiscoverCategory? = null,
45-
val tokens: Loadable<List<Token>> = Loadable.Loading(),
57+
val tokens: Loadable<List<LeaderboardEntry>> = Loadable.Loading(),
4658
val minimumHolderAmount: Fiat = 10.toFiat(),
4759
)
4860

@@ -55,7 +67,7 @@ internal class TokenDiscoveryViewModel @Inject constructor(
5567
val fromUser: Boolean = false
5668
) : Event
5769

58-
data class OnTokensUpdated(val loadable: Loadable<List<Token>>) : Event
70+
data class OnTokensUpdated(val loadable: Loadable<List<LeaderboardEntry>>) : Event
5971

6072
data class LoadTokensForCategory(val category: DiscoverCategory) : Event
6173
data object Refresh : Event
@@ -97,8 +109,9 @@ internal class TokenDiscoveryViewModel @Inject constructor(
97109
.map { it.category }
98110
.map { currencyController.discoverTokens(it) }
99111
.onResult(
100-
onSuccess = {
101-
dispatchEvent(Event.OnTokensUpdated(Loadable.Loaded(it)))
112+
onSuccess = { tokens ->
113+
val entries = tokens.map { LeaderboardEntry(key = it.address.base58(), token = it) }
114+
dispatchEvent(Event.OnTokensUpdated(Loadable.Loaded(entries)))
102115
},
103116
onError = { error ->
104117
dispatchEvent(

0 commit comments

Comments
 (0)