Reland "Add API for discovering assets" (#119277)
* add asset manifest bin loading and asset manifest api
* use new api for image resolution
* remove upfront smc data casting
* fix typecasting issue
* remove unused import
* fix tests
* lints
* lints
* fix import
* revert image resolution changes
* Update image_resolution_test.dart
* Update decode_and_parse_asset_manifest.dart
* make targetDevicePixelRatio optional
* Update packages/flutter/lib/src/services/asset_manifest.dart
Co-authored-by: Jonah Williams <jonahwilliams@google.com>
* Update packages/flutter/lib/src/services/asset_manifest.dart
Co-authored-by: Jonah Williams <jonahwilliams@google.com>
* fix immutable not being imported
* return List in AssetManifest methods, fix annotation import
* simplify onError callback
* make AssetManifest methods abstract instead of throwing UnimplementedError
* simplify AssetVariant.key docstring
* tweak _AssetManifestBin docstring
* make AssetManifest and AssetVariant doc strings more specific
* use List.of instead of List.from for type-safety
* adjust import
* change _AssetManifestBin comment from doc comment to normal comment
* revert to callback function for onError in loadStructuredBinaryData
* add more to the docstring of AssetManifest.listAssets and AssetVariant.key
* add tests for CachingAssetBundle caching behavior
* add simple test to ensure loadStructuredBinaryData correctly calls load
* Update asset_manifest.dart
* update docstring for AssetManifest.getAssetVariants
* rename getAssetVariants, have it include main asset
* rename isMainAsset field of AssetMetadata to main
* (slightly) shorten name of describeAssetAndVariants
* rename describeAssetVariants back to getAssetVariants
* add tests for TestAssetBundle
* nits
* fix typo in docstring
* remove no longer necessary non-null asserts
* update gallery and google_fonts versions
---------
Co-authored-by: Jonah Williams <jonahwilliams@google.com>
diff --git a/dev/benchmarks/microbenchmarks/lib/foundation/decode_and_parse_asset_manifest.dart b/dev/benchmarks/microbenchmarks/lib/foundation/decode_and_parse_asset_manifest.dart
index b64c153..5082abc 100644
--- a/dev/benchmarks/microbenchmarks/lib/foundation/decode_and_parse_asset_manifest.dart
+++ b/dev/benchmarks/microbenchmarks/lib/foundation/decode_and_parse_asset_manifest.dart
@@ -2,10 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-import 'dart:convert';
-
-import 'package:flutter/foundation.dart';
-import 'package:flutter/services.dart' show PlatformAssetBundle;
+import 'package:flutter/services.dart' show AssetManifest, PlatformAssetBundle, rootBundle;
import 'package:flutter/widgets.dart';
import '../common.dart';
@@ -18,16 +15,12 @@
final BenchmarkResultPrinter printer = BenchmarkResultPrinter();
WidgetsFlutterBinding.ensureInitialized();
final Stopwatch watch = Stopwatch();
- final PlatformAssetBundle bundle = PlatformAssetBundle();
+ final PlatformAssetBundle bundle = rootBundle as PlatformAssetBundle;
- final ByteData assetManifestBytes = await bundle.load('money_asset_manifest.json');
watch.start();
for (int i = 0; i < _kNumIterations; i++) {
+ await AssetManifest.loadFromAssetBundle(bundle);
bundle.clear();
- final String json = utf8.decode(assetManifestBytes.buffer.asUint8List());
- // This is a test, so we don't need to worry about this rule.
- // ignore: invalid_use_of_visible_for_testing_member
- await AssetImage.manifestParser(json);
}
watch.stop();
diff --git a/dev/devicelab/lib/versions/gallery.dart b/dev/devicelab/lib/versions/gallery.dart
index 3c1e217..b604b06 100644
--- a/dev/devicelab/lib/versions/gallery.dart
+++ b/dev/devicelab/lib/versions/gallery.dart
@@ -3,4 +3,4 @@
// found in the LICENSE file.
/// The pinned version of flutter gallery, used for devicelab tests.
-const String galleryVersion = 'b6728704a6441ac37a21e433a1e43c990780d47b';
+const String galleryVersion = 'afcf15fe40d8b9243bad30895d3ba1ad49014550';
diff --git a/packages/flutter/lib/services.dart b/packages/flutter/lib/services.dart
index 681c918..7e7fb02 100644
--- a/packages/flutter/lib/services.dart
+++ b/packages/flutter/lib/services.dart
@@ -11,6 +11,7 @@
library services;
export 'src/services/asset_bundle.dart';
+export 'src/services/asset_manifest.dart';
export 'src/services/autofill.dart';
export 'src/services/binary_messenger.dart';
export 'src/services/binding.dart';
diff --git a/packages/flutter/lib/src/services/asset_bundle.dart b/packages/flutter/lib/src/services/asset_bundle.dart
index 971fdaf..da2719c 100644
--- a/packages/flutter/lib/src/services/asset_bundle.dart
+++ b/packages/flutter/lib/src/services/asset_bundle.dart
@@ -96,12 +96,22 @@
}
/// Retrieve a string from the asset bundle, parse it with the given function,
- /// and return the function's result.
+ /// and return that function's result.
///
/// Implementations may cache the result, so a particular key should only be
/// used with one parser for the lifetime of the asset bundle.
Future<T> loadStructuredData<T>(String key, Future<T> Function(String value) parser);
+ /// Retrieve [ByteData] from the asset bundle, parse it with the given function,
+ /// and return that function's result.
+ ///
+ /// Implementations may cache the result, so a particular key should only be
+ /// used with one parser for the lifetime of the asset bundle.
+ Future<T> loadStructuredBinaryData<T>(String key, FutureOr<T> Function(ByteData data) parser) async {
+ final ByteData data = await load(key);
+ return parser(data);
+ }
+
/// If this is a caching asset bundle, and the given key describes a cached
/// asset, then evict the asset from the cache so that the next time it is
/// loaded, the cache will be reread from the asset bundle.
@@ -154,6 +164,16 @@
return parser(await loadString(key));
}
+ /// Retrieve [ByteData] from the asset bundle, parse it with the given function,
+ /// and return the function's result.
+ ///
+ /// The result is not cached. The parser is run each time the resource is
+ /// fetched.
+ @override
+ Future<T> loadStructuredBinaryData<T>(String key, FutureOr<T> Function(ByteData data) parser) async {
+ return parser(await load(key));
+ }
+
// TODO(ianh): Once the underlying network logic learns about caching, we
// should implement evict().
@@ -173,6 +193,7 @@
// TODO(ianh): Replace this with an intelligent cache, see https://github.com/flutter/flutter/issues/3568
final Map<String, Future<String>> _stringCache = <String, Future<String>>{};
final Map<String, Future<dynamic>> _structuredDataCache = <String, Future<dynamic>>{};
+ final Map<String, Future<dynamic>> _structuredBinaryDataCache = <String, Future<dynamic>>{};
@override
Future<String> loadString(String key, { bool cache = true }) {
@@ -221,16 +242,66 @@
return completer.future;
}
+ /// Retrieve bytedata from the asset bundle, parse it with the given function,
+ /// and return the function's result.
+ ///
+ /// The result of parsing the bytedata is cached (the bytedata itself is not).
+ /// For any given `key`, the `parser` is only run the first time.
+ ///
+ /// Once the value has been parsed, the future returned by this function for
+ /// subsequent calls will be a [SynchronousFuture], which resolves its
+ /// callback synchronously.
+ @override
+ Future<T> loadStructuredBinaryData<T>(String key, FutureOr<T> Function(ByteData data) parser) {
+ if (_structuredBinaryDataCache.containsKey(key)) {
+ return _structuredBinaryDataCache[key]! as Future<T>;
+ }
+
+ // load can return a SynchronousFuture in certain cases, like in the
+ // flutter_test framework. So, we need to support both async and sync flows.
+ Completer<T>? completer; // For async flow.
+ SynchronousFuture<T>? result; // For sync flow.
+
+ load(key)
+ .then<T>(parser)
+ .then<void>((T value) {
+ result = SynchronousFuture<T>(value);
+ if (completer != null) {
+ // The load and parse operation ran asynchronously. We already returned
+ // from the loadStructuredBinaryData function and therefore the caller
+ // was given the future of the completer.
+ completer.complete(value);
+ }
+ }, onError: (Object error, StackTrace stack) {
+ completer!.completeError(error, stack);
+ });
+
+ if (result != null) {
+ // The above code ran synchronously. We can synchronously return the result.
+ _structuredBinaryDataCache[key] = result!;
+ return result!;
+ }
+
+ // Since the above code is being run asynchronously and thus hasn't run its
+ // `then` handler yet, we'll return a completer that will be completed
+ // when the handler does run.
+ completer = Completer<T>();
+ _structuredBinaryDataCache[key] = completer.future;
+ return completer.future;
+ }
+
@override
void evict(String key) {
_stringCache.remove(key);
_structuredDataCache.remove(key);
+ _structuredBinaryDataCache.remove(key);
}
@override
void clear() {
_stringCache.clear();
_structuredDataCache.clear();
+ _structuredBinaryDataCache.clear();
}
@override
@@ -272,7 +343,7 @@
bool debugUsePlatformChannel = false;
assert(() {
// dart:io is safe to use here since we early return for web
- // above. If that code is changed, this needs to be gaurded on
+ // above. If that code is changed, this needs to be guarded on
// web presence. Override how assets are loaded in tests so that
// the old loader behavior that allows tests to load assets from
// the current package using the package prefix.
diff --git a/packages/flutter/lib/src/services/asset_manifest.dart b/packages/flutter/lib/src/services/asset_manifest.dart
new file mode 100644
index 0000000..cddf798
--- /dev/null
+++ b/packages/flutter/lib/src/services/asset_manifest.dart
@@ -0,0 +1,134 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/foundation.dart';
+
+import 'asset_bundle.dart';
+import 'message_codecs.dart';
+
+const String _kAssetManifestFilename = 'AssetManifest.bin';
+
+/// Contains details about available assets and their variants.
+/// See [Asset variants](https://docs.flutter.dev/development/ui/assets-and-images#asset-variants)
+/// to learn about asset variants and how to declare them.
+abstract class AssetManifest {
+ /// Loads asset manifest data from an [AssetBundle] object and creates an
+ /// [AssetManifest] object from that data.
+ static Future<AssetManifest> loadFromAssetBundle(AssetBundle bundle) {
+ return bundle.loadStructuredBinaryData(_kAssetManifestFilename, _AssetManifestBin.fromStandardMessageCodecMessage);
+ }
+
+ /// Lists the keys of all main assets. This does not include assets
+ /// that are variants of other assets.
+ ///
+ /// The logical key maps to the path of an asset specified in the pubspec.yaml
+ /// file at build time.
+ ///
+ /// See [Specifying assets](https://docs.flutter.dev/development/ui/assets-and-images#specifying-assets)
+ /// and [Loading assets](https://docs.flutter.dev/development/ui/assets-and-images#loading-assets) for more
+ /// information.
+ List<String> listAssets();
+
+ /// Retrieves metadata about an asset and its variants.
+ ///
+ /// Note that this method considers a main asset to be a variant of itself and
+ /// includes it in the returned list.
+ ///
+ /// Throws an [ArgumentError] if [key] cannot be found within the manifest. To
+ /// avoid this, use a key obtained from the [listAssets] method.
+ List<AssetMetadata> getAssetVariants(String key);
+}
+
+// Lazily parses the binary asset manifest into a data structure that's easier to work
+// with.
+//
+// The binary asset manifest is a map of asset keys to a list of objects
+// representing the asset's variants.
+//
+// The entries with each variant object are:
+// - "asset": the location of this variant to load it from.
+// - "dpr": The device-pixel-ratio that the asset is best-suited for.
+//
+// New fields could be added to this object schema to support new asset variation
+// features, such as themes, locale/region support, reading directions, and so on.
+class _AssetManifestBin implements AssetManifest {
+ _AssetManifestBin(Map<Object?, Object?> standardMessageData): _data = standardMessageData;
+
+ factory _AssetManifestBin.fromStandardMessageCodecMessage(ByteData message) {
+ final dynamic data = const StandardMessageCodec().decodeMessage(message);
+ return _AssetManifestBin(data as Map<Object?, Object?>);
+ }
+
+ final Map<Object?, Object?> _data;
+ final Map<String, List<AssetMetadata>> _typeCastedData = <String, List<AssetMetadata>>{};
+
+ @override
+ List<AssetMetadata> getAssetVariants(String key) {
+ // We lazily delay typecasting to prevent a performance hiccup when parsing
+ // large asset manifests. This is important to keep an app's first asset
+ // load fast.
+ if (!_typeCastedData.containsKey(key)) {
+ final Object? variantData = _data[key];
+ if (variantData == null) {
+ throw ArgumentError('Asset key $key was not found within the asset manifest.');
+ }
+ _typeCastedData[key] = ((_data[key] ?? <Object?>[]) as Iterable<Object?>)
+ .cast<Map<Object?, Object?>>()
+ .map((Map<Object?, Object?> data) => AssetMetadata(
+ key: data['asset']! as String,
+ targetDevicePixelRatio: data['dpr']! as double,
+ main: false,
+ ))
+ .toList();
+
+ _data.remove(key);
+ }
+
+ final AssetMetadata mainAsset = AssetMetadata(key: key,
+ targetDevicePixelRatio: null,
+ main: true
+ );
+
+ return <AssetMetadata>[mainAsset, ..._typeCastedData[key]!];
+ }
+
+ @override
+ List<String> listAssets() {
+ return <String>[..._data.keys.cast<String>(), ..._typeCastedData.keys];
+ }
+}
+
+/// Contains information about an asset.
+@immutable
+class AssetMetadata {
+ /// Creates an object containing information about an asset.
+ const AssetMetadata({
+ required this.key,
+ required this.targetDevicePixelRatio,
+ required this.main,
+ });
+
+ /// The device pixel ratio that this asset is most ideal for. This is determined
+ /// by the name of the parent folder of the asset file. For example, if the
+ /// parent folder is named "3.0x", the target device pixel ratio of that
+ /// asset will be interpreted as 3.
+ ///
+ /// This will be null if the parent folder name is not a ratio value followed
+ /// by an "x".
+ ///
+ /// See [Declaring resolution-aware image assets](https://docs.flutter.dev/development/ui/assets-and-images#resolution-aware)
+ /// for more information.
+ final double? targetDevicePixelRatio;
+
+ /// The asset's key, which is the path to the asset specified in the pubspec.yaml
+ /// file at build time.
+ final String key;
+
+ /// Whether or not this is a main asset. In other words, this is true if
+ /// this asset is not a variant of another asset.
+ ///
+ /// See [Asset variants](https://docs.flutter.dev/development/ui/assets-and-images#asset-variants)
+ /// for more about asset variants.
+ final bool main;
+}
diff --git a/packages/flutter/test/services/asset_bundle_test.dart b/packages/flutter/test/services/asset_bundle_test.dart
index 8a97df3..ef00683 100644
--- a/packages/flutter/test/services/asset_bundle_test.dart
+++ b/packages/flutter/test/services/asset_bundle_test.dart
@@ -14,16 +14,28 @@
@override
Future<ByteData> load(String key) async {
- loadCallCount[key] = loadCallCount[key] ?? 0 + 1;
+ loadCallCount[key] = (loadCallCount[key] ?? 0) + 1;
if (key == 'AssetManifest.json') {
return ByteData.view(Uint8List.fromList(const Utf8Encoder().convert('{"one": ["one"]}')).buffer);
}
+ if (key == 'AssetManifest.bin') {
+ return const StandardMessageCodec().encodeMessage(<String, Object>{
+ 'one': <Object>[]
+ })!;
+ }
+
+ if (key == 'counter') {
+ return ByteData.view(Uint8List.fromList(const Utf8Encoder().convert(loadCallCount[key]!.toString())).buffer);
+ }
+
if (key == 'one') {
return ByteData(1)..setInt8(0, 49);
}
+
throw FlutterError('key not found');
}
+
}
void main() {
@@ -40,7 +52,7 @@
final String assetString = await bundle.loadString('one');
expect(assetString, equals('1'));
- expect(bundle.loadCallCount['one'], 1);
+ expect(bundle.loadCallCount['one'], 2);
late Object loadException;
try {
@@ -101,4 +113,69 @@
),
);
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/56314
+
+ test('CachingAssetBundle caches results for loadString, loadStructuredData, and loadBinaryStructuredData', () async {
+ final TestAssetBundle bundle = TestAssetBundle();
+
+ final String firstLoadStringResult = await bundle.loadString('counter');
+ final String secondLoadStringResult = await bundle.loadString('counter');
+ expect(firstLoadStringResult, '1');
+ expect(secondLoadStringResult, '1');
+
+ final String firstLoadStructuredDataResult = await bundle.loadStructuredData('AssetManifest.json', (String value) => Future<String>.value('one'));
+ final String secondLoadStructuredDataResult = await bundle.loadStructuredData('AssetManifest.json', (String value) => Future<String>.value('two'));
+ expect(firstLoadStructuredDataResult, 'one');
+ expect(secondLoadStructuredDataResult, 'one');
+
+ final String firstLoadStructuredBinaryDataResult = await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData value) => Future<String>.value('one'));
+ final String secondLoadStructuredBinaryDataResult = await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData value) => Future<String>.value('two'));
+ expect(firstLoadStructuredBinaryDataResult, 'one');
+ expect(secondLoadStructuredBinaryDataResult, 'one');
+ });
+
+ test("CachingAssetBundle.clear clears all cached values'", () async {
+ final TestAssetBundle bundle = TestAssetBundle();
+
+ await bundle.loadString('counter');
+ bundle.clear();
+ final String secondLoadStringResult = await bundle.loadString('counter');
+ expect(secondLoadStringResult, '2');
+
+ await bundle.loadStructuredData('AssetManifest.json', (String value) => Future<String>.value('one'));
+ bundle.clear();
+ final String secondLoadStructuredDataResult = await bundle.loadStructuredData('AssetManifest.json', (String value) => Future<String>.value('two'));
+ expect(secondLoadStructuredDataResult, 'two');
+
+ await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData value) => Future<String>.value('one'));
+ bundle.clear();
+ final String secondLoadStructuredBinaryDataResult = await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData value) => Future<String>.value('two'));
+ expect(secondLoadStructuredBinaryDataResult, 'two');
+ });
+
+ test('CachingAssetBundle.evict evicts a particular key from the cache', () async {
+ final TestAssetBundle bundle = TestAssetBundle();
+
+ await bundle.loadString('counter');
+ bundle.evict('counter');
+ final String secondLoadStringResult = await bundle.loadString('counter');
+ expect(secondLoadStringResult, '2');
+
+ await bundle.loadStructuredData('AssetManifest.json', (String value) => Future<String>.value('one'));
+ bundle.evict('AssetManifest.json');
+ final String secondLoadStructuredDataResult = await bundle.loadStructuredData('AssetManifest.json', (String value) => Future<String>.value('two'));
+ expect(secondLoadStructuredDataResult, 'two');
+
+ await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData value) => Future<String>.value('one'));
+ bundle.evict('AssetManifest.bin');
+ final String secondLoadStructuredBinaryDataResult = await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData value) => Future<String>.value('two'));
+ expect(secondLoadStructuredBinaryDataResult, 'two');
+ });
+
+ test('loadStructuredBinaryData correctly loads ByteData', () async {
+ final TestAssetBundle bundle = TestAssetBundle();
+ final Map<Object?, Object?> assetManifest =
+ await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData data) => const StandardMessageCodec().decodeMessage(data) as Map<Object?, Object?>);
+ expect(assetManifest.keys.toList(), equals(<String>['one']));
+ expect(assetManifest['one'], <Object>[]);
+ });
}
diff --git a/packages/flutter/test/services/asset_manifest_test.dart b/packages/flutter/test/services/asset_manifest_test.dart
new file mode 100644
index 0000000..4bd9c92
--- /dev/null
+++ b/packages/flutter/test/services/asset_manifest_test.dart
@@ -0,0 +1,68 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+class TestAssetBundle extends AssetBundle {
+ @override
+ Future<ByteData> load(String key) async {
+ if (key == 'AssetManifest.bin') {
+ final Map<String, List<Object>> binManifestData = <String, List<Object>>{
+ 'assets/foo.png': <Object>[
+ <String, Object>{
+ 'asset': 'assets/2x/foo.png',
+ 'dpr': 2.0
+ }
+ ],
+ 'assets/bar.png': <Object>[],
+ };
+
+ final ByteData data = const StandardMessageCodec().encodeMessage(binManifestData)!;
+ return data;
+ }
+
+ throw ArgumentError('Unexpected key');
+ }
+
+ @override
+ Future<T> loadStructuredData<T>(String key, Future<T> Function(String value) parser) async {
+ return parser(await loadString(key));
+ }
+}
+
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ test('loadFromBundle correctly parses a binary asset manifest', () async {
+ final AssetManifest manifest = await AssetManifest.loadFromAssetBundle(TestAssetBundle());
+
+ expect(manifest.listAssets(), unorderedEquals(<String>['assets/foo.png', 'assets/bar.png']));
+
+ final List<AssetMetadata> fooVariants = manifest.getAssetVariants('assets/foo.png');
+ expect(fooVariants.length, 2);
+ final AssetMetadata firstFooVariant = fooVariants[0];
+ expect(firstFooVariant.key, 'assets/foo.png');
+ expect(firstFooVariant.targetDevicePixelRatio, null);
+ expect(firstFooVariant.main, true);
+ final AssetMetadata secondFooVariant = fooVariants[1];
+ expect(secondFooVariant.key, 'assets/2x/foo.png');
+ expect(secondFooVariant.targetDevicePixelRatio, 2.0);
+ expect(secondFooVariant.main, false);
+
+ final List<AssetMetadata> barVariants = manifest.getAssetVariants('assets/bar.png');
+ expect(barVariants.length, 1);
+ final AssetMetadata firstBarVariant = barVariants[0];
+ expect(firstBarVariant.key, 'assets/bar.png');
+ expect(firstBarVariant.targetDevicePixelRatio, null);
+ expect(firstBarVariant.main, true);
+ });
+
+ test('getAssetVariants throws if given a key not contained in the asset manifest', () async {
+ final AssetManifest manifest = await AssetManifest.loadFromAssetBundle(TestAssetBundle());
+
+ expect(() => manifest.getAssetVariants('invalid asset key'), throwsArgumentError);
+ });
+}