skydoves/testing-compose-in-release-mode
Use this skill to ensure Jetpack Compose performance numbers reflect production reality by measuring against a release variant with R8 enabled, Live Literals disabled, and Compose Compiler reports read from the release output directory. Covers why debug builds lie (interpreted Compose runtime, JIT warmup, Live Literals constant-getters), how to set up a release-with-symbols measurement build, and how to wire Macrobenchmark, Compose Compiler reports, Layout Inspector, simpleperf, and Android Studio Profiler against it. Cited result is roughly 75 percent startup gain and 60 percent frame-render gain debug to release. Use when the developer reports "slow startup", "jank", "dropped frames", "high recomposition count", or quotes timings from `assembleDebug`, Layout Inspector, or `CompilationMode.None`. Use when reviewing a perf bug, setting up a CI perf gate, or before filing a perf regression.
npx skills add https://github.com/skydoves/compose-performance-skills --skill testing-compose-in-release-mode
Compose ships unbundled — its UI runtime is loaded from app code, runs interpreted in debug, and the Live Literals plugin turns every constant into a getter. Debug recomposition counts, debug startup numbers, and debug compiler reports are not what users experience. This skill teaches Claude how to set up a release-with-symbols build for honest measurement before any perf claim is made.
CompilationMode.None.../../build/configuring-r8-for-compose/SKILL.md.../generating-baseline-profiles/SKILL.md.../../stability/diagnosing-compose-stability/SKILL.md.signingConfig signingConfigs.debug on the release type), but unsigned release builds will not install.org.jetbrains.kotlin.plugin.compose Gradle plugin.Surface these to the developer when they push back on "but my numbers feel real":
sourceInformation() calls, devirtualizes ComposerImpl, and constant-folds composable args.0.dp, "Hello", Color.Red — in a getter. The recomposer treats these as dynamic, so reports flag composables as taking unstable params even when source code only uses constants.sourceInformation strings remain, and the cost-per-frame budget is bigger than what release ships.Cited measurement: roughly 75 percent startup gain and 60 percent frame-render gain when switching debug to release with R8. Source: Ben Trengrove, "Why should you always test Compose performance in release" (Android Developers Medium).
../../build/configuring-r8-for-compose/SKILL.md for the full keep-rule story. Minimum:android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// Use debug signing if there is no release keystore yet — the build still installs.
signingConfig = signingConfigs.getByName("debug")
}
}
}
featureFlags enum exposes only IntrinsicRemember, OptimizeNonSkippingGroups, PausableComposition, and StrongSkipping. On the legacy Compose Compiler 1.5.x extension the property was liveLiterals, marked deprecated. For measurement, build the release variant — Live Literals is off by default in release. If a separate "benchmark" build type is used (recommended — release minus signing constraints), it inherits the release defaults.../../stability/diagnosing-compose-stability/SKILL.md for how to interpret the reports../gradlew :app:assembleRelease -PcomposeCompilerReports=true
ls app/build/compose_compiler/
# app_release-classes.txt, app_release-composables.txt, app_release-composables.csv, app_release-module.json
CompilationMode.Partial(BaselineProfileMode.Require). The benchmark module itself stays its own variant; the target app must be release. Cross-link ../generating-baseline-profiles/SKILL.md.@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
@get:Rule val rule = MacrobenchmarkRule()
@Test fun startupRelease() = rule.measureRepeated(
packageName = "com.example",
metrics = listOf(StartupTimingMetric()),
iterations = 10,
startupMode = StartupMode.COLD,
compilationMode = CompilationMode.Partial(BaselineProfileMode.Require),
) {
pressHome()
startActivityAndWait()
}
}
@TraceRecomposition. The Layout Inspector recomposition count surface is a debug-only convenience. For an authoritative count, use skydoves' @TraceRecomposition from compose-stability-analyzer against a release-with-symbols build. Cross-link ../tracing-recompositions-at-runtime/SKILL.md.simpleperf and the Android Studio Profiler can resolve native (NDK) frames in the release variant:android {
buildTypes {
release {
isMinifyEnabled = true
// NDK / native symbol level only — controls .so debug symbol packaging.
ndk { debugSymbolLevel = "FULL" }
}
}
}
ndk { debugSymbolLevel = "FULL" } controls NDK / native symbol packaging only; it does not affect Kotlin / Java frame readability. Kotlin frame readability comes from mapping.txt, which R8 always produces when isMinifyEnabled = true. Pair native profiling with R8 retrace (see ../../build/configuring-r8-for-compose/SKILL.md) to map obfuscated Kotlin stacks back to source lines.
# WRONG
./gradlew :app:assembleDebug
cat app/build/compose_compiler/debug/app-composables.txt
# WRONG because: debug enables Live Literals which turns every constant into a getter, so reports show false-positive unstable params and inflate non-skippable counts.
# RIGHT
./gradlew :app:assembleRelease -PcomposeCompilerReports=true
cat app/build/compose_compiler/app_release-composables.txt
// WRONG
rule.measureRepeated(
packageName = "com.example",
metrics = listOf(StartupTimingMetric()),
iterations = 5,
startupMode = StartupMode.COLD,
compilationMode = CompilationMode.None,
)
// WRONG because: CompilationMode.None disables AOT compilation and the target app may also be the debug variant — interpreted Compose stack plus JIT warmup makes startup numbers 3-4x worse than what users see, and any regression diff is dominated by JIT noise.
// RIGHT
rule.measureRepeated(
packageName = "com.example",
metrics = listOf(StartupTimingMetric()),
iterations = 10,
startupMode = StartupMode.COLD,
compilationMode = CompilationMode.Partial(BaselineProfileMode.Require),
)
// And the targetPackage points to the release variant of the app under test.
// WRONG
// "Layout Inspector says ProductCard recomposes 50 times per scroll, so the bug is real
// and we need to mark every parameter @Stable."
// WRONG because: Layout Inspector counts are sampled and approximate; Live Literals can inflate them; the count may double or halve in release. Confirm with @TraceRecomposition or Macrobenchmark FrameTimingMetric in a release build before chasing a fix.
// RIGHT
// Step 1: Reproduce in release build with @TraceRecomposition on the suspect composable.
// Step 2: If the release-build trace still shows the count, then diagnose with
// ../../stability/diagnosing-compose-stability/SKILL.md against the release reports.
// Step 3: Fix, then re-measure with FrameTimingMetric in Macrobenchmark.
@TraceRecomposition(traceStates = true)
@Composable
fun ProductCard(product: Product) { /* ... */ }
// WRONG
// "I ran my benchmark against the debug variant — Live Literals was on, so my recomposition
// counts and frame timings were inflated, and I cannot trust the report."
// WRONG because: Live Literals is on in debug to support Android Studio's live edit; it wraps
// constants in getters that the recomposer treats as dynamic. Stability reports and recomposition
// counts from a debug build are not measurement evidence.
// RIGHT — measure release; Live Literals is off there by default in the new (Kotlin 2.0+)
// Compose Compiler Gradle DSL. The featureFlags enum exposes IntrinsicRemember,
// OptimizeNonSkippingGroups, PausableComposition, and StrongSkipping — there is no LiveLiterals
// flag to set. Just build the release variant and read the release reports:
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
// WRONG
// "Startup is 1420 ms after my change."
// WRONG because: no variant, no device, no compilation mode, no iteration count — unreviewable. The same code measures 460 ms in release with R8 + a baseline profile on a Pixel 6.
// RIGHT
// "Startup TimeToInitialDisplay p50 = 460 ms (release, R8 on, baseline profile required,
// Pixel 6 stock, cold start, 10 iterations, Macrobenchmark)."
CompilationMode.None, or from emulator-only runs are diagnostic at best.build/compose_compiler/<module>_release-*). Debug reports are corrupted by Live Literals.@TraceRecomposition counts (deterministic, runtime-instrumented). Use the latter for any conclusion.ndk { debugSymbolLevel = "FULL" } on the release variant so simpleperf, the Android Studio Profiler, and crash retrace work without rebuilding.release (or a release-derived build type), not debug.isMinifyEnabled = true and proguard-android-optimize.txt are present on that variant.release (Live Literals is off by default there in the Kotlin 2.0+ Compose Compiler DSL — there is no LiveLiterals feature flag to toggle)._release suffix in their names (app_release-composables.txt, etc.).CompilationMode.Partial(BaselineProfileMode.Require) and the target app installed for the run is the release variant.@TraceRecomposition (or Macrobenchmark FrameTimingMetric) on a release build.@TraceRecomposition): https://github.com/skydoves/compose-stability-analyzerTake skydoves/testing-compose-in-release-mode from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.