b72847b822
With the install watcher answering MIUI's dialog, all four tests ran and three produced numbers: coldStart 574.17 ms P50 (553.95-730.19), opening a 1000-file folder 14 frames P50 (13-17), scrolling it with four swipes 98 frames P50 (93-100). The search benchmark still fails, on the app bar's search action not being in the accessibility tree after twenty seconds - the setup wait is raised from ten to twenty now that the folder render is known to be the slow step. The frame counts are labelled as frame counts and not as jank, because this phone reports no frame durations at all: the same results file contains frameCount and nothing else, so 14 frames says the interaction is short and says nothing about whether a frame was slow. The doc also keeps the measurements that explain the watcher's shape - the dialog is FLAG_SECURE, its lifetime varies between under a second and 8.5 s, and pm install from an app uid fails earlier with MIUI's XSpace SecurityException - along with the honest cost: MIUI keeps mCurrentFocus on the installer after the window is gone, so a coordinate tap can land on the home screen, which it did. The watcher no longer reuses a saved position at all and refuses to tap within two seconds of the previous tap.
198 lines
14 KiB
Markdown
198 lines
14 KiB
Markdown
# 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 four times: 615 ms before the storage grant was
|
||
in place (the app was showing the permission screen), 532 ms after it, 560 ms (min 539, max 698) on an
|
||
earlier full-suite run, and 574 ms (min 554, max 730) on the run that first completed all four benchmarks.
|
||
The four runs agree within a phone's patience; the 532 ms figure above is the middle one.
|
||
|
||
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 by `tools/benchmark-fixture.sh`
|
||
and removed with `--remove`. `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.
|
||
|
||
### What the module has measured
|
||
|
||
The run that first completed all four tests (MIUI's install dialog answered by
|
||
`tools/benchmark-watch-install.sh`, 5 iterations each, `CompilationMode.None()`):
|
||
|
||
| benchmark | result | what it is |
|
||
|---|---|---|
|
||
| `coldStart` | **574.17 ms** P50 (min 553.95, max 730.19) | time to initial display — the fourth measurement of the same thing, alongside 615 / 532 / 560 ms |
|
||
| `openAFolderOfAThousandFiles` | **14 frames** P50 (min 13, max 17) | frames drawn from the folder tap until `file-0.txt` is on screen |
|
||
| `scrollAFolderOfAThousandFiles` | **98 frames** P50 (min 93, max 100) | frames drawn across four swipes through the folder |
|
||
| `searchInsideAFolderOfAThousandFiles` | **failed** | the app bar's search action is not in the accessibility tree within 20 s of the folder opening, although the same taps by hand find it immediately |
|
||
|
||
**Those frame counts are not a jank metric, and the difference matters.** `FrameTimingMetric` normally
|
||
publishes `frameDurationCpuMs` and `frameOverrunMs` — how long each frame took and how far past its
|
||
deadline it ran. This phone reports neither: the same run's JSON contains `frameCount` and nothing else,
|
||
so "14 frames to open the folder" is how many frames the interaction took, not how long any of them
|
||
lasted. It says the interaction is short (14 frames is about a quarter of a second at 60 Hz); it cannot
|
||
say whether any of those frames was slow. A device that reports frame durations — an emulator or any
|
||
non-MIUI phone — is what turns this into a jank number.
|
||
|
||
Two notes on provenance. The results JSON from that run was deleted by the next run's cleanup before it
|
||
could be copied a second time, so the figures above come from parsing it once, immediately after the run;
|
||
re-running on a device without an install gate is the way to confirm them. And the run still ends with a
|
||
failing `search` test, so its number is missing rather than zero.
|
||
|
||
### Why the reinstall cannot be answered by the app
|
||
|
||
macrobenchmark installs its target before measuring it, and on this phone MIUI puts that install behind
|
||
`com.miui.securitycenter/.permcenter.install.AdbInstallActivity`. The test cannot answer it itself:
|
||
`CompilationMode.installPackageFromPaths` builds `pm install -t …` and runs it through
|
||
`Shell.executeScriptCaptureStdout`, so the call blocks until the child exits and no test code is running
|
||
while the dialog is up.
|
||
|
||
`tools/benchmark-watch-install.sh` answers it from the host instead — but it is a workaround with a cost,
|
||
and it is not part of the benchmark workflow for that reason: MIUI keeps `mCurrentFocus` pointing at the
|
||
installer for a moment after the window has been removed, so a tap sent on that signal can land on the
|
||
home screen underneath (observed: it opened Telegram). The script now reads the button off the screen
|
||
before every tap and refuses to tap when the installer is not focused, which narrows the race to the
|
||
handful of milliseconds between that check and the tap — it does not close it. MIUI's own **Install via
|
||
USB** setting is what removes the dialog; with it on, installs need no tap at all.
|
||
|
||
Three further measurements about that dialog, kept because they explain the workaround's shape:
|
||
|
||
- It is `FLAG_SECURE`: a `screencap` taken while `mCurrentFocus` was the installer shows the home screen
|
||
behind it, which is why screenshots of "the dialog" show nothing.
|
||
- Its lifetime is not stable. One 0.1 s focus trace caught it as
|
||
`13:51:27.70 AdbInstallActivity` → `13:51:28.38 null` — too short to read, let alone tap — while a later
|
||
trace showed the same window focused for 8.5 s, long enough for a dump and a tap. Presumably the
|
||
difference is whether MIUI is waiting for an answer or merely announcing the install.
|
||
- `pm install` started from an *app* uid does not even reach the gate: MIUI's `XSpaceManagerServiceImpl`
|
||
throws `SecurityException: You either need MANAGE_USERS or CREATE_USERS permission to: query users`.
|
||
The rule's install therefore runs through the shell, which is the path MIUI intercepts.
|
||
|
||
### The four switches that make an unattended run possible at all
|
||
|
||
1. **The app under test is the app itself** — same package id, release key — because a new package id
|
||
cannot be installed on this phone (`INSTALL_FAILED_USER_RESTRICTED`, and an APK installed by tapping
|
||
ended up visible to `pm list packages` but invisible to `PackageManager`). The cost is recorded in
|
||
`:app`'s build file: **this build replaces a release install**; reinstalling the release APK puts it
|
||
back.
|
||
2. **The test module declares a `benchmark` build type.** AGP 9's `com.android.test` extension has no
|
||
`testBuildType` and no `targetVariant` — only `targetProjectPath` — and the target variant is matched
|
||
*by name* against the test module's own build types. Without that type every run installed
|
||
`app-debug.apk`, so the numbers would have been debug numbers. The type also has to
|
||
`initWith(getByName("debug"))`: a declared type starts unsigned, and an unsigned test APK fails with
|
||
`INSTALL_PARSE_FAILED_NO_CERTIFICATES`.
|
||
3. **The storage grant is walked through the UI, not set with `appops`.** The rule uninstalls and
|
||
reinstalls the app, and an uninstall clears app-ops, so a grant applied by the fixture script is gone
|
||
by the time the app starts. `appops set` from inside the test does not work either — even under
|
||
`adoptShellPermissionIdentity`, the command fails to stderr (which `executeShellCommand` does not
|
||
return) and `appops get` still reads `No operations`. What does work is tapping what a user taps:
|
||
the app's "Grant full access" button, then the platform's consent, then leaving the all-files page.
|
||
`grantStorageAccess` in `BrowserBenchmark` does exactly that, in `setupBlock`, unmeasured.
|
||
4. **The fixture is created through adb**, because `UiAutomation.executeShellCommand` runs with the *test
|
||
app's* uid and cannot write `/sdcard`.
|
||
|
||
### The workflow
|
||
|
||
```bash
|
||
./tools/benchmark-fixture.sh # /sdcard/AABenchmark, 1000 empty files
|
||
./tools/benchmark-watch-install.sh --seconds 900 & # answers MIUI's install dialog if it stays up
|
||
./gradlew :benchmark:connectedBenchmarkAndroidTest
|
||
```
|
||
|
||
`coldStart` alone can be run with `-Pandroid.testInstrumentationRunnerArguments.class=…BrowserBenchmark#coldStart`.
|
||
The results land in `benchmark/build/outputs/androidTest-results/connected/benchmark/`, and — worth
|
||
knowing, because it cost an hour — the per-test JSON that holds the numbers is written to
|
||
`/sdcard/Android/media/<test-pkg>/additional_test_output/` and **deleted together with the test app when
|
||
the run ends**, often before Gradle has copied it. Copying it in a loop while the run is in progress is
|
||
what recovered the numbers above.
|
||
|
||
## Not measured yet, and why
|
||
|
||
| number | status |
|
||
|---|---|
|
||
| Folder open with 1 000 files | **14 frames** P50 — measured, but as a frame *count*, not a jank figure |
|
||
| Folder open with 5 000 / 20 000 files | not measured: the fixture script counts to 1 000, and every larger run needs another install cycle |
|
||
| Scrolling that folder | **98 frames** P50 over four swipes — again a frame count |
|
||
| Typing into search | **failed** in the automated run: the app bar's search action is not in the accessibility tree within 20 s of the folder opening, although the same taps by hand find it immediately |
|
||
| Thumbnail paint while scrolling a photo grid | not measured: no test for it yet, and no frame durations on this device |
|
||
|
||
What the interaction numbers need is a device that reports frame durations; what the *repeated* runs need
|
||
is a device that installs unattended. This phone gives neither, and the two are independent: a
|
||
non-MIUI phone or an emulator with the system language set to English would answer both, and the fixture
|
||
is a folder of empty files, so nothing device-specific is being measured.
|
||
|
||
## 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.
|