Kapow

View the Project on GitHub sanchezpaco/kapow

Performance

Baseline and method for the app-wide fluidity research (2026-08-28). Numbers are from the release build (make deploy-release) on the Z Fold (SM-F971B, Android 17), folded (cover screen 1248×1972, 60 Hz render rate on a 120 Hz-capable panel — dumpsys display → mActiveRenderFrameRate). The unfolded posture is not measured yet; the same recipe applies. Debug builds run 10–30× slower in hot Kotlin and are useless for these numbers.

Method

Baseline (release, Fold folded, 60 Hz)

Comics: Aura (CBZ, 15 light JPEG pages), Doctor Doom #9 (CBZ 25 MB, 24 pages ≈ 2000×3000), Blacksad #1 (CBR/RAR 165 MB), Spiderman 2099 #4 (CBR 14 MB).

Interaction Frames Janky p50 / p90 / p99 GPU p90 Notes
Cold start → first window — — 111–117 ms — warm 18–20 ms; covers settled ≈ 0.3 s later
Library fling ×8 (38 comics) 594 0.7 % 5 / 5 / 5 ms 3 ms 2nd pass 0.9 %, slow drag 0.2 %
Open comic → first page — — Aura 85–130 ms, 2099 139, Doom #9 175–204, Blacksad 509 — ≈ 2.4 ms/MB SAF copy, then decode 16–104 ms
Page turns ×6–8, bubbles off 228–374 2.1–2.6 % 5 / 5 / 10–13 ms 3–5 ms decode 60–75 ms (Doom), 90–131 ms (Blacksad), never on the main thread
Page turns ×8, bubbles on, cold 371 2.2 % 5 / 11 / 19 ms 11 ms per page: decode ≈ 70 + bubbles 150–235 + plan 5–70 ms, serial
Page turns ×8, bubbles on, cached 367 2.2 % 5 / 6 / 16 ms 6 ms  
Bubble toggle on (cold, Doom p10) 52 3.9 % 14 / 15 / 28 ms 14 ms GPU-bound frames while the overlay appears
Double-tap zoom + pan flings 182–280 1.6–3.3 % 5 / 5–7 / 14–16 ms 3–6 ms  
Guided View enter (cold panels) 27–30 0–3 % 5 / 6 / 7–12 ms 5 ms panels 128–214 ms, detected when the page becomes current
Guided View next panel ×8–10 269–304 3.0–3.6 % 5 / 6 / 14–15 ms 6 ms  
HUD show/hide ×6 154 3.9 % 5 / 5 / 16 ms 3 ms Blacksad ×4: p95 12 / p99 20 ms
Settings panel toggle ×6 163 3.7 % 5 / 5 / 13 ms 3 ms  
Thumbnail scrubber drag (CBR) 633 0.6 % 5 / 5 / 6 ms 5 ms  
Reader → library 20 5 % 5 / 12–15 / 20–27 ms 2 ms one slow frame per exit (n = 4)

Zero Slow bitmap uploads anywhere (hardware bitmaps, see file-formats.md).

What the trace says

Perfetto over 25 s of Blacksad page turns and HUD taps: 469 of 474 app frames under 8.4 ms, four between 8.4 and 16.7 ms, one at 21 ms. Decode and detection live on DefaultDispatcher threads; the main thread’s only work is Choreographer#doFrame. The pattern behind almost every janky frame in the table is the same: the first frame after an interaction spends 9–11 ms in Record View#draw() (Compose re-recording the reader’s display list when the HUD, the overlay or the pager state changes), reported by gfxinfo as one Slow issue draw commands frame per gesture. It fits inside the 16.7 ms budget at 60 Hz most of the time, which is why the app feels smooth, and it is what would break first at 120 Hz.

Findings, ranked

  1. Bubble overlay GPU cost. With bubbles on, the page-turn animation’s GPU time doubles (p90 11 ms vs 5 ms) and the toggle renders 50 frames at ≈ 14 ms GPU. The overlay is layer-cached for static frames, but during the pager’s graphicsLayer animation the per-bubble bitmaps (copy + crescent fill) are composited every frame. Largest measured cost; the only one that touches the frame budget at 60 Hz.
  2. First-frame display-list re-record (9–11 ms main thread) on every gesture: HUD toggle, settings panel, page turn, guided step, zoom. Root of the 3–4 % janky figures. Candidate: isolate the HUD and the page in separate layers so toggling chrome does not invalidate the page draw.
  3. Open latency scales with archive size: the whole file is copied from SAF to cacheDir (≈ 2.4 ms/MB) before the archive is opened, and CBR then extracts every item to disk again (CbrComicSource.extractAllItems). A 165 MB RAR shows its first page after 509 ms; a 400 MB one would take ≈ 1.2 s. Candidates: open CBZ/PDF from the SAF file descriptor without copying; extract RAR items in reading order starting at the saved page.
  4. Cold detection pipeline is serial per page (PARALLEL_DETECTIONS = 1, ≈ 250 ms per page in bubble mode; panels 130–210 ms in Guided View, only started when the page becomes current). It keeps up at one turn every 0.5 s; faster flipping or scrubber jumps show bubbles/panels late. Candidate: preload panels with the pages when Guided View is on.
  5. Reader → library transition: one 20–27 ms frame while the library recomposes and Coil re-binds covers.
  6. Refresh rate: the app renders at 60 Hz. Requesting 120 Hz for scroll and page turns is a product decision that only pays off after 1 and 2.

Not worth touching (baseline is at the noise floor): library scroll, startup, thumbnail scrubber, zoom/pan, decode placement.

Follow-up: findings 1 and 2 (2026-08-28)

Where this leaves the app

Every interaction in the table now sits at 2–4 % gfxinfo jank with the per-gesture frame being SurfaceFlinger’s idle wake-up, main-thread frames ≈ 4 ms warm, GPU ≤ 6 ms with or without bubbles, RAR/PDF opening independent of file size and Guided View never waiting for a detection. The unfolded posture is still unmeasured; the same recipe applies.

Unfolded posture (inner screen 2448×1848, spread, 60 Hz, 2026-08-28)

Same build family (after fixes 1–4), Muerte (62-page RAR, resumed at p.20) unless noted. HUD on the inner screen: bubbles ≈ (2032,88), Guided View ≈ (2192,88), settings ≈ (2352,88), back ≈ (96,88); centre tap (1224,924).

Interaction Frames Janky p50 / p90 / p99 GPU p50 / p90 Notes
Cold start → first window — — 118–131 ms —  
Library fling ×8 (5 columns) 477 1.9–2.1 % 5 / 6–8 / 13 ms 5 / 5–7 ms  
Open Muerte, resumed at p.20 — — first pages at +347–362 ms — RAR tail-first; head batch lands at ≈ 1 s
Spread turns ×8, bubbles off 411–429 4.0–5.0 % 7 / 9–10 / 14–15 ms 7 / 9 ms two 2160 px hardware bitmaps per frame
Spread turns ×8, bubbles on, cold 417 3.4 % 7 / 15 / 26 ms 7 / 14 ms fresh overlay layers rendered mid-animation
Spread turns ×8, bubbles on, cached 423 4.3 % 7 / 10 / 18 ms 7 / 9 ms = bubbles off
Bubble toggle on (cold, spread) 29 6.9 % 11 / 24 / 34 ms 7 / 13 ms six overlays planned + two layers created
Guided View enter (cold panels) 30 10 % (3/30) 9 / 12 / 14 ms 7 / 10 ms panels 233–395 ms, preloaded two ahead
Guided View next ×10 334 3.3 % 7 / 13 / 20 ms 7 / 13 ms  
Double-tap zoom + pan 44 4.5 % 5 / 9 / 18 ms 5 / 8 ms  
HUD show/hide ×6 (first after open) 84 8.3 % 7 / 11 / 20 ms 6 / 10 ms first show pays the one-off allocations
Settings toggle ×6 181 3.9 % 8 / 9 / 16 ms 7 / 9 ms  
Reader → library 20 5 % 7 / 32 / 40 ms 6 / 6 ms bigger library composition on the inner screen

The inner screen is GPU-bound, not CPU-bound: the RenderThread spends 0.7 ms per frame and the GPU ≈ 6 ms (Perfetto GPU completion, 296 frames, max 12 ms). Two full-height hardware bitmaps sampled down from 2160 px plus the ambient radial gradient over 4.5 MP. An A/B without the gradient took GPU p50 7 → 5 ms and p90 9 → 7 ms; AmbientBackdrop now paints it in an Offscreen layer at a quarter of the screen size scaled ×4 (1/16 of the shader work; the blend is a smooth gradient, indistinguishable), landing at GPU p50 6 / p90 8 ms. The single-page pager’s turn layer uses ModulateAlpha when no overlay is drawn, so its fade no longer needs a per-page saveLayer; verified folded afterwards (Doom #9 turns 3.2 % janky, p50 5 / GPU p90 5 ms, the fade intact mid-turn). Everything sits under the 16.7 ms budget; at 120 Hz the spread would not.

Always-on bubble detection, measured (2026-09-04)

The ROADMAP had carried “measure on the Fold what always-on bubble detection costs per page” for days. Measured on the user’s Z Fold against the installed release (com.sanchezpaco.kapow 1.0.0), folded on the cover screen at 1248×1972, dumpsys gfxinfo around twelve page turns:

comic bubbles frames janky p50 p99 slow uploads
Doctor Muerte off 366 1.64 % 5 ms 11 ms 0
Doctor Muerte on 1.3× 362 2.21 % 5 ms 20 ms 0
Un Mundo Bajo Muerte #1 off 350 2.00 % 5 ms 13 ms 0
Un Mundo Bajo Muerte #1 on 1.3× 407 2.70 % 5 ms 21 ms 0
Un Mundo Bajo Muerte #2 (cold) off 495 2.02 % 5 ms 14 ms 0
Un Mundo Bajo Muerte #2 (cold) on 1.4× 494 2.63 % 5 ms 14 ms 0
Un Mundo Bajo Muerte #2 on 1.4×, unfolded portrait 405 2.96 % 5 ms 15 ms 0

Turning bubbles on costs roughly half a point of janky frames and moves p99 from ~12 to ~20 ms. It does not drop frames. Rapid turning (0.35 s apart) does not change that, and neither does 1.4× over 1.3×.

The per-page work is a different number, and the one that is actually large. PageLoader logs it (adb logcat | grep PageLoader); on cold pages:

so ~250–350 ms between the page appearing and its bubbles appearing. Warm pages (detections already in Room) drop to 59–67 ms with a 0–5 ms plan.

These are two different symptoms and they need separating before chasing either: “the page turn stutters” is frame work and currently measures clean; “the bubbles take a moment to appear” is the 250–350 ms cold path and is real.

Two things worth knowing before the next attempt:

Status: a user-reported “big lag / FPS drop” could not be reproduced in this session across Doctor Muerte and Un Mundo Bajo Muerte, folded and unfolded, at both scales. Parked until it recurs with a comic and page to reproduce from.

When gfxinfo says the reader is fine and it is not (2026-09-04)

Scrolling the continuous vertical strip with enlarged bubbles on froze for over a second at a time and then jumped forward. Every number gfxinfo reports said the reader was healthy: 752 frames over the scroll, 3.2 % janky against the Fold’s 120 Hz deadline, zero missed vsync, zero slow bitmap uploads, p99 12 ms. All of that was true. The app was rendering — it was rendering the same frame.

gfxinfo measures how long frames take, not whether they show anything new, so it cannot see this class of bug at all. What found it was a screenrecord of the scroll turned into a 5 fps contact sheet (ffmpeg -vf "fps=5,tile=8x5"): seven identical tiles in a row is a frozen second, and it is obvious at a glance. Reach for the recording, not the counter, whenever the complaint is “it stutters” but the frame stats are clean.

The cause was two pieces of per-page work running on whatever thread called them, which was the main thread: BubbleLayout.enlarge (the collision and silhouette pass) and decoding the detections Room hands back (PageDetectionCodec). Neither had a withContext of its own — they inherited the caller’s, and the caller was produceState, which runs on the composition’s main dispatcher. PageLoader.preload launched on the caller’s dispatcher for the same reason.

The paged reader pays that cost for one or two pages and it disappears into the page turn. A strip pays it for every page that scrolls in, so it turned into a stutter roughly once per page. Fixed by pushing all three onto Dispatchers.Default.

Two smaller gotchas from the same session:

The paged reader with bubbles on, cold (2026-09-04, evening)

The section above closed with “could not be reproduced, parked until it recurs”. It reproduces on the emulator, and the cause is not frame work at all.

Measured in release on the Fold AVD (emulator-5554, 1968×2184 unfolded), Ben Reilly #1 (CBR, 24 pages), paged reader, enlarged bubbles on at 1.3×, twenty page turns 1.3 s apart. Cold means the release build had never opened the comic, so page_detections was empty; warm is the second pass.

run decode / page bubbles / page plan / page decodes per page bubbles behind the reader janky frames
cold, before 891–3373 ms 2321–6229 ms 149–2346 ms 2 up to 7 pages 405/1157 = 35 %
cold, after 212–552 ms 125–638 ms 0–279 ms 1 0 77/787 = 9.8 %
warm, before 192–590 ms from Room 28–538 ms 1 0 97/740 = 13.1 %
warm, after 176–307 ms from Room 38–140 ms 1 0 43/789 = 5.5 %

Missed vsyncs over the same turns went from 196 to 2 cold, and 22 to 1 warm.

The other two postures, after the fix, warm, driven with swipes:

posture decode / page plan / page janky frames missed vsync
folded cover width (wm size 1080x2520) 203–272 ms 56–163 ms 51/818 = 6.2 % 0
rotated spread (two pages) 382–392 ms 121–162 ms 45/539 = 8.3 % 1

The spread still decodes some pages twice. Its preload window is five pages wide and it composes two ZoomablePages at once, so the five-entry load cache is smaller than the working set. It costs nothing visible here — the spread kept up with a turn every 2.2 s and janked less than the single page did before the fix — so the cache size was left alone rather than guessed upward. Worth revisiting only if the spread is measured falling behind.

Nothing ever froze. The contact sheet shows every page arriving on the turn, before and after. What the reporter would call lag is the other symptom: on a cold comic the bubble pipeline fell behind the reader and never caught up, so the page on screen kept the “bubbles are coming” spinner in its top-right corner and drew the balloons at their printed size for the whole read. The backlog only drained about 5.5 s after the last page turn. Turning bubbles on appeared to do nothing except make the app slow.

The cause was one line of ordering in PageLoader.planOverlay:

val bubbles = bubbles(index)   // waits on the detection semaphore, seconds
val art = load(index)          // cache evicted by now -> decodes again

load caches five pages and the preload window is four wide, so any page that sat waiting for its turn at the detector had its art evicted before the layout pass asked for it. Every page was decoded twice, and each wasted decode evicted a page the reader was about to need, which slowed the next decode, which widened the backlog. The log makes it obvious once you look for it: the sequence bubbles on page N → decode on page N → bubble plan on page N repeats for every page.

Loading the art before the detection holds a reference across the wait, so the second load is a cache hit. That single swap removes the duplicate decode and the whole spiral with it: the pipeline now finishes each page inside its 1.3 s dwell and never falls behind. preload also sorts the window with preloadOrder, which puts the focused page first and then the pages ahead of the reader — the old sortedBy { abs(it - around) } tied page−1 with page+1 and prepared the page you had just left first.

Notes for the next measurement: