Unverified Commit e3db0488 authored by Andrew Kolos's avatar Andrew Kolos Committed by GitHub

Speed up first asset load by using the binary-formatted asset manifest for...

Speed up first asset load by using the binary-formatted asset manifest for image resolution (#118782)

* 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

* fix outdated type name

* restore AssetManifest docstrings

* update test

* update other test

* make error message for invalid keys more useful
parent 7175de4f
...@@ -14,6 +14,6 @@ void main() { ...@@ -14,6 +14,6 @@ void main() {
// If this asset couldn't be loaded, the exception message would be // If this asset couldn't be loaded, the exception message would be
// "asset failed to load" // "asset failed to load"
expect(tester.takeException().toString(), contains('Invalid image data')); expect(tester.takeException().toString(), contains('The key was not found in the asset manifest'));
}); });
} }
...@@ -4,15 +4,12 @@ ...@@ -4,15 +4,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'image_provider.dart'; import 'image_provider.dart';
const String _kAssetManifestFileName = 'AssetManifest.json';
/// A screen with a device-pixel ratio strictly less than this value is /// A screen with a device-pixel ratio strictly less than this value is
/// considered a low-resolution screen (typically entry-level to mid-range /// considered a low-resolution screen (typically entry-level to mid-range
/// laptops, desktop screens up to QHD, low-end tablets such as Kindle Fire). /// laptops, desktop screens up to QHD, low-end tablets such as Kindle Fire).
...@@ -284,18 +281,18 @@ class AssetImage extends AssetBundleImageProvider { ...@@ -284,18 +281,18 @@ class AssetImage extends AssetBundleImageProvider {
Completer<AssetBundleImageKey>? completer; Completer<AssetBundleImageKey>? completer;
Future<AssetBundleImageKey>? result; Future<AssetBundleImageKey>? result;
chosenBundle.loadStructuredData<Map<String, List<String>>?>(_kAssetManifestFileName, manifestParser).then<void>( AssetManifest.loadFromAssetBundle(chosenBundle)
(Map<String, List<String>>? manifest) { .then((AssetManifest manifest) {
final String chosenName = _chooseVariant( final Iterable<AssetMetadata> candidateVariants = _getVariants(manifest, keyName);
final AssetMetadata chosenVariant = _chooseVariant(
keyName, keyName,
configuration, configuration,
manifest == null ? null : manifest[keyName], candidateVariants,
)!; );
final double chosenScale = _parseScale(chosenName);
final AssetBundleImageKey key = AssetBundleImageKey( final AssetBundleImageKey key = AssetBundleImageKey(
bundle: chosenBundle, bundle: chosenBundle,
name: chosenName, name: chosenVariant.key,
scale: chosenScale, scale: chosenVariant.targetDevicePixelRatio ?? _naturalResolution,
); );
if (completer != null) { if (completer != null) {
// We already returned from this function, which means we are in the // We already returned from this function, which means we are in the
...@@ -309,14 +306,15 @@ class AssetImage extends AssetBundleImageProvider { ...@@ -309,14 +306,15 @@ class AssetImage extends AssetBundleImageProvider {
// ourselves. // ourselves.
result = SynchronousFuture<AssetBundleImageKey>(key); result = SynchronousFuture<AssetBundleImageKey>(key);
} }
}, })
).catchError((Object error, StackTrace stack) { .onError((Object error, StackTrace stack) {
// We had an error. (This guarantees we weren't called synchronously.) // We had an error. (This guarantees we weren't called synchronously.)
// Forward the error to the caller. // Forward the error to the caller.
assert(completer != null); assert(completer != null);
assert(result == null); assert(result == null);
completer!.completeError(error, stack); completer!.completeError(error, stack);
}); });
if (result != null) { if (result != null) {
// The code above ran synchronously, and came up with an answer. // The code above ran synchronously, and came up with an answer.
// Return the SynchronousFuture that we created above. // Return the SynchronousFuture that we created above.
...@@ -328,35 +326,34 @@ class AssetImage extends AssetBundleImageProvider { ...@@ -328,35 +326,34 @@ class AssetImage extends AssetBundleImageProvider {
return completer.future; return completer.future;
} }
/// Parses the asset manifest string into a strongly-typed map. Iterable<AssetMetadata> _getVariants(AssetManifest manifest, String key) {
@visibleForTesting try {
static Future<Map<String, List<String>>?> manifestParser(String? jsonData) { return manifest.getAssetVariants(key);
if (jsonData == null) { } catch (e) {
return SynchronousFuture<Map<String, List<String>>?>(null); throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('Unable to load asset with key "$key".'),
ErrorDescription(
'''
The key was not found in the asset manifest.
Make sure the key is correct and the appropriate file or folder is specified in pubspec.yaml.
'''),
]);
} }
// TODO(ianh): JSON decoding really shouldn't be on the main thread.
final Map<String, dynamic> parsedJson = json.decode(jsonData) as Map<String, dynamic>;
final Iterable<String> keys = parsedJson.keys;
final Map<String, List<String>> parsedManifest = <String, List<String>> {
for (final String key in keys) key: List<String>.from(parsedJson[key] as List<dynamic>),
};
// TODO(ianh): convert that data structure to the right types.
return SynchronousFuture<Map<String, List<String>>?>(parsedManifest);
} }
String? _chooseVariant(String main, ImageConfiguration config, List<String>? candidates) { AssetMetadata _chooseVariant(String mainAssetKey, ImageConfiguration config, Iterable<AssetMetadata> candidateVariants) {
if (config.devicePixelRatio == null || candidates == null || candidates.isEmpty) { if (config.devicePixelRatio == null || candidateVariants.isEmpty) {
return main; return candidateVariants.firstWhere((AssetMetadata variant) => variant.main);
} }
// TODO(ianh): Consider moving this parsing logic into _manifestParser. final SplayTreeMap<double, AssetMetadata> candidatesByDevicePixelRatio =
final SplayTreeMap<double, String> mapping = SplayTreeMap<double, String>(); SplayTreeMap<double, AssetMetadata>();
for (final String candidate in candidates) { for (final AssetMetadata candidate in candidateVariants) {
mapping[_parseScale(candidate)] = candidate; candidatesByDevicePixelRatio[candidate.targetDevicePixelRatio ?? _naturalResolution] = candidate;
} }
// TODO(ianh): implement support for config.locale, config.textDirection, // TODO(ianh): implement support for config.locale, config.textDirection,
// config.size, config.platform (then document this over in the Image.asset // config.size, config.platform (then document this over in the Image.asset
// docs) // docs)
return _findBestVariant(mapping, config.devicePixelRatio!); return _findBestVariant(candidatesByDevicePixelRatio, config.devicePixelRatio!);
} }
// Returns the "best" asset variant amongst the available `candidates`. // Returns the "best" asset variant amongst the available `candidates`.
...@@ -371,17 +368,17 @@ class AssetImage extends AssetBundleImageProvider { ...@@ -371,17 +368,17 @@ class AssetImage extends AssetBundleImageProvider {
// lowest key higher than `value`. // lowest key higher than `value`.
// - If the screen has high device pixel ratio, choose the variant with the // - If the screen has high device pixel ratio, choose the variant with the
// key nearest to `value`. // key nearest to `value`.
String? _findBestVariant(SplayTreeMap<double, String> candidates, double value) { AssetMetadata _findBestVariant(SplayTreeMap<double, AssetMetadata> candidatesByDpr, double value) {
if (candidates.containsKey(value)) { if (candidatesByDpr.containsKey(value)) {
return candidates[value]!; return candidatesByDpr[value]!;
} }
final double? lower = candidates.lastKeyBefore(value); final double? lower = candidatesByDpr.lastKeyBefore(value);
final double? upper = candidates.firstKeyAfter(value); final double? upper = candidatesByDpr.firstKeyAfter(value);
if (lower == null) { if (lower == null) {
return candidates[upper]; return candidatesByDpr[upper]!;
} }
if (upper == null) { if (upper == null) {
return candidates[lower]; return candidatesByDpr[lower]!;
} }
// On screens with low device-pixel ratios the artifacts from upscaling // On screens with low device-pixel ratios the artifacts from upscaling
...@@ -389,30 +386,10 @@ class AssetImage extends AssetBundleImageProvider { ...@@ -389,30 +386,10 @@ class AssetImage extends AssetBundleImageProvider {
// ratios because the physical pixels are larger. Choose the higher // ratios because the physical pixels are larger. Choose the higher
// resolution image in that case instead of the nearest one. // resolution image in that case instead of the nearest one.
if (value < _kLowDprLimit || value > (lower + upper) / 2) { if (value < _kLowDprLimit || value > (lower + upper) / 2) {
return candidates[upper]; return candidatesByDpr[upper]!;
} else { } else {
return candidates[lower]; return candidatesByDpr[lower]!;
}
}
static final RegExp _extractRatioRegExp = RegExp(r'/?(\d+(\.\d*)?)x$');
double _parseScale(String key) {
if (key == assetName) {
return _naturalResolution;
}
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 _naturalResolution; // i.e. default to 1.0x
} }
@override @override
......
...@@ -71,7 +71,7 @@ class _AssetManifestBin implements AssetManifest { ...@@ -71,7 +71,7 @@ class _AssetManifestBin implements AssetManifest {
if (!_typeCastedData.containsKey(key)) { if (!_typeCastedData.containsKey(key)) {
final Object? variantData = _data[key]; final Object? variantData = _data[key];
if (variantData == null) { if (variantData == null) {
throw ArgumentError('Asset key $key was not found within the asset manifest.'); throw ArgumentError('Asset key "$key" was not found.');
} }
_typeCastedData[key] = ((_data[key] ?? <Object?>[]) as Iterable<Object?>) _typeCastedData[key] = ((_data[key] ?? <Object?>[]) as Iterable<Object?>)
.cast<Map<Object?, Object?>>() .cast<Map<Object?, Object?>>()
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:convert';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
...@@ -13,18 +12,14 @@ import 'package:flutter_test/flutter_test.dart'; ...@@ -13,18 +12,14 @@ import 'package:flutter_test/flutter_test.dart';
class TestAssetBundle extends CachingAssetBundle { class TestAssetBundle extends CachingAssetBundle {
TestAssetBundle(this._assetBundleMap); TestAssetBundle(this._assetBundleMap);
final Map<String, List<String>> _assetBundleMap; final Map<String, List<Map<Object?, Object?>>> _assetBundleMap;
Map<String, int> loadCallCount = <String, int>{}; Map<String, int> loadCallCount = <String, int>{};
String get _assetBundleContents {
return json.encode(_assetBundleMap);
}
@override @override
Future<ByteData> load(String key) async { Future<ByteData> load(String key) async {
if (key == 'AssetManifest.json') { if (key == 'AssetManifest.bin') {
return ByteData.view(Uint8List.fromList(const Utf8Encoder().convert(_assetBundleContents)).buffer); return const StandardMessageCodec().encodeMessage(_assetBundleMap)!;
} }
loadCallCount[key] = loadCallCount[key] ?? 0 + 1; loadCallCount[key] = loadCallCount[key] ?? 0 + 1;
...@@ -45,9 +40,10 @@ class TestAssetBundle extends CachingAssetBundle { ...@@ -45,9 +40,10 @@ class TestAssetBundle extends CachingAssetBundle {
void main() { void main() {
group('1.0 scale device tests', () { group('1.0 scale device tests', () {
void buildAndTestWithOneAsset(String mainAssetPath) { void buildAndTestWithOneAsset(String mainAssetPath) {
final Map<String, List<String>> assetBundleMap = <String, List<String>>{}; final Map<String, List<Map<dynamic, dynamic>>> assetBundleMap =
<String, List<Map<dynamic, dynamic>>>{};
assetBundleMap[mainAssetPath] = <String>[]; assetBundleMap[mainAssetPath] = <Map<Object?, Object?>>[];
final AssetImage assetImage = AssetImage( final AssetImage assetImage = AssetImage(
mainAssetPath, mainAssetPath,
...@@ -93,11 +89,13 @@ void main() { ...@@ -93,11 +89,13 @@ void main() {
const String mainAssetPath = 'assets/normalFolder/normalFile.png'; const String mainAssetPath = 'assets/normalFolder/normalFile.png';
const String variantPath = 'assets/normalFolder/3.0x/normalFile.png'; const String variantPath = 'assets/normalFolder/3.0x/normalFile.png';
final Map<String, List<String>> assetBundleMap = final Map<String, List<Map<dynamic, dynamic>>> assetBundleMap =
<String, List<String>>{}; <String, List<Map<dynamic, dynamic>>>{};
assetBundleMap[mainAssetPath] = <String>[mainAssetPath, variantPath];
final Map<dynamic, dynamic> mainAssetVariantManifestEntry = <dynamic, dynamic>{};
mainAssetVariantManifestEntry['asset'] = variantPath;
mainAssetVariantManifestEntry['dpr'] = 3.0;
assetBundleMap[mainAssetPath] = <Map<dynamic, dynamic>>[mainAssetVariantManifestEntry];
final TestAssetBundle testAssetBundle = TestAssetBundle(assetBundleMap); final TestAssetBundle testAssetBundle = TestAssetBundle(assetBundleMap);
final AssetImage assetImage = AssetImage( final AssetImage assetImage = AssetImage(
...@@ -123,10 +121,10 @@ void main() { ...@@ -123,10 +121,10 @@ void main() {
test('When high-res device and high-res asset not present in bundle then return main variant', () { test('When high-res device and high-res asset not present in bundle then return main variant', () {
const String mainAssetPath = 'assets/normalFolder/normalFile.png'; const String mainAssetPath = 'assets/normalFolder/normalFile.png';
final Map<String, List<String>> assetBundleMap = final Map<String, List<Map<dynamic, dynamic>>> assetBundleMap =
<String, List<String>>{}; <String, List<Map<dynamic, dynamic>>>{};
assetBundleMap[mainAssetPath] = <String>[mainAssetPath]; assetBundleMap[mainAssetPath] = <Map<Object?, Object?>>[];
final TestAssetBundle testAssetBundle = TestAssetBundle(assetBundleMap); final TestAssetBundle testAssetBundle = TestAssetBundle(assetBundleMap);
...@@ -162,10 +160,13 @@ void main() { ...@@ -162,10 +160,13 @@ void main() {
double chosenAssetRatio, double chosenAssetRatio,
String expectedAssetPath, String expectedAssetPath,
) { ) {
final Map<String, List<String>> assetBundleMap = final Map<String, List<Map<dynamic, dynamic>>> assetBundleMap =
<String, List<String>>{}; <String, List<Map<dynamic, dynamic>>>{};
assetBundleMap[mainAssetPath] = <String>[mainAssetPath, variantPath]; final Map<dynamic, dynamic> mainAssetVariantManifestEntry = <dynamic, dynamic>{};
mainAssetVariantManifestEntry['asset'] = variantPath;
mainAssetVariantManifestEntry['dpr'] = 3.0;
assetBundleMap[mainAssetPath] = <Map<dynamic, dynamic>>[mainAssetVariantManifestEntry];
final TestAssetBundle testAssetBundle = TestAssetBundle(assetBundleMap); final TestAssetBundle testAssetBundle = TestAssetBundle(assetBundleMap);
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
@TestOn('!chrome') @TestOn('!chrome')
library; library;
import 'dart:convert';
import 'dart:ui' as ui show Image; import 'dart:ui' as ui show Image;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
...@@ -18,27 +19,31 @@ import '../image_data.dart'; ...@@ -18,27 +19,31 @@ import '../image_data.dart';
ByteData testByteData(double scale) => ByteData(8)..setFloat64(0, scale); ByteData testByteData(double scale) => ByteData(8)..setFloat64(0, scale);
double scaleOf(ByteData data) => data.getFloat64(0); double scaleOf(ByteData data) => data.getFloat64(0);
const String testManifest = ''' final Map<dynamic, dynamic> testManifest = json.decode('''
{ {
"assets/image.png" : [ "assets/image.png" : [
"assets/image.png", {"asset": "assets/1.5x/image.png", "dpr": 1.5},
"assets/1.5x/image.png", {"asset": "assets/2.0x/image.png", "dpr": 2.0},
"assets/2.0x/image.png", {"asset": "assets/3.0x/image.png", "dpr": 3.0},
"assets/3.0x/image.png", {"asset": "assets/4.0x/image.png", "dpr": 4.0}
"assets/4.0x/image.png"
] ]
} }
'''; ''') as Map<Object?, Object?>;
class TestAssetBundle extends CachingAssetBundle { class TestAssetBundle extends CachingAssetBundle {
TestAssetBundle({ this.manifest = testManifest }); TestAssetBundle({ required Map<dynamic, dynamic> manifest }) {
this.manifest = const StandardMessageCodec().encodeMessage(manifest)!;
}
final String manifest; late final ByteData manifest;
@override @override
Future<ByteData> load(String key) { Future<ByteData> load(String key) {
late ByteData data; late ByteData data;
switch (key) { switch (key) {
case 'AssetManifest.bin':
data = manifest;
break;
case 'assets/image.png': case 'assets/image.png':
data = testByteData(1.0); data = testByteData(1.0);
break; break;
...@@ -61,14 +66,6 @@ class TestAssetBundle extends CachingAssetBundle { ...@@ -61,14 +66,6 @@ class TestAssetBundle extends CachingAssetBundle {
return SynchronousFuture<ByteData>(data); return SynchronousFuture<ByteData>(data);
} }
@override
Future<String> loadString(String key, { bool cache = true }) {
if (key == 'AssetManifest.json') {
return SynchronousFuture<String>(manifest);
}
return SynchronousFuture<String>('');
}
@override @override
String toString() => '${describeIdentity(this)}()'; String toString() => '${describeIdentity(this)}()';
} }
...@@ -107,7 +104,7 @@ Widget buildImageAtRatio(String imageName, Key key, double ratio, bool inferSize ...@@ -107,7 +104,7 @@ Widget buildImageAtRatio(String imageName, Key key, double ratio, bool inferSize
devicePixelRatio: ratio, devicePixelRatio: ratio,
), ),
child: DefaultAssetBundle( child: DefaultAssetBundle(
bundle: bundle ?? TestAssetBundle(), bundle: bundle ?? TestAssetBundle(manifest: testManifest),
child: Center( child: Center(
child: inferSize ? child: inferSize ?
Image( Image(
...@@ -260,46 +257,21 @@ void main() { ...@@ -260,46 +257,21 @@ void main() {
expect(getRenderImage(tester, key).scale, 4.0); 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 { 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 // If both a main asset and a 1.0x asset are specified, then prefer
// the 1.0x asset. // the 1.0x asset.
const String manifest = ''' final Map<Object?, Object?> manifest = json.decode('''
{ {
"assets/image.png" : [ "assets/image.png" : [
"assets/image.png", {"asset": "assets/1.0x/image.png", "dpr": 1.0},
"assets/1.0x/image.png", {"asset": "assets/1.5x/image.png", "dpr": 1.5},
"assets/1.5x/image.png", {"asset": "assets/2.0x/image.png", "dpr": 2.0},
"assets/2.0x/image.png", {"asset": "assets/3.0x/image.png", "dpr": 3.0},
"assets/3.0x/image.png", {"asset": "assets/4.0x/image.png", "dpr": 4.0}
"assets/4.0x/image.png"
] ]
} }
'''; ''') as Map<Object?, Object?>;
final AssetBundle bundle = TestAssetBundle(manifest: manifest); final AssetBundle bundle = TestAssetBundle(manifest: manifest);
const double ratio = 1.0; const double ratio = 1.0;
...@@ -338,14 +310,14 @@ void main() { ...@@ -338,14 +310,14 @@ void main() {
// if higher resolution assets are not available we will pick the best // if higher resolution assets are not available we will pick the best
// available. // available.
testWidgets('Low-resolution assets', (WidgetTester tester) async { testWidgets('Low-resolution assets', (WidgetTester tester) async {
final AssetBundle bundle = TestAssetBundle(manifest: ''' final Map<Object?, Object?> manifest = json.decode('''
{ {
"assets/image.png" : [ "assets/image.png" : [
"assets/image.png", {"asset": "assets/1.5x/image.png", "dpr": 1.5}
"assets/1.5x/image.png"
] ]
} }
'''); ''') as Map<Object?, Object?>;
final AssetBundle bundle = TestAssetBundle(manifest: manifest);
Future<void> testRatio({required double ratio, required double expectedScale}) async { Future<void> testRatio({required double ratio, required double expectedScale}) async {
Key key = GlobalKey(); Key key = GlobalKey();
......
...@@ -2000,10 +2000,9 @@ void main() { ...@@ -2000,10 +2000,9 @@ void main() {
); );
expect( expect(
tester.takeException().toString(), tester.takeException().toString(),
equals( equals('Unable to load asset with key "missing-asset".\n'
'Unable to load asset: "missing-asset".\n' 'The key was not found in the asset manifest.\n'
'The asset does not exist or has empty data.', 'Make sure the key is correct and the appropriate file or folder is specified in pubspec.yaml.'),
),
); );
await tester.pump(); await tester.pump();
await expectLater( await expectLater(
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment