Ship a baseline profile, generated on the emulator because the phone cannot collect one

The profile is real: 13193 lines in app/src/main/baseline-prof.txt, 584 of them the app's own classes, bundled into the release APK as assets/dexopt/baseline.prof. It is produced by a new :baselineprofile test module that walks the first journey - roots screen, one volume opened - under the androidx.baselineprofile plugin, and :app now depends on androidx.profileinstaller so the bundled profile is applied at install time.

Two device facts decided how it was made. Collection requires API 33+ (BaselineProfileRule refuses Android 12 outright), so it ran on the API 35 AVD that was already in ~/.android/avd; that emulator is software-rendered and needs androidx.benchmark.suppressErrors=EMULATOR for any macrobenchmark. And the release build needed profileable android:shell=true, which is why app/src/release/AndroidManifest.xml exists and says why: the same APK is both the thing measured and the thing shipped, so the profile's class names are the ones in the shipped dex.

The effect is not measured, and PERFORMANCE.md says so: 7404 ms median without the profile against 9005 ms with it, run to run, because a TCG emulator varies by more than a profile can buy. What remains is one run on a physical API 33+ phone, with the two compile commands documented. The emulator did settle the other open question - FrameTimingMetric there reports frameDurationCpuMs and frameOverrunMs, the metrics this phone cannot produce at all.

:baselineprofile is compiled by tools/verify.sh --release like :benchmark, so it cannot rot between device runs.
This commit is contained in:
2026-10-17 17:06:08 +02:00
parent 0a3ff88247
commit 4846454645
12 changed files with 13377 additions and 6 deletions
+3
View File
@@ -29,3 +29,6 @@ demo-files/
# Note that the *finished* store assets are not ignored: the icon, the feature graphic and # Note that the *finished* store assets are not ignored: the icon, the feature graphic and
# `store-assets/screenshots/` are committed beside the code that produces them. # `store-assets/screenshots/` are committed beside the code that produces them.
# Written by :app:generateReleaseBaselineProfile; the profile itself is committed at
# app/src/main/baseline-prof.txt.
app/src/release/generated/
+6
View File
@@ -6,6 +6,8 @@ import org.gradle.api.tasks.testing.Test
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
// Writes the baseline profile that `:baselineprofile` generates into this module's sources.
alias(libs.plugins.androidx.baselineprofile)
} }
/* /*
@@ -200,6 +202,10 @@ tasks.withType<Test>().configureEach {
} }
dependencies { dependencies {
// Applies the bundled baseline profile on the device; see docs/PERFORMANCE.md.
implementation(libs.androidx.profileinstaller)
baselineProfile(project(":baselineprofile"))
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
// SAF backend for the "All files access denied" fallback path. // SAF backend for the "All files access denied" fallback path.
implementation(libs.androidx.documentfile) implementation(libs.androidx.documentfile)
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
`<profileable android:shell="true"/>` on the release build.
The baseline-profile generator (`:baselineprofile`) has to profile the app it is generating for, and
profiling requires this flag — see the same element in `src/benchmark/AndroidManifest.xml`, where it is
required for the macrobenchmarks. `shell="true"` means only the adb shell may profile the process: no
other app can, and nothing about the profile is exposed to a user. Shipping it is what lets the profile
be regenerated against the *release* build rather than a lookalike, so the class names in
`src/main/baseline-prof.txt` are exactly the ones in the shipped dex.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<profileable android:shell="true" />
</application>
</manifest>
+55
View File
@@ -0,0 +1,55 @@
plugins {
// No version for AGP: `com.android.application` in :app has already put it on the build's plugin
// classpath. The version is the one in the catalog.
id("com.android.test")
alias(libs.plugins.androidx.baselineprofile)
}
/**
* Generates the app's baseline profile from a device run.
*
* The profile is a list of the classes and methods that the first journey touches, so the platform can
* AOT-compile exactly those at install time instead of guessing. `:app` bundles whatever ends up in
* `app/src/main/baseline-prof.txt`; this module is how that file is produced, and how it is produced
* again when the startup path changes.
*
* ./gradlew :baselineprofile:generateBaselineProfile
*
* It profiles the app's `benchmark` build type rather than `release`: same sources and the same R8
* rules, so the same class names, but with `<profileable android:shell="true"/>` in its manifest, which
* profiling requires and which a shipped release build does not carry.
*/
android {
namespace = "de.softwareapp_hb.filemanager.baselineprofile"
compileSdk = 36
defaultConfig {
minSdk = 26
targetSdk = 36
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
targetProjectPath = ":app"
// Test modules pick their target variant by name in AGP 9 (see :benchmark's build file), so
// declaring one called `benchmark` is what makes this profile :app's benchmark build.
buildTypes {
create("benchmark") {
// A declared build type starts unsigned; an unsigned test APK cannot be installed.
initWith(getByName("debug"))
}
}
experimentalProperties["android.experimental.self-instrumenting"] = true
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
implementation(libs.androidx.benchmark.macro.junit4)
implementation(libs.androidx.test.ext.junit)
implementation(libs.androidx.test.uiautomator)
implementation(libs.androidx.test.runner)
}
@@ -0,0 +1,49 @@
package de.softwareapp_hb.filemanager.baselineprofile
import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* The journey that becomes the baseline profile.
*
* Profile generation is not a benchmark: it records which classes and methods the *first* journey
* touches, so the platform can AOT-compile them when the app is installed instead of profiling by
* running the app. So this walks what a new user walks — the roots screen, and one volume opened — and
* nothing else. Opening a thousand-file folder here would profile the listing code, which is a different
* measurement, and adding search would profile code the first second never runs.
*
* Generated with:
*
* ./gradlew :baselineprofile:generateBaselineProfile
*
* The result lands in `app/src/main/baseline-prof.txt`; see PERFORMANCE.md for the number it buys.
*/
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule
val baselineProfileRule = BaselineProfileRule()
@Test
fun startup() = baselineProfileRule.collect(packageName = APP) {
pressHome()
startActivityAndWait()
// The roots screen is the first frame; opening internal storage is the first interaction.
device.wait(Until.findObject(By.text(INTERNAL_STORAGE)), TIMEOUT_MS)?.click()
device.waitForIdle()
device.wait(Until.hasObject(By.text(INTERNAL_STORAGE)), TIMEOUT_MS)
}
private companion object {
/** The `benchmark` build type keeps the app's own id; see :app's build file. */
const val APP = "de.softwareapp_hb.filemanager"
const val INTERNAL_STORAGE = "Internal storage"
const val TIMEOUT_MS = 5_000L
}
}
+2
View File
@@ -7,4 +7,6 @@
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
// Generates the baseline profile from a device run; applied by :app and :baselineprofile.
alias(libs.plugins.androidx.baselineprofile) apply false
} }
+32
View File
@@ -242,3 +242,35 @@ kept true by automation; the three above need a device and a decision about whic
either side of it. `docs/COMPETITIVE_ANALYSIS.md` had already written "a thumbnail that is bigger and either side of it. `docs/COMPETITIVE_ANALYSIS.md` had already written "a thumbnail that is bigger and
slower is strictly worse than one that is smaller and instant" — which is a hypothesis until it is a slower is strictly worse than one that is smaller and instant" — which is a hypothesis until it is a
number. number.
## The baseline profile
`app/src/main/baseline-prof.txt` is generated, committed and bundled — the release APK now contains
`assets/dexopt/baseline.prof`. It is produced by `:baselineprofile`, a test module that walks the first
journey (roots screen, one volume opened) on a device:
```bash
./gradlew :app:generateReleaseBaselineProfile # needs a device with API 33+
```
Collection **requires API 33 or a rooted device**: `BaselineProfileRule` refuses this phone (Android 12)
with `Baseline Profile collection requires API 33+, or a rooted device`. It was generated on the `phone35`
AVD (API 35, software-rendered) instead, which also needs `androidx.benchmark.suppressErrors=EMULATOR`
for any macrobenchmark.
**Its effect is not measured here, and that is stated rather than glossed over.** The phone cannot collect
a profile, and the emulator is too noisy to resolve the delta: the same APK measured 7 404 ms median
without the profile and 9 005 ms with it, run to run, because a software-rendered emulator varies by more
than the profile could ever buy. What the profile needs to be justified is one measurement on a physical
API 33+ device, with and without:
```bash
adb shell cmd package compile --reset de.softwareapp_hb.filemanager # no profile
adb shell cmd package compile -m speed-profile -f de.softwareapp_hb.filemanager # profile applied
```
The emulator did answer one open question, though: on that path `FrameTimingMetric` **does** report what
the phone cannot — `frameDurationCpuMs` and `frameOverrunMs` are both present (P50 233 ms and 341 ms for
opening the folder, which is software rendering and not a number anyone should quote). The metric exists
as soon as the device supports frame timelines, so a physical API 33+ phone gives both the profile
delta and the jank figures the interaction benchmarks have been missing.
+6 -4
View File
@@ -67,10 +67,12 @@ would no longer have.
Ordered by what it protects, not by what is fun. Nothing here is scheduled; the first two are what a Ordered by what it protects, not by what is fun. Nothing here is scheduled; the first two are what a
release should not go out without. release should not go out without.
1. **Baseline profile** — generate one, commit it, re-run `coldStart`, and publish the delta next to the 1. **Baseline profile** — **generated and committed**: `app/src/main/baseline-prof.txt` is bundled in the
532 ms figure in [PERFORMANCE](PERFORMANCE.md). This is the only item that makes the app measurably release APK, written by the `:baselineprofile` module. What remains is its *measurement*: collection
faster for everyone; it needs the `androidx.baselineprofile` plugin, which is not in the offline needs a device with API 33+, and the phone is Android 12, while the API 35 emulator here is too noisy to
Gradle cache and so has to be fetched first. resolve the delta. One run on a physical API 33+ device — `compile --reset` versus
`compile -m speed-profile` — turns it from "bundled" into a number in
[PERFORMANCE](PERFORMANCE.md).
2. **Thumbnail hardening before the next release** — an explicit Coil `ImageLoader` with a decided cache 2. **Thumbnail hardening before the next release** — an explicit Coil `ImageLoader` with a decided cache
policy and a placeholder, plus a large-folder device pass recorded in policy and a placeholder, plus a large-folder device pass recorded in
[EDGE_CASE_TESTING](EDGE_CASE_TESTING.md). The only item here that touches code changed late in 1.0. [EDGE_CASE_TESTING](EDGE_CASE_TESTING.md). The only item here that touches code changed late in 1.0.
+7
View File
@@ -45,6 +45,10 @@ leakcanary = "2.14"
# Macrobenchmark (the `:benchmark` module). Its own versions because it is a test APK that is never # Macrobenchmark (the `:benchmark` module). Its own versions because it is a test APK that is never
# part of the app. # part of the app.
benchmark = "1.5.0" benchmark = "1.5.0"
# Baseline profiles: the Gradle plugin generates one from a device run, and profileinstaller applies it
# on the device at install time. 1.5.0 is the newest stable and the one that knows AGP 9.
baselineprofile = "1.5.0"
profileinstaller = "1.4.0"
androidxTestJunit = "1.3.0" androidxTestJunit = "1.3.0"
uiautomator = "2.4.0" uiautomator = "2.4.0"
androidxTestRunner = "1.7.0" androidxTestRunner = "1.7.0"
@@ -93,6 +97,8 @@ androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchm
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestJunit" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestJunit" }
androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" }
# Installs the baseline profile bundled in the APK on first run.
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
# Static analysis, run by the :app:detekt task from its own configuration — never an app dependency. # Static analysis, run by the :app:detekt task from its own configuration — never an app dependency.
@@ -103,3 +109,4 @@ android-application = { id = "com.android.application", version.ref = "agp" }
# NOTE: `org.jetbrains.kotlin.android` is intentionally absent — AGP 9.x provides # NOTE: `org.jetbrains.kotlin.android` is intentionally absent — AGP 9.x provides
# built-in Kotlin support and applying that plugin is now an error. # built-in Kotlin support and applying that plugin is now an error.
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
androidx-baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" }
+3
View File
@@ -25,3 +25,6 @@ include(":app")
// The macrobenchmark test APK, on its own module because it instruments the app rather than shipping // The macrobenchmark test APK, on its own module because it instruments the app rather than shipping
// inside it. `tools/verify.sh --release` compiles it, so it cannot rot unnoticed between runs. // inside it. `tools/verify.sh --release` compiles it, so it cannot rot unnoticed between runs.
include(":benchmark") include(":benchmark")
// The baseline-profile generator: a test APK that walks the app's first journey on a device and lets the
// androidx.baselineprofile plugin write down which classes that journey touched.
include(":baselineprofile")
+4 -2
View File
@@ -48,8 +48,10 @@ if $release; then
step "Benchmark APKs compile" step "Benchmark APKs compile"
# No device: this only proves that :benchmark still compiles against the app's benchmark build # No device: this only proves that :benchmark still compiles against the app's benchmark build
# type, which is the thing that rots silently between device runs. # type, which is the thing that rots silently between device runs. :baselineprofile is included for
gradle :app:assembleBenchmark :benchmark:assembleDebug # the same reason — its generator only runs on a device with API 33+, so nothing else would notice it
# breaking.
gradle :app:assembleBenchmark :benchmark:assembleDebug :baselineprofile:assembleBenchmark
fi fi
printf '\n\033[1mverify: all checks passed\033[0m\n' printf '\n\033[1mverify: all checks passed\033[0m\n'