Files
file-manager/docs/PERFORMANCE.md
T
SoftwareApp 5a6ae3f71d Record what actually blocks the interaction benchmarks: the reinstall clears the grant
The diagnosis is now precise and it is not the installer dialog. Macrobenchmark's rule uninstalls the app and reinstalls it before measuring; an uninstall clears app-ops, so the MANAGE_EXTERNAL_STORAGE grant that tools/benchmark-fixture.sh applies is gone by the time the test navigates. appops get reads No operations after a run, the app correctly shows its choose-a-folder fallback, and the volume row the test looks for is not on screen - which is exactly the assertion that fired.

No test can work around it: granting an app-op needs the shell uid, and UiAutomation.executeShellCommand runs as the test app. So the three interaction benchmarks cannot run as macrobenchmark tests on any device where this app needs the grant. PERFORMANCE.md now says that, names the two paths that would give the numbers (a plain instrumented test that avoids the rule, or a benchmark flow that needs no grant), and says what each costs. The cold-start number stands.
2026-10-17 10:40:56 +02:00

143 lines
9.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Performance
What has been measured, on what, and — explicitly — what has **not** been measured at all. The rule
this file exists to enforce comes from the competitive survey: the market leader's top complaint is
thumbnails that load late, its own support reply to a speed complaint was *"your SD card is slow"*,
and the lesson recorded there was to measure what was measured and never close a performance
complaint by blaming the hardware.
Until this file existed, the app had **no performance number of any kind**.
## Cold start
| build | device | runs (ms) | median |
|---|---|---|---|
| debug, `1.0.0-debug` | Xiaomi phone, Android 15 | 1 758 · 1 773 · 1 814 · 1 926 · 2 043 | **1 814 ms** |
| `benchmark` (R8, not debuggable, profileable) | same | — | **532 ms** |
The first is `./tools/measure-startup.sh 5` (`am start -W`, one warm-up start discarded because the first
launch after an install pays for dexopt). The second is `BrowserBenchmark#coldStart` in the `:benchmark`
module: `StartupTimingMetric`, five iterations, `CompilationMode.None()`, measured as time to initial
display — min 499 ms, median 532 ms, max 805 ms. Measured twice: 615 ms before the storage grant was in
place (the app was showing the permission screen) and 532 ms after, which is the figure above.
Two caveats belong to that number rather than to a footnote. The tool itself warns *"Unable to clear
Runtime Image, subsequent launches/iterations may exhibit faster startup than production due to
accelerated class loading"*, so it is optimistic by an unmeasured amount on this Android version. And
`CompilationMode.None()` means no compilation profile is applied — what a fresh install does — so a
baseline profile would lower it further and is the obvious next measurement.
The debug build is deliberately the *slow* case — StrictMode on, LeakCanary attached, no R8 — which is
why the same screen takes it three times as long. That is the honest illustration of why a debug number
must never be published as the app's performance.
### What StrictMode found on the first cold start
The same debug build now logs StrictMode violations, and the first cold start produced five, against a
1 814 ms start:
| duration | where | ours? |
|---|---|---|
| 492 ms | `BrowserViewModel.Factory` → `app.cacheDir` | **yes** — `Context.getCacheDir()` checks and creates the private cache directory while the view model is being created, on the main thread |
| 397 ms | `BrowserViewModel.rootsOf` → `StorageProvider.space` → `VolumeRepository.stat` | **yes** — free space for every volume is read with `StatFs` inside `refresh()`, which the screen calls from a `LaunchedEffect` |
| 477 ms | `FileManagerTheme` → `typography = FileManagerTypography` | no — the platform loading its own typefaces on first composition |
| 54 ms × 2 | `miui.util.font.FontSettings` via `MainActivity.onCreate` | no — the OEM's font hook doing `File.exists` while the activity is created |
That is **~1.5 s of main-thread disk I/O inside a 1.8 s start**, of which **~0.9 s is ours**. Both of
ours are mechanical to move and neither is fixed yet: the cache directory is only needed when a preview
copies something, and the free-space line on the roots screen can arrive a frame later. They are the
first items of the speed work.
## Measured on demand: the `:benchmark` module
`benchmark/` holds four macrobenchmark tests that measure the interaction numbers above on a benchmark
build of the app: release rules (R8 on, not debuggable) and profileable, **under the app's own package id
and signed with the release key**, so installing it *updates* an installed release build and keeps its
data. A separate package id (`.perf`) was the first design and is the tidier one, but on this MIUI phone a
new package id cannot be installed without a human: `adb install` is refused with
`INSTALL_FAILED_USER_RESTRICTED`, and installing by tapping the APK left the package visible to
`pm list packages` yet invisible to `PackageManager`, which is what macrobenchmark reports as *"Unable to
find target package"*. The trade-off is written in `:app`'s build file: **this build replaces a release
install**, and reinstalling the release APK puts it back.
| benchmark | metric | what it answers |
|---|---|---|
| `coldStart` | `StartupTimingMetric` | tap → first frame |
| `openAFolderOfAThousandFiles` | `FrameTimingMetric` | jank while a 1 000-file folder fills in |
| `scrollAFolderOfAThousandFiles` | `FrameTimingMetric` | jank while scrolling that folder |
| `searchInsideAFolderOfAThousandFiles` | `FrameTimingMetric` | jank while typing into search |
They share a fixture — `/sdcard/AABenchmark`, a thousand empty files, created once per run and deleted
in `@AfterClass` — and the storage grant is applied with `appops`, so an unattended run is possible.
`CompilationMode.None()` is deliberate: no profile is applied, which is the honest cold-install
baseline. `CompilationMode.Partial()` plus a baseline profile is the follow-up, and needs the same
module.
**Two of these have produced a number** (`coldStart`, above); the three interaction ones have not, and
the reason is a device policy rather than the code. Getting this far took five fixes, each written next
to what it constrains: the app under test needs the app's own package id and release key (a new id cannot
be installed here); the test APK needs package visibility for it; the fixture has to be created through
adb, because `UiAutomation.executeShellCommand` runs with the *test app's* uid and cannot write `/sdcard`;
`testBuildType` has to sit behind `-PbenchmarkBuild`, or `:app:testDebugUnitTest` stops existing; and
`hasProperty` is needed to read that flag, because the Provider form read as absent and the runner then
installed the *debug* APK and failed on it.
The workflow:
```bash
./tools/benchmark-fixture.sh # the fixture, and the storage grant the benchmark build lacks
./gradlew -PbenchmarkBuild :benchmark:connectedDebugAndroidTest
```
**The Gradle runner is required, not preferred.** Macrobenchmark's rule uninstalls the app at the start
of a run and reinstalls it before measuring. That alone is survivable; what is not is the side effect:
**an uninstall clears the app's app-ops**, so the `MANAGE_EXTERNAL_STORAGE` grant from
`tools/benchmark-fixture.sh` is gone by the time the test navigates. The app then correctly shows its
"choose a folder" fallback, the volume row is not on the screen, and the assertion says so. Checked
directly — `appops get … MANAGE_EXTERNAL_STORAGE` reads `No operations` after a run that passed through
the reinstall.
There is no way around that from inside the test: granting an app-op needs the shell's uid, and
`UiAutomation.executeShellCommand` runs with the *test app's* uid. So on a device where this app needs
that grant — which is every device, for the browser screen — **the three interaction benchmarks cannot run
as macrobenchmark tests here**, and the cold-start number above stands alone.
Two paths would give the numbers, and neither is a small change:
- **A plain instrumented test instead of a macrobenchmark one** (no rule, so no reinstall): install the
benchmark build once, grant, then navigate with UiAutomator and read frame durations from
`FrameMetrics`. It measures the release-like build, but it is our own measurement code rather than a
maintained library, and `FrameTimingMetric` exists precisely so nobody has to write that.
- **An app that does not need the grant for the flow being measured** — for instance a benchmark
build that browses its own external files directory, which needs no permission. That measures a
different screen than the one users open, so it answers a narrower question than "how fast does
opening a 1 000-file folder feel".
## Not measured yet, and why
| number | how it would be taken | why it is not here |
|---|---|---|
| Folder open, 1 000 / 5 000 / 20 000 files | macrobenchmark `StartupTimingMetric` over a fixture volume, or `TraceSectionMetric` around the listing | needs a `:benchmark` module and a profileable build installed under the **release package id**; on this MIUI phone the first install of a new package id waits for a confirmation dialog, so it cannot run unattended |
| Thumbnail paint while scrolling a photo grid | `FrameTimingMetric` | same |
| Keystroke → search result | `TraceSectionMetric` around the query and the index read | same |
The module does not exist yet, deliberately: half-configured build wiring that nobody can run is worse
than an honest gap. It is the next measurement step, and it belongs with the baseline-profile work,
which needs the same module.
## What is measured instead, meanwhile
The gate measures things that are not latency but do not rot: the test count, lint, detekt against its
baseline, and whether `docs/CODE_MAP.md` still matches the tree. Those are the numbers that can be
kept true by automation; the three above need a device and a decision about which device.
## Two rules
- **No number that was not taken from a real build on a real device.** Not from a debug build
pretending to be release, and not from reading the code.
- **No performance change judged by reading the code.** The bigger-thumbnail change (28 → 48 dp in a
row, 40 → 96 dp in a grid) is the standing example: it may be exactly right, and nobody measured
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
number.