Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ packages:
path: ".."
relative: true
source: path
version: "0.5.0"
version: "0.6.0"
objective_c:
dependency: transitive
description:
Expand Down
1 change: 1 addition & 0 deletions lib/morphnext.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
152 changes: 152 additions & 0 deletions lib/src/cache/morph_cache.dart
Original file line number Diff line number Diff line change
@@ -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<Object, MorphPlan>(
maximumSize: MorphCache.defaultMaxMorphs,
maximumSizeBytes: MorphCache.defaultMaxBytes,
);
final Map<Object, Future<MorphPlan>> _pending = <Object, Future<MorphPlan>>{};

int get maxMorphs => _maxMorphs;
int get maxBytes => _maxBytes;
int get currentMorphs => _cache.length;
int get currentBytes => _cache.currentSizeBytes;
int get generation => _generation;

Future<MorphPlan> load(
Object key,
Future<MorphPlan> Function() create,
int Function(MorphPlan plan) sizeOf,
) {
final cached = _cache.get(key);
if (cached != null) return Future<MorphPlan>.value(cached);
final pending = _pending[key];
if (pending != null) return pending;

final requestGeneration = _generation;
late final Future<MorphPlan> 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<Object, MorphPlan>(
maximumSize: maxMorphs,
maximumSizeBytes: maxBytes,
);
_pending.clear();
}
}
31 changes: 31 additions & 0 deletions lib/src/cache/sized_lru_cache.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ final class SizedLruCache<K extends Object, V extends Object> {

/// 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<K, _SizedEntry<V>> _entries =
LinkedHashMap<K, _SizedEntry<V>>();
var _currentSizeBytes = 0;
Expand All @@ -32,6 +39,30 @@ final class SizedLruCache<K extends Object, V extends Object> {
_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<V>(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);
Expand Down
7 changes: 7 additions & 0 deletions lib/src/font/font_asset_resolver.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<GlyphOutline> resolve(
IconData icon, [
MorphFontSelection selection = defaultMorphFontSelection,
Expand Down
Loading