The 19-26 ms mid-scroll stalls (Mali-only, fixed content offsets) were the Skia renderer's final synchronous GrDirectContext::flush() blocking the UI thread on Mali driver back-pressure — proven by per-stage timestamps in the vendored renderer (stall frames: flush 19-24 ms, walk 1-3 ms, 1 binding re-eval, image imports 0.1 ms each). - vendor/i-slint-renderer-skia (pinned 1.18.1, [patch.crates-io]): remove that one flush. Redundant on the wgpu backend: the surface already calls flush_and_submit before queue.present, same Vulkan queue, in-order. Phone: 0 stalls across 4 runs (max 3.6-7.4 ms vs the 8.3 ms budget), display verified clean. - docs/BUGS/slint-skia-final-flush-jank.md: full evidence, prior art (slint-ui/slint#12030 closest), open question for a future upstream filing (not filed yet). - Profiling also retired the O(n)-walk theory: Slint 1.18 prunes the item walk to ~133-152 items; the windowed Flickable built on that theory was already reverted (ListView is smooth with the fix). - Strip all jank-hunt instrumentation: FR/SCR/TICK/APLYD/STG logging, stg markers, .gallery_* file knobs, jsamp thread sampler, DbgScroll programmatic scroll, nblog perf-file mirroring. Also fixes the 'launches into unstoppable fast scroll' bug: a leftover .gallery_cmd was re-read on every launch (file never deleted) and overrode user swipes — the hook is gone. - Keep: nblog (logd backpressure is real), zero-dimension decode guards, keep-alive (default-off). - HANDOFF: replace stale windowed-Flickable section with the actual root cause/fix; 15 unit tests pass. Verified on phone (aarch64, 8580 photos) and emulator (x86_64).
727 lines
43 KiB
Markdown
727 lines
43 KiB
Markdown
# Gallery — development hand-off
|
||
|
||
A high-performance image gallery for Android (primary), Linux and macOS,
|
||
written in Rust with the **Slint** UI toolkit. This document is the starting
|
||
point for development: it captures every decision, gotcha, and measurement
|
||
from the framework evaluation that preceded it, so no learning has to be
|
||
rediscovered.
|
||
|
||
**Status: M3 done (2026-10-27, twice overhauled)** — M0 scaffold, M1 metadata
|
||
scan + sqlite + instant list paint, M2 thumbnail engine (turbojpeg DCT-shrink
|
||
decode, worker pool, disk cache, cancellation), M3 grid UI (3-column ListView
|
||
grid, shimmer placeholders, memory-bound cell retention) all verified: 15
|
||
unit tests (incl. two queue-priority regressions), 5k synthetic engine test,
|
||
and on a real Pixel 9 Pro (8,577 photos: 60 fps, 0 hitches). The thumbnail
|
||
loader was rewritten TWICE on 2026-10-27 because "stop → long shimmer → small
|
||
move → everything pops" kept surviving: first the scroll handler forgot cells
|
||
when scrolling stopped (fix: **viewport reconciliation** — the 10 Hz tick is
|
||
the sole authority, the invariant "every viewport cell is painted / in flight
|
||
/ failed" is restored unconditionally each tick and `debug_assert`ed), then
|
||
the worker-level FIFO still let stale transit work delay a fresh viewport
|
||
(fix: **worker-queue prepend + cap** — bursts are prepended ahead of older
|
||
queued work; per-worker cap drops the oldest tail with a `skipped` result).
|
||
Stress-verified on the emulator (debug build) + phone before shipping:
|
||
fast up/down 0–120 ms to full coverage, cold landings bounded by decode
|
||
throughput, chaos scrolling max oldest in-flight job 217 ms (was 8 s),
|
||
0 invariant violations in ~1,450 ticks, 0 panics/ANRs. Next: M4 viewer.
|
||
|
||
---
|
||
|
||
## 1. Goals
|
||
|
||
- Browse and view a local photo collection (thousands of images) that stays
|
||
smooth: instant grid, no decode jank, cached thumbnails.
|
||
- Full-screen viewer with pinch-zoom/pan.
|
||
- Correct EXIF orientation, HEIC support (Android camera default).
|
||
- Same codebase on macOS + Linux (desktop `cargo run`) and Android
|
||
(`cargo apk2`).
|
||
- Quality of the *platform experience* matters: safe-area insets, IME-free
|
||
UI, native-feeling widgets, animations — first-class support preferred over
|
||
manual patching.
|
||
|
||
Non-goals (for now): editing, cloud sync, video, widgets/live-updates.
|
||
|
||
## 2. Why Slint (decision summary)
|
||
|
||
Full evaluation in `/mnt/Backup/prog/android/ui/REPORT.md` (same tree:
|
||
`~/prog/android/ui/REPORT.md`), raw notes in `NOTES.md` there. Headline:
|
||
|
||
| | Slint 1.18.1 | eframe/egui 0.37.0 |
|
||
|---|---|---|
|
||
| Virtualized list (the gallery's lifeline) | ✅ `ListView` std-widget, only visible rows instantiated | ❌ `ScrollArea::show` lays out all children; `show_rows` = manual virtualization API |
|
||
| Async image → UI | swap `slint::Image` into a property; only that tile redraws (retained mode) | `ctx.load_texture` + custom loader; visible set rebuilt every frame |
|
||
| Lifecycle hook for permissions | ✅ `init_with_event_listener` (Start/Resume/Pause/Stop/SaveState/...) | ❌ none in `App` trait (winit swallows it) |
|
||
| Android safe-area | ✅ `safe-area-insets` built in | manual 32px-ish panel workaround (winit#3910) |
|
||
| Animation | ✅ declarative built-in engine | hand-rolled |
|
||
| IME text entry | ✅ | ✅ |
|
||
| APK size (release aarch64) | 8.57 MB | 2.87 MB |
|
||
| Clean build time (x86_64, this machine) | 195 s / 270 crates | 76 s / 137 crates |
|
||
| Desktop Linux system deps | `libfontconfig1` + `pkg-config` | none |
|
||
| License | royalty-free for proprietary mobile/desktop (embedded excluded) / GPLv3 / commercial | MIT OR Apache-2.0 |
|
||
| Min Android API | 26 | 24 |
|
||
|
||
For a stateful, form-and-grid app, the declarative model was the right fit
|
||
(see the discussion about `pad`'s immediate-mode frame friction); egui wins
|
||
for games/dashboards and on APK size. Known Slint caveat: an open upstream
|
||
"abort on pointer scroll" bug in the Android backend (`todo!()` in the
|
||
pointer-event path) — verify it's fixed in 1.19 before relying on heavy
|
||
scrolling; if it fires, the panic is obvious in logcat.
|
||
|
||
## 3. Environment, build & test loop
|
||
|
||
Everything below is verified working on this machine (2026-10-27).
|
||
|
||
```bash
|
||
export ANDROID_HOME=$HOME/android-sdk
|
||
export ANDROID_NDK_ROOT=$HOME/android-sdk/ndk/27.0.12077973
|
||
export PATH="$HOME/.cargo/bin:$ANDROID_HOME/platform-tools:$PATH"
|
||
|
||
# Rust 1.99.1 via rustup; tools: cargo-apk2 1.5.0, cargo-ndk 4.1.2
|
||
./scripts/build.sh debug # debug x86_64 APK + adb install + launch
|
||
./scripts/build.sh # release x86_64 (emulator)
|
||
./scripts/build.sh aarch64 # release aarch64 (device)
|
||
cargo run # desktop (Linux needs libfontconfig1+pkg-config)
|
||
```
|
||
|
||
- Emulator: `emulator -avd pad_avd -no-window -no-audio -gpu off -no-snapshot`
|
||
(google_apis x86_64, Android 15). **`-gpu off -no-snapshot` matters** —
|
||
swiftshader_indirect + snapshot crashed the emulator once during the eval.
|
||
If it won't start, the host key lost its wireless pairing — see the pairing
|
||
procedure in `~/.pi/agent/AGENTS.md` (phone) / emulator docs (this AVD).
|
||
- APK lands at `target/<debug|release>/apk/gallery.apk`.
|
||
- Release signing uses the debug keystore
|
||
(`CARGO_APK_RELEASE_KEYSTORE=$HOME/.android/debug.keystore`,
|
||
`CARGO_APK_RELEASE_KEYSTORE_PASSWORD=android`) — fine for personal use;
|
||
for Play, generate a real keystore and point the env vars at it.
|
||
- `cargo apk2` quirk: `--target` (not `-t`). `--lib` builds the cdylib.
|
||
- Grant "All files access" to the app on the emulator after install:
|
||
`adb shell am start -a android.settings.MANAGE_ALL_FILES_ACCESS_PERMISSION`
|
||
(or Settings UI). `adb install -r` keeps the grant.
|
||
- **Emulator test data (M3)**: the AVD's `/Pictures/gallery-test/` has ~5k
|
||
*fake* JPEG stubs (zlib garbage — they scan fine but fail to decode, good
|
||
for volume tests). Real, decodable test JPEGs live in
|
||
`/Pictures/realtest/` (150, generated with PIL). They were `touch`-ed to a
|
||
future mtime so they sort to the TOP of the grid — when reasoning about
|
||
"which cells should have images", remember only cells 0–149 are real;
|
||
everything below is stubs and correctly shows shimmer. **`/Pictures/realtest_extra/`
|
||
holds 700 more real, decodable JPEGs (PIL, 1200–4032 px, mtime 2031 so they
|
||
sort to the very top, above the 150 in `realtest`) — added for the
|
||
scroll-back backlog repro; the real region is now ~850 files (283 rows).
|
||
`adb push` of a directory nests a subfolder (push files individually or
|
||
`mv` up); `adb shell touch -d '…'` works for setting mtimes. The thumb cache is at
|
||
`/sdcard/Android/data/wow.gmp.gallery/files/thumbs` (wipe it to force
|
||
re-decode).
|
||
- Perf measurement: `adb shell perfetto` / `adb shell dumpsys gfxinfo wow.gmp.gallery`,
|
||
`systrace`-style capture via Perfetto trace shell. Cold start:
|
||
`adb shell am start -W`.
|
||
|
||
## 4. Architecture
|
||
|
||
The UI framework is only the display layer. The performance lives in the
|
||
thumbnail engine, and it is framework-agnostic:
|
||
|
||
```
|
||
┌─ metadata ─────────────────────────────────────────────────────────┐
|
||
│ sqlite (rusqlite, bundled): path, mtime, size, w, h, orientation, │
|
||
│ thumb_path, date_taken — one background scan pass │
|
||
└──────────────┬─────────────────────────────────────────────────────┘
|
||
│ model → Slint (list of visible-range items)
|
||
┌─ thumbnail pool (tokio-free: N std::threads, N ≈ cores/2) ─────────┐
|
||
│ per job: token cancellation (AtomicU64 gen counter) + priority │
|
||
│ 1. disk cache hit? .thumbs/{sha1(path|mtime|size)}.jpg → done │
|
||
│ 2. decode AT TARGET SIZE (never full-size for the grid): │
|
||
│ JPEG: zune-jpeg "turbo" (decode+downscale in ONE pass, │
|
||
│ pure Rust, actively maintained) or turbojpeg (SIMD) │
|
||
│ HEIC: imagepipe (0.5, 2026-08) — VERIFY Android NDK build │
|
||
│ PNG: image/png │
|
||
│ 3. apply EXIF orientation (once, here — never per frame) │
|
||
│ 4. re-encode small JPEG → disk cache │
|
||
│ 5. channel → UI: (id, slint::Image) │
|
||
│ prefetch: decode visible ± 1 row; cancel items scrolled out │
|
||
└────────────────────────────────────────────────────────────────────┘
|
||
┌─ UI (Slint) ───────────────────────────────────────────────────────┐
|
||
│ grid screen: ListView (vertical) → row of N square Image cells │
|
||
│ viewer screen: full-size async decode (once, cached) + manual │
|
||
│ two-finger pinch transform (no built-in in 1.18) │
|
||
└────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
Module layout:
|
||
|
||
```
|
||
src/
|
||
lib.rs # android_main + platform init + scan/UI glue (M1/M2)
|
||
main.rs # desktop entry [exists]
|
||
main.slint # screens (split into ui/ components at M3) [exists]
|
||
db.rs # sqlite schema + incremental scan apply [done M1]
|
||
scan.rs # directory walk, image detection, walk_stream [done M1]
|
||
thumb.rs # pool, dispatcher, disk cache, generation cancel [done M2]
|
||
images.rs # turbojpeg shrink-decode, EXIF orientation, PNG [done M2]
|
||
viewer.rs # full-size load + transform state (M4)
|
||
perm.rs # storage permission check/request (M5)
|
||
```
|
||
|
||
Crate choices (all verified current on crates.io, 2026-10):
|
||
|
||
| crate | version | why |
|
||
|---|---|---|
|
||
| `slint` / `slint-build` | 1.18.1 | pinned; upgrade to 1.19 after the pointer-scroll fix lands |
|
||
| `rusqlite` | 0.41, `bundled` feature | SQLite without system deps; cross-compiles to Android |
|
||
| `turbojpeg` | 1.5.1, `cmake` feature | libjpeg-turbo DCT-level shrink decode (1/2,1/4,1/8) — 48 MP at 1/8 peaks ~2.3 MB vs 137 MB full decode. Vendors + cross-builds libjpeg-turbo via CMake/NDK (verified). Chosen over `libvips` (same DCT shrink mechanism, +6–10 MB of .so files, LGPL, 4.3k downloads) and over `zune-jpeg` (no DCT shrink = full-size decode for 12 MP photos). |
|
||
| `image` | 0.25, `default-features=false, png` | PNG thumbs + `imageops::resize` final fit |
|
||
| `kamadak-exif` | 0.6 | EXIF orientation tag (all 8 values applied as pixel maps); `experimental::Writer` used only in tests |
|
||
| `sha1` | 0.11 | cache keys |
|
||
| `imagepipe` | — (deferred) | HEIC: the real device has **0 HEIC** files (9109 JPEG, 27 PNG), so HEIC support is deferred; `image` can gain `heic` or libvips can replace the JPEG path later — the engine API is decoder-agnostic |
|
||
|
||
Decode sizes: grid tiles render at ~cell×2 (Retina) — e.g. 500 px side.
|
||
Full-screen: full decode once (12 MP ≈ 30–80 ms off-thread, fine), cached in
|
||
the in-memory LRU until memory pressure.
|
||
|
||
**The five failure modes the current (unknown) app likely has** — this design
|
||
eliminates all five: (1) full-size decode for grid cells, (2) no disk
|
||
thumbnail cache, (3) no list virtualization, (4) decode on UI thread without
|
||
cancellation, (5) EXIF rotation applied per frame.
|
||
|
||
## 5. UI plan (Slint patterns)
|
||
|
||
- **Grid**: `ListView` is the only virtualized std-widget (confirmed in
|
||
source docs: "Only visible rows are instantiated"). Grid = vertical
|
||
`ListView`, each row a horizontal layout of square `Image` cells
|
||
(columns ≈ `floor(window-width / cell)`; recompute on resize).
|
||
Cells bind `image <=> model.image` — when the pool delivers a thumbnail,
|
||
only that cell invalidates (retained mode; no repaint calls to remember).
|
||
- **Placeholder→image**: cell shows a `Rectangle` (shimmer optional, M6)
|
||
until its `image` is set.
|
||
- **Image cache**: Slint's built-in path-based LRU is 5 MB (`CLruCache` in
|
||
`internal/core/graphics/image/cache.rs`) — too small and the wrong layer
|
||
for us; our cache lives in Rust (disk + compressed-bytes LRU).
|
||
No mipmaps in the Skia renderer — irrelevant since we pre-downsample.
|
||
- **Viewer**: no pinch helper in Slint 1.18 (`TouchArea` has no pinch
|
||
callback; grepped the stdlib). Manual two-finger math on `TouchArea`
|
||
(distance/centroid → scale/translation, ~50 lines; crib
|
||
`slint-src/examples/native-gestures` — source checkout is at
|
||
`~/prog/android/ui/slint-src`, tag v1.18.1). Double-tap-to-zoom is a nice
|
||
extra (M6).
|
||
- **Screens**: single `Window` with a stacked layout (grid + viewer overlay)
|
||
or a `Navigator`-style state property; keep it simple until M4.
|
||
- **Safe area**: always pad with `root.safe-area-insets.{top,bottom}` (done
|
||
in the scaffold).
|
||
- **Animation**: use built-in `animate` statements (syntax: `animate <prop>
|
||
{ duration: ...; easing: ...; }` — it is a *statement*, not an element).
|
||
Slide/fade for the viewer is a one-liner.
|
||
|
||
## 6. Slint 1.18 API gotchas (all hit firsthand in the eval)
|
||
|
||
1. `animate` is a statement (`animate width { duration: 300ms; }`), not an
|
||
`Animation {}` element.
|
||
2. Text input widget is `LineEdit`, not `TextField`.
|
||
3. Markdown interpolation: `text: "Count: \{count}"` (backslash-brace).
|
||
4. `length` is the unit type for lengths (`property <length>`, not
|
||
`logical<length>` in the 1.18 Rust API).
|
||
5. No Rust macros/closures inside `.slint` callbacks — callbacks call global
|
||
functions or invoke Rust via `@global` / imported callbacks.
|
||
6. 1.18 ↔ 1.19 **docs drift** — when reading docs online, check which
|
||
version; the local source at `~/prog/android/ui/slint-src` (tag v1.18.1)
|
||
is ground truth for the pinned version. `git grep` beats the docs.
|
||
7. `slint::android::init_with_event_listener(app, |event: &PollEvent| ...)`
|
||
re-exports the whole `android-activity` crate as
|
||
`slint::android::android_activity`; `AndroidApp` is also re-exported at
|
||
`slint::android::AndroidApp`.
|
||
8. `slint::Image` runtime construction: `Image::from_rgba8_premultiplied`
|
||
(also `from_array`); assign to an `image` property to update a cell.
|
||
9. Desktop Linux: needs `libfontconfig1` + `pkg-config` (pure Rust on macOS).
|
||
10. Retained-mode redraw: changing a property re-renders only affected
|
||
elements — design bindings so thumbnail delivery touches one property.
|
||
11. **`in property` is NOT auto-bound to a same-named `for` variable.** In
|
||
`for cell[c] in row.cells: CellView { ... }` you must write
|
||
`CellView { cell: cell; ... }` — otherwise the component's `in property
|
||
<Cell> cell` stays at its default (empty) and the cell renders blank
|
||
while *looking* correct. (This silently broke M3 for an hour.)
|
||
12. **Updating a nested model in place does NOT reach a ListView row that
|
||
was recycled while off-screen.** In-place `inner.set_row_data(col, …)`
|
||
updates only the currently-live `CellView`s; a row whose `GridRow`
|
||
instance was recycled keeps showing stale cells until the next scroll.
|
||
The reliable path is to replace the WHOLE outer row:
|
||
`outer.set_row_data(row, Item { cells: ModelRc::new(VecModel::from(…)) })`
|
||
— the outer `row_changed` makes the ListView re-bind `row-data` and the
|
||
fresh nested read picks up the new state. Batch cell changes per row and
|
||
do ≤ ~16 row-replacements per 100 ms tick (see `apply_row_batch`), or a
|
||
fast scroll mass-updates and ANRs.
|
||
13. `ListView` has no `spacing`/`gap` property in 1.18 — bake inter-row
|
||
rhythm into the item height (we add a 4 px bottom margin inside
|
||
`GridRow`; `ROW_SPACING` in lib.rs must match).
|
||
|
||
## 7. Permissions & platform integration (Android)
|
||
|
||
Declared in `Cargo.toml` (verified schema in cargo-apk2 1.5 / ndk-build2:
|
||
`uses_permission` is **top-level** under `[package.metadata.android]`, and
|
||
`Permission` supports only `name` + `max_sdk_version`):
|
||
`READ_MEDIA_IMAGES`, `READ_EXTERNAL_STORAGE` (maxSdk 32),
|
||
`MANAGE_EXTERNAL_STORAGE`.
|
||
|
||
Runtime flow (M5), using the `ANDROID_APP` clone stashed in `src/lib.rs`:
|
||
|
||
1. **MANAGE_EXTERNAL_STORAGE** (primary path, same as passgo): no runtime
|
||
dialog exists — check `Environment.isExternalStorageManager()` via JNI,
|
||
if false launch `Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION` via
|
||
`ANDROID_APP.get().run_on_java_main_thread(...)` (attach `JNIEnv` from
|
||
`vm_as_ptr()`; Activity from `activity_as_ptr()`), then **re-check on
|
||
`MainEvent::Resume`** — the hook is already wired in `src/lib.rs`
|
||
(TODO(M5) marker).
|
||
2. **READ_MEDIA_IMAGES / READ_EXTERNAL_STORAGE**: `requestPermissions` via
|
||
the same JNI route; the bare `NativeActivity` does not forward
|
||
`onRequestPermissionsResult` to native, so either (a) poll
|
||
`checkSelfPermission` after requesting (good enough), or (b) ship a custom
|
||
`NativeActivity` subclass via cargo-apk2 `java_sources = "android/java"`
|
||
(manifest `activity name` points at it) — the proper callback, reusable
|
||
pattern from the eval.
|
||
3. Facts that constrain design (verified in android-activity 0.6.1 source):
|
||
no `NewIntent` event; `poll_events` may only run on the `android_main`
|
||
thread (Slint owns it — don't add a second consumer); `AndroidApp` is
|
||
`Clone` and a clone works from any thread (`run_on_java_main_thread`,
|
||
`vm_as_ptr`, `activity_as_ptr`).
|
||
4. **Source discovery** for photos: start with a plain directory scan of
|
||
`/storage/emulated/0/Pictures` + `DCIM` (works with all-files access,
|
||
emulator-friendly, and it's what makes the app testable without
|
||
MediaStore). MediaStore integration (API-aware, scoped to
|
||
`READ_MEDIA_IMAGES`) is a later refinement if directory scanning proves
|
||
insufficient.
|
||
|
||
## 8. Performance targets & measurement
|
||
|
||
Targets (emulator is NOT representative for absolute decode times — validate
|
||
on a real device with the aarch64 build):
|
||
|
||
- Grid: 60 fps scrolling at 5k+ items; zero full-size decodes on the hot path.
|
||
- First frame: grid of placeholders < 100 ms after launch (metadata from
|
||
sqlite, images async).
|
||
- Thumbnail decode (grid size): < 5 ms off-thread per JPEG (measure with
|
||
`std::time::Instant` logs; zune-jpeg turbo should be well under).
|
||
- Steady-state memory: in-memory thumb cache bounded (~64–128 MB compressed
|
||
bytes on a phone) + Slint's texture memory for visible tiles only.
|
||
- Cold start (emulator, release): compare against the eval baseline
|
||
(Slint sample ≈ 1–2 s; gallery must not regress > 0.5 s).
|
||
|
||
**Scroll-FPS measurement (real device, verified 2026-10-27):**
|
||
`dumpsys gfxinfo wow.gmp.gallery` reports **0 frames** for Slint — the
|
||
NativeActivity window renders via its own EGL surface and is not tracked by
|
||
gfxinfo on Android 15 (works for normal View apps). Use SurfaceFlinger's
|
||
per-layer latency instead:
|
||
|
||
```bash
|
||
# find the layer name:
|
||
adb shell dumpsys SurfaceFlinger | grep "VRI-wow.gmp.gallery"
|
||
# while scrolling (swipes via `input swipe`): capture 1 s of frame history
|
||
adb shell "dumpsys SurfaceFlinger --latency 'VRI-wow.gmp.gallery/android.app.NativeActivity#<id>'"
|
||
# each line: latch_time display_time … — display_time is the vsync grid.
|
||
# fps = 1e6 / avg(display gap); jank = display gaps > ~25 ms; the latch
|
||
# column shows frame PRODUCTION timing (p95 ~17 ms = on time at 60 Hz).
|
||
```
|
||
|
||
Result (Pixel 9 Pro, release aarch64, 8,577 photos, 500 px thumbs):
|
||
sustained **60.0 fps, 0 display hitches** while scrolling, both cold cache
|
||
(production p95 21–25 ms) and warm cache (production avg 16.6 ms). The
|
||
display presents at a 60 Hz vsync grid (Slint requests Default framerate;
|
||
120 Hz is a later polish item, not an M3 acceptance criterion).
|
||
Note: the shimmer `animation-tick()` keeps frames flowing even at rest,
|
||
so latency history is continuous — filter by swiping if you need
|
||
scroll-only frames.
|
||
|
||
Test data (M2): generate a synthetic set on the device — a small Rust `bin`
|
||
or Python script that writes N=5,000 JPEGs of varied sizes/orientations into
|
||
`/storage/emulated/0/Pictures/gallery-test/` (include a few HEICs if
|
||
imagepipe works; include rotated EXIF). Reuse it for every perf run.
|
||
|
||
Measure with: `adb shell am start -W` (cold start),
|
||
`dumpsys gfxinfo wow.gmp.gallery framestats`, Perfetto trace (GPU + main +
|
||
pool threads), and `logcat` pool timing logs behind a `debug_timing` flag.
|
||
|
||
## 8.5 Jank hunt (IN PROGRESS — phone is the acceptance target)
|
||
|
||
Symptom: user reports janky grid scroll (moderate drag, no fling) on the
|
||
phone (Pixel 9 Pro, 120 Hz). Emulator repro via programmatic scroll hook.
|
||
|
||
### What is ruled out (all measured)
|
||
- **App tick cost**: 7–34 µs/tick, even inside 28–39 ms gaps. Not the tick.
|
||
- **Logd backpressure**: fixed — `nblog` (bounded mpsc + drain thread,
|
||
nanosecond `try_send` from UI). Idle CPU 71% → 5%.
|
||
- **60/120 Hz display zone flap**: SystemUI (uid 10156) was asserting a 60 Hz
|
||
override every minute; pinned with `settings put system min_refresh_rate
|
||
120` (reversible: `settings delete system min_refresh_rate`).
|
||
- **Shimmer animation**: `animation-tick()` in a per-cell binding evaluated
|
||
O(ALL 8577 cells) `sin()` every frame (70.9% CPU idle). Now a static
|
||
placeholder; proper O(visible) shimmer is M6 work.
|
||
- **Whole-row model replacement**: now in-place `cells.set_row_data` (one
|
||
property change; texture upload only when pixels change).
|
||
- **Texture upload CPU**: APLY 1.2–2.9 ms/cell (480 KB) — under budget, and
|
||
too cheap to explain 28–39 ms (would need 12–17 MB/s on a Mali).
|
||
|
||
### Measured facts
|
||
- Emulator, programmatic scroll (`/sdcard/Download/.gallery_cmd` →
|
||
`scroll <px/ms> <ms>`), real textures: 28–146 ms cadence. During every
|
||
stall the UI thread is **100 % on-CPU** (jsample: state R, wchan 0) —
|
||
Slint's render pass is CPU-bound.
|
||
- Emulator, 300 items instead of 5852: **equally janky** → scene/item count
|
||
is NOT the cost driver.
|
||
- Emulator, grey 1×1 images (`.gallery_grey` touch file): steady ~16.6 ms
|
||
(60 Hz pacing), worst 44 ms → the cost is **rasterizing ~30 live textured
|
||
cells at 1080×2400 through the emulator's software GL (llvmpipe)**.
|
||
The emulator has a hard rendering ceiling and can never scroll this
|
||
smoothly — it validates LOGIC (invariants, paint coverage), not feel.
|
||
- Phone: 64 % of frames on the 8.3 ms (120 Hz) grid; ~4 % take 28–39 ms of
|
||
non-tick work with d≈0 (UI thread not in our code). Frequency ≈ every
|
||
170 ms — much faster than new cells enter the viewport (~1.7 s), so it is
|
||
NOT first-paint/upload timing. Suspects left: GPU/present contention
|
||
(Mali + SystemUI both at 120 Hz), Slint EGL/swap path, or an
|
||
event-loop/vsync interaction. Needs the phone.
|
||
|
||
### Tools / hooks
|
||
The hunt's file hooks (`.gallery_cmd`, `.gallery_*` knobs, SCRDBG/TICK/APLY
|
||
logs, jsamp sampler) were REMOVED in the 2026-10-28 cleanup — see §8.9.
|
||
`nblog` (non-blocking logger) and the debug-level coverage logs stayed.
|
||
|
||
### Outcome
|
||
The decisive A/B was not needed: stage timestamps inside the vendored
|
||
renderer isolated the stall to the Skia renderer's final synchronous
|
||
flush (Mali driver back-pressure). Fix + evidence: §8.9 and
|
||
`docs/BUGS/slint-skia-final-flush-jank.md`.
|
||
3. Compare against Fossify (installed on the phone, user says smooth):
|
||
`dumpsys SurfaceFlinger --latency` on both apps' layers during identical
|
||
scrolls.
|
||
4. **Priority fix already applied** (commit after this edit):
|
||
- `thumb-0..3` + `thumb-dispatch` demoted to nice +2 (they inherited the
|
||
UI's -10 and could preempt it during turbojpeg bursts).
|
||
- Slint's `slint vsync` Choreographer waker (created at nice +10 by
|
||
Android — the ONLY wakeup source for the main loop while animating,
|
||
see i-slint-backend-android-activity/vsync.rs) is raised to nice 0 by
|
||
a `prio-fix` thread in android_main (log: "slint vsync raised to nice
|
||
0"). A starved waker delays the main loop's wakeup by 3–4 frames at
|
||
120 Hz = 25–33 ms with zero tick cost — the measured phone signature.
|
||
Verified on emulator: nice holds at 0 while foreground; Android
|
||
re-nices backgrounded processes (expected, irrelevant).
|
||
- Verification on phone: check the two log lines, then
|
||
`for t in /proc/$(pidof wow.gmp.gallery)/task/*; do ...` and confirm
|
||
thumb-* at 2, slint vsync at 0, before/while the user scrolls.
|
||
5. Candidate code changes (after the A/B): GRID_TARGET 500→400 (one-time
|
||
cache invalidation — don't do it before knowing the culprit), 1 painted
|
||
cell/tick instead of 2.
|
||
|
||
## 9. Milestones
|
||
|
||
- **M0 — scaffold (DONE)**: builds (x86_64 + aarch64 toolchain), installs,
|
||
runs on emulator; permission-aware init + Resume hook; build script;
|
||
manifest with permissions; this document.
|
||
- **M1 — metadata (DONE, 2026-10-27)**: `db.rs` + `scan.rs`; background scan
|
||
thread → sqlite → `VecModel` in a `ListView` (paths as text). Acceptance
|
||
verified on emulator: **5002 files in 153 ms**; relaunch `0+ 0~ 0-` in 2 ms;
|
||
mtime-touch → `1~`, delete → `1-`. Status line shows `N files (a+ u~ d–) in X ms`.
|
||
List paint is instant on relaunch: the DB list is shown first
|
||
("N files (cached)"), the background walk then runs and refreshes
|
||
("Updating… N"). First launch streams results into the list in 512-file
|
||
batches while scanning — the UI never waits for the full DB build
|
||
(`walk_stream`). Hidden dirs (`.thumbnails`, …) are skipped.
|
||
Real-device numbers (Pixel-class, 8.5k photos): cold scan ~4.2–5.9 s
|
||
(filesystem metadata cost — the walk is the bottleneck, not sqlite),
|
||
relaunch paint instant, rescan ~4.2 s. Walk parallelism is an M2/M7
|
||
optimization candidate.
|
||
Gotchas hit (also see §6/§12):
|
||
- `slint::Result` / `slint::WindowHandle` **do not exist in 1.18.1** as
|
||
published on crates.io — desktop bin is `fn main() -> Result<(),
|
||
slint::PlatformError>` + `use slint::ComponentHandle;`. (The M0 desktop
|
||
bin never actually compiled; only the Android lib was built. The eval
|
||
sample `slint_app` is broken the same way.)
|
||
- `.slint` modulo is `Math.mod(x, y)`, not `%`.
|
||
- ListView rows: `for path[index] in root.items: …` (`index` is not in
|
||
scope unless named; `parent` = the ListView).
|
||
- `rusqlite(bundled)` needs `CC_<triple>` pointing at NDK clang for
|
||
Android — `scripts/build.sh` now exports them.
|
||
- Android DB path: `EXTERNAL_DATA_DIR` is **not set** by this
|
||
NativeActivity setup and `temp_dir()` = `/data/user/0` (unwritable).
|
||
Use `/storage/emulated/0/Android/data/<pkg>/files`, internal files dir
|
||
fallback (see `db::android_data_dir`).
|
||
- Emulator: grant all-files access once via
|
||
`adb shell appops set wow.gmp.gallery MANAGE_EXTERNAL_STORAGE allow`; it
|
||
survives `adb install -r`.
|
||
- Files pushed with `adb push` are owned by `shell` mode 640 — `chmod 644`
|
||
or the app can't read them.
|
||
- **M2 — thumbnail engine (DONE, 2026-10-27)**: `images.rs` (turbojpeg
|
||
DCT-level shrink decode via 1/2/1/4/1/8 `ScalingFactor`, all 8 EXIF
|
||
orientations as uniform `(x,y)→(dx,dy)` pixel maps, `image::imageops::resize`
|
||
final fit, re-encode to disk cache at q85; PNG via `image`) + `thumb.rs`
|
||
(dispatcher thread round-robining to N=cores/2 workers clamped 2..8,
|
||
unbounded job queue, disk cache `thumbs/{sha1(path|mtime|size|target)}.jpg`
|
||
— target in the key so list-64px and grid-500px coexist; atomic
|
||
`.tmp`→rename; generation counter for cancellation; per-worker sqlite
|
||
connection writes `set_thumb` w/h/orientation/thumb_path) + `lib.rs`
|
||
(process-wide `AppState` in `OnceLock`, 10 Hz drain timer — `Timer` is
|
||
`!Send`, leaked with `std::mem::forget`, scroll-prefetch with 50-row
|
||
margin, `do_prefetch` flag so streaming scan batches don't re-prefetch) +
|
||
`main.slint` (rows carry `image` now).
|
||
*Accept, verified:* 12 unit tests + 5k synthetic engine test (cold fill,
|
||
100% cache warm refill, generation cancel). Release aarch64 APK 9.8 MB.
|
||
**Real device (Pixel 9 Pro, 8577 photos):** cold scan ~5 s with list
|
||
streaming; warm relaunch paints the cached list instantly; thumbnails
|
||
render in every visible row (screenshot-verified); relaunch cache hits
|
||
~100% (255–258 of first 256 jobs); 2 WebP files misnamed `.jpg` fail
|
||
gracefully (logged, row stays empty). Worker log cadence: every 256 jobs.
|
||
*Known transient (unreproducible): the very first aarch64-release install
|
||
rendered a pure-black screen (no drain-timer "painted" logs) until the
|
||
next launch; it did **not** reproduce on the emulator (screen-off launch +
|
||
wake) nor across repeated device launches. Suspected screen-lock / display
|
||
state race on first GL frame. If it recurs: force a window refresh on
|
||
Resume (see §10.7).*
|
||
*Build gotchas hit (also in build.sh comments):*
|
||
- `ANDROID_PLATFORM=android-26` (NDK CMake toolchain) **conflicts** with
|
||
the `android-build` crate (Slint's Java compile) unless `ANDROID_JAR`
|
||
is set explicitly to `platforms/android-35/android.jar`.
|
||
- x86_64 host builds of turbojpeg-sys need `nasm` (`~/.local/bin/nasm`;
|
||
aarch64 does not).
|
||
- `ADB_SERIAL=…` env var picks the device in build.sh (phone + emulator
|
||
are often both attached).
|
||
- build.sh keywords: `debug` (x86_64), `debug-aarch64`, `x86_64`,
|
||
`aarch64`; second arg `no-install` to skip install.
|
||
- **M3 — grid UI (DONE 2026-10-27)**: 3-column `ListView` grid (`GridRow` =
|
||
one list item holding up to 3 square `CellView`s), shimmer placeholder via
|
||
a time-driven `animation-tick()` expression, scroll-driven prefetch
|
||
(`scrolled → on_scroll`), memory-bound retention (cells drop to shimmer
|
||
when they leave the active range; disk cache repaints them in ~10 ms).
|
||
Key fixes along the way (see §6.11–13): `in property` must be assigned
|
||
explicitly in a `for`; cell updates must replace the whole outer row
|
||
(`apply_row_batch`) and are capped at 16 rows/tick to avoid ANR on fast
|
||
scroll; `on_scroll` only re-anchors the active range + submits prefetch
|
||
(no model mutation), all mutation is in the 10 Hz drain timer.
|
||
|
||
**Thumbnail loading: viewport reconciliation + worker-level priority
|
||
(rewritten 2026-10-27 after two field-reported bugs).** Design doc is the
|
||
header comment of `src/lib.rs`; engine in `src/thumb.rs`. The invariants:
|
||
|
||
1. *The viewport is the only source of truth.* `on_scroll` only records the
|
||
viewport range (zero work). The 10 Hz tick is the sole authority AND the
|
||
sole submitter: CONSUME finished results (paint viewport ones, capped),
|
||
EVICT far pixels (hard cap `MAX_PAINTED=120`), FILL the viewport
|
||
(center-first), PRUNE queued jobs outside the retention band. After each
|
||
tick: every viewport cell is PAINTED, IN-FLIGHT, or FAILED — restored,
|
||
not maintained, so no scroll pattern can leave a visible cell forgotten
|
||
(the original bug: re-submission only happened in `on_scroll`, so when
|
||
scrolling STOPPED, forgotten cells stayed empty until the next small
|
||
move). `debug_assert`ed every tick.
|
||
2. *The viewport is first at EVERY queue level.* The central queue
|
||
(`Mutex<VecDeque>` + `Condvar`, prepend-on-submit) is drained as
|
||
priority-ordered bursts and PREPENDED to the per-worker queues (also
|
||
`Mutex<VecDeque>` + `Condvar`, NOT mpsc — mpsc is FIFO-only). Each
|
||
worker queue is capped (96 ≈ two screens): overflow is dropped from the
|
||
TAIL (oldest transit work) with a `skipped: true` result; the UI clears
|
||
the in-flight marker, the cell is NOT failed, re-entry re-submits it
|
||
(disk-cache hit). A burst ≤ viewport (~45) < cap, so fresh viewport work
|
||
is never dropped. `in_flight` accounting: +1 at submit, −1 at finish OR
|
||
at cap-drop; the UI's `inflight` map (path→time) is the dedupe key and
|
||
is cleared on every result (incl. `skipped`) — a dropped job whose
|
||
path has a newer queued job may be briefly re-submitted (one wasted
|
||
decode, self-cleans).
|
||
|
||
Regression tests: `thumb::tests::scroll_back_cache_hit_not_starved`
|
||
(central-level: 6.3 s FIFO starvation → 130 ms) and
|
||
`thumb::tests::fresh_batch_preempts_stale_worker_queue` (worker-level: a
|
||
fresh job submitted while a slow batch is queued must finish within the
|
||
first two results, never behind the whole old queue).
|
||
|
||
*Stress results (emulator x86_64 DEBUG build — slowest plausible hardware —
|
||
then release aarch64 on the Pixel 9 Pro):* fast up/down ×5 (the reported
|
||
repro): viewport fully covered 0–119 ms after stop (4/5 already covered at
|
||
stop); cold landings: 1.0–1.2 s = pure decode throughput (p rises
|
||
monotonically while stale jobs wait BEHIND, oldest growing 700→1900 ms
|
||
harmlessly); chaos ×6 + 25 s idle soak: covered before every stop, max
|
||
oldest in-flight job 217 ms (pre-fix: 8,055 ms), max queue depth 45 (one
|
||
viewport), 0 invariant violations in ~1,450 ticks, 0 panics, idle
|
||
converges to 0 queued. Phone smoke: 99.98% cache hits during 8k-cell
|
||
stress scroll, 0 panics/ANRs, one 541 ms transient stall alarm (cold
|
||
decode burst).
|
||
|
||
*Verified (emulator, 150 real JPEGs + 5k stubs): first paint 0.6 s cold;
|
||
warm relaunch from disk cache; 0 ANRs through 16 fast swipes; backlog
|
||
drains to 0 when idle. Verified on Pixel 9 Pro (release aarch64, 8,577
|
||
photos): first screen ~0.7 s cold at 500 px; scroll = sustained 60 fps
|
||
with 0 display hitches (cold and warm cache) — see §8 for the
|
||
measurement method (gfxinfo does NOT track Slint's window).*
|
||
- **M4 — viewer**: tap cell → full decode + overlay; pinch/pan (manual
|
||
math), double-tap zoom; back gesture/button; EXIF orientation correct.
|
||
- **M5 — permissions**: implement `perm.rs` per §7; launch flow when
|
||
un-granted (settings card → open Settings → recheck on Resume); test the
|
||
exact passgo-style flow on a fresh install.
|
||
- **M6 — polish**: dark theme, animations (slide/fade), search/filter
|
||
(name/date), sort; double-tap zoom; battery-friendly (pool idles, no
|
||
busy repaint — Slint is retained, verify with gfxinfo "total frames").
|
||
- **M7 — perf & release**: real-device (aarch64) pass; Perfetto; memory
|
||
bounds; release APK size check (expect ~8–9 MB like the sample);
|
||
consider the 1.19 upgrade (verify pointer-scroll fix first).
|
||
|
||
## 10. Open questions & risks
|
||
|
||
1. **HEIC (RESOLVED for now)**: the real device has 0 HEIC files (9109
|
||
JPEG, 27 PNG), so HEIC is deferred. When needed: add a decoder to
|
||
`images.rs` dispatch (libheif-rs / `image` heic feature / libvips);
|
||
the engine API is decoder-agnostic.
|
||
2. **Slint pointer-scroll `todo!()` panic** (open issue in the Android
|
||
backend at eval time): could fire on trackpad/touch scroll. Test scrolling
|
||
on M3; if it fires, patch locally (the fix is small) or upgrade to 1.19
|
||
when available.
|
||
3. **Max texture size**: full-screen decode of 12 MP+ images (e.g.
|
||
8192 px) may exceed GPU max texture size on some devices — verify on
|
||
M4; fall back to a 2×-max downscale for display.
|
||
4. **Slint license**: royalty-free tier covers proprietary mobile/desktop/
|
||
web; embedded devices excluded — irrelevant for us, but the repo README
|
||
should state the chosen tier (royalty-free) once the app ships.
|
||
5. **Docs drift**: trust the pinned source (`~/prog/android/ui/slint-src`
|
||
@ v1.18.1) over online docs; bump pin + re-verify after any Slint upgrade.
|
||
6. **rusqlite `bundled`** compiles SQLite from source in every target
|
||
(adds ~30–60 s to clean builds) — acceptable; revisit if it hurts.
|
||
7. **Transient black screen (M2 first launch, unreproducible)**: one
|
||
aarch64-release launch rendered pure black with a dead drain timer until
|
||
relaunch; not reproduced on emulator or device since. Candidate fix if it
|
||
recurs: in the `init_with_event_listener` Resume handler, call
|
||
`window.request_redraw()` / check `window.size()` (a 0×0 or stale surface
|
||
after a screen-off start would match the symptoms). Don't add it
|
||
speculatively.
|
||
|
||
## 11. Pointers
|
||
|
||
- Framework eval (full report + raw notes + both sample apps + screenshots):
|
||
`~/prog/android/ui/` — `REPORT.md`, `NOTES.md`, `slint_app/`, `eframe_app/`,
|
||
`slint-src/` (v1.18.1), `egui-src/` (0.37.0).
|
||
- Slint docs (version-sensitive!): https://slint.dev/docs — Android guide:
|
||
`docs/guide/platforms/mobile/android.mdx` in the source tree.
|
||
- `examples/native-gestures`, `demos/` in `slint-src` — reference code for
|
||
gestures, lists, images.
|
||
- passgo (existing Go app using the same all-files-access pattern):
|
||
`~/prog/go/passgo` — its `scripts/build.sh` and manifest handling are a
|
||
proven reference for the permission UX flow.
|
||
- Emulator AVD: `pad_avd` (google_apis x86_64, API 35); start command in §3.
|
||
- Gitea: push target `git+ssh://gitea@git.wow.st/gmp/gallery.git`
|
||
(repo must exist on the server first, like the other gmp/* repos).
|
||
|
||
### 8.7 GPU-resident thumbnails (jank fix, 2026-10-28)
|
||
|
||
Root cause of the perceived scroll jank (measured with the per-frame
|
||
`set_rendering_notifier` hook, `FR` lines in `.gallery_perf.log`): every
|
||
**first present** of a new `Image` uploaded its pixels inside the render
|
||
frame — Slint's Skia backend has no RGB888 path (byte-by-byte conversion
|
||
on the UI thread) and no API to pre-upload; measured 11–30 ms per 400 px
|
||
texture = 1.3 visible hitches/s at 120 Hz. Grey-image A/B: 20 hitches/s →
|
||
1. Not a frame-rate, input, or layout issue; Fossify (RenderThread
|
||
pipelining) doesn't have this cost.
|
||
|
||
Fix (no Slint patch, no re-architecture): the **decode worker creates the
|
||
texture on Slint's own wgpu device** (captured once from
|
||
`GraphicsAPI::WGPU30` at `RenderingSetup`, `src/gputex.rs`) and submits
|
||
the upload via `queue.write_texture` (async, no fence wait). UI thread
|
||
wraps it in `Image::try_from(texture)` (needs `Rgba8Unorm` +
|
||
`TEXTURE_BINDING|RENDER_ATTACHMENT`; `unstable-wgpu-30` slint feature;
|
||
wgpu/Vulkan `bytes_per_row` 256-multiple padding). Fallback to
|
||
`from_rgba8` CPU path when the device isn't captured yet.
|
||
Result (Pixel 9 Pro, 120 Hz, 400 px): **zero mid-scroll renders > 4.4 ms**
|
||
(median 1.9 ms, p95 2.9 ms); only post-idle first-frames are expensive
|
||
(invisible — screen static).
|
||
|
||
Perf file: `/sdcard/Download/.gallery_perf.log` (SCR/TICK/APLY/FR).
|
||
Clear it by restarting the app (writer holds the fd).
|
||
|
||
### 8.8 Jank-hunt round 2 + the keep-alive incident (2026-10-28)
|
||
|
||
Follow-up on "still seeing jank" (post-GPU-texture). Instrumentation:
|
||
in-app thread sampler (`src/jsamp.rs`, `/proc/self/task`, knob
|
||
`.gallery_jsamp`), per-core cpufreq, cgroup/PSI probes. Findings:
|
||
|
||
- **Cgroup CPU quota ruled out**: this device's unified hierarchy has the
|
||
`cpu` controller DISABLED (`cgroup.controllers` = `memory` only; no
|
||
`cpu.max` file) — no per-app bandwidth cap exists.
|
||
- **`/proc` facts**: utime/stime are USER_HZ ticks (1/100 s), so
|
||
per-thread CPU deltas are 10 ms-quantized. The app's SELinux context
|
||
DENIES `/proc/stat`, `/proc/pressure/cpu`, and the unified cgroup files
|
||
(even the app's own cgroup), but allows `/proc/self/task/*` and
|
||
cpufreq `scaling_cur_freq`. **A denied read in a tight loop is an ANR:
|
||
each denial is an audit event; ~100/s froze the whole phone.**
|
||
jsamp now probes readability once and disables denied features forever.
|
||
- **Real profile** (single verified 120 Hz swipe, 370 frames): steady
|
||
scroll = 1.5–2.5 ms frames, **zero spikes**. All 20–40 ms frames were
|
||
the first frame after an input pause (15–100 ms) — resume/warm-up
|
||
cost; ~820 MHz little-core clock during spikes vs 1.95 GHz between
|
||
(EAS downclock in the gap, not a cgroup/driver block). Swipe starts
|
||
after only 2 s pauses: zero spikes; only >10 s app idle pays the big
|
||
(43 ms) cold frame.
|
||
- **KEEP-ALIVE INCIDENT (phone freeze)**: the `gputex::Keepalive`
|
||
thread (16 ms buffer-copy submits on Slint's wgpu queue from a 5th
|
||
thread) made the display pipeline FREEZE on Mali G715 during a 14-min
|
||
session — phone UI hung for seconds, 51 in-flight decodes stalled ~6 s
|
||
(workers also `write_texture` on the same queue). System did not
|
||
reboot (system_server/surfaceflinger 5-day uptime), but the user
|
||
experience was "crashing my phone". **Root lesson: do not run
|
||
concurrent submitters on Slint's Mali queue.** Keep-alive is now
|
||
INACTIVE BY DEFAULT (debug knob `.gallery_keep`). The jsamp sampler
|
||
was REMOVED in the 2026-10-28 cleanup (see §8.9).
|
||
- Status (post jank-fix, §8.9): with the Skia final-flush removed, the
|
||
mid-scroll stalls are GONE (0 stalls, max 3.9–7.4 ms across 4 runs,
|
||
phone). Residual after the fix: scroll-START ramp frames (16–27 ms
|
||
in the first ~400 ms after >2 s idle, CPU-clock ramp on the 820 MHz
|
||
little cores) — revisit only if the user still notices them.
|
||
|
||
### 8.9 Scroll jank — root cause & fix (2026-10-28, CLOSED)
|
||
|
||
The mid-scroll 19–26 ms stalls (2–4 per run, at fixed content offsets,
|
||
Mali-only — emulator/SwiftShader never reproduced) are **fixed**.
|
||
|
||
**Root cause (proven by stage-timestamp profiling of the vendored
|
||
renderer):** the Slint Skia renderer's final synchronous
|
||
`GrDirectContext::flush()` at the end of every frame
|
||
(`render_components_to_canvas`) blocked the UI thread 19.2–20.4 ms
|
||
whenever the Mali driver queue was momentarily busy (texture uploads
|
||
landing, first-draw pipeline state). Steady-state cost of that call:
|
||
0.3–0.8 ms. SwiftShader's flush returns instantly → emulator never
|
||
reproduced it, and every CPU-side hypothesis failed.
|
||
|
||
Full evidence, per-stage tables, prior art and the upstream-filing
|
||
question: **`docs/BUGS/slint-skia-final-flush-jank.md`**.
|
||
|
||
**Fix (kept, per user decision):** `vendor/i-slint-renderer-skia`
|
||
(pinned 1.18.1, `[patch.crates-io]`) with that one flush removed.
|
||
It is redundant on the wgpu backend: `wgpu_30_surface::render` calls
|
||
`flush_and_submit` (Skia `submit()`) before `queue.present`, same
|
||
Vulkan queue, in-order. `vendor/` contains ONLY this crate; the rest
|
||
of the patch set is a single commented-out hunk with an explanation.
|
||
Re-apply that hunk when bumping Slint. Upstream NOT filed yet (we
|
||
cannot be 100% sure the flush isn't doing a job for non-wgpu
|
||
backends) — the bug doc is written so it can be filed later.
|
||
|
||
**What the profiling also settled:**
|
||
- Slint 1.18 DOES prune the item walk (~133–152 visited items, not
|
||
O(rows)). The earlier O(n)-walk diagnosis (and the windowed
|
||
Flickable that was built on it) was WRONG; the windowed Flickable
|
||
was reverted per user directive (use Slint's ListView — it is
|
||
smooth once the flush is gone).
|
||
- Stall frames had 0–3 new Skia images (0.1 ms each), 1 binding
|
||
re-evaluation, 0 ms partial-renderer: none of the usual suspects.
|
||
|
||
**Verification (phone, Mali G715, 8,580 photos):**
|
||
- slow scroll 5 s ×3: 0 stalls, max 3.6 / 5.8 / 3.9 ms
|
||
- fast fling 8× velocity: 0 stalls, max 7.4 ms, p50 1.0 ms
|
||
- display screenshot-verified clean
|
||
- budget at 120 Hz is 8.3 ms → 65–99 % headroom
|
||
|
||
**Cleanup done (2026-10-28):** all jank-hunt instrumentation stripped
|
||
(FR/SCR/TICK/APLYD/STG logging, `stg.rs`, `.gallery_*` knobs, jsamp
|
||
thread sampler, perf-file mirroring in nblog, DbgScroll). KEPT:
|
||
nblog non-blocking logger (logd backpressure is real), zero-dimension
|
||
decode guards (thumb.rs + gputex.rs), keep-alive (still default-off,
|
||
`.gallery_keep`), debug-level coverage logs.
|
||
|
||
**BUG FIXED while cleaning:** the app launched into an unstoppable
|
||
fast scroll on the user's phone — a leftover `.gallery_cmd`
|
||
(`scroll -12 4000`) from the last A/B run: DbgScroll never deleted
|
||
the file, and a fresh process sees `cmd_mtime == None` → re-reads it
|
||
on every launch; while active it also overrode the user's swipes.
|
||
Fix: the debug-scroll hook (and all file knobs) are gone; the
|
||
leftover file was deleted from the device.
|
||
|
||
**Next: M4 viewer** (tap cell → full decode + overlay, pinch/pan,
|
||
double-tap zoom, back gesture, EXIF orientation). Work on the
|
||
emulator first (user directive).
|