Skip to content

Perf(reader): stop manhwa scroll degrading after chapters and drawer use#72

Merged
Aatricks merged 9 commits into
mainfrom
fix/manwha-scroll-performance
Jul 6, 2026
Merged

Perf(reader): stop manhwa scroll degrading after chapters and drawer use#72
Aatricks merged 9 commits into
mainfrom
fix/manwha-scroll-performance

Conversation

@Aatricks

@Aatricks Aatricks commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Fixes long-strip scroll micro-stutter that got worse after a chapter or two and stuttered almost every time after opening then closing the library drawer. Worst on fast up/down drags.

Changes

  • Coil memory cache 0.25 -> 0.5 of heap. Strips decode into ~9MB HARDWARE slices, so at 0.25 the LRU held under one chapter's worth and fast up/down drags or scroll-back re-decoded evicted slices every time.
  • Reader drawer reads a dedicated lean flow (sections + isEmpty) instead of the whole-library uiState combine, so opening it no longer group+sorts the entire library on the drawer path.
  • Restore edge-swipe-to-open the drawer in continuous scroll; paged mode keeps the left edge for page turns.
  • Revert an unrelated restore-fallback change that dropped the percent self-heal for precise restores, and restore its tests.

Also on this branch (earlier reader-perf commits)

Per-URL image-dimension state with per-chapter pruning, prefetch driven by snapshotFlow, library-state combine moved off the main thread with a lifecycle-scoped subscription, nested-scroll-connection and progress-save reworks to cut per-frame scroll work, and an e2e suite covering scroll, drawer and state restoration.

Verification

testStandardDebugUnitTest + detekt green. Drawer and fast up/down confirmed smooth on-device.

claude and others added 9 commits July 5, 2026 21:54
AsyncImage.onSuccess re-fires every time a recycled item re-enters
composition (memory-cache hits included), so a fast up/down drag calls
persistImageDimensions once per re-entry per image. Each call wrote a
fresh Pair into the SnapshotStateMap (invalidating every composed reader
of the map), re-enqueued a Room upsert of the same row, and cancelled/
relaunched the content-dim apply job — a per-frame storm during the
exact gesture where scroll jank is most visible.

Early-return when the stored dimensions already match, so revisited
images no longer pay any of that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gzsfvi9WhP4KJDvAhEqR9y
SnapshotStateMap tracks reads per-map, not per-key, so every visible
image (each reads the map in ReaderImageView and in the ScrollingReaderView
item scope for slice count) was invalidated and recomposed whenever ANY
image resolved its dimensions — despite the comment claiming per-key
granularity. During a scroll through a fresh chapter that meant every
decode recomposed the whole visible set.

Replace the map with one MutableState per URL behind
ConcurrentHashMap.computeIfAbsent (atomic, unlike getOrPut, so the State
a composable subscribes to is always the one later writes land on). Read
sites subscribe via remember(url) + State delegate, so a write now
invalidates only that image's scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gzsfvi9WhP4KJDvAhEqR9y
The dimension state was never cleared: ImageDimensionManager.reset() was
only reachable from ReaderViewModel.resetState(), which has no callers,
and the ViewModel is Activity-scoped — so the per-URL state grew by
every image ever displayed, for the whole session.

Replace reset() with pruneForChapter(currentImageUrls), called after a
chapter load succeeds (web + epub paths). Only entries missing from the
new chapter are dropped: a same-chapter reload keeps its dims, a failed
navigation never prunes (the old chapter stays on screen), and revisited
chapters re-seed declared dimensions from the Room cache at parse time.
Pending DB flushes are left untouched so in-flight persists complete.
Delete the dead resetState().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gzsfvi9WhP4KJDvAhEqR9y
getLikelyMediaState stats up to 3 candidate files (exists/length/mtime)
and ReaderImageView called it twice per image item — on the main thread,
in composition, re-executed every time a recycled item re-entered during
a scroll gesture.

Memoize the state string in ImageCache and invalidate it wherever a
url's candidate files can change: the cache mutators, trimToSize/clearAll
(whole-memo drop, trim deletes arbitrary files), and every terminal
branch of WebContentLoader.downloadAndCacheImageInternal — the only
production path that creates or replaces cached media. Staleness can
never render a wrong image: HttpMediaCacheFetcher re-checks disk
authoritatively; the string only feeds the Coil memory-cache key, the
loading-UI choice, and repair eligibility.

ReaderImageView now derives isInitiallyCached from localMediaState, so
each composition entry performs one (memoized) probe instead of two disk
probes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gzsfvi9WhP4KJDvAhEqR9y
Keying the prefetch LaunchedEffect on listState.firstVisibleItemIndex
read the index during composition, so every item boundary crossed while
scrolling recomposed the entire ContentArea scope and cancelled+
relaunched the effect coroutine. Observe the index via snapshotFlow in
one persistent effect per chapter instead; composition no longer
subscribes to per-frame scroll state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gzsfvi9WhP4KJDvAhEqR9y
Extend the existing helper to recurse into PageContent (no callers
relied on the old shallow behavior — it had none) instead of adding a
fourth private copy of the Image/ImageGroup traversal in ReaderViewModel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gzsfvi9WhP4KJDvAhEqR9y
…timing

Adversarial review of the perf series surfaced four timing holes, each
confirmed by several independent passes:

- Media-state memo invalidation moved from the download awaiter's finally
  into the shared deferred's invokeOnCompletion. The awaiter can be
  cancelled (Coil disposes a request when the item scrolls away) before
  the file lands; the detached download then wrote the file with no
  invalidation, leaving a permanent stale 'missing' entry — placeholder
  flash on every re-entry of a fully cached image.
- ImageCache mutators now invalidate AFTER mutating disk; invalidating
  first let a concurrent probe re-memoize the pre-mutation state.
- trimToSize only clears the memo when files were actually deleted (and
  after the walk). Trim runs after chapter loads and every ~30s during
  scroll-driven prefetch and is usually a no-op — the unconditional
  clear was periodically defeating the memoization outright.
- getLikelyMediaState serves hits with a plain lookup; the size guard
  (which can clear the map) runs only on the miss/insert path, matching
  the integrityVerdicts pattern.
- pruneForChapter now runs after the new content is committed to uiState,
  so the still-composed outgoing chapter is never stripped mid-display
  and late decodes cannot resurrect just-pruned entries; it also
  re-enqueues surviving urls into the content rebuild so a chapter
  sharing an image with its predecessor still gets its element healed
  despite the duplicate-persist guard.
- A failed Room dimension flush re-queues its batch (persistAll now
  reports success); the duplicate-persist guard would otherwise swallow
  every future chance to retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gzsfvi9WhP4KJDvAhEqR9y
Refactor(tests): invalidate media state in ImageCacheTest after writing bytes
Build(gradle): update gradle wrapper properties and scripts for consistency
Long-strip scroll micro-stuttered on fast up/down drags, got worse after a
chapter or two, and stuttered almost every time after opening then closing the
library drawer.

- Coil memory cache raised 0.25 -> 0.5 of heap. Strips are region-decoded into
  ~9MB HARDWARE slices, so at 0.25 the LRU held under one chapter's worth and a
  fast up/down drag or scroll-back re-decoded evicted slices every time.
- Reader drawer now reads a dedicated lean flow (sections + isEmpty) instead of
  the whole-library uiState combine, so opening it no longer group+sorts the
  entire library (getGroupedByTitle/BySource) on the drawer path.
- Restore edge-swipe-to-open the drawer in continuous scroll; paged mode keeps
  the left edge for page turns.
- Revert an unrelated restore-fallback change that dropped the percent self-heal
  for precise restores, and restore its tests.

Supporting work kept from the same effort: library-state combine moved off the
main thread with a lifecycle-scoped subscription, nested-scroll-connection and
progress-save reworks to cut per-frame work during scroll, and an e2e suite
covering scroll, drawer and state restoration.
@Aatricks Aatricks merged commit 8c9b9c0 into main Jul 6, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants