Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -61,10 +67,17 @@ 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
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
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
build/
/captures
.externalNativeBuild
.cxx
Expand All @@ -23,3 +23,4 @@ local.properties
/.idea/runConfigurations.xml
/.idea/studiobot.xml
/.idea/vcs.xml
.idea/androidTestResultsUserPreferences.xml
4 changes: 3 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -79,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)
}
Original file line number Diff line number Diff line change
@@ -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<ComponentActivity>()

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"
}
}
64 changes: 64 additions & 0 deletions benchmarks/BenchmarkResultsParser.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions benchmarks/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import org.junit.runner.RunWith
*/
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {

@get:Rule
val baselineProfileRule = BaselineProfileRule()

Expand Down
Loading
Loading