From 2b8f2d05045590cd4aeb9f02c67baaa69db35e5e Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 26 Jan 2023 11:05:00 -0800 Subject: [PATCH] Add API for discovering assets (#118410) * 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 * Update packages/flutter/lib/src/services/asset_manifest.dart Co-authored-by: Jonah Williams * 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 Co-authored-by: Jonah Williams --- .../decode_and_parse_asset_manifest.dart | 13 +- packages/flutter/lib/services.dart | 1 + .../lib/src/services/asset_bundle.dart | 75 +++++++++- .../lib/src/services/asset_manifest.dart | 134 ++++++++++++++++++ .../test/services/asset_bundle_test.dart | 81 ++++++++++- .../test/services/asset_manifest_test.dart | 68 +++++++++ 6 files changed, 358 insertions(+), 14 deletions(-) create mode 100644 packages/flutter/lib/src/services/asset_manifest.dart create mode 100644 packages/flutter/test/services/asset_manifest_test.dart 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 b64c1532ce..5082abcf2f 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 @@ void main() async { 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/packages/flutter/lib/services.dart b/packages/flutter/lib/services.dart index a0ff42f677..1b2c0b5f67 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 971fdaf58f..da2719c92c 100644 --- a/packages/flutter/lib/src/services/asset_bundle.dart +++ b/packages/flutter/lib/src/services/asset_bundle.dart @@ -96,12 +96,22 @@ abstract class AssetBundle { } /// 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 loadStructuredData(String key, Future 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 loadStructuredBinaryData(String key, FutureOr 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 @@ class NetworkAssetBundle extends AssetBundle { 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 loadStructuredBinaryData(String key, FutureOr 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 @@ abstract class CachingAssetBundle extends AssetBundle { // TODO(ianh): Replace this with an intelligent cache, see https://github.com/flutter/flutter/issues/3568 final Map> _stringCache = >{}; final Map> _structuredDataCache = >{}; + final Map> _structuredBinaryDataCache = >{}; @override Future loadString(String key, { bool cache = true }) { @@ -221,16 +242,66 @@ abstract class CachingAssetBundle extends AssetBundle { 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 loadStructuredBinaryData(String key, FutureOr Function(ByteData data) parser) { + if (_structuredBinaryDataCache.containsKey(key)) { + return _structuredBinaryDataCache[key]! as Future; + } + + // 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? completer; // For async flow. + SynchronousFuture? result; // For sync flow. + + load(key) + .then(parser) + .then((T value) { + result = SynchronousFuture(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(); + _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 @@ class PlatformAssetBundle extends CachingAssetBundle { 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 0000000000..cddf7984f8 --- /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 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 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 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 standardMessageData): _data = standardMessageData; + + factory _AssetManifestBin.fromStandardMessageCodecMessage(ByteData message) { + final dynamic data = const StandardMessageCodec().decodeMessage(message); + return _AssetManifestBin(data as Map); + } + + final Map _data; + final Map> _typeCastedData = >{}; + + @override + List 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] ?? []) as Iterable) + .cast>() + .map((Map 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 [mainAsset, ..._typeCastedData[key]!]; + } + + @override + List listAssets() { + return [..._data.keys.cast(), ..._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 8a97df3daa..ef00683938 100644 --- a/packages/flutter/test/services/asset_bundle_test.dart +++ b/packages/flutter/test/services/asset_bundle_test.dart @@ -14,16 +14,28 @@ class TestAssetBundle extends CachingAssetBundle { @override Future 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({ + 'one': [] + })!; + } + + 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 @@ void main() { 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 @@ void main() { ), ); }, 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.value('one')); + final String secondLoadStructuredDataResult = await bundle.loadStructuredData('AssetManifest.json', (String value) => Future.value('two')); + expect(firstLoadStructuredDataResult, 'one'); + expect(secondLoadStructuredDataResult, 'one'); + + final String firstLoadStructuredBinaryDataResult = await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData value) => Future.value('one')); + final String secondLoadStructuredBinaryDataResult = await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData value) => Future.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.value('one')); + bundle.clear(); + final String secondLoadStructuredDataResult = await bundle.loadStructuredData('AssetManifest.json', (String value) => Future.value('two')); + expect(secondLoadStructuredDataResult, 'two'); + + await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData value) => Future.value('one')); + bundle.clear(); + final String secondLoadStructuredBinaryDataResult = await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData value) => Future.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.value('one')); + bundle.evict('AssetManifest.json'); + final String secondLoadStructuredDataResult = await bundle.loadStructuredData('AssetManifest.json', (String value) => Future.value('two')); + expect(secondLoadStructuredDataResult, 'two'); + + await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData value) => Future.value('one')); + bundle.evict('AssetManifest.bin'); + final String secondLoadStructuredBinaryDataResult = await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData value) => Future.value('two')); + expect(secondLoadStructuredBinaryDataResult, 'two'); + }); + + test('loadStructuredBinaryData correctly loads ByteData', () async { + final TestAssetBundle bundle = TestAssetBundle(); + final Map assetManifest = + await bundle.loadStructuredBinaryData('AssetManifest.bin', (ByteData data) => const StandardMessageCodec().decodeMessage(data) as Map); + expect(assetManifest.keys.toList(), equals(['one'])); + expect(assetManifest['one'], []); + }); } 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 0000000000..4bd9c92223 --- /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 load(String key) async { + if (key == 'AssetManifest.bin') { + final Map> binManifestData = >{ + 'assets/foo.png': [ + { + 'asset': 'assets/2x/foo.png', + 'dpr': 2.0 + } + ], + 'assets/bar.png': [], + }; + + final ByteData data = const StandardMessageCodec().encodeMessage(binManifestData)!; + return data; + } + + throw ArgumentError('Unexpected key'); + } + + @override + Future loadStructuredData(String key, Future 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(['assets/foo.png', 'assets/bar.png'])); + + final List 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 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); + }); +}