Skip to content
Open
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
92 changes: 90 additions & 2 deletions packages/vector_graphics/lib/src/vector_graphics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,11 @@ class _PictureData {
final PictureInfo pictureInfo;
_PictureKey key;
int count = 0;

/// True when [precacheVectorGraphic] holds a reference to this entry.
/// Prevents double-counting if [precacheVectorGraphic] is called more than
/// once for the same key.
bool precached = false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

almost certainly want this to be final (or at least read-only)

}

@immutable
Expand Down Expand Up @@ -370,8 +375,8 @@ class _VectorGraphicWidgetState extends State<VectorGraphic> {
}
}

Future<_PictureData> _loadPicture(
BuildContext context,
static Future<_PictureData> _loadPicture(
BuildContext? context,
_PictureKey key,
BytesLoader loader,
) {
Expand Down Expand Up @@ -671,6 +676,89 @@ class _RawPictureVectorGraphicWidget extends SingleChildRenderObjectWidget {
}
}

/// Warms the [VectorGraphic] picture cache for [loader] using [context] to
/// resolve [Locale] and [TextDirection], so that a subsequent [VectorGraphic]
/// widget with the same loader renders synchronously from cache instead of
/// triggering a decode on the first frame.
///
/// This is analogous to [precacheImage] for raster images. Call it in
/// [State.didChangeDependencies] before navigating to a route that contains a
/// [VectorGraphic]:
///
/// ```dart
/// @override
/// void didChangeDependencies() {
/// super.didChangeDependencies();
/// precacheVectorGraphic(AssetBytesLoader('assets/icon.vg'), context);
/// }
/// ```
///
/// The [context] is used to derive the [Locale] and [TextDirection] that
/// [VectorGraphic] uses as part of its picture cache key. Providing the same
/// context (or one with identical inherited locale and directionality) avoids
/// a cache miss when the widget is first built.
///
/// The decoded picture is kept alive in the internal picture cache with a
/// reference count of 1. When a [VectorGraphic] widget mounts with the same
/// key it increments the count; when it disposes the count is decremented.
/// The precache reference ensures the picture survives route transitions where
/// all [VectorGraphic] widgets temporarily unmount (count would otherwise drop
/// to zero and the picture would be disposed). Calling this function multiple
/// times for the same key is safe; only one reference is held regardless.
///
/// If [onError] is provided it is called with the error and stack trace
/// instead of propagating the exception; otherwise the error is rethrown.
Future<void> precacheVectorGraphic(
BytesLoader loader,
BuildContext context, {
bool clipViewbox = true,
VectorGraphicsErrorListener? onError,
}) async {
final Locale? locale = Localizations.maybeLocaleOf(context);
final TextDirection? textDirection = Directionality.maybeOf(context);
final Object loaderKey = loader.cacheKey(context);
final _PictureKey key = _PictureKey(loaderKey, locale, textDirection, clipViewbox);

// If there is already a live entry, hold our own reference so the picture
// survives route transitions (counts would otherwise drop to zero while all
// VectorGraphic widgets are temporarily unmounted). Guard with `precached`
// so repeated calls for the same key don't leak extra references.
final _PictureData? existing =
_VectorGraphicWidgetState._livePictureCache[key];
if (existing != null) {
if (!existing.precached) {
existing.precached = true;
existing.count += 1;
}
return;
}

try {
final _PictureData data =
await _VectorGraphicWidgetState._loadPicture(context, key, loader);
// A widget (or a concurrent precache call) may have populated the cache
// while we were awaiting the decode.
final _PictureData? inCache =
_VectorGraphicWidgetState._livePictureCache[key];
if (inCache != null) {
if (!inCache.precached) {
inCache.precached = true;
inCache.count += 1;
}
} else {
data.count += 1;
data.precached = true;
_VectorGraphicWidgetState._livePictureCache[key] = data;
}
} catch (error, stackTrace) {
if (onError != null) {
onError.call(error, stackTrace);
} else {
rethrow;
}
}
Comment thread
dhananjay6561 marked this conversation as resolved.
}

/// Utility functionality for interaction with vector graphic assets.
class VectorGraphicUtilities {
const VectorGraphicUtilities._();
Expand Down
1 change: 1 addition & 0 deletions packages/vector_graphics/lib/vector_graphics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export 'src/vector_graphics.dart'
PictureInfo,
VectorGraphic,
VectorGraphicUtilities,
precacheVectorGraphic,
vg;
250 changes: 250 additions & 0 deletions packages/vector_graphics/test/caching_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,244 @@ void main() {

expect(testBundle.loadKeys, <String>['bar.svg', 'foo.svg', 'foo.svg']);
});

testWidgets(
'precacheVectorGraphic warms live cache so VectorGraphic renders without reloading',
(WidgetTester tester) async {
final testBundle = TestAssetBundle();

// Precache with the same context that VectorGraphic will inherit.
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: DefaultAssetBundle(
bundle: testBundle,
child: Builder(
builder: (BuildContext context) {
precacheVectorGraphic(
const AssetBytesLoader('foo.svg'),
context,
);
return const SizedBox();
},
),
),
),
);
await tester.runAsync(
() => Future<void>.delayed(Duration.zero),
);

expect(testBundle.loadKeys.single, 'foo.svg');

// Now mount a VectorGraphic with the same key -- it should hit the cache.
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: DefaultAssetBundle(
bundle: testBundle,
child: VectorGraphic(
loader: const AssetBytesLoader('foo.svg'),
),
),
),
);
await tester.pumpAndSettle();

// The asset was already decoded; no second load should occur.
expect(testBundle.loadKeys.single, 'foo.svg');
},
);

testWidgets(
'precacheVectorGraphic keeps picture alive across widget unmount/remount',
(WidgetTester tester) async {
final testBundle = TestAssetBundle();

await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: DefaultAssetBundle(
bundle: testBundle,
child: Builder(
builder: (BuildContext context) {
precacheVectorGraphic(
const AssetBytesLoader('foo.svg'),
context,
);
return const SizedBox();
},
),
),
),
);
await tester.runAsync(
() => Future<void>.delayed(Duration.zero),
);

expect(testBundle.loadKeys.single, 'foo.svg');

// Mount then unmount -- simulates a route transition.
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: DefaultAssetBundle(
bundle: testBundle,
child: VectorGraphic(
loader: const AssetBytesLoader('foo.svg'),
),
),
),
);
await tester.pumpAndSettle();
await tester.pumpWidget(const SizedBox());

// Remount -- the precache reference should keep the picture alive.
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: DefaultAssetBundle(
bundle: testBundle,
child: VectorGraphic(
loader: const AssetBytesLoader('foo.svg'),
),
),
),
);
await tester.pumpAndSettle();

// Only a single load should have happened across all mounts.
expect(testBundle.loadKeys.single, 'foo.svg');
},
);

testWidgets(
'precacheVectorGraphic calls onError and does not throw on loader failure',
(WidgetTester tester) async {
Object? caughtError;

await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Builder(
builder: (BuildContext context) {
precacheVectorGraphic(
const _FailingBytesLoader(),
context,
onError: (Object error, StackTrace? _) {
caughtError = error;
},
);
return const SizedBox();
},
),
),
);
await tester.runAsync(
() => Future<void>.delayed(Duration.zero),
);

expect(caughtError, isNotNull);
},
);

testWidgets(
'precacheVectorGraphic called twice for the same key does not double-count',
(WidgetTester tester) async {
final testBundle = TestAssetBundle();

late BuildContext capturedContext;
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: DefaultAssetBundle(
bundle: testBundle,
child: Builder(
builder: (BuildContext context) {
capturedContext = context;
return const SizedBox();
},
),
),
),
);

// Call precache twice; only one asset load should happen and the picture
// should survive an unmount/remount without a second decode.
await tester.runAsync(() async {
await precacheVectorGraphic(
const AssetBytesLoader('foo.svg'),
capturedContext,
);
await precacheVectorGraphic(
const AssetBytesLoader('foo.svg'),
capturedContext,
);
});

expect(testBundle.loadKeys.single, 'foo.svg');

// Mount then fully unmount.
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: DefaultAssetBundle(
bundle: testBundle,
child: VectorGraphic(
loader: const AssetBytesLoader('foo.svg'),
),
),
),
);
await tester.pumpAndSettle();
await tester.pumpWidget(const SizedBox());

// Remount — double-precache must not have leaked a phantom reference that
// keeps the count above what _maybeReleasePicture expects, causing the
// picture to be disposed prematurely or kept alive one extra decrement too
// many. A single load across all pumps confirms correct reference balance.
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: DefaultAssetBundle(
bundle: testBundle,
child: VectorGraphic(
loader: const AssetBytesLoader('foo.svg'),
),
),
),
);
await tester.pumpAndSettle();

expect(testBundle.loadKeys.single, 'foo.svg');
},
);

testWidgets(
'precacheVectorGraphic rethrows when no onError is provided',
(WidgetTester tester) async {
late Future<void> precacheFuture;

await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Builder(
builder: (BuildContext context) {
precacheFuture = precacheVectorGraphic(
const _FailingBytesLoader(),
context,
);
return const SizedBox();
},
),
),
);

await tester.runAsync(() async {
await expectLater(precacheFuture, throwsException);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when I check out your PR and flutter test test/caching_test.dart to run the tests, the tests which are using _FailingBytesLoader are not passing for me. The test framework is complaining about an exception being thrown.

The tests in vector_graphics_test.dart which use ThrowingBytesLoader use expect(tester.takeException(), isNull);, do we need to do something similar here?

});
},
);
}

class TestAssetBundle extends Fake implements AssetBundle {
Expand Down Expand Up @@ -361,3 +599,15 @@ class TestWidgetsLocalizations extends DefaultWidgetsLocalizations {
@override
TextDirection get textDirection => TextDirection.ltr;
}

class _FailingBytesLoader extends BytesLoader {
const _FailingBytesLoader();

@override
Future<ByteData> loadBytes(BuildContext? context) {
return Future<ByteData>.error(Exception('load failed'));
}

@override
Object cacheKey(BuildContext? context) => '_FailingBytesLoader';
}
Loading