blob: 3c202777e53b2b5812f76c83bc9bd8d66664cbd5 [file] [log] [blame] [edit]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import '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).
const double _kLowDprLimit = 2.0;
/// Fetches an image from an [AssetBundle], having determined the exact image to
/// use based on the context.
///
/// Given a main asset and a set of variants, AssetImage chooses the most
/// appropriate asset for the current context, based on the device pixel ratio
/// and size given in the configuration passed to [resolve].
///
/// To show a specific image from a bundle without any asset resolution, use an
/// [AssetBundleImageProvider].
///
/// ## Naming assets for matching with different pixel densities
///
/// Main assets are presumed to match a nominal pixel ratio of 1.0. To specify
/// assets targeting different pixel ratios, place the variant assets in
/// the application bundle under subdirectories named in the form "Nx", where
/// N is the nominal device pixel ratio for that asset.
///
/// For example, suppose an application wants to use an icon named
/// "heart.png". This icon has representations at 1.0 (the main icon), as well
/// as 2.0 and 4.0 pixel ratios (variants). The asset bundle should then
/// contain the following assets:
///
/// heart.png
/// 2.0x/heart.png
/// 4.0x/heart.png
///
/// On a device with a 1.0 device pixel ratio, the image chosen would be
/// heart.png; on a device with a 2.0 device pixel ratio, the image chosen
/// would be 2.0x/heart.png; on a device with a 4.0 device pixel ratio, the
/// image chosen would be 4.0x/heart.png.
///
/// On a device with a device pixel ratio that does not exactly match an
/// available asset the "best match" is chosen. Which asset is the best depends
/// on the screen. Low-resolution screens (those with device pixel ratio
/// strictly less than 2.0) use a different matching algorithm from the
/// high-resolution screen. Because in low-resolution screens the physical
/// pixels are visible to the user upscaling artifacts (e.g. blurred edges) are
/// more pronounced. Therefore, a higher resolution asset is chosen, if
/// available. For higher-resolution screens, where individual physical pixels
/// are not visible to the user, the asset variant with the pixel ratio that's
/// the closest to the screen's device pixel ratio is chosen.
///
/// For example, for a screen with device pixel ratio 1.25 the image chosen
/// would be 2.0x/heart.png, even though heart.png (i.e. 1.0) is closer. This
/// is because the screen is considered low-resolution. For a screen with
/// device pixel ratio of 2.25 the image chosen would also be 2.0x/heart.png.
/// This is because the screen is considered to be a high-resolution screen,
/// and therefore upscaling a 2.0x image to 2.25 won't result in visible
/// upscaling artifacts. However, for a screen with device-pixel ratio 3.25 the
/// image chosen would be 4.0x/heart.png because it's closer to 4.0 than it is
/// to 2.0.
///
/// Choosing a higher-resolution image than necessary may waste significantly
/// more memory if the difference between the screen device pixel ratio and
/// the device pixel ratio of the image is high. To reduce memory usage,
/// consider providing more variants of the image. In the example above adding
/// a 3.0x/heart.png variant would improve memory usage for screens with device
/// pixel ratios between 3.0 and 3.5.
///
/// [ImageConfiguration] can be used to customize the selection of the image
/// variant by setting [ImageConfiguration.devicePixelRatio] to value different
/// from the default. The default value is derived from
/// [MediaQueryData.devicePixelRatio] by [createLocalImageConfiguration].
///
/// The directory level of the asset does not matter as long as the variants are
/// at the equivalent level; that is, the following is also a valid bundle
/// structure:
///
/// icons/heart.png
/// icons/1.5x/heart.png
/// icons/2.0x/heart.png
///
/// assets/icons/3.0x/heart.png would be a valid variant of
/// assets/icons/heart.png.
///
/// ## Fetching assets
///
/// When fetching an image provided by the app itself, use the [assetName]
/// argument to name the asset to choose. For instance, consider the structure
/// above. First, the `pubspec.yaml` of the project should specify its assets in
/// the `flutter` section:
///
/// ```yaml
/// flutter:
/// assets:
/// - icons/heart.png
/// ```
///
/// Then, to fetch the image, use:
/// ```dart
/// const AssetImage('icons/heart.png')
/// ```
///
/// {@tool snippet}
///
/// The following shows the code required to write a widget that fully conforms
/// to the [AssetImage] and [Widget] protocols. (It is essentially a
/// bare-bones version of the [widgets.Image] widget made to work specifically for
/// an [AssetImage].)
///
/// ```dart
/// class MyImage extends StatefulWidget {
/// const MyImage({
/// super.key,
/// required this.assetImage,
/// });
///
/// final AssetImage assetImage;
///
/// @override
/// State<MyImage> createState() => _MyImageState();
/// }
///
/// class _MyImageState extends State<MyImage> {
/// ImageStream? _imageStream;
/// ImageInfo? _imageInfo;
///
/// @override
/// void didChangeDependencies() {
/// super.didChangeDependencies();
/// // We call _getImage here because createLocalImageConfiguration() needs to
/// // be called again if the dependencies changed, in case the changes relate
/// // to the DefaultAssetBundle, MediaQuery, etc, which that method uses.
/// _getImage();
/// }
///
/// @override
/// void didUpdateWidget(MyImage oldWidget) {
/// super.didUpdateWidget(oldWidget);
/// if (widget.assetImage != oldWidget.assetImage) {
/// _getImage();
/// }
/// }
///
/// void _getImage() {
/// final ImageStream? oldImageStream = _imageStream;
/// _imageStream = widget.assetImage.resolve(createLocalImageConfiguration(context));
/// if (_imageStream!.key != oldImageStream?.key) {
/// // If the keys are the same, then we got the same image back, and so we don't
/// // need to update the listeners. If the key changed, though, we must make sure
/// // to switch our listeners to the new image stream.
/// final ImageStreamListener listener = ImageStreamListener(_updateImage);
/// oldImageStream?.removeListener(listener);
/// _imageStream!.addListener(listener);
/// }
/// }
///
/// void _updateImage(ImageInfo imageInfo, bool synchronousCall) {
/// setState(() {
/// // Trigger a build whenever the image changes.
/// _imageInfo?.dispose();
/// _imageInfo = imageInfo;
/// });
/// }
///
/// @override
/// void dispose() {
/// _imageStream?.removeListener(ImageStreamListener(_updateImage));
/// _imageInfo?.dispose();
/// _imageInfo = null;
/// super.dispose();
/// }
///
/// @override
/// Widget build(BuildContext context) {
/// return RawImage(
/// image: _imageInfo?.image, // this is a dart:ui Image object
/// scale: _imageInfo?.scale ?? 1.0,
/// );
/// }
/// }
/// ```
/// {@end-tool}
///
/// ## Assets in packages
///
/// To fetch an asset from a package, the [package] argument must be provided.
/// For instance, suppose the structure above is inside a package called
/// `my_icons`. Then to fetch the image, use:
///
/// ```dart
/// const AssetImage('icons/heart.png', package: 'my_icons')
/// ```
///
/// Assets used by the package itself should also be fetched using the [package]
/// argument as above.
///
/// If the desired asset is specified in the `pubspec.yaml` of the package, it
/// is bundled automatically with the app. In particular, assets used by the
/// package itself must be specified in its `pubspec.yaml`.
///
/// A package can also choose to have assets in its 'lib/' folder that are not
/// specified in its `pubspec.yaml`. In this case for those images to be
/// bundled, the app has to specify which ones to include. For instance a
/// package named `fancy_backgrounds` could have:
///
/// lib/backgrounds/background1.png
/// lib/backgrounds/background2.png
/// lib/backgrounds/background3.png
///
/// To include, say the first image, the `pubspec.yaml` of the app should specify
/// it in the `assets` section:
///
/// ```yaml
/// assets:
/// - packages/fancy_backgrounds/backgrounds/background1.png
/// ```
///
/// The `lib/` is implied, so it should not be included in the asset path.
///
/// See also:
///
/// * [Image.asset] for a shorthand of an [Image] widget backed by [AssetImage]
/// when used without a scale.
@immutable
class AssetImage extends AssetBundleImageProvider {
/// Creates an object that fetches an image from an asset bundle.
///
/// The [assetName] argument must not be null. It should name the main asset
/// from the set of images to choose from. The [package] argument must be
/// non-null when fetching an asset that is included in package. See the
/// documentation for the [AssetImage] class itself for details.
const AssetImage(
this.assetName, {
this.bundle,
this.package,
});
/// The name of the main asset from the set of images to choose from. See the
/// documentation for the [AssetImage] class itself for details.
final String assetName;
/// The name used to generate the key to obtain the asset. For local assets
/// this is [assetName], and for assets from packages the [assetName] is
/// prefixed 'packages/<package_name>/'.
String get keyName => package == null ? assetName : 'packages/$package/$assetName';
/// The bundle from which the image will be obtained.
///
/// If the provided [bundle] is null, the bundle provided in the
/// [ImageConfiguration] passed to the [resolve] call will be used instead. If
/// that is also null, the [rootBundle] is used.
///
/// The image is obtained by calling [AssetBundle.load] on the given [bundle]
/// using the key given by [keyName].
final AssetBundle? bundle;
/// The name of the package from which the image is included. See the
/// documentation for the [AssetImage] class itself for details.
final String? package;
// We assume the main asset is designed for a device pixel ratio of 1.0
static const double _naturalResolution = 1.0;
@override
Future<AssetBundleImageKey> obtainKey(ImageConfiguration configuration) {
// This function tries to return a SynchronousFuture if possible. We do this
// because otherwise showing an image would always take at least one frame,
// which would be sad. (This code is called from inside build/layout/paint,
// which all happens in one call frame; using native Futures would guarantee
// that we resolve each future in a new call frame, and thus not in this
// build/layout/paint sequence.)
final AssetBundle chosenBundle = bundle ?? configuration.bundle ?? rootBundle;
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(
keyName,
configuration,
manifest == null ? null : manifest[keyName],
)!;
final double chosenScale = _parseScale(chosenName);
final AssetBundleImageKey key = AssetBundleImageKey(
bundle: chosenBundle,
name: chosenName,
scale: chosenScale,
);
if (completer != null) {
// We already returned from this function, which means we are in the
// asynchronous mode. Pass the value to the completer. The completer's
// future is what we returned.
completer.complete(key);
} else {
// We haven't yet returned, so we must have been called synchronously
// just after loadStructuredData returned (which means it provided us
// with a SynchronousFuture). Let's return a SynchronousFuture
// 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);
});
if (result != null) {
// The code above ran synchronously, and came up with an answer.
// Return the SynchronousFuture that we created above.
return result!;
}
// The code above hasn't yet run its "then" handler yet. Let's prepare a
// completer for it to use when it does run.
completer = Completer<AssetBundleImageKey>();
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);
}
// 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;
}
// 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;
}
// 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!);
}
// Returns the "best" asset variant amongst the available `candidates`.
//
// The best variant is chosen as follows:
// - Choose a variant whose key matches `value` exactly, if available.
// - If `value` is less than the lowest key, choose the variant with the
// lowest key.
// - If `value` is greater than the highest key, choose the variant with
// the highest key.
// - If the screen has low device pixel ratio, choose the variant with the
// 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]!;
}
final double? lower = candidates.lastKeyBefore(value);
final double? upper = candidates.firstKeyAfter(value);
if (lower == null) {
return candidates[upper];
}
if (upper == null) {
return candidates[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];
} 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 _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;
}
@override
int get hashCode => Object.hash(keyName, bundle);
@override
String toString() => '${objectRuntimeType(this, 'AssetImage')}(bundle: $bundle, name: "$keyName")';
}