From ea56b2cccc72c611caca609067d57842728ad6eb Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Mon, 5 Dec 2022 10:39:10 -0800 Subject: [PATCH] Speed up first asset load by encoding asset manifest in binary rather than JSON (#113637) --- .../decode_and_parse_asset_manifest.dart | 60 +- .../test/example_code_parser_test.dart | 7 + .../lib/src/painting/image_resolution.dart | 246 ++- .../lib/src/services/asset_bundle.dart | 83 +- .../test/painting/image_resolution_test.dart | 98 +- .../test/services/asset_bundle_test.dart | 10 +- .../test/widgets/image_resolution_test.dart | 80 +- packages/flutter_tools/lib/src/asset.dart | 120 +- packages/flutter_tools/pubspec.yaml | 6 +- ...omponents_gen_snapshot_validator_test.dart | 17 +- .../asset_bundle_package_fonts_test.dart | 5 +- .../asset_bundle_package_test.dart | 1570 ++++++++++++----- .../test/general.shard/asset_bundle_test.dart | 114 +- .../asset_bundle_variant_test.dart | 580 ++++-- 14 files changed, 2077 insertions(+), 919 deletions(-) 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 b64c1532ce22..d6b0f7c6732b 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 @@ -3,9 +3,9 @@ // found in the LICENSE file. import 'dart:convert'; +import 'dart:typed_data'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart' show PlatformAssetBundle; +import 'package:flutter/services.dart' show PlatformAssetBundle, StandardMessageCodec; import 'package:flutter/widgets.dart'; import '../common.dart'; @@ -18,16 +18,14 @@ void main() async { final BenchmarkResultPrinter printer = BenchmarkResultPrinter(); WidgetsFlutterBinding.ensureInitialized(); final Stopwatch watch = Stopwatch(); - final PlatformAssetBundle bundle = PlatformAssetBundle(); - final ByteData assetManifestBytes = await bundle.load('money_asset_manifest.json'); + final ByteData assetManifest = await loadAssetManifest(); + watch.start(); for (int i = 0; i < _kNumIterations; i++) { - bundle.clear(); - final String json = utf8.decode(assetManifestBytes.buffer.asUint8List()); - // This is a test, so we don't need to worry about this rule. + // This is effectively a test. // ignore: invalid_use_of_visible_for_testing_member - await AssetImage.manifestParser(json); + AssetImage.parseAssetManifest(assetManifest); } watch.stop(); @@ -40,3 +38,49 @@ void main() async { printer.printToStdout(); } + +final RegExp _extractRatioRegExp = RegExp(r'/?(\d+(\.\d*)?)x$'); + +Future loadAssetManifest() async { + double parseScale(String key) { + final Uri assetUri = Uri.parse(key); + String directoryPath = ''; + if (assetUri.pathSegments.length > 1) { + directoryPath = assetUri.pathSegments[assetUri.pathSegments.length - 2]; + } + final Match? match = _extractRatioRegExp.firstMatch(directoryPath); + if (match != null && match.groupCount > 0) { + return double.parse(match.group(1)!); + } + return 1.0; + } + + final Map result = {}; + final PlatformAssetBundle bundle = PlatformAssetBundle(); + + // For the benchmark, we use the older JSON format and then convert it to the modern binary format. + final ByteData jsonAssetManifestBytes = await bundle.load('money_asset_manifest.json'); + final String jsonAssetManifest = utf8.decode(jsonAssetManifestBytes.buffer.asUint8List()); + + final Map assetManifest = json.decode(jsonAssetManifest) as Map; + + for (final MapEntry manifestEntry in assetManifest.entries) { + final List resultVariants = []; + final List entries = (manifestEntry.value as List).cast(); + for (final String variant in entries) { + if (variant == manifestEntry.key) { + // With the newer binary format, don't include the main asset in it's + // list of variants. This reduces parsing time at runtime. + continue; + } + final Map resultVariant = {}; + final double variantDevicePixelRatio = parseScale(variant); + resultVariant['asset'] = variant; + resultVariant['dpr'] = variantDevicePixelRatio; + resultVariants.add(resultVariant); + } + result[manifestEntry.key] = resultVariants; + } + + return const StandardMessageCodec().encodeMessage(result)!; +} diff --git a/dev/integration_tests/flutter_gallery/test/example_code_parser_test.dart b/dev/integration_tests/flutter_gallery/test/example_code_parser_test.dart index 94276fb38e19..a1baa460ed34 100644 --- a/dev/integration_tests/flutter_gallery/test/example_code_parser_test.dart +++ b/dev/integration_tests/flutter_gallery/test/example_code_parser_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:flutter/services.dart'; import 'package:flutter_gallery/gallery/example_code_parser.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -58,4 +60,9 @@ class TestAssetBundle extends AssetBundle { @override String toString() => '$runtimeType@$hashCode()'; + + @override + Future loadStructuredBinaryData(String key, FutureOr Function(ByteData data) parser) async { + return parser(await load(key)); + } } diff --git a/packages/flutter/lib/src/painting/image_resolution.dart b/packages/flutter/lib/src/painting/image_resolution.dart index 1e427742bf9a..f978dff4c8db 100644 --- a/packages/flutter/lib/src/painting/image_resolution.dart +++ b/packages/flutter/lib/src/painting/image_resolution.dart @@ -11,7 +11,8 @@ import 'package:flutter/services.dart'; import 'image_provider.dart'; -const String _kAssetManifestFileName = 'AssetManifest.json'; +const String _kLegacyAssetManifestFilename = 'AssetManifest.json'; +const String _kAssetManifestFilename = 'AssetManifest.bin'; /// A screen with a device-pixel ratio strictly less than this value is /// considered a low-resolution screen (typically entry-level to mid-range @@ -284,18 +285,45 @@ class AssetImage extends AssetBundleImageProvider { Completer? completer; Future? result; - chosenBundle.loadStructuredData>?>(_kAssetManifestFileName, manifestParser).then( - (Map>? manifest) { - final String chosenName = _chooseVariant( + Future<_AssetManifest> loadJsonAssetManifest() { + Future<_AssetManifest> parseJson(String data) { + final _AssetManifest parsed = _LegacyAssetManifest.fromJsonString(data); + return SynchronousFuture<_AssetManifest>(parsed); + } + return chosenBundle.loadStructuredData(_kLegacyAssetManifestFilename, parseJson); + } + + // TODO(andrewkolos): Once google3 and google-fonts-flutter are migrated + // away from using AssetManifest.json, remove all references to it. + // See https://github.com/flutter/flutter/issues/114913. + Future<_AssetManifest>? manifest; + + // Since AssetBundle load calls can be synchronous (e.g. in the case of tests), + // it is not sufficient to only use catchError/onError or the onError parameter + // of Future.then--we also have to use a synchronous try/catch. Once google3 + // tooling starts producing AssetManifest.bin, this block can be removed. + try { + manifest = chosenBundle.loadStructuredBinaryData(_kAssetManifestFilename, _AssetManifestBin.fromStandardMessageCodecMessage); + } catch (error) { + manifest = loadJsonAssetManifest(); + } + + manifest + // To understand why we use this no-op `then` instead of `catchError`/`onError`, + // see https://github.com/flutter/flutter/issues/115601 + .then((_AssetManifest manifest) => manifest, + onError: (Object? error, StackTrace? stack) => loadJsonAssetManifest()) + .then((_AssetManifest manifest) { + final List<_AssetVariant> candidateVariants = manifest.getVariants(keyName); + final _AssetVariant chosenVariant = _chooseVariant( keyName, configuration, - manifest == null ? null : manifest[keyName], - )!; - final double chosenScale = _parseScale(chosenName); + candidateVariants, + ); final AssetBundleImageKey key = AssetBundleImageKey( bundle: chosenBundle, - name: chosenName, - scale: chosenScale, + name: chosenVariant.asset, + scale: chosenVariant.devicePixelRatio, ); if (completer != null) { // We already returned from this function, which means we are in the @@ -309,14 +337,15 @@ class AssetImage extends AssetBundleImageProvider { // ourselves. result = SynchronousFuture(key); } - }, - ).catchError((Object error, StackTrace stack) { - // We had an error. (This guarantees we weren't called synchronously.) - // Forward the error to the caller. - assert(completer != null); - assert(result == null); - completer!.completeError(error, stack); - }); + }) + .onError((Object error, StackTrace stack) { + // We had an error. (This guarantees we weren't called synchronously.) + // Forward the error to the caller. + assert(completer != null); + assert(result == null); + completer!.completeError(error, stack); + }); + if (result != null) { // The code above ran synchronously, and came up with an answer. // Return the SynchronousFuture that we created above. @@ -328,35 +357,29 @@ class AssetImage extends AssetBundleImageProvider { return completer.future; } - /// Parses the asset manifest string into a strongly-typed map. + /// Parses the asset manifest's file contents into it's Dart representation. @visibleForTesting - static Future>?> manifestParser(String? jsonData) { - if (jsonData == null) { - return SynchronousFuture>?>(null); - } - // TODO(ianh): JSON decoding really shouldn't be on the main thread. - final Map parsedJson = json.decode(jsonData) as Map; - final Iterable keys = parsedJson.keys; - final Map> parsedManifest = > { - for (final String key in keys) key: List.from(parsedJson[key] as List), - }; - // TODO(ianh): convert that data structure to the right types. - return SynchronousFuture>?>(parsedManifest); + // Return type is set to Object?, because the specific type is private. + static Object? parseAssetManifest(ByteData bytes) { + return _AssetManifestBin.fromStandardMessageCodecMessage(bytes); } - String? _chooseVariant(String main, ImageConfiguration config, List? candidates) { - if (config.devicePixelRatio == null || candidates == null || candidates.isEmpty) { - return main; + _AssetVariant _chooseVariant(String mainAssetKey, ImageConfiguration config, List<_AssetVariant> candidateVariants) { + final _AssetVariant mainAsset = _AssetVariant(asset: mainAssetKey, + devicePixelRatio: _naturalResolution); + if (config.devicePixelRatio == null || candidateVariants.isEmpty) { + return mainAsset; } - // TODO(ianh): Consider moving this parsing logic into _manifestParser. - final SplayTreeMap mapping = SplayTreeMap(); - for (final String candidate in candidates) { - mapping[_parseScale(candidate)] = candidate; + final SplayTreeMap candidatesByDevicePixelRatio = + SplayTreeMap(); + for (final _AssetVariant candidate in candidateVariants) { + candidatesByDevicePixelRatio[candidate.devicePixelRatio] = candidate; } + candidatesByDevicePixelRatio.putIfAbsent(_naturalResolution, () => mainAsset); // TODO(ianh): implement support for config.locale, config.textDirection, // config.size, config.platform (then document this over in the Image.asset // docs) - return _findBestVariant(mapping, config.devicePixelRatio!); + return _findBestVariant(candidatesByDevicePixelRatio, config.devicePixelRatio!); } // Returns the "best" asset variant amongst the available `candidates`. @@ -371,17 +394,17 @@ class AssetImage extends AssetBundleImageProvider { // lowest key higher than `value`. // - If the screen has high device pixel ratio, choose the variant with the // key nearest to `value`. - String? _findBestVariant(SplayTreeMap candidates, double value) { - if (candidates.containsKey(value)) { - return candidates[value]!; + _AssetVariant _findBestVariant(SplayTreeMap candidatesByDpr, double value) { + if (candidatesByDpr.containsKey(value)) { + return candidatesByDpr[value]!; } - final double? lower = candidates.lastKeyBefore(value); - final double? upper = candidates.firstKeyAfter(value); + final double? lower = candidatesByDpr.lastKeyBefore(value); + final double? upper = candidatesByDpr.firstKeyAfter(value); if (lower == null) { - return candidates[upper]; + return candidatesByDpr[upper]!; } if (upper == null) { - return candidates[lower]; + return candidatesByDpr[lower]!; } // On screens with low device-pixel ratios the artifacts from upscaling @@ -389,20 +412,116 @@ class AssetImage extends AssetBundleImageProvider { // ratios because the physical pixels are larger. Choose the higher // resolution image in that case instead of the nearest one. if (value < _kLowDprLimit || value > (lower + upper) / 2) { - return candidates[upper]; + return candidatesByDpr[upper]!; } else { - return candidates[lower]; + return candidatesByDpr[lower]!; } } + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is AssetImage + && other.keyName == keyName + && other.bundle == bundle; + } + + @override + int get hashCode => Object.hash(keyName, bundle); + + @override + String toString() => '${objectRuntimeType(this, 'AssetImage')}(bundle: $bundle, name: "$keyName")'; +} + +/// Centralizes parsing and typecasting of the contents of the asset manifest file, +/// which is generated by the flutter tool at build time. +abstract class _AssetManifest { + List<_AssetVariant> getVariants(String key); +} + +/// Parses the binary asset manifest into a data structure that's easier to work with. +/// +/// The asset manifest is a map of asset files to a list of objects containing +/// information about variants of that asset. +/// +/// 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 Object? data = const StandardMessageCodec().decodeMessage(message); + return _AssetManifestBin(data! as Map); + } + + final Map _data; + final Map> _typeCastedData = >{}; + + @override + List<_AssetVariant> getVariants(String key) { + // We lazily delay typecasting to prevent a performance hiccup when parsing + // large asset manifests. + if (!_typeCastedData.containsKey(key)) { + _typeCastedData[key] = ((_data[key] ?? []) as List) + .cast>() + .map(_AssetVariant.fromManifestData) + .toList(); + } + return _typeCastedData[key]!; + } +} + +class _LegacyAssetManifest implements _AssetManifest { + _LegacyAssetManifest({ + required this.manifest, + }); + + factory _LegacyAssetManifest.fromJsonString(String jsonString) { + List<_AssetVariant> adaptLegacyVariantList(String mainAsset, List variants) { + return variants + .map((String variant) => + _AssetVariant(asset: variant, devicePixelRatio: _parseScale(mainAsset, variant))) + .toList(); + } + + if (jsonString == null) { + return _LegacyAssetManifest(manifest: >{}); + } + final Map parsedJson = json.decode(jsonString) as Map; + final Iterable keys = parsedJson.keys; + final Map> parsedManifest = > { + for (final String key in keys) key: List.from(parsedJson[key]! as List), + }; + final Map> manifestWithParsedVariants = + parsedManifest.map((String asset, List variants) => + MapEntry>(asset, adaptLegacyVariantList(asset, variants))); + + return _LegacyAssetManifest(manifest: manifestWithParsedVariants); + } + + final Map> manifest; + static final RegExp _extractRatioRegExp = RegExp(r'/?(\d+(\.\d*)?)x$'); + static const double _naturalResolution = 1.0; + + @override + List<_AssetVariant> getVariants(String key) { + return manifest[key] ?? const <_AssetVariant>[]; + } - double _parseScale(String key) { - if (key == assetName) { + static double _parseScale(String mainAsset, String variant) { + // The legacy asset manifest includes the main asset within its variant list. + if (mainAsset == variant) { return _naturalResolution; } - final Uri assetUri = Uri.parse(key); + final Uri assetUri = Uri.parse(variant); String directoryPath = ''; if (assetUri.pathSegments.length > 1) { directoryPath = assetUri.pathSegments[assetUri.pathSegments.length - 2]; @@ -412,22 +531,23 @@ class AssetImage extends AssetBundleImageProvider { if (match != null && match.groupCount > 0) { return double.parse(match.group(1)!); } + return _naturalResolution; // i.e. default to 1.0x } +} - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) { - return false; - } - return other is AssetImage - && other.keyName == keyName - && other.bundle == bundle; - } +class _AssetVariant { + _AssetVariant({ + required this.asset, + required this.devicePixelRatio, + }); - @override - int get hashCode => Object.hash(keyName, bundle); + factory _AssetVariant.fromManifestData(Object data) { + final Map asStructuredData = data as Map; + return _AssetVariant(asset: asStructuredData['asset']! as String, + devicePixelRatio: asStructuredData['dpr']! as double); + } - @override - String toString() => '${objectRuntimeType(this, 'AssetImage')}(bundle: $bundle, name: "$keyName")'; + final double devicePixelRatio; + final String asset; } diff --git a/packages/flutter/lib/src/services/asset_bundle.dart b/packages/flutter/lib/src/services/asset_bundle.dart index 776035aa6871..ba971e20aea4 100644 --- a/packages/flutter/lib/src/services/asset_bundle.dart +++ b/packages/flutter/lib/src/services/asset_bundle.dart @@ -96,12 +96,25 @@ 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); + if (data == null) { + throw FlutterError('Unable to load asset: $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. @@ -156,6 +169,18 @@ 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 { + assert(key != null); + assert(parser != null); + return parser(await load(key)); + } + // TODO(ianh): Once the underlying network logic learns about caching, we // should implement evict(). @@ -175,6 +200,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 }) { @@ -225,16 +251,69 @@ 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) { + assert(key != null); + assert(parser != null); + + 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 err, StackTrace? stack) { + completer!.completeError(err, 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 @@ -276,7 +355,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/test/painting/image_resolution_test.dart b/packages/flutter/test/painting/image_resolution_test.dart index 8e04f2aada4f..962cf9d83ee2 100644 --- a/packages/flutter/test/painting/image_resolution_test.dart +++ b/packages/flutter/test/painting/image_resolution_test.dart @@ -13,18 +13,14 @@ import 'package:flutter_test/flutter_test.dart'; class TestAssetBundle extends CachingAssetBundle { TestAssetBundle(this._assetBundleMap); - final Map> _assetBundleMap; + final Map>> _assetBundleMap; Map loadCallCount = {}; - String get _assetBundleContents { - return json.encode(_assetBundleMap); - } - @override Future load(String key) async { - if (key == 'AssetManifest.json') { - return ByteData.view(Uint8List.fromList(const Utf8Encoder().convert(_assetBundleContents)).buffer); + if (key == 'AssetManifest.bin') { + return const StandardMessageCodec().encodeMessage(_assetBundleMap)!; } loadCallCount[key] = loadCallCount[key] ?? 0 + 1; @@ -42,12 +38,71 @@ class TestAssetBundle extends CachingAssetBundle { } } +class BundleWithoutAssetManifestBin extends CachingAssetBundle { + BundleWithoutAssetManifestBin(this._legacyAssetBundleMap); + + final Map> _legacyAssetBundleMap; + + Map loadCallCount = {}; + + @override + Future load(String key) async { + ByteData testByteData(double scale) => ByteData(8)..setFloat64(0, scale); + + if (key == 'AssetManifest.bin') { + throw FlutterError('AssetManifest.bin was not found.'); + } + if (key == 'AssetManifest.json') { + return ByteData.view(Uint8List.fromList(const Utf8Encoder().convert(json.encode(_legacyAssetBundleMap))).buffer); + } + switch (key) { + case 'assets/image.png': + return testByteData(1.0); // see "...with a main asset and a 1.0x asset" + case 'assets/2.0x/image.png': + return testByteData(1.5); + } + + throw FlutterError('Unexpected key: $key'); + } + + @override + Future loadBuffer(String key) async { + final ByteData data = await load(key); + return ui.ImmutableBuffer.fromUint8List(data.buffer.asUint8List()); + } +} + void main() { + + // TODO(andrewkolos): Once google3 is migrated away from using AssetManifest.json, + // remove all references to it. See https://github.com/flutter/flutter/issues/114913. + test('AssetBundle falls back to using AssetManifest.json if AssetManifest.bin cannot be found.', () async { + const String assetPath = 'assets/image.png'; + final Map> assetBundleMap = >{}; + assetBundleMap[assetPath] = []; + final AssetImage assetImage = AssetImage(assetPath, bundle: BundleWithoutAssetManifestBin(assetBundleMap)); + final AssetBundleImageKey key = await assetImage.obtainKey(ImageConfiguration.empty); + expect(key.name, assetPath); + expect(key.scale, 1.0); + }); + + test('When using AssetManifest.json, on a high DPR device, a high dpr variant is selected.', () async { + const String assetPath = 'assets/image.png'; + const String asset2xPath = 'assets/2.0x/image.png'; + final Map> assetBundleMap = >{}; + assetBundleMap[assetPath] = [asset2xPath]; + final AssetImage assetImage = AssetImage(assetPath, bundle: BundleWithoutAssetManifestBin(assetBundleMap)); + final AssetBundleImageKey key = await assetImage.obtainKey(const ImageConfiguration(devicePixelRatio: 2.0)); + expect(key.name, asset2xPath); + expect(key.scale, 2.0); + }); + group('1.0 scale device tests', () { void buildAndTestWithOneAsset(String mainAssetPath) { - final Map> assetBundleMap = >{}; + final Map>> assetBundleMap = + >>{}; - assetBundleMap[mainAssetPath] = []; + assetBundleMap[mainAssetPath] = >[]; final AssetImage assetImage = AssetImage( mainAssetPath, @@ -93,10 +148,13 @@ void main() { const String mainAssetPath = 'assets/normalFolder/normalFile.png'; const String variantPath = 'assets/normalFolder/3.0x/normalFile.png'; - final Map> assetBundleMap = - >{}; + final Map>> assetBundleMap = + >>{}; - assetBundleMap[mainAssetPath] = [mainAssetPath, variantPath]; + final Map mainAssetVariantManifestEntry = {}; + mainAssetVariantManifestEntry['asset'] = variantPath; + mainAssetVariantManifestEntry['dpr'] = 3.0; + assetBundleMap[mainAssetPath] = >[mainAssetVariantManifestEntry]; final TestAssetBundle testAssetBundle = TestAssetBundle(assetBundleMap); @@ -123,10 +181,10 @@ void main() { test('When high-res device and high-res asset not present in bundle then return main variant', () { const String mainAssetPath = 'assets/normalFolder/normalFile.png'; - final Map> assetBundleMap = - >{}; + final Map>> assetBundleMap = + >>{}; - assetBundleMap[mainAssetPath] = [mainAssetPath]; + assetBundleMap[mainAssetPath] = >[]; final TestAssetBundle testAssetBundle = TestAssetBundle(assetBundleMap); @@ -156,16 +214,18 @@ void main() { const String mainAssetPath = 'assets/normalFolder/normalFile.png'; const String variantPath = 'assets/normalFolder/3.0x/normalFile.png'; - void buildBundleAndTestVariantLogic( double deviceRatio, double chosenAssetRatio, String expectedAssetPath, ) { - final Map> assetBundleMap = - >{}; + final Map>> assetBundleMap = + >>{}; - assetBundleMap[mainAssetPath] = [mainAssetPath, variantPath]; + final Map mainAssetVariantManifestEntry = {}; + mainAssetVariantManifestEntry['asset'] = variantPath; + mainAssetVariantManifestEntry['dpr'] = 3.0; + assetBundleMap[mainAssetPath] = >[mainAssetVariantManifestEntry]; final TestAssetBundle testAssetBundle = TestAssetBundle(assetBundleMap); diff --git a/packages/flutter/test/services/asset_bundle_test.dart b/packages/flutter/test/services/asset_bundle_test.dart index 8a97df3daa2c..4beac89d52ba 100644 --- a/packages/flutter/test/services/asset_bundle_test.dart +++ b/packages/flutter/test/services/asset_bundle_test.dart @@ -9,14 +9,14 @@ import 'package:flutter/painting.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -class TestAssetBundle extends CachingAssetBundle { +class _TestAssetBundle extends CachingAssetBundle { Map loadCallCount = {}; @override Future load(String key) async { 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(json.decode('{"one":[]}'))!; } if (key == 'one') { @@ -30,7 +30,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); test('Caching asset bundle test', () async { - final TestAssetBundle bundle = TestAssetBundle(); + final _TestAssetBundle bundle = _TestAssetBundle(); final ByteData assetData = await bundle.load('one'); expect(assetData.getInt8(0), equals(49)); @@ -53,7 +53,7 @@ void main() { test('AssetImage.obtainKey succeeds with ImageConfiguration.empty', () async { // This is a regression test for https://github.com/flutter/flutter/issues/12392 - final AssetImage assetImage = AssetImage('one', bundle: TestAssetBundle()); + final AssetImage assetImage = AssetImage('one', bundle: _TestAssetBundle()); final AssetBundleImageKey key = await assetImage.obtainKey(ImageConfiguration.empty); expect(key.name, 'one'); expect(key.scale, 1.0); diff --git a/packages/flutter/test/widgets/image_resolution_test.dart b/packages/flutter/test/widgets/image_resolution_test.dart index 644f2c47903d..da6ac354693e 100644 --- a/packages/flutter/test/widgets/image_resolution_test.dart +++ b/packages/flutter/test/widgets/image_resolution_test.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. @TestOn('!chrome') +import 'dart:convert'; import 'dart:ui' as ui show Image; import 'package:flutter/foundation.dart'; @@ -16,27 +17,32 @@ import '../image_data.dart'; ByteData testByteData(double scale) => ByteData(8)..setFloat64(0, scale); double scaleOf(ByteData data) => data.getFloat64(0); -const String testManifest = ''' +final Map testManifest = json.decode(''' { "assets/image.png" : [ - "assets/image.png", - "assets/1.5x/image.png", - "assets/2.0x/image.png", - "assets/3.0x/image.png", - "assets/4.0x/image.png" + {"asset": "assets/1.5x/image.png", "dpr": 1.5}, + {"asset": "assets/2.0x/image.png", "dpr": 2.0}, + {"asset": "assets/3.0x/image.png", "dpr": 3.0}, + {"asset": "assets/4.0x/image.png", "dpr": 4.0} ] } -'''; +''') as Map; class TestAssetBundle extends CachingAssetBundle { - TestAssetBundle({ this.manifest = testManifest }); - final String manifest; + TestAssetBundle({ required Map manifest }) { + this.manifest = const StandardMessageCodec().encodeMessage(manifest)!; + } + + late final ByteData manifest; @override Future load(String key) { late ByteData data; switch (key) { + case 'AssetManifest.bin': + data = manifest; + break; case 'assets/image.png': data = testByteData(1.0); break; @@ -59,14 +65,6 @@ class TestAssetBundle extends CachingAssetBundle { return SynchronousFuture(data); } - @override - Future loadString(String key, { bool cache = true }) { - if (key == 'AssetManifest.json') { - return SynchronousFuture(manifest); - } - return SynchronousFuture(''); - } - @override String toString() => '${describeIdentity(this)}()'; } @@ -106,7 +104,7 @@ Widget buildImageAtRatio(String imageName, Key key, double ratio, bool inferSize devicePixelRatio: ratio, ), child: DefaultAssetBundle( - bundle: bundle ?? TestAssetBundle(), + bundle: bundle ?? TestAssetBundle(manifest: testManifest), child: Center( child: inferSize ? Image( @@ -259,46 +257,21 @@ void main() { expect(getRenderImage(tester, key).scale, 4.0); }); - testWidgets('Image for device pixel ratio 1.0, with no main asset', (WidgetTester tester) async { - const String manifest = ''' - { - "assets/image.png" : [ - "assets/1.5x/image.png", - "assets/2.0x/image.png", - "assets/3.0x/image.png", - "assets/4.0x/image.png" - ] - } - '''; - final AssetBundle bundle = TestAssetBundle(manifest: manifest); - - const double ratio = 1.0; - Key key = GlobalKey(); - await pumpTreeToLayout(tester, buildImageAtRatio(image, key, ratio, false, images, bundle)); - expect(getRenderImage(tester, key).size, const Size(200.0, 200.0)); - expect(getRenderImage(tester, key).scale, 1.5); - key = GlobalKey(); - await pumpTreeToLayout(tester, buildImageAtRatio(image, key, ratio, true, images, bundle)); - expect(getRenderImage(tester, key).size, const Size(48.0, 48.0)); - expect(getRenderImage(tester, key).scale, 1.5); - }); - testWidgets('Image for device pixel ratio 1.0, with a main asset and a 1.0x asset', (WidgetTester tester) async { // If both a main asset and a 1.0x asset are specified, then prefer // the 1.0x asset. - const String manifest = ''' + final Map manifest = json.decode(''' { "assets/image.png" : [ - "assets/image.png", - "assets/1.0x/image.png", - "assets/1.5x/image.png", - "assets/2.0x/image.png", - "assets/3.0x/image.png", - "assets/4.0x/image.png" + {"asset": "assets/1.0x/image.png", "dpr":1.0}, + {"asset": "assets/1.5x/image.png", "dpr":1.5}, + {"asset": "assets/2.0x/image.png", "dpr":2.0}, + {"asset": "assets/3.0x/image.png", "dpr":3.0}, + {"asset": "assets/4.0x/image.png", "dpr":4.0} ] } - '''; + ''') as Map; final AssetBundle bundle = TestAssetBundle(manifest: manifest); const double ratio = 1.0; @@ -337,14 +310,13 @@ void main() { // if higher resolution assets are not available we will pick the best // available. testWidgets('Low-resolution assets', (WidgetTester tester) async { - final AssetBundle bundle = TestAssetBundle(manifest: ''' + final AssetBundle bundle = TestAssetBundle(manifest: json.decode(''' { "assets/image.png" : [ - "assets/image.png", - "assets/1.5x/image.png" + {"asset": "assets/1.5x/image.png", "dpr": 1.5} ] } - '''); + ''') as Map); Future testRatio({required double ratio, required double expectedScale}) async { Key key = GlobalKey(); diff --git a/packages/flutter_tools/lib/src/asset.dart b/packages/flutter_tools/lib/src/asset.dart index 684e1a9a12d7..5fab11848098 100644 --- a/packages/flutter_tools/lib/src/asset.dart +++ b/packages/flutter_tools/lib/src/asset.dart @@ -2,8 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:typed_data'; + import 'package:meta/meta.dart'; import 'package:package_config/package_config.dart'; +import 'package:standard_message_codec/standard_message_codec.dart'; import 'base/context.dart'; import 'base/deferred_component.dart'; @@ -136,6 +139,9 @@ class ManifestAssetBundle implements AssetBundle { _splitDeferredAssets = splitDeferredAssets, _licenseCollector = LicenseCollector(fileSystem: fileSystem); + // We assume the main asset is designed for a device pixel ratio of 1.0 + static const double _defaultResolution = 1.0; + final Logger _logger; final FileSystem _fileSystem; final LicenseCollector _licenseCollector; @@ -161,7 +167,8 @@ class ManifestAssetBundle implements AssetBundle { DateTime? _lastBuildTimestamp; - static const String _kAssetManifestJson = 'AssetManifest.json'; + static const String _kAssetManifestBinFileName = 'AssetManifest.bin'; + static const String _kAssetManifestJsonFileName = 'AssetManifest.json'; static const String _kNoticeFile = 'NOTICES'; // Comically, this can't be name with the more common .gz file extension // because when it's part of an AAR and brought into another APK via gradle, @@ -229,8 +236,13 @@ class ManifestAssetBundle implements AssetBundle { // device. _lastBuildTimestamp = DateTime.now(); if (flutterManifest.isEmpty) { - entries[_kAssetManifestJson] = DevFSStringContent('{}'); - entryKinds[_kAssetManifestJson] = AssetKind.regular; + entries[_kAssetManifestJsonFileName] = DevFSStringContent('{}'); + entryKinds[_kAssetManifestJsonFileName] = AssetKind.regular; + final ByteData emptyAssetManifest = + const StandardMessageCodec().encodeMessage({})!; + entries[_kAssetManifestBinFileName] = + DevFSByteContent(emptyAssetManifest.buffer.asUint8List(0, emptyAssetManifest.lengthInBytes)); + entryKinds[_kAssetManifestBinFileName] = AssetKind.regular; return 0; } @@ -426,7 +438,11 @@ class ManifestAssetBundle implements AssetBundle { _wildcardDirectories[uri] ??= _fileSystem.directory(uri); } - final DevFSStringContent assetManifest = _createAssetManifest(assetVariants, deferredComponentsAssetVariants); + final Map> assetManifest = + _createAssetManifest(assetVariants, deferredComponentsAssetVariants); + final DevFSStringContent assetManifestJson = DevFSStringContent(json.encode(assetManifest)); + final DevFSByteContent assetManifestBinary = _createAssetManifestBinary(assetManifest); + final DevFSStringContent fontManifest = DevFSStringContent(json.encode(fonts)); final LicenseResult licenseResult = _licenseCollector.obtainLicenses(packageConfig, additionalLicenseFiles); if (licenseResult.errorMessages.isNotEmpty) { @@ -450,7 +466,8 @@ class ManifestAssetBundle implements AssetBundle { _fileSystem.file('DOES_NOT_EXIST_RERUN_FOR_WILDCARD$suffix').absolute); } - _setIfChanged(_kAssetManifestJson, assetManifest, AssetKind.regular); + _setIfChanged(_kAssetManifestJsonFileName, assetManifestJson, AssetKind.regular); + _setIfChanged(_kAssetManifestBinFileName, assetManifestBinary, AssetKind.regular); _setIfChanged(kFontManifestJson, fontManifest, AssetKind.regular); _setLicenseIfChanged(licenseResult.combinedLicenses, targetPlatform); return 0; @@ -459,17 +476,31 @@ class ManifestAssetBundle implements AssetBundle { @override List additionalDependencies = []; - void _setIfChanged(String key, DevFSStringContent content, AssetKind assetKind) { - if (!entries.containsKey(key)) { - entries[key] = content; - entryKinds[key] = assetKind; - return; + void _setIfChanged(String key, DevFSContent content, AssetKind assetKind) { + bool areEqual(List o1, List o2) { + if (o1.length != o2.length) { + return false; + } + + for (int index = 0; index < o1.length; index++) { + if (o1[index] != o2[index]) { + return false; + } + } + + return true; } - final DevFSStringContent? oldContent = entries[key] as DevFSStringContent?; - if (oldContent?.string != content.string) { - entries[key] = content; - entryKinds[key] = assetKind; + + final DevFSContent? oldContent = entries[key]; + // In the case that the content is unchanged, we want to avoid an overwrite + // as the isModified property may be reset to true, + if (oldContent is DevFSByteContent && content is DevFSByteContent && + areEqual(oldContent.bytes, content.bytes)) { + return; } + + entries[key] = content; + entryKinds[key] = assetKind; } void _setLicenseIfChanged( @@ -621,14 +652,14 @@ class ManifestAssetBundle implements AssetBundle { return deferredComponentsAssetVariants; } - DevFSStringContent _createAssetManifest( + Map> _createAssetManifest( Map<_Asset, List<_Asset>> assetVariants, Map>> deferredComponentsAssetVariants ) { - final Map> jsonObject = >{}; - final Map<_Asset, List> jsonEntries = <_Asset, List>{}; + final Map> manifest = >{}; + final Map<_Asset, List> entries = <_Asset, List>{}; assetVariants.forEach((_Asset main, List<_Asset> variants) { - jsonEntries[main] = [ + entries[main] = [ for (final _Asset variant in variants) variant.entryUri.path, ]; @@ -636,26 +667,69 @@ class ManifestAssetBundle implements AssetBundle { if (deferredComponentsAssetVariants != null) { for (final Map<_Asset, List<_Asset>> componentAssets in deferredComponentsAssetVariants.values) { componentAssets.forEach((_Asset main, List<_Asset> variants) { - jsonEntries[main] = [ + entries[main] = [ for (final _Asset variant in variants) variant.entryUri.path, ]; }); } } - final List<_Asset> sortedKeys = jsonEntries.keys.toList() + final List<_Asset> sortedKeys = entries.keys.toList() ..sort((_Asset left, _Asset right) => left.entryUri.path.compareTo(right.entryUri.path)); for (final _Asset main in sortedKeys) { final String decodedEntryPath = Uri.decodeFull(main.entryUri.path); - final List rawEntryVariantsPaths = jsonEntries[main]!; + final List rawEntryVariantsPaths = entries[main]!; final List decodedEntryVariantPaths = rawEntryVariantsPaths .map((String value) => Uri.decodeFull(value)) .toList(); - jsonObject[decodedEntryPath] = decodedEntryVariantPaths; + manifest[decodedEntryPath] = decodedEntryVariantPaths; + } + return manifest; + } + + DevFSByteContent _createAssetManifestBinary( + Map> assetManifest + ) { + double parseScale(String key) { + final Uri assetUri = Uri.parse(key); + String directoryPath = ''; + if (assetUri.pathSegments.length > 1) { + directoryPath = assetUri.pathSegments[assetUri.pathSegments.length - 2]; + } + + final Match? match = _extractRatioRegExp.firstMatch(directoryPath); + if (match != null && match.groupCount > 0) { + return double.parse(match.group(1)!); + } + return _defaultResolution; + } + + final Map result = {}; + + for (final MapEntry manifestEntry in assetManifest.entries) { + final List resultVariants = []; + final List entries = (manifestEntry.value as List).cast(); + for (final String variant in entries) { + if (variant == manifestEntry.key) { + // With the newer binary format, don't include the main asset in it's + // list of variants. This reduces parsing time at runtime. + continue; + } + final Map resultVariant = {}; + final double variantDevicePixelRatio = parseScale(variant); + resultVariant['asset'] = variant; + resultVariant['dpr'] = variantDevicePixelRatio; + resultVariants.add(resultVariant); + } + result[manifestEntry.key] = resultVariants; } - return DevFSStringContent(json.encode(jsonObject)); + + final ByteData message = const StandardMessageCodec().encodeMessage(result)!; + return DevFSByteContent(message.buffer.asUint8List(0, message.lengthInBytes)); } + static final RegExp _extractRatioRegExp = RegExp(r'/?(\d+(\.\d*)?)x$'); + /// Prefixes family names and asset paths of fonts included from packages with /// 'packages/' List _parsePackageFonts( diff --git a/packages/flutter_tools/pubspec.yaml b/packages/flutter_tools/pubspec.yaml index 91dafbe172c1..aa69dcc343aa 100644 --- a/packages/flutter_tools/pubspec.yaml +++ b/packages/flutter_tools/pubspec.yaml @@ -57,6 +57,8 @@ dependencies: vm_service: 9.4.0 + standard_message_codec: 0.0.1+3 + _fe_analyzer_shared: 50.0.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" analyzer: 5.2.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" boolean_selector: 2.1.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" @@ -88,7 +90,6 @@ dependencies: watcher: 1.0.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" dev_dependencies: - collection: 1.17.0 file_testing: 3.0.0 pubspec_parse: 1.2.1 @@ -97,9 +98,10 @@ dev_dependencies: json_annotation: 4.7.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" node_preamble: 2.0.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" test: 1.22.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + collection: 1.17.0 dartdoc: # Exclude this package from the hosted API docs. nodoc: true -# PUBSPEC CHECKSUM: 65eb +# PUBSPEC CHECKSUM: 408d diff --git a/packages/flutter_tools/test/general.shard/android/deferred_components_gen_snapshot_validator_test.dart b/packages/flutter_tools/test/general.shard/android/deferred_components_gen_snapshot_validator_test.dart index 6962b50a6aae..9366653644a9 100644 --- a/packages/flutter_tools/test/general.shard/android/deferred_components_gen_snapshot_validator_test.dart +++ b/packages/flutter_tools/test/general.shard/android/deferred_components_gen_snapshot_validator_test.dart @@ -220,7 +220,7 @@ loading-units-spelled-wrong: expect(logger.statusText, contains('Errors checking the following files:')); expect(logger.statusText, contains("Invalid loading units yaml file, 'loading-units' entry did not exist.")); - expect(logger.statusText.contains('Previously existing loading units no longer exist:\n\n LoadingUnit 2\n Libraries:\n - lib1\n'), false); + expect(logger.statusText, isNot(contains('Previously existing loading units no longer exist:\n\n LoadingUnit 2\n Libraries:\n - lib1\n'))); }); testWithoutContext('loadingUnitCache validator detects malformed file: not a list', () async { @@ -382,7 +382,7 @@ loading-units: validator.displayResults(); validator.attemptToolExit(); - expect(logger.statusText.contains('Errors checking the following files:'), false); + expect(logger.statusText, isNot(contains('Errors checking the following files:'))); }); testWithoutContext('androidStringMapping modifies strings file', () async { @@ -448,9 +448,10 @@ loading-units: .childDirectory('main') .childFile('AndroidManifest.xml'); expect(manifestOutput.existsSync(), true); - expect(manifestOutput.readAsStringSync().contains(''), true); - expect(manifestOutput.readAsStringSync().contains('android:value="invalidmapping"'), false); - expect(manifestOutput.readAsStringSync().contains("