diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b313a0..5f040e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.6.0 + +- Add configurable application-wide morph cache limits, public cache statistics, + clearing, disabling, and resetting. + ## 0.5.0 - Add controlled `MorphIcon` and interruptible `AnimatedMorphIcon` widgets. diff --git a/README.md b/README.md index ff4df90..b3102ba 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,32 @@ semantic image node; leave it null for a decorative icon. When Fonts and morph plans are loaded lazily and cached with bounded memory use. Animation repaints directly without rebuilding the widget on every frame. +The application-wide morph cache has limits similar to Flutter's image cache. +It is shared by morphnext widgets in the current Dart isolate. The defaults +work without configuration. Applications that need explicit limits can set +both before `runApp`: + +```dart +MorphCache.configure( + maxMorphs: 100, + maxBytes: 16 * 1024 * 1024, +); +``` + +`maxMorphs` counts completed morphs retained for reuse. `A → B` and `B → +A`, LTR and RTL, and different font parameters are separate morphs. One-off +morphs from an interrupted intermediate shape are not retained. A morph larger +than `maxBytes` is still built and used by the current animation, but it is not +cached and does not evict existing entries. + +`currentMorphs` and `currentBytes` expose the current cache statistics. Pending +morphs are not counted. `currentBytes` is the size of vector buffers owned by +retained morphs; it excludes decoded fonts, sampled source shapes, Dart object +overhead, and memory owned by Flutter. Use `MorphCache.clear()` to empty the +cache, `MorphCache.disable()` to empty and disable it, and `MorphCache.reset()` +to restore `defaultMaxMorphs` and `defaultMaxBytes`. `configure` throws +`ArgumentError` unless both limits are positive. + ## Limitations morphnext preserves filled contour topology and keeps holes open, but arbitrary diff --git a/example/pubspec.lock b/example/pubspec.lock index b4550d3..5b74d43 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -286,7 +286,7 @@ packages: path: ".." relative: true source: path - version: "0.5.0" + version: "0.6.0" objective_c: dependency: transitive description: diff --git a/lib/morphnext.dart b/lib/morphnext.dart index 7c9a9b5..ffa5d89 100644 --- a/lib/morphnext.dart +++ b/lib/morphnext.dart @@ -2,6 +2,7 @@ library; export 'package:flutter/physics.dart' show SpringDescription; +export 'src/cache/morph_cache.dart' show MorphCache; export 'src/morph_spring.dart' show MorphSprings; export 'src/widgets/animated_morph_icon.dart' show AnimatedMorphIcon; export 'src/widgets/morph_icon.dart' show MorphIcon; diff --git a/lib/src/cache/morph_cache.dart b/lib/src/cache/morph_cache.dart new file mode 100644 index 0000000..bc6c406 --- /dev/null +++ b/lib/src/cache/morph_cache.dart @@ -0,0 +1,152 @@ +import '../geometry/morph_plan.dart'; +import 'sized_lru_cache.dart'; + +/// Controls the application-wide cache of completed icon morphs. +/// +/// The cache is shared by all morphnext widgets in the current Dart isolate. +/// A completed morph is identified by its ordered icon pair, text direction, +/// font parameters, and asset bundle. Morphs built from an interrupted +/// intermediate shape are one-off values and are not retained. +abstract final class MorphCache { + /// The default maximum number of retained completed morphs. + static const int defaultMaxMorphs = 128; + + /// The default maximum size of retained morph geometry: 16 MiB. + static const int defaultMaxBytes = 16 << 20; + + /// The configured maximum number of retained completed morphs. + /// + /// This is zero while caching is disabled. + static int get maxMorphs => MorphCacheStore.instance.maxMorphs; + + /// The configured maximum size of retained morph geometry, in bytes. + /// + /// This is zero while caching is disabled. + static int get maxBytes => MorphCacheStore.instance.maxBytes; + + /// The number of completed morphs currently retained by the cache. + /// + /// Morphs that are still being built are not included. + static int get currentMorphs => MorphCacheStore.instance.currentMorphs; + + /// The size of vector buffers owned by currently retained morphs, in bytes. + /// + /// This does not include pending morphs, decoded fonts, sampled source + /// shapes, Dart object overhead, or memory owned by Flutter. + static int get currentBytes => MorphCacheStore.instance.currentBytes; + + /// Sets both cache limits and immediately clears all retained morphs. + /// + /// A morph is retained only while both limits permit it. A completed morph + /// larger than [maxBytes] is returned to its caller without being cached and + /// without evicting existing morphs. Work started before this call may still + /// finish for its current caller, but its result cannot repopulate the cache. + /// + /// Throws an [ArgumentError] when either argument is not positive. Use + /// [disable] instead of passing zero. + static void configure({required int maxMorphs, required int maxBytes}) { + if (maxMorphs <= 0) { + throw ArgumentError.value(maxMorphs, 'maxMorphs', 'Must be positive'); + } + if (maxBytes <= 0) { + throw ArgumentError.value(maxBytes, 'maxBytes', 'Must be positive'); + } + MorphCacheStore.instance.configure( + maxMorphs: maxMorphs, + maxBytes: maxBytes, + ); + } + + /// Immediately removes all retained morphs without changing the limits. + /// + /// Work already in progress may still finish for its current caller, but its + /// result cannot repopulate the cache. + static void clear() => MorphCacheStore.instance.clear(); + + /// Clears the cache and prevents completed morphs from being retained. + /// + /// Morphs are still built and used by current animations. Call [configure] + /// or [reset] to enable caching again. + static void disable() => MorphCacheStore.instance.disable(); + + /// Restores the default limits and clears all retained morphs. + static void reset() => MorphCacheStore.instance.configure( + maxMorphs: defaultMaxMorphs, + maxBytes: defaultMaxBytes, + ); +} + +/// Internal isolate-wide storage shared by repositories for all asset bundles. +final class MorphCacheStore { + MorphCacheStore._(); + + static final MorphCacheStore instance = MorphCacheStore._(); + + var _maxMorphs = MorphCache.defaultMaxMorphs; + var _maxBytes = MorphCache.defaultMaxBytes; + var _generation = 0; + var _cache = SizedLruCache( + maximumSize: MorphCache.defaultMaxMorphs, + maximumSizeBytes: MorphCache.defaultMaxBytes, + ); + final Map> _pending = >{}; + + int get maxMorphs => _maxMorphs; + int get maxBytes => _maxBytes; + int get currentMorphs => _cache.length; + int get currentBytes => _cache.currentSizeBytes; + int get generation => _generation; + + Future load( + Object key, + Future Function() create, + int Function(MorphPlan plan) sizeOf, + ) { + final cached = _cache.get(key); + if (cached != null) return Future.value(cached); + final pending = _pending[key]; + if (pending != null) return pending; + + final requestGeneration = _generation; + late final Future future; + future = create() + .then((plan) { + if (requestGeneration == _generation) { + _cache.putSized(key, plan, sizeOf(plan)); + } + return plan; + }) + .whenComplete(() { + if (identical(_pending[key], future)) _pending.remove(key); + }); + _pending[key] = future; + return future; + } + + void configure({required int maxMorphs, required int maxBytes}) { + _maxMorphs = maxMorphs; + _maxBytes = maxBytes; + _replaceCache(maxMorphs: maxMorphs, maxBytes: maxBytes); + } + + void disable() { + _maxMorphs = 0; + _maxBytes = 0; + _replaceCache(maxMorphs: 0, maxBytes: 0); + } + + void clear() { + _generation++; + _cache.clear(); + _pending.clear(); + } + + void _replaceCache({required int maxMorphs, required int maxBytes}) { + _generation++; + _cache = SizedLruCache( + maximumSize: maxMorphs, + maximumSizeBytes: maxBytes, + ); + _pending.clear(); + } +} diff --git a/lib/src/cache/sized_lru_cache.dart b/lib/src/cache/sized_lru_cache.dart index c7eddc4..efa4802 100644 --- a/lib/src/cache/sized_lru_cache.dart +++ b/lib/src/cache/sized_lru_cache.dart @@ -12,6 +12,13 @@ final class SizedLruCache { /// The maximum number of retained bytes. final int maximumSizeBytes; + + /// The number of currently retained entries. + int get length => _entries.length; + + /// The combined recorded size of all retained entries. + int get currentSizeBytes => _currentSizeBytes; + final LinkedHashMap> _entries = LinkedHashMap>(); var _currentSizeBytes = 0; @@ -32,6 +39,30 @@ final class SizedLruCache { _trim(); } + /// Inserts an already-sized [value]. + /// + /// Returns false without changing the cache when [sizeBytes] exceeds the + /// byte limit or caching is disabled. + bool putSized(K key, V value, int sizeBytes) { + assert(sizeBytes >= 0); + if (maximumSize == 0 || + maximumSizeBytes == 0 || + sizeBytes > maximumSizeBytes) { + return false; + } + _remove(key); + _entries[key] = _SizedEntry(value, sizeBytes); + _currentSizeBytes += sizeBytes; + _trim(); + return true; + } + + /// Removes every retained entry. + void clear() { + _entries.clear(); + _currentSizeBytes = 0; + } + /// Records [sizeBytes] if [value] is still the value retained for [key]. bool updateSizeIfSame(K key, V value, int sizeBytes) { assert(sizeBytes >= 0); diff --git a/lib/src/font/font_asset_resolver.dart b/lib/src/font/font_asset_resolver.dart index 1f1988f..64ea7b4 100644 --- a/lib/src/font/font_asset_resolver.dart +++ b/lib/src/font/font_asset_resolver.dart @@ -26,6 +26,13 @@ final class FontAssetResolver { maximumSizeBytes: _maximumRetainedGlyphBytes, ); + /// Clears all manifest, font, and glyph data retained by this resolver. + void clear() { + _manifest = null; + _fonts.clear(); + _glyphs.clear(); + } + Future resolve( IconData icon, [ MorphFontSelection selection = defaultMorphFontSelection, diff --git a/lib/src/morph_repository.dart b/lib/src/morph_repository.dart index 4880989..5f664f6 100644 --- a/lib/src/morph_repository.dart +++ b/lib/src/morph_repository.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'package:flutter/widgets.dart'; +import 'cache/morph_cache.dart'; import 'cache/sized_lru_cache.dart'; import 'font/font_asset_resolver.dart'; import 'font/font_selection.dart'; @@ -11,17 +12,17 @@ import 'geometry/shape.dart'; const _maximumCachedShapes = 256; const _maximumRetainedShapeBytes = 8 << 20; -const _maximumCachedPlans = 128; -const _maximumRetainedPlanBytes = 16 << 20; const _minimumContourPointCount = 64; const _maximumContourPointCount = 2048; const _samplesPerCubicSegment = 16; typedef _ShapeCacheKey = (IconData, TextDirection, MorphFontSelection); -typedef _PlanCacheKey = (IconData, IconData, TextDirection, MorphFontSelection); /// Extracts, normalizes, and caches morph geometry for one asset bundle. final class MorphRepository { - MorphRepository._(this.resolver); + MorphRepository._(AssetBundle bundle) + : _bundle = bundle, + resolver = FontAssetResolver(bundle), + _cacheGeneration = MorphCacheStore.instance.generation; static final Expando _repositories = Expando('morphnext repositories'); @@ -29,28 +30,25 @@ final class MorphRepository { static MorphRepository forBundle(AssetBundle bundle) { final existing = _repositories[bundle]; if (existing != null) return existing; - final repository = MorphRepository._(FontAssetResolver(bundle)); + final repository = MorphRepository._(bundle); _repositories[bundle] = repository; return repository; } + final AssetBundle _bundle; final FontAssetResolver resolver; + int _cacheGeneration; final SizedLruCache<_ShapeCacheKey, Future> _shapes = SizedLruCache<_ShapeCacheKey, Future>( maximumSize: _maximumCachedShapes, maximumSizeBytes: _maximumRetainedShapeBytes, ); - final SizedLruCache<_PlanCacheKey, Future> _plans = - SizedLruCache<_PlanCacheKey, Future>( - maximumSize: _maximumCachedPlans, - maximumSizeBytes: _maximumRetainedPlanBytes, - ); - Future shapeFor( IconData icon, TextDirection direction, [ MorphFontSelection fontSelection = defaultMorphFontSelection, ]) { + _synchronizeCacheGeneration(); final key = (icon, direction, fontSelection); final existing = _shapes.get(key); if (existing != null) return existing; @@ -75,26 +73,15 @@ final class MorphRepository { TextDirection direction, [ MorphFontSelection fontSelection = defaultMorphFontSelection, ]) { - final key = (from, to, direction, fontSelection); - final existing = _plans.get(key); - if (existing != null) return existing; - late final Future future; - future = () async { - try { - final shapes = await Future.wait(>[ - shapeFor(from, direction, fontSelection), - shapeFor(to, direction, fontSelection), - ]); - final plan = buildMorphPlan(shapes[0], shapes[1]); - _plans.updateSizeIfSame(key, future, _planBytes(plan)); - return plan; - } catch (_) { - _plans.removeIfSame(key, future); - rethrow; - } - }(); - _plans.put(key, future); - return future; + _synchronizeCacheGeneration(); + final key = _MorphCacheKey(_bundle, from, to, direction, fontSelection); + return MorphCacheStore.instance.load(key, () async { + final shapes = await Future.wait(>[ + shapeFor(from, direction, fontSelection), + shapeFor(to, direction, fontSelection), + ]); + return buildMorphPlan(shapes[0], shapes[1]); + }, _planBytes); } Future planFromShape( @@ -102,8 +89,18 @@ final class MorphRepository { IconData to, TextDirection direction, [ MorphFontSelection fontSelection = defaultMorphFontSelection, - ]) async => - buildMorphPlan(source, await shapeFor(to, direction, fontSelection)); + ]) async { + _synchronizeCacheGeneration(); + return buildMorphPlan(source, await shapeFor(to, direction, fontSelection)); + } + + void _synchronizeCacheGeneration() { + final generation = MorphCacheStore.instance.generation; + if (_cacheGeneration == generation) return; + _cacheGeneration = generation; + _shapes.clear(); + resolver.clear(); + } Future _createShape( IconData icon, @@ -186,3 +183,32 @@ int _planBytes(MorphPlan plan) => plan.items.fold( item.targetInSourceFrame.lengthInBytes + item.orientedTarget.lengthInBytes, ); + +final class _MorphCacheKey { + const _MorphCacheKey( + this.bundle, + this.from, + this.to, + this.direction, + this.fontSelection, + ); + + final AssetBundle bundle; + final IconData from; + final IconData to; + final TextDirection direction; + final MorphFontSelection fontSelection; + + @override + bool operator ==(Object other) => + other is _MorphCacheKey && + identical(bundle, other.bundle) && + from == other.from && + to == other.to && + direction == other.direction && + fontSelection == other.fontSelection; + + @override + int get hashCode => + Object.hash(identityHashCode(bundle), from, to, direction, fontSelection); +} diff --git a/pubspec.yaml b/pubspec.yaml index 4627d23..b504a4c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: morphnext description: Interruptible, spring-driven vector icon morphing for Flutter without per-pair animation assets. -version: 0.5.0 +version: 0.6.0 homepage: https://kicknext.github.io/morphnext/ repository: https://github.com/KickNext/morphnext issue_tracker: https://github.com/KickNext/morphnext/issues diff --git a/test/morph_cache_test.dart b/test/morph_cache_test.dart new file mode 100644 index 0000000..2336771 --- /dev/null +++ b/test/morph_cache_test.dart @@ -0,0 +1,242 @@ +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:morphnext/morphnext.dart'; +import 'package:morphnext/src/morph_repository.dart'; + +import 'support/test_asset_bundle.dart'; +import 'support/test_font_builder.dart'; +import 'support/test_icons.dart'; + +void main() { + setUp(MorphCache.reset); + tearDown(MorphCache.reset); + + test('exposes defaults and empty statistics after reset', () { + expect(MorphCache.maxMorphs, MorphCache.defaultMaxMorphs); + expect(MorphCache.maxBytes, MorphCache.defaultMaxBytes); + expect(MorphCache.currentMorphs, 0); + expect(MorphCache.currentBytes, 0); + }); + + test('configure requires positive limits', () { + MorphCache.configure(maxMorphs: 7, maxBytes: 700); + expect( + () => MorphCache.configure(maxMorphs: 0, maxBytes: 1), + throwsArgumentError, + ); + expect( + () => MorphCache.configure(maxMorphs: 1, maxBytes: 0), + throwsArgumentError, + ); + expect( + () => MorphCache.configure(maxMorphs: -1, maxBytes: 1), + throwsArgumentError, + ); + expect( + () => MorphCache.configure(maxMorphs: 1, maxBytes: -1), + throwsArgumentError, + ); + expect(MorphCache.maxMorphs, 7); + expect(MorphCache.maxBytes, 700); + }); + + test('completed morphs update public statistics', () async { + final repository = MorphRepository.forBundle(fixtureBundle()); + + await repository.planFor( + testQuadraticIcon, + testCompositeIcon, + TextDirection.ltr, + ); + + expect(MorphCache.currentMorphs, 1); + expect(MorphCache.currentBytes, greaterThan(0)); + }); + + test('maxMorphs evicts the least recently used completed morph', () async { + MorphCache.configure(maxMorphs: 1, maxBytes: 1 << 20); + final repository = MorphRepository.forBundle(aliasedFixtureBundle(3)); + final first = await repository.planFor( + testAliasIcon(0), + testAliasIcon(1), + TextDirection.ltr, + ); + + await repository.planFor( + testAliasIcon(1), + testAliasIcon(2), + TextDirection.ltr, + ); + final rebuilt = await repository.planFor( + testAliasIcon(0), + testAliasIcon(1), + TextDirection.ltr, + ); + + expect(MorphCache.currentMorphs, 1); + expect(identical(rebuilt, first), isFalse); + }); + + test( + 'direction, font settings, and order identify distinct morphs', + () async { + final repository = MorphRepository.forBundle(fixtureBundle()); + const varied = ( + fill: null, + weight: 650.0, + grade: null, + opticalSize: null, + fontWeight: null, + ); + + await repository.planFor( + testQuadraticIcon, + testCompositeIcon, + TextDirection.ltr, + ); + await repository.planFor( + testCompositeIcon, + testQuadraticIcon, + TextDirection.ltr, + ); + await repository.planFor( + testQuadraticIcon, + testCompositeIcon, + TextDirection.rtl, + ); + await repository.planFor( + testQuadraticIcon, + testCompositeIcon, + TextDirection.ltr, + varied, + ); + + expect(MorphCache.currentMorphs, 4); + }, + ); + + test('interrupted one-off morphs are not retained', () async { + final repository = MorphRepository.forBundle(fixtureBundle()); + final source = await repository.shapeFor( + testQuadraticIcon, + TextDirection.ltr, + ); + + await repository.planFromShape( + source, + testCompositeIcon, + TextDirection.ltr, + ); + + expect(MorphCache.currentMorphs, 0); + expect(MorphCache.currentBytes, 0); + }); + + test('configure replaces limits and clears retained morphs', () async { + final repository = MorphRepository.forBundle(fixtureBundle()); + await repository.planFor( + testQuadraticIcon, + testCompositeIcon, + TextDirection.ltr, + ); + + MorphCache.configure(maxMorphs: 50, maxBytes: 8 << 20); + + expect(MorphCache.maxMorphs, 50); + expect(MorphCache.maxBytes, 8 << 20); + expect(MorphCache.currentMorphs, 0); + expect(MorphCache.currentBytes, 0); + }); + + test('clear preserves limits and disable prevents retention', () async { + MorphCache.configure(maxMorphs: 50, maxBytes: 8 << 20); + final repository = MorphRepository.forBundle(fixtureBundle()); + await repository.planFor( + testQuadraticIcon, + testCompositeIcon, + TextDirection.ltr, + ); + + MorphCache.clear(); + + expect(MorphCache.maxMorphs, 50); + expect(MorphCache.maxBytes, 8 << 20); + expect(MorphCache.currentMorphs, 0); + + MorphCache.disable(); + await repository.planFor( + testQuadraticIcon, + testCompositeIcon, + TextDirection.ltr, + ); + expect(MorphCache.maxMorphs, 0); + expect(MorphCache.maxBytes, 0); + expect(MorphCache.currentMorphs, 0); + expect(MorphCache.currentBytes, 0); + }); + + test( + 'reset restores defaults, enables caching, and clears entries', + () async { + MorphCache.disable(); + final repository = MorphRepository.forBundle(fixtureBundle()); + await repository.planFor( + testQuadraticIcon, + testCompositeIcon, + TextDirection.ltr, + ); + + MorphCache.reset(); + + expect(MorphCache.maxMorphs, MorphCache.defaultMaxMorphs); + expect(MorphCache.maxBytes, MorphCache.defaultMaxBytes); + expect(MorphCache.currentMorphs, 0); + await repository.planFor( + testQuadraticIcon, + testCompositeIcon, + TextDirection.ltr, + ); + expect(MorphCache.currentMorphs, 1); + }, + ); + + test('a morph larger than maxBytes is used but not retained', () async { + MorphCache.configure(maxMorphs: 10, maxBytes: 1); + final repository = MorphRepository.forBundle(fixtureBundle()); + + final plan = await repository.planFor( + testQuadraticIcon, + testCompositeIcon, + TextDirection.ltr, + ); + + expect(plan.items, isNotEmpty); + expect(MorphCache.currentMorphs, 0); + expect(MorphCache.currentBytes, 0); + }); + + test('cleared pending morph cannot return to the cache', () async { + final bundle = DelayedTestAssetBundle.fonts( + manifest: >{ + testFontFamily: ['assets/icons.ttf'], + }, + assets: { + 'assets/icons.ttf': TestFontBuilder.trueType(), + }, + )..delay('assets/icons.ttf'); + final future = MorphRepository.forBundle( + bundle, + ).planFor(testQuadraticIcon, testCompositeIcon, TextDirection.ltr); + + expect(MorphCache.currentMorphs, 0); + expect(MorphCache.currentBytes, 0); + MorphCache.clear(); + bundle.release('assets/icons.ttf'); + await future; + + expect(MorphCache.currentMorphs, 0); + expect(MorphCache.currentBytes, 0); + }); +} diff --git a/test/src/cache/sized_lru_cache_test.dart b/test/src/cache/sized_lru_cache_test.dart index d1f9417..aa2586b 100644 --- a/test/src/cache/sized_lru_cache_test.dart +++ b/test/src/cache/sized_lru_cache_test.dart @@ -46,4 +46,21 @@ void main() { expect(cache.get(2), same(second)); expect(cache.get(3), isNull); }); + + test('an already-sized oversized value leaves the cache unchanged', () { + final cache = SizedLruCache( + maximumSize: 2, + maximumSizeBytes: 8, + ); + final resident = Object(); + final oversized = Object(); + cache.putSized(1, resident, 8); + + expect(cache.putSized(2, oversized, 9), isFalse); + + expect(cache.length, 1); + expect(cache.currentSizeBytes, 8); + expect(cache.get(1), same(resident)); + expect(cache.get(2), isNull); + }); }