Files
file-manager/docs/PERFORMANCE.md
T
SoftwareApp 14615f9842 Take the two main-thread disk reads off the startup path, and report what that bought
Both costs StrictMode found on a cold start are now off the main thread. scratchRoot is a Lazy<File>, so Context.getCacheDir - which checks and creates the private cache directory, measured at 492 ms - runs when a preview first copies something instead of while the view model is being created. The free-space probes moved into fillRootFreeSpace and fillFreeSpace, which run on Dispatchers.IO after the rows are already on screen and update the state when the filesystem answers; nothing waits for them, and fillFreeSpace checks the path is still the one on screen so a fast double-tap cannot leave a folder showing another one's number.

Measured after: StrictMode no longer reports either violation, and the only ones left are MIUI's own font hook - FontSettings.loadFontSetting, TypefaceUtils.loadFontSettings, InterceptorProxy.create via MainActivity.onCreate, in 50-78 ms pairs. The debug cold start went from a 1814 ms median to 1803 ms and the benchmark coldStart from 519.5/531.6 ms to 485.6/568.9/600.0 ms. That is no improvement, and PERFORMANCE.md says so in those words: both violations were first-call costs (cache directory creation once per install, a cold StatFs page cache), the timed runs are warm, and the device's run-to-run spread of 115 ms is wider than anything the change could have bought. What it does change is the launch right after an install, which is the benchmark's first iteration: 648 ms max fresh against 550 ms warm.
2026-10-17 16:11:28 +02:00

245 lines
17 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 six times, on five different runs: 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), 574 ms (min 554, max 730), 519.5 ms (min 503.6, max 544.7) and 531.6 ms (min 475.6,
max 552.0) — the last two on the runs that used `CompilationMode.Ignore()` and measured all four
benchmarks. They 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 was ours**. Both of
ours were mechanical to move — the cache directory is only needed when a preview copies something, and the
free-space line can arrive a frame later — and both are fixed now; what they bought, measured, is below.
### After the fix: what moved, and what did not
Both are now fixed — `scratchRoot` is a `Lazy<File>` resolved on first use, and the free-space probes run
in `Dispatchers.IO` and update the state when they return (`fillRootFreeSpace`, `fillFreeSpace`). Measured
afterwards:
| what | before | after |
|---|---|---|
| StrictMode at start, ours | 492 ms `cacheDir`, 397 ms `StatFs` | **gone** |
| StrictMode at start, not ours | 477 ms typefaces, 54 ms × 2 MIUI font hook | 50–78 ms in pairs, all from `miui.util.font.FontSettings.loadFontSetting` / `TypefaceUtils.loadFontSettings` / `contentcatcher.InterceptorProxy.create` via `MainActivity.onCreate` |
| debug cold start, 5 runs | median 1 814 ms | median 1 803 ms (1 697 · 1 752 · 1 803 · 1 823 · 2 507) |
| `coldStart` on the benchmark build | 519.5 / 531.6 ms | 485.6 / 568.9 / 600.0 ms |
**The numbers did not improve, and that is worth stating rather than dressing up.** The two violations were
*first-call* costs: creating the cache directory happens once per install, and `StatFs` is slow when its
page cache is cold. Neither is paid on a warm device, which is what all five timed runs are — and the
run-to-run spread on this phone (±115 ms between the three benchmark runs above) is larger than anything
the change could have bought. What the fix does buy is that the main thread no longer does this work at
all, which is exactly the case the benchmark's *first* iteration shows: its max is 648 ms after a fresh
install, against 550 ms on a warm one.
The honest conclusion is that the remaining 1.8 s of a debug start is not made of these two calls. It is
R8-less code, Compose's first composition, the typefaces, and MIUI's font hook — and the release-like
build already pays 0.5 s, not 1.8 s. Making a *number* out of any further work needs either a quieter
device or many more runs; making the *app* faster needs a baseline profile (next section).
## 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 two runs that completed all four tests — same APK, `CompilationMode.Ignore()`, 5 iterations each, no
install during either run:
| benchmark | run 1 | run 2 | what it is |
|---|---|---|---|
| `coldStart` | **519.5 ms** (503.6–544.7) | **531.6 ms** (475.6–552.0) | time to initial display |
| `openAFolderOfAThousandFiles` | **14 frames** (11–17) | **12 frames** (12–17) | frames from the folder tap until `file-0.txt` is on screen |
| `scrollAFolderOfAThousandFiles` | **94 frames** (48–96) | **97 frames** (92–100) | frames across four swipes through the folder |
| `searchInsideAFolderOfAThousandFiles` | **38 frames** (33–44) | **39 frames** (36–40) | frames from opening search until `file-99.txt` is the result |
The two runs agree closely, which is the point of running them twice: the method is stable enough that a
change in the app would show up as a change in these numbers rather than as noise.
**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.
One bug had to be fixed before `search` could be measured, and it is worth knowing about because it looked
like a UI problem and was a state problem: the search iteration ends with search mode open, and with
`Ignore()` nothing reinstalls the app between iterations, so the *next* iteration found a search field
where it expected an app bar — the assertion fired every time. The hierarchy dump taken at the point of
failure showed a `TextView` whose *text* was "Search this folder" and a `desc='Clear search'` beside it:
the app was already searching. `openFixture` now calls `killProcess()` before every iteration, so each one
starts on the roots screen. The dump itself stays: on a missing action it writes
`search-action-missing.xml` into the test app's external files directory, which is what made this
diagnosable instead of a guess.
### The install moved out of the test — that is the fix
The problem was never the dialog; it was *who* installs. `CompilationMode.None()` reports
`shouldReset = true`, and that is what makes `resetAndCompile` uninstall the app and reinstall it with
`pm install -t --skip-verification` before every single test. On this phone that install is intercepted by
`com.miui.securitycenter/.permcenter.install.AdbInstallActivity`, which the test cannot answer: the install
call blocks until `pm` exits, so no test code is running while the dialog is up.
`CompilationMode.Ignore()` reports `shouldReset = **false**` — the library installs nothing and measures
what is there. So the APK is installed once, from the host, and the run contains no install at all:
```bash
./tools/benchmark-fixture.sh # /sdcard/AABenchmark, 1000 empty files
adb install -r app/build/outputs/apk/benchmark/app-benchmark.apk
adb install -r benchmark/build/outputs/apk/benchmark/benchmark-benchmark.apk
adb shell am instrument -w -e class de.softwareapp_hb.filemanager.benchmark.BrowserBenchmark \
de.softwareapp_hb.filemanager.benchmark/androidx.test.runner.AndroidJUnitRunner
```
Measured: with that command the four tests run with **zero installs** — `tools/benchmark-watch-install.sh`
logs heartbeats and nothing else — and three of them pass. `Ignore()` applies no compilation profile either,
so the numbers still describe code that has never run; a freshly installed APK has no profile to begin with.
Running through Gradle still works, but Gradle's own install steps happen before the tests and are gated
too, which is why `am instrument` is the documented path.
`tools/benchmark-watch-install.sh` is kept for the one thing it is good at: the two host-side `adb install`
commands above. It reads the Install button off the screen and taps only while the installer is focused. It
is *not* a solution for a run — MIUI keeps `mCurrentFocus` on the installer for a moment after the window is
gone, so a tap sent on that signal can land on the home screen behind it (observed: it opened Telegram).
Why the dialog could not simply be answered from the test, kept because it explains the shape of all this:
- The dialog 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 another
showed the same window focused for 8.5 s.
- `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, the path MIUI intercepts.
- `MIUI`'s SecurityCenter cannot be disabled to get out of the way: `pm disable-user` answers
`SecurityException: Cannot disable miui core packages`.
### 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 survives because nothing uninstalls any more.** An uninstall clears app-ops, so
while the rule was reinstalling the app, a grant applied by the fixture script was gone by the time the
app started — and `appops set` from inside the test does not work either (even under
`adoptShellPermissionIdentity` it fails to stderr, which `executeShellCommand` does not return, and
`appops get` still reads `No operations`). With `Ignore()` the grant lands once and stays.
`grantStorageAccess` in `BrowserBenchmark` still walks the app's own "Grant full access" flow when the
app comes up without access, which costs nothing when it is not needed.
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
adb install -r app/build/outputs/apk/benchmark/app-benchmark.apk # the build under measure
adb install -r benchmark/build/outputs/apk/benchmark/benchmark-benchmark.apk
adb shell am instrument -w -e class de.softwareapp_hb.filemanager.benchmark.BrowserBenchmark \
de.softwareapp_hb.filemanager.benchmark/androidx.test.runner.AndroidJUnitRunner
```
One test can be run by naming it: `-e class …BrowserBenchmark#coldStart`. The numbers are printed in the
instrumentation output itself, and the per-test JSON and Perfetto traces are left in
`/sdcard/Android/media/de.softwareapp_hb.filemanager.benchmark/` — they used to be deleted with the test app
when a run ended, which is why the earlier numbers had to be copied mid-run.
## Still not measured
| number | status |
|---|---|
| Folder open with 5 000 / 20 000 files | not measured: the fixture script counts to 1 000 |
| Thumbnail paint while scrolling a photo grid | not measured: no test for it yet |
| Any jank figure at all | not measurable here: this phone's `FrameTimingMetric` output is `frameCount` only, with no `frameDurationCpuMs` or `frameOverrunMs` |
What the interaction numbers still need is a device that reports frame durations — an emulator or any
non-MIUI phone would do, and the fixture is a folder of empty files, so nothing device-specific is being
measured. The install side is solved: `CompilationMode.Ignore()` plus installing from the host means a run
touches the package manager zero times.
## 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.