From 785257bb31be05032a62a27fc0809fd544351ced Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Wed, 29 Apr 2026 16:53:02 +0800 Subject: [PATCH 1/8] Relocate benchmark files, enable debuggable benchmarks, and expand startup testing coverage. - Move `BaselineProfileGenerator.kt` and `StartupBenchmark.kt` from `androidTest` to `main` source set in the benchmarks module. - Set `isDebuggable = true` for the `benchmark` build type in `benchmarks/build.gradle.kts`. - Refactor `StartupBenchmark.kt` to include tests for COLD, WARM, and HOT startup modes. - Implement UI assertions in `StartupBenchmark` to verify Time to Initial Display (TTID) and Time to Full Display (TTFD) during measurements. --- benchmarks/build.gradle.kts | 2 +- .../benchmarks/StartupBenchmark.kt | 48 ------ .../benchmarks/BaselineProfileGenerator.kt | 1 - .../benchmarks/StartupBenchmark.kt | 141 ++++++++++++++++++ 4 files changed, 142 insertions(+), 50 deletions(-) delete mode 100644 benchmarks/src/androidTest/java/com/aquib/androidperflab/benchmarks/StartupBenchmark.kt rename benchmarks/src/{androidTest => main}/java/com/aquib/androidperflab/benchmarks/BaselineProfileGenerator.kt (99%) create mode 100644 benchmarks/src/main/java/com/aquib/androidperflab/benchmarks/StartupBenchmark.kt diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index e1cc416..876a475 100644 --- a/benchmarks/build.gradle.kts +++ b/benchmarks/build.gradle.kts @@ -21,7 +21,7 @@ android { buildTypes { // "benchmark" must match the build type created in :app so AGP can find the APK. create("benchmark") { - isDebuggable = false + isDebuggable = true signingConfig = signingConfigs.getByName("debug") matchingFallbacks += listOf("release") } diff --git a/benchmarks/src/androidTest/java/com/aquib/androidperflab/benchmarks/StartupBenchmark.kt b/benchmarks/src/androidTest/java/com/aquib/androidperflab/benchmarks/StartupBenchmark.kt deleted file mode 100644 index eb921cb..0000000 --- a/benchmarks/src/androidTest/java/com/aquib/androidperflab/benchmarks/StartupBenchmark.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.aquib.androidperflab.benchmarks - -import androidx.benchmark.macro.CompilationMode -import androidx.benchmark.macro.StartupMode -import androidx.benchmark.macro.StartupTimingMetric -import androidx.benchmark.macro.junit4.MacrobenchmarkRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -private const val TARGET_PACKAGE = "com.aquib.androidperflab" - -/** - * Measures cold-start time under three compilation modes so you can compare: - * - None → fully interpreted (JIT-only, worst case) - * - Partial → Baseline Profile applied (typical release build) - * - Full → everything AOT-compiled (best case ceiling) - * - * Run with: ./gradlew :benchmarks:connectedBenchmarkAndroidTest - */ -@RunWith(AndroidJUnit4::class) -class StartupBenchmark { - - @get:Rule - val benchmarkRule = MacrobenchmarkRule() - - @Test - fun startupNoCompilation() = startup(CompilationMode.None()) - - @Test - fun startupBaselineProfile() = startup(CompilationMode.Partial()) - - @Test - fun startupFullAot() = startup(CompilationMode.Full()) - - private fun startup(compilationMode: CompilationMode) { - benchmarkRule.measureRepeated( - packageName = TARGET_PACKAGE, - metrics = listOf(StartupTimingMetric()), - compilationMode = compilationMode, - startupMode = StartupMode.COLD, - iterations = 5, - setupBlock = { pressHome() }, - measureBlock = { startActivityAndWait() }, - ) - } -} diff --git a/benchmarks/src/androidTest/java/com/aquib/androidperflab/benchmarks/BaselineProfileGenerator.kt b/benchmarks/src/main/java/com/aquib/androidperflab/benchmarks/BaselineProfileGenerator.kt similarity index 99% rename from benchmarks/src/androidTest/java/com/aquib/androidperflab/benchmarks/BaselineProfileGenerator.kt rename to benchmarks/src/main/java/com/aquib/androidperflab/benchmarks/BaselineProfileGenerator.kt index e1496cc..916b0e6 100644 --- a/benchmarks/src/androidTest/java/com/aquib/androidperflab/benchmarks/BaselineProfileGenerator.kt +++ b/benchmarks/src/main/java/com/aquib/androidperflab/benchmarks/BaselineProfileGenerator.kt @@ -17,7 +17,6 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class BaselineProfileGenerator { - @get:Rule val baselineProfileRule = BaselineProfileRule() diff --git a/benchmarks/src/main/java/com/aquib/androidperflab/benchmarks/StartupBenchmark.kt b/benchmarks/src/main/java/com/aquib/androidperflab/benchmarks/StartupBenchmark.kt new file mode 100644 index 0000000..f9504f9 --- /dev/null +++ b/benchmarks/src/main/java/com/aquib/androidperflab/benchmarks/StartupBenchmark.kt @@ -0,0 +1,141 @@ +package com.aquib.androidperflab.benchmarks + +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.MacrobenchmarkScope +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +private const val TARGET_PACKAGE = "com.aquib.androidperflab" +private const val RENDER_TIMEOUT_MS = 5_000L + +/** + * Measures app startup time across all three Android startup modes. + * + * Mode │ Process │ Activity │ What executes + * ───────┼─────────┼──────────┼────────────────────────────────────────────────────── + * COLD │ killed │ gone │ Application.onCreate() + Activity.onCreate() (full SDK init) + * WARM │ alive │ gone │ Activity.onCreate() only (SDK init skipped) + * HOT │ alive │ alive │ onStart() + onResume() only (cheapest path) + * + * Metrics per iteration (written to benchmarkData.json): + * timeToInitialDisplayMs — TTID: system-measured first frame + * timeToFullDisplayMs — TTFD: app-reported via reportFullyDrawn() + * (absent for HOT start — onCreate is not called) + * + * Each mode runs 10 iterations under CompilationMode.None() (JIT only, no AOT) + * to produce a worst-case, maximally repeatable baseline. + * + * Run: + * ./gradlew :benchmarks:connectedBenchmarkAndroidTest \ + * -Pandroid.testInstrumentationRunnerArguments.class=\ + * com.aquib.androidperflab.benchmarks.StartupBenchmark + * + * Output: + * benchmarks/build/outputs/connected_android_test_additional_output/ + * benchmark/connected//StartupBenchmark-benchmarkData.json + */ +@RunWith(AndroidJUnit4::class) +class StartupBenchmark { + + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + /** + * Cold start — most expensive. + * Process is killed before every iteration. + * Application.onCreate() runs in full, blocking the main thread for ~750 ms + * across the five fake SDK initialisations. + */ + @Test + fun startupCold() = measureStartup(StartupMode.COLD, expectTtfd = true) + + /** + * Warm start — medium cost. + * Process stays alive; Activity is destroyed and recreated between iterations. + * Application.onCreate() is skipped, so the ~750 ms SDK penalty is absent. + * Remaining cost: Activity + Compose + LazyColumn first composition. + */ + @Test + fun startupWarm() = measureStartup(StartupMode.WARM, expectTtfd = true) + + /** + * Hot start — cheapest path. + * Process and Activity are both kept alive. + * The system calls onStart() + onResume(); Compose recomposes only what changed. + * reportFullyDrawn() is NOT called (onCreate is skipped), so TTFD is absent from + * the JSON for this mode. + */ + @Test + fun startupHot() = measureStartup(StartupMode.HOT, expectTtfd = false) + + // ── Core measurement ───────────────────────────────────────────────────── + + private fun measureStartup(startupMode: StartupMode, expectTtfd: Boolean) { + benchmarkRule.measureRepeated( + packageName = TARGET_PACKAGE, + metrics = listOf(StartupTimingMetric()), + compilationMode = CompilationMode.None(), + startupMode = startupMode, + iterations = 10, + setupBlock = { + pressHome() + }, + measureBlock = { + startActivityAndWait() + assertTtidCaptured() + if (expectTtfd) assertTtfdCaptured() + }, + ) + } + + // ── Assertion helpers ──────────────────────────────────────────────────── + + /** + * Asserts TTID was captured: verifies the target package is in the foreground + * and that the first feed item ("Post #0 — Technology") is visible, confirming + * the first frame rendered real content rather than a blank window surface. + */ + private fun MacrobenchmarkScope.assertTtidCaptured() { + assertEquals( + "Wrong package in foreground — did the app crash during startup?", + TARGET_PACKAGE, + device.currentPackageName, + ) + val firstItem = device.wait( + Until.hasObject(By.text("Post #0 — Technology")), + RENDER_TIMEOUT_MS, + ) + assertNotNull( + "\"Post #0 — Technology\" not visible within ${RENDER_TIMEOUT_MS} ms — " + + "first frame may not have rendered real content", + firstItem, + ) + } + + /** + * Asserts TTFD was captured: waits for the feed's LazyColumn to be scrollable, + * which requires the full Compose layout pass to complete — the same condition + * that triggers reportFullyDrawn() in MainActivity.onCreate(). Not called for + * HOT mode since onCreate() is skipped and TTFD is intentionally absent. + */ + private fun MacrobenchmarkScope.assertTtfdCaptured() { + val scrollable = device.wait( + Until.hasObject(By.scrollable(true)), + RENDER_TIMEOUT_MS, + ) + assertNotNull( + "No scrollable view found within ${RENDER_TIMEOUT_MS} ms — " + + "reportFullyDrawn() may not have fired or the feed did not compose", + scrollable, + ) + } +} From f36924ecdbfa254b4de826e298dc63dfc4935075 Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Wed, 29 Apr 2026 18:25:28 +0800 Subject: [PATCH 2/8] Configure benchmark build types and add JUnit4 benchmark dependency --- app/build.gradle.kts | 3 ++- benchmarks/build.gradle.kts | 3 ++- gradle/libs.versions.toml | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3c5c311..6df44b4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,12 +26,13 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + signingConfig = signingConfigs.getByName("debug") } // Dedicated build type for running Macrobenchmarks and generating Baseline Profiles. // Mirrors release config but keeps the debug signing cert so the benchmark module // can install it without a release keystore on CI. create("benchmark") { - initWith(buildTypes.getByName("release")) + initWith(getByName("release")) signingConfig = signingConfigs.getByName("debug") matchingFallbacks += listOf("release") isDebuggable = false diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index 876a475..7b6abe5 100644 --- a/benchmarks/build.gradle.kts +++ b/benchmarks/build.gradle.kts @@ -21,7 +21,7 @@ android { buildTypes { // "benchmark" must match the build type created in :app so AGP can find the APK. create("benchmark") { - isDebuggable = true + isDebuggable = false signingConfig = signingConfigs.getByName("debug") matchingFallbacks += listOf("release") } @@ -37,6 +37,7 @@ android { } dependencies { + implementation(libs.androidx.benchmark.junit4) implementation(libs.androidx.benchmark.macro.junit4) implementation(libs.androidx.test.uiautomator) implementation(libs.androidx.junit) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c1cdfc9..bcefe6f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,6 +47,7 @@ androidx-compose-material3 = { group = "androidx.compose.material3", nam androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } # Macrobenchmark +androidx-benchmark-junit4 = { group = "androidx.benchmark", name = "benchmark-junit4", version.ref = "benchmarkMacro" } androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmarkMacro" } androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } From 7c656b82d71972794f69897de90e2825e9af6e9f Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Thu, 30 Apr 2026 10:14:11 +0800 Subject: [PATCH 3/8] Add macrobenchmark for scroll performance and update UI components with test tags --- .gitignore | 3 +- .../benchmarks/ScrollBenchmark.kt | 91 +++++++++++++++++++ .../androidperflab/ui/AnimatedListScreen.kt | 10 +- .../com/aquib/androidperflab/ui/HomeScreen.kt | 7 +- 4 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 benchmarks/src/main/java/com/aquib/androidperflab/benchmarks/ScrollBenchmark.kt diff --git a/.gitignore b/.gitignore index 69eb46f..3a99c97 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ /.idea/navEditor.xml /.idea/assetWizardSettings.xml .DS_Store -/build +build/ /captures .externalNativeBuild .cxx @@ -23,3 +23,4 @@ local.properties /.idea/runConfigurations.xml /.idea/studiobot.xml /.idea/vcs.xml +.idea/androidTestResultsUserPreferences.xml diff --git a/benchmarks/src/main/java/com/aquib/androidperflab/benchmarks/ScrollBenchmark.kt b/benchmarks/src/main/java/com/aquib/androidperflab/benchmarks/ScrollBenchmark.kt new file mode 100644 index 0000000..06d9976 --- /dev/null +++ b/benchmarks/src/main/java/com/aquib/androidperflab/benchmarks/ScrollBenchmark.kt @@ -0,0 +1,91 @@ +package com.aquib.androidperflab.benchmarks + +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.FrameTimingMetric +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Direction +import androidx.test.uiautomator.Until +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +private const val TARGET_PACKAGE = "com.aquib.androidperflab" +private const val RENDER_TIMEOUT_MS = 5_000L + +/** + * Measures scroll rendering performance on the unoptimized AnimatedListScreen. + * + * Metrics reported per iteration (written to benchmarkData.json): + * frameDurationCpuMs — p50 / p90 / p95 / p99 frame durations + * frameOverrunMs — per-frame budget overrun (devices with HW timestamps only) + * jankyFrameCount — number of frames exceeding the 16 ms / 60 fps deadline + * jankyFramePercent — janky frames as a fraction of total frames rendered + * + * The AnimatedListScreen is intentionally unoptimized: + * - No key{} lambda in LazyColumn.items() → position-based diffing on recomposition + * - Alpha state read in composition scope, not inside graphicsLayer → full recompose per frame + * - animateContentSize() on every card → expensive layout on every frame + * - Inline Color construction per recomposition → allocation pressure + * + * Run: + * ./gradlew :benchmarks:connectedBenchmarkAndroidTest \ + * -Pandroid.testInstrumentationRunnerArguments.class=\ + * com.aquib.androidperflab.benchmarks.ScrollBenchmark + * + * Output: + * benchmarks/build/outputs/connected_android_test_additional_output/ + * benchmark/connected//ScrollBenchmark-benchmarkData.json + */ +@RunWith(AndroidJUnit4::class) +class ScrollBenchmark { + + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + /** + * Cold-starts the app for each of 5 iterations, navigates to AnimatedListScreen + * in the un-measured setupBlock, then scrolls the 80-item LazyColumn 5 pages down + * and 5 pages up while FrameTimingMetric records every frame. + * + * CompilationMode.None() keeps the JVM in JIT-only mode so results reflect the + * worst-case unoptimized baseline (no AOT profile warm-up across iterations). + */ + @Test + fun scrollAnimatedListUnoptimized() { + benchmarkRule.measureRepeated( + packageName = TARGET_PACKAGE, + metrics = listOf(FrameTimingMetric()), + compilationMode = CompilationMode.None(), + startupMode = StartupMode.COLD, + iterations = 5, + setupBlock = { + pressHome() + startActivityAndWait() + + // Find FAB by content description + val fab = device.wait(Until.findObject(By.desc("animated_list_fab")), RENDER_TIMEOUT_MS) + ?: throw RuntimeException("Could not find the FAB to navigate to Animated List") + fab.click() + + // Wait for the list to be present on screen before starting measurement + val listAppeared = device.wait(Until.hasObject(By.desc("animated_list")), RENDER_TIMEOUT_MS) + check(listAppeared) { "AnimatedListScreen did not appear within ${RENDER_TIMEOUT_MS}ms" } + }, + measureBlock = { + // Guard against any residual render delay from the heavy 80-item + // infinite-animation composition before attempting to find the node. + device.wait(Until.hasObject(By.desc("animated_list")), RENDER_TIMEOUT_MS) + + // Find the list by content description + val list = device.findObject(By.desc("animated_list")) + ?: throw RuntimeException("Could not find the animated list scrollable object") + + repeat(5) { list.scroll(Direction.DOWN, 1.0f) } + repeat(5) { list.scroll(Direction.UP, 1.0f) } + }, + ) + } +} diff --git a/ui/src/main/java/com/aquib/androidperflab/ui/AnimatedListScreen.kt b/ui/src/main/java/com/aquib/androidperflab/ui/AnimatedListScreen.kt index 62d3e94..3d12cea 100644 --- a/ui/src/main/java/com/aquib/androidperflab/ui/AnimatedListScreen.kt +++ b/ui/src/main/java/com/aquib/androidperflab/ui/AnimatedListScreen.kt @@ -32,6 +32,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp private data class AnimatedItem( @@ -62,7 +65,12 @@ fun AnimatedListScreen( TextButton(onClick = onBack, modifier = Modifier.padding(start = 4.dp, top = 4.dp)) { Text("← Back") } - LazyColumn(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag("animated_list") + .semantics { contentDescription = "animated_list" } + ) { // BAD: no key lambda — Compose cannot track item identity across recompositions, // so any structural change causes it to diff by position rather than by id. items(items) { item -> diff --git a/ui/src/main/java/com/aquib/androidperflab/ui/HomeScreen.kt b/ui/src/main/java/com/aquib/androidperflab/ui/HomeScreen.kt index 2cce5ae..a0623b7 100644 --- a/ui/src/main/java/com/aquib/androidperflab/ui/HomeScreen.kt +++ b/ui/src/main/java/com/aquib/androidperflab/ui/HomeScreen.kt @@ -15,6 +15,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -40,7 +43,9 @@ fun HomeScreen(modifier: Modifier = Modifier) { onClick = { showAnimatedList = true }, modifier = Modifier .align(Alignment.BottomEnd) - .padding(16.dp), + .padding(16.dp) + .testTag("animated_list_fab") + .semantics { contentDescription = "animated_list_fab" }, ) { Text("▶") } From 63f5076b5398bfbce67f46c1886f575e5d378e19 Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Thu, 30 Apr 2026 11:59:17 +0800 Subject: [PATCH 4/8] Implement `RecompositionBenchmark` to measure UI stability and add test tags to `DetailScreen` buttons --- app/build.gradle.kts | 1 + .../androidperflab/RecompositionBenchmark.kt | 124 ++++++++++++++++++ gradle/libs.versions.toml | 2 + .../aquib/androidperflab/ui/DetailScreen.kt | 5 +- 4 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 app/src/androidTest/java/com/aquib/androidperflab/RecompositionBenchmark.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6df44b4..e6a91fb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,4 +80,5 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.kotlinx.coroutines.test) } diff --git a/app/src/androidTest/java/com/aquib/androidperflab/RecompositionBenchmark.kt b/app/src/androidTest/java/com/aquib/androidperflab/RecompositionBenchmark.kt new file mode 100644 index 0000000..ea000a5 --- /dev/null +++ b/app/src/androidTest/java/com/aquib/androidperflab/RecompositionBenchmark.kt @@ -0,0 +1,124 @@ +package com.aquib.androidperflab + +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Recomposer +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.aquib.androidperflab.ui.DetailScreen +import com.aquib.androidperflab.ui.FeedItem +import com.aquib.androidperflab.ui.theme.AndroidPerfLabTheme +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RecompositionBenchmark { + // Shared scheduler drives virtual time for runTest blocks in this class. + private val testScheduler = TestCoroutineScheduler() + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val testItem = FeedItem( + id = 42, + title = "Performance Testing in Android", + subtitle = "Benchmarking Compose recompositions", + description = "Android performance testing involves many dimensions including startup " + + "time, frame rendering, and recomposition overhead. This article explores " + + "techniques for measuring each of these in depth.", + author = "Mohd Aquib", + imageUrl = "", + timestampMillis = 1_700_000_000_000L, + ) + + @Before + fun setUp() { + composeTestRule.setContent { + AndroidPerfLabTheme { + DetailScreen(item = testItem, onBack = {}) + } + } + // Pause Compose's virtual clock so the LaunchedEffect tick loop in DetailScreen + // does not fire between measurement checkpoints, keeping deltas deterministic. + composeTestRule.mainClock.autoAdvance = false + // Drain the initial composition pass before any test captures a baseline count. + composeTestRule.mainClock.advanceTimeByFrame() + composeTestRule.waitForIdle() + } + + // ── Like button ────────────────────────────────────────────────────────────── + + @Test + fun likeButton_recompositionCount() { + val before = totalChangeCount() + + composeTestRule.onNodeWithTag("detail_like_button").performClick() + composeTestRule.mainClock.advanceTimeByFrame() + composeTestRule.waitForIdle() + + val delta = totalChangeCount() - before + record("like_button_click", delta) + } + + // ── Bookmark button ────────────────────────────────────────────────────────── + + @Test + fun bookmarkButton_recompositionCount() { + val before = totalChangeCount() + + composeTestRule.onNodeWithTag("detail_bookmark_button").performClick() + composeTestRule.mainClock.advanceTimeByFrame() + composeTestRule.waitForIdle() + + val delta = totalChangeCount() - before + record("bookmark_button_click", delta) + } + + // ── Tick-driven recompositions ─────────────────────────────────────────────── + + @Test + fun tickEffect_recompositionCountPerInterval() = runTest(testScheduler) { + val tickCount = 5 + var totalDelta = 0L + + repeat(tickCount) { index -> + val before = totalChangeCount() + + // Advance Compose's main clock to unblock the delay(500L) in LaunchedEffect. + composeTestRule.mainClock.advanceTimeBy(500L) + // Advance TestCoroutineScheduler by the same interval so virtual time + // stays in sync for any coroutines running on testScheduler. + testScheduler.advanceTimeBy(500L) + composeTestRule.waitForIdle() + + val delta = totalChangeCount() - before + totalDelta += delta + Log.d(TAG, "Tick ${index + 1}: $delta recompositions") + } + + record("tick_effect_per_interval", totalDelta / tickCount) + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private fun totalChangeCount(): Long = + Recomposer.runningRecomposers.value.sumOf { it.changeCount } + + private fun record(interaction: String, recompositionCount: Long) { + Log.d(TAG, "[$interaction] recompositions per interaction: $recompositionCount") + val bundle = Bundle().apply { putLong(interaction, recompositionCount) } + InstrumentationRegistry.getInstrumentation().sendStatus(2, bundle) + } + + companion object { + private const val TAG = "RecompositionBenchmark" + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bcefe6f..ddbd9bc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ coil = "3.0.4" junit = "4.13.2" junitVersion = "1.2.1" espressoCore = "3.6.1" +coroutinesTest = "1.9.0" # ──────────────────────────────────────────────────────────────── [libraries] @@ -62,6 +63,7 @@ coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp" junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" } # ──────────────────────────────────────────────────────────────── [bundles] diff --git a/ui/src/main/java/com/aquib/androidperflab/ui/DetailScreen.kt b/ui/src/main/java/com/aquib/androidperflab/ui/DetailScreen.kt index 28e8275..8306dba 100644 --- a/ui/src/main/java/com/aquib/androidperflab/ui/DetailScreen.kt +++ b/ui/src/main/java/com/aquib/androidperflab/ui/DetailScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage @@ -209,8 +210,8 @@ private fun DetailInteractionBar( Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { // BAD: onLike / onBookmark are inline lambdas — new instances each recomposition, // preventing Compose from skipping Button recomposition. - Button(onClick = onLike) { Text(likeLabel) } - OutlinedButton(onClick = onBookmark) { Text(bookmarkLabel) } + Button(onClick = onLike, modifier = Modifier.testTag("detail_like_button")) { Text(likeLabel) } + OutlinedButton(onClick = onBookmark, modifier = Modifier.testTag("detail_bookmark_button")) { Text(bookmarkLabel) } } } From 792f247394ce558d1b89a5412f009b065f3b3cdf Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Thu, 30 Apr 2026 12:11:20 +0800 Subject: [PATCH 5/8] Add benchmark results parsing script and update CI workflow to display summaries in GitHub Step Summary --- .github/workflows/ci.yml | 6 +++ benchmarks/BenchmarkResultsParser.py | 64 ++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 benchmarks/BenchmarkResultsParser.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27378da..4970bd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,12 @@ jobs: arch: x86_64 script: ./gradlew :benchmarks:connectedBenchmarkAndroidTest + - name: Parse Benchmark Results + if: always() + run: | + echo "### Macrobenchmark Results" >> $GITHUB_STEP_SUMMARY + python3 benchmarks/BenchmarkResultsParser.py >> $GITHUB_STEP_SUMMARY + - name: Upload benchmark JSON if: always() uses: actions/upload-artifact@v4 diff --git a/benchmarks/BenchmarkResultsParser.py b/benchmarks/BenchmarkResultsParser.py new file mode 100644 index 0000000..8279bf7 --- /dev/null +++ b/benchmarks/BenchmarkResultsParser.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +import json +import glob +import os + +def format_value(val): + if val is None: + return "-" + if isinstance(val, (int, float)): + return f"{val:.2f}" + return str(val) + +def main(): + # Search for benchmark data files in the standard output directory + search_path = 'benchmarks/build/outputs/connected_android_test_additional_output/**/*-benchmarkData.json' + files = glob.glob(search_path, recursive=True) + + if not files: + # Fallback to search from current directory + files = glob.glob('**/*-benchmarkData.json', recursive=True) + + if not files: + print("No benchmark results found.") + return + + print("| Metric | Min | Median | Max |") + print("| :--- | :---: | :---: | :---: |") + + # Track metrics to avoid duplicates if multiple files are found + seen_results = set() + + for file_path in files: + try: + with open(file_path, 'r') as f: + data = json.load(f) + + if 'benchmarks' not in data: + continue + + for benchmark in data['benchmarks']: + benchmark_name = benchmark.get('name', 'Unknown') + metrics = benchmark.get('metrics', {}) + + for metric_name, values in metrics.items(): + m_min = values.get('minimum') + m_median = values.get('median') + m_max = values.get('maximum') + + # Some metrics might be in nested objects depending on version + # but usually minimum/median/maximum are at the top level of the metric object + + display_name = f"{benchmark_name}_{metric_name}" + result_row = (display_name, m_min, m_median, m_max) + + if result_row not in seen_results: + print(f"| {display_name} | {format_value(m_min)} | {format_value(m_median)} | {format_value(m_max)} |") + seen_results.add(result_row) + except Exception as e: + # Print error to stderr so it doesn't mess up the markdown table on stdout + import sys + print(f"Error parsing {file_path}: {e}", file=sys.stderr) + +if __name__ == "__main__": + main() From 1dff5f8031ee90e80d5a242d8187c2add21d935e Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Thu, 30 Apr 2026 12:37:08 +0800 Subject: [PATCH 6/8] Update CI workflow to make gradlew executable before running Gradle tasks --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4970bd8..4b474d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Make gradlew executable + run: chmod +x gradlew + - uses: actions/setup-java@v4 with: java-version: 17 @@ -43,6 +46,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Make gradlew executable + run: chmod +x gradlew + - uses: actions/setup-java@v4 with: java-version: 17 From 12f125a1425fb692bb8fe5c29b66786094a3cc57 Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Thu, 30 Apr 2026 12:43:03 +0800 Subject: [PATCH 7/8] Update Gradle wrapper checksum for version 9.4.1 --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 4e673d1..b2d99cb 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ #Tue Apr 28 10:38:29 SGT 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06 +distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true From 9f285be821b761032965bb7ab22703698e9cd507 Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Thu, 30 Apr 2026 12:50:58 +0800 Subject: [PATCH 8/8] Update CI workflow to use default emulator target and set emulator boot timeout --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b474d1..2d62e46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,8 +67,9 @@ jobs: uses: reactivecircus/android-emulator-runner@v2 with: api-level: 34 - target: aosp_atd + target: default arch: x86_64 + emulator-boot-timeout: 600 script: ./gradlew :benchmarks:connectedBenchmarkAndroidTest - name: Parse Benchmark Results