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>
This commit is contained in:
137
lib/editor/pdf/page_tile_cache.dart
Normal file
137
lib/editor/pdf/page_tile_cache.dart
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
150
test/page_tile_cache_test.dart
Normal file
150
test/page_tile_cache_test.dart
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// Unit tests for the DPI-bucketed page-tile cache (P0.5 step 10, automatable
|
||||||
|
// slice). Covers LRU eviction, MRU promotion, per-key replacement, host
|
||||||
|
// eviction, dpiBucketFor snapping, and post-frame disposal of evicted images.
|
||||||
|
//
|
||||||
|
// The pdfrx tile RENDERING (page_tile.dart) is device-gated and not tested
|
||||||
|
// here; this is the pure cache data structure.
|
||||||
|
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:badnote/editor/pdf/page_tile_cache.dart';
|
||||||
|
|
||||||
|
Future<ui.Image> _img() async {
|
||||||
|
final recorder = ui.PictureRecorder();
|
||||||
|
Canvas(recorder).drawRect(
|
||||||
|
const Rect.fromLTWH(0, 0, 2, 2),
|
||||||
|
Paint()..color = const Color(0xFF000000),
|
||||||
|
);
|
||||||
|
final picture = recorder.endRecording();
|
||||||
|
final image = await picture.toImage(2, 2);
|
||||||
|
picture.dispose();
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('dpiBucketFor', () {
|
||||||
|
test('snaps continuous scale to a coarse bucket (ceil by step)', () {
|
||||||
|
expect(dpiBucketFor(1.0, step: 0.5), 2); // 1.0/0.5 = 2
|
||||||
|
expect(dpiBucketFor(1.1, step: 0.5), 3); // ceil(2.2)
|
||||||
|
expect(dpiBucketFor(0.4, step: 0.5), 1); // ceil(0.8) = 1
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is monotonic non-decreasing in scale', () {
|
||||||
|
var prev = 0;
|
||||||
|
for (final s in [0.3, 0.6, 1.0, 1.6, 2.0, 2.9]) {
|
||||||
|
final b = dpiBucketFor(s, step: 0.5, maxBucket: 100);
|
||||||
|
expect(b, greaterThanOrEqualTo(prev));
|
||||||
|
prev = b;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('caps at maxBucket (bounds retained-DPI memory)', () {
|
||||||
|
expect(dpiBucketFor(99.0, step: 0.5, maxBucket: 6), 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guards invalid scale', () {
|
||||||
|
expect(dpiBucketFor(0), 1);
|
||||||
|
expect(dpiBucketFor(-3), 1);
|
||||||
|
expect(dpiBucketFor(double.nan), 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('PageTileCache LRU', () {
|
||||||
|
test('get returns null on miss, the image on hit', () async {
|
||||||
|
final cache = PageTileCache(maxTiles: 4);
|
||||||
|
const key = TileKey('p0', 2);
|
||||||
|
expect(cache.get(key), isNull);
|
||||||
|
final img = await _img();
|
||||||
|
cache.put(key, img);
|
||||||
|
expect(identical(cache.get(key), img), isTrue);
|
||||||
|
cache.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TileKey equality is by (hostId, dpiBucket)', () {
|
||||||
|
expect(const TileKey('p0', 2), const TileKey('p0', 2));
|
||||||
|
expect(const TileKey('p0', 2), isNot(const TileKey('p0', 3)));
|
||||||
|
expect(const TileKey('p0', 2), isNot(const TileKey('p1', 2)));
|
||||||
|
expect(const TileKey('p0', 2).hashCode, const TileKey('p0', 2).hashCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('evicts the least-recently-used beyond the cap', () async {
|
||||||
|
final cache = PageTileCache(maxTiles: 2);
|
||||||
|
cache.put(const TileKey('a', 1), await _img());
|
||||||
|
cache.put(const TileKey('b', 1), await _img());
|
||||||
|
cache.put(const TileKey('c', 1), await _img()); // evicts 'a'
|
||||||
|
expect(cache.length, 2);
|
||||||
|
expect(cache.keys, isNot(contains(const TileKey('a', 1))));
|
||||||
|
expect(cache.get(const TileKey('a', 1)), isNull);
|
||||||
|
expect(cache.get(const TileKey('b', 1)), isNotNull);
|
||||||
|
cache.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get promotes MRU so the OTHER entry is evicted next', () async {
|
||||||
|
final cache = PageTileCache(maxTiles: 2);
|
||||||
|
cache.put(const TileKey('a', 1), await _img());
|
||||||
|
cache.put(const TileKey('b', 1), await _img());
|
||||||
|
cache.get(const TileKey('a', 1)); // 'a' now MRU → 'b' is LRU
|
||||||
|
cache.put(const TileKey('c', 1), await _img()); // evicts 'b'
|
||||||
|
expect(cache.get(const TileKey('a', 1)), isNotNull);
|
||||||
|
expect(cache.get(const TileKey('b', 1)), isNull);
|
||||||
|
cache.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('evictHostsExcept drops other hosts, keeps live ones', () async {
|
||||||
|
final cache = PageTileCache(maxTiles: 8);
|
||||||
|
cache.put(const TileKey('p0', 1), await _img());
|
||||||
|
cache.put(const TileKey('p0', 2), await _img());
|
||||||
|
cache.put(const TileKey('p1', 1), await _img());
|
||||||
|
cache.put(const TileKey('p2', 1), await _img());
|
||||||
|
cache.evictHostsExcept({'p0', 'p1'});
|
||||||
|
expect(cache.keys.map((k) => k.hostId).toSet(), {'p0', 'p1'});
|
||||||
|
expect(cache.length, 3);
|
||||||
|
cache.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('PageTileCache disposal (post-frame)', () {
|
||||||
|
testWidgets('an evicted tile is disposed after the frame', (tester) async {
|
||||||
|
final cache = PageTileCache(maxTiles: 1);
|
||||||
|
final first = await _img();
|
||||||
|
cache.put(const TileKey('a', 1), first);
|
||||||
|
cache.put(const TileKey('b', 1), await _img()); // evicts 'a' → defers
|
||||||
|
expect(first.debugDisposed, isFalse, reason: 'deferred, not yet');
|
||||||
|
await tester.pump(); // run the post-frame callback
|
||||||
|
expect(first.debugDisposed, isTrue);
|
||||||
|
cache.dispose();
|
||||||
|
await tester.pump();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('re-putting a different image for a key disposes the old one',
|
||||||
|
(tester) async {
|
||||||
|
final cache = PageTileCache(maxTiles: 4);
|
||||||
|
final old = await _img();
|
||||||
|
cache.put(const TileKey('a', 1), old);
|
||||||
|
cache.put(const TileKey('a', 1), await _img());
|
||||||
|
await tester.pump();
|
||||||
|
expect(old.debugDisposed, isTrue);
|
||||||
|
expect(cache.length, 1);
|
||||||
|
cache.dispose();
|
||||||
|
await tester.pump();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('dispose() frees all retained tiles', (tester) async {
|
||||||
|
final cache = PageTileCache(maxTiles: 8);
|
||||||
|
final a = await _img();
|
||||||
|
final b = await _img();
|
||||||
|
cache.put(const TileKey('a', 1), a);
|
||||||
|
cache.put(const TileKey('b', 1), b);
|
||||||
|
cache.dispose();
|
||||||
|
await tester.pump();
|
||||||
|
expect(a.debugDisposed, isTrue);
|
||||||
|
expect(b.debugDisposed, isTrue);
|
||||||
|
expect(cache.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user