Files
BadNote/lib/editor/pdf/page_tile_cache.dart
Akiba So 07b543f3e1 feat(p0.5): DPI-bucketed PageTileCache + dpiBucketFor (step 10, automatable slice)
The "heavy" page-bitmap cache (R11/MF2), deliberately SEPARATE from the
resolution-independent ink Picture cache: page tiles are only crisp at the DPI
they were rasterized for, so TileKey carries a DPI bucket. get()/put() (tiles
render async via pdfrx), bounded LRU with MRU promotion, per-key replacement
disposes the old image, evictHostsExcept() for scroll-out, and post-frame
ui.Image disposal so the raster thread never frees an in-use image.

dpiBucketFor() snaps a continuous pinch scale to a coarse bucket (ceil by step,
capped at maxBucket) so a smooth zoom re-uses tiles instead of spawning one per
frame and bounds retained-DPI memory (~3× cap).

The pdfrx tile RENDERING (page_tile.dart) + zoom-settle DPI refresh remain
device-gated (crisp-at-4× on the Surface) — only the cache data structure is
automatable, and it is here, fully unit-tested.

flutter analyze lib/editor clean; 102/102 tests (+12: bucket math, LRU, MRU,
host eviction, post-frame disposal via debugDisposed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 03:05:52 +08:00

138 lines
5.0 KiB
Dart
Raw Permalink 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.
// lib/editor/pdf/page_tile_cache.dart
//
// Bounded LRU cache of rasterized PAGE tiles (ui.Image), DPI-bucketed.
//
// This is the "heavy" cache (a single A4 page at 3× DPI is ~18 MB) and is
// DELIBERATELY SEPARATE from the resolution-independent ink Picture cache
// (render/ink_picture_cache.dart): ink is vector and valid at any zoom, but a
// page bitmap is only crisp at the DPI it was rasterized for, so its key
// carries a DPI bucket (R11 / MF2). On zoom-settle the page_tile renderer
// re-rasterizes at the new bucket and put()s it here; matrix-upscale of a lower
// bucket is the accepted transient until the new tile lands.
//
// Tiles are rendered ASYNCHRONOUSLY (pdfrx PdfPage.render / a re-laid-out
// PdfPageView), so the cache is get()/put() — NOT getOrBuild — and the caller
// owns the async render. Evicted images are disposed via a post-frame callback
// so Flutter's raster thread is never asked to free a ui.Image it may still be
// sampling this frame.
import 'dart:collection';
import 'dart:ui' as ui;
import 'package:flutter/widgets.dart';
/// Identity of a cached page tile: which host (page) and which DPI bucket.
///
/// The DPI bucket (an integer, e.g. round(scale × base-DPI) snapped to a step)
/// keeps the key space small so a smooth pinch doesn't spawn a distinct tile
/// per frame — only per bucket.
@immutable
class TileKey {
const TileKey(this.hostId, this.dpiBucket);
final String hostId;
final int dpiBucket;
@override
bool operator ==(Object other) =>
other is TileKey &&
other.hostId == hostId &&
other.dpiBucket == dpiBucket;
@override
int get hashCode => Object.hash(hostId, dpiBucket);
@override
String toString() => 'TileKey($hostId @dpi$dpiBucket)';
}
/// Bounded LRU cache of page-tile [ui.Image]s keyed by [TileKey].
///
/// Capacity is a TILE COUNT (not bytes); size the window to the device memory
/// budget — full-DPI tiles for visible ±1 pages, off-window pages downgraded to
/// a 1× tier elsewhere (see the plan's R10 resolution). Eviction disposes the
/// image post-frame.
class PageTileCache {
PageTileCache({int maxTiles = 6})
: assert(maxTiles > 0),
_maxTiles = maxTiles;
final int _maxTiles;
// Insertion-ordered; accessed entries are moved to the back so the front is
// always the least-recently-used.
final LinkedHashMap<TileKey, ui.Image> _cache =
LinkedHashMap<TileKey, ui.Image>();
/// Number of tiles currently retained.
int get length => _cache.length;
/// The keys currently retained, most-recently-used LAST.
Iterable<TileKey> get keys => _cache.keys;
/// Returns the cached image for [key] (promoting it to most-recently-used),
/// or null on a miss. The caller renders + [put]s on a miss.
ui.Image? get(TileKey key) {
final image = _cache.remove(key);
if (image == null) return null;
_cache[key] = image; // promote to MRU
return image;
}
/// Inserts [image] for [key], evicting the least-recently-used tiles beyond
/// the cap. If a DIFFERENT image was already stored for [key], the old one is
/// disposed (post-frame). Re-putting the identical image is a no-op promote.
void put(TileKey key, ui.Image image) {
final existing = _cache.remove(key);
if (existing != null && !identical(existing, image)) {
_disposeDeferred(existing);
}
_cache[key] = image;
while (_cache.length > _maxTiles) {
final lruKey = _cache.keys.first;
_disposeDeferred(_cache.remove(lruKey)!);
}
}
/// Evicts every tile whose host is NOT in [liveHostIds] (e.g. pages that
/// scrolled out of the mounted window). Disposed post-frame.
void evictHostsExcept(Set<String> liveHostIds) {
final doomed = _cache.keys
.where((k) => !liveHostIds.contains(k.hostId))
.toList(growable: false);
for (final key in doomed) {
_disposeDeferred(_cache.remove(key)!);
}
}
/// Disposes all retained tiles (post-frame). Call from the owner's dispose.
void dispose() {
final images = List<ui.Image>.from(_cache.values);
_cache.clear();
for (final image in images) {
_disposeDeferred(image);
}
}
static void _disposeDeferred(ui.Image image) {
// Defer to after the current frame so the raster thread is done with it.
// If no binding/frame is scheduled (e.g. a unit test that never pumps),
// fall back to disposing on the next microtask so images aren't leaked.
final binding = WidgetsBinding.instance;
binding.addPostFrameCallback((_) => image.dispose());
binding.scheduleFrame();
}
}
/// Snaps a continuous render scale to a coarse DPI bucket so a smooth pinch
/// re-uses tiles instead of spawning one per frame. [step] is the bucket
/// granularity in the same units as [scale] (e.g. 0.5). The result is capped at
/// [maxBucket] to bound retained-tile memory (the plan's ~3× cap, R11).
int dpiBucketFor(double scale, {double step = 0.5, int maxBucket = 6}) {
if (!scale.isFinite || scale <= 0) return 1;
final bucket = (scale / step).ceil();
if (bucket < 1) return 1;
return bucket > maxBucket ? maxBucket : bucket;
}