Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reland "Speed up first asset load by using the binary-formatted asset manifest for image resolution" #122505

107 changes: 37 additions & 70 deletions packages/flutter/lib/src/painting/image_resolution.dart
Expand Up @@ -4,15 +4,12 @@

import 'dart:async';
import 'dart:collection';
import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';

import 'image_provider.dart';

const String _kAssetManifestFileName = 'AssetManifest.json';

/// A screen with a device-pixel ratio strictly less than this value is
/// considered a low-resolution screen (typically entry-level to mid-range
/// laptops, desktop screens up to QHD, low-end tablets such as Kindle Fire).
Expand Down Expand Up @@ -284,18 +281,18 @@ class AssetImage extends AssetBundleImageProvider {
Completer<AssetBundleImageKey>? completer;
Future<AssetBundleImageKey>? result;

chosenBundle.loadStructuredData<Map<String, List<String>>?>(_kAssetManifestFileName, manifestParser).then<void>(
(Map<String, List<String>>? manifest) {
final String chosenName = _chooseVariant(
AssetManifest.loadFromAssetBundle(chosenBundle)
.then((AssetManifest manifest) {
final Iterable<AssetMetadata>? candidateVariants = manifest.getAssetVariants(keyName);
final AssetMetadata 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.key,
scale: chosenVariant.targetDevicePixelRatio ?? _naturalResolution,
);
if (completer != null) {
// We already returned from this function, which means we are in the
Expand All @@ -309,14 +306,15 @@ class AssetImage extends AssetBundleImageProvider {
// ourselves.
result = SynchronousFuture<AssetBundleImageKey>(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.
Expand All @@ -328,35 +326,24 @@ class AssetImage extends AssetBundleImageProvider {
return completer.future;
}

/// Parses the asset manifest string into a strongly-typed map.
@visibleForTesting
static Future<Map<String, List<String>>?> manifestParser(String? jsonData) {
if (jsonData == null) {
return SynchronousFuture<Map<String, List<String>>?>(null);
AssetMetadata _chooseVariant(String mainAssetKey, ImageConfiguration config, Iterable<AssetMetadata>? candidateVariants) {
if (candidateVariants == null) {
return AssetMetadata(key: mainAssetKey, targetDevicePixelRatio: null, main: true);
}
// 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) {
if (config.devicePixelRatio == null || candidates == null || candidates.isEmpty) {
return main;
if (config.devicePixelRatio == null) {
return candidateVariants.firstWhere((AssetMetadata variant) => variant.main);
}
// TODO(ianh): Consider moving this parsing logic into _manifestParser.
final SplayTreeMap<double, String> mapping = SplayTreeMap<double, String>();
for (final String candidate in candidates) {
mapping[_parseScale(candidate)] = candidate;

final SplayTreeMap<double, AssetMetadata> candidatesByDevicePixelRatio =
SplayTreeMap<double, AssetMetadata>();
for (final AssetMetadata candidate in candidateVariants) {
candidatesByDevicePixelRatio[candidate.targetDevicePixelRatio ?? _naturalResolution] = candidate;
}
// 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`.
Expand All @@ -371,48 +358,28 @@ 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<double, String> candidates, double value) {
if (candidates.containsKey(value)) {
return candidates[value]!;
AssetMetadata _findBestVariant(SplayTreeMap<double, AssetMetadata> 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
// images are more visible than on screens with a higher device-pixel
// 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];
}
}

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 candidatesByDpr[lower]!;
}
return _naturalResolution; // i.e. default to 1.0x
}

@override
Expand Down
2 changes: 1 addition & 1 deletion packages/flutter/lib/src/services/asset_bundle.dart
Expand Up @@ -266,6 +266,7 @@ abstract class CachingAssetBundle extends AssetBundle {
.then<T>(parser)
.then<void>((T value) {
result = SynchronousFuture<T>(value);
_structuredBinaryDataCache[key] = result!;
Copy link
Member

Choose a reason for hiding this comment

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

How about a unit test that directly invokes loadStructuredBinaryData and asserts that the cache is populated sychonously?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea. I added some new tests (and moved others around).

if (completer != null) {
// The load and parse operation ran asynchronously. We already returned
// from the loadStructuredBinaryData function and therefore the caller
Expand All @@ -278,7 +279,6 @@ abstract class CachingAssetBundle extends AssetBundle {

if (result != null) {
// The above code ran synchronously. We can synchronously return the result.
_structuredBinaryDataCache[key] = result!;
return result!;
}

Expand Down
12 changes: 5 additions & 7 deletions packages/flutter/lib/src/services/asset_manifest.dart
Expand Up @@ -30,14 +30,12 @@ abstract class AssetManifest {
/// information.
List<String> listAssets();

/// Retrieves metadata about an asset and its variants.
/// Retrieves metadata about an asset and its variants. Returns null if the
/// key was not found in the asset manifest.
///
/// 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);
List<AssetMetadata>? getAssetVariants(String key);
}

// Lazily parses the binary asset manifest into a data structure that's easier to work
Expand All @@ -64,14 +62,14 @@ class _AssetManifestBin implements AssetManifest {
final Map<String, List<AssetMetadata>> _typeCastedData = <String, List<AssetMetadata>>{};

@override
List<AssetMetadata> getAssetVariants(String key) {
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.');
return null;
}
_typeCastedData[key] = ((_data[key] ?? <Object?>[]) as Iterable<Object?>)
.cast<Map<Object?, Object?>>()
Expand Down
41 changes: 21 additions & 20 deletions packages/flutter/test/painting/image_resolution_test.dart
Expand Up @@ -2,7 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:convert';
import 'dart:ui' as ui;

import 'package:flutter/foundation.dart';
Expand All @@ -13,18 +12,14 @@ import 'package:flutter_test/flutter_test.dart';
class TestAssetBundle extends CachingAssetBundle {
TestAssetBundle(this._assetBundleMap);

final Map<String, List<String>> _assetBundleMap;
final Map<String, List<Map<Object?, Object?>>> _assetBundleMap;

Map<String, int> loadCallCount = <String, int>{};

String get _assetBundleContents {
return json.encode(_assetBundleMap);
}

@override
Future<ByteData> 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;
Expand All @@ -45,9 +40,10 @@ class TestAssetBundle extends CachingAssetBundle {
void main() {
group('1.0 scale device tests', () {
void buildAndTestWithOneAsset(String mainAssetPath) {
final Map<String, List<String>> assetBundleMap = <String, List<String>>{};
final Map<String, List<Map<Object?, Object?>>> assetBundleMap =
<String, List<Map<Object?, Object?>>>{};

assetBundleMap[mainAssetPath] = <String>[];
assetBundleMap[mainAssetPath] = <Map<Object?, Object?>>[];

final AssetImage assetImage = AssetImage(
mainAssetPath,
Expand Down Expand Up @@ -93,11 +89,13 @@ void main() {
const String mainAssetPath = 'assets/normalFolder/normalFile.png';
const String variantPath = 'assets/normalFolder/3.0x/normalFile.png';

final Map<String, List<String>> assetBundleMap =
<String, List<String>>{};

assetBundleMap[mainAssetPath] = <String>[mainAssetPath, variantPath];
final Map<String, List<Map<Object?, Object?>>> assetBundleMap =
<String, List<Map<Object?, Object?>>>{};

final Map<Object?, Object?> mainAssetVariantManifestEntry = <Object?, Object?>{};
mainAssetVariantManifestEntry['asset'] = variantPath;
mainAssetVariantManifestEntry['dpr'] = 3.0;
assetBundleMap[mainAssetPath] = <Map<Object?, Object?>>[mainAssetVariantManifestEntry];
final TestAssetBundle testAssetBundle = TestAssetBundle(assetBundleMap);

final AssetImage assetImage = AssetImage(
Expand All @@ -123,10 +121,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<String, List<String>> assetBundleMap =
<String, List<String>>{};
final Map<String, List<Map<Object?, Object?>>> assetBundleMap =
<String, List<Map<Object?, Object?>>>{};

assetBundleMap[mainAssetPath] = <String>[mainAssetPath];
assetBundleMap[mainAssetPath] = <Map<Object?, Object?>>[];

final TestAssetBundle testAssetBundle = TestAssetBundle(assetBundleMap);

Expand Down Expand Up @@ -162,10 +160,13 @@ void main() {
double chosenAssetRatio,
String expectedAssetPath,
) {
final Map<String, List<String>> assetBundleMap =
<String, List<String>>{};
final Map<String, List<Map<Object?, Object?>>> assetBundleMap =
<String, List<Map<Object?, Object?>>>{};

assetBundleMap[mainAssetPath] = <String>[mainAssetPath, variantPath];
final Map<Object?, Object?> mainAssetVariantManifestEntry = <Object?, Object?>{};
mainAssetVariantManifestEntry['asset'] = variantPath;
mainAssetVariantManifestEntry['dpr'] = 3.0;
assetBundleMap[mainAssetPath] = <Map<Object?, Object?>>[mainAssetVariantManifestEntry];

final TestAssetBundle testAssetBundle = TestAssetBundle(assetBundleMap);

Expand Down