Skip to content

Commit

Permalink
Reland "Speed up first asset load by using the binary-formatted asset…
Browse files Browse the repository at this point in the history
… manifest for image resolution" (#122505)

Reland "Speed up first asset load by using the binary-formatted asset manifest for image resolution"
  • Loading branch information
andrewkolos authored Mar 15, 2023
1 parent e08d250 commit 313b016
Show file tree
Hide file tree
Showing 7 changed files with 169 additions and 215 deletions.
107 changes: 37 additions & 70 deletions packages/flutter/lib/src/painting/image_resolution.dart
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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!;
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading

0 comments on commit 313b016

Please sign in to comment.