image_resolution.dart 16.9 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6 7 8 9
// 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';
10
import 'package:flutter/services.dart';
11 12 13 14 15

import 'image_provider.dart';

const String _kAssetManifestFileName = 'AssetManifest.json';

16 17 18 19 20
/// 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;

21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
/// 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
40 41
/// as 2.0 and 4.0 pixel ratios (variants). The asset bundle should then
/// contain the following assets:
42
///
43 44 45
///     heart.png
///     2.0x/heart.png
///     4.0x/heart.png
46 47
///
/// On a device with a 1.0 device pixel ratio, the image chosen would be
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
/// 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].
84 85 86 87 88
///
/// 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:
///
89 90 91
///     icons/heart.png
///     icons/1.5x/heart.png
///     icons/2.0x/heart.png
92
///
93 94 95
/// assets/icons/3.0x/heart.png would be a valid variant of
/// assets/icons/heart.png.
///
96 97 98 99
/// ## 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
100
/// above. First, the `pubspec.yaml` of the project should specify its assets in
101 102 103 104 105 106 107 108
/// the `flutter` section:
///
/// ```yaml
/// flutter:
///   assets:
///     - icons/heart.png
/// ```
///
109
/// Then, to fetch the image, use:
110
/// ```dart
111
/// const AssetImage('icons/heart.png')
112 113
/// ```
///
114 115 116 117 118 119 120 121 122 123
/// {@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({
124
///     super.key,
125
///     required this.assetImage,
126
///   });
127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149
///
///   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);
150
///     if (widget.assetImage != oldWidget.assetImage) {
151
///       _getImage();
152
///     }
153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194
///   }
///
///   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}
///
195 196 197 198 199 200 201
/// ## 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
202
/// const AssetImage('icons/heart.png', package: 'my_icons')
203 204 205 206 207
/// ```
///
/// Assets used by the package itself should also be fetched using the [package]
/// argument as above.
///
208
/// If the desired asset is specified in the `pubspec.yaml` of the package, it
209
/// is bundled automatically with the app. In particular, assets used by the
210
/// package itself must be specified in its `pubspec.yaml`.
211 212
///
/// A package can also choose to have assets in its 'lib/' folder that are not
213
/// specified in its `pubspec.yaml`. In this case for those images to be
214 215 216
/// bundled, the app has to specify which ones to include. For instance a
/// package named `fancy_backgrounds` could have:
///
217 218 219
///     lib/backgrounds/background1.png
///     lib/backgrounds/background2.png
///     lib/backgrounds/background3.png
220
///
221
/// To include, say the first image, the `pubspec.yaml` of the app should specify
222 223 224
/// it in the `assets` section:
///
/// ```yaml
225 226
///   assets:
///     - packages/fancy_backgrounds/backgrounds/background1.png
227 228
/// ```
///
Ian Hickson's avatar
Ian Hickson committed
229
/// The `lib/` is implied, so it should not be included in the asset path.
230
///
231 232 233 234
/// See also:
///
///  * [Image.asset] for a shorthand of an [Image] widget backed by [AssetImage]
///    when used without a scale.
235
@immutable
236 237 238
class AssetImage extends AssetBundleImageProvider {
  /// Creates an object that fetches an image from an asset bundle.
  ///
239 240 241 242
  /// 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.
243 244
  const AssetImage(
    this.assetName, {
245 246 247
    this.bundle,
    this.package,
  }) : assert(assetName != null);
248

249
  /// The name of the main asset from the set of images to choose from. See the
250
  /// documentation for the [AssetImage] class itself for details.
251 252 253 254 255 256
  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';
257 258 259 260 261 262 263 264

  /// 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]
265
  /// using the key given by [keyName].
266
  final AssetBundle? bundle;
267

268 269
  /// The name of the package from which the image is included. See the
  /// documentation for the [AssetImage] class itself for details.
270
  final String? package;
271

272 273 274 275 276 277 278 279 280 281 282 283
  // 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;
284 285
    Completer<AssetBundleImageKey>? completer;
    Future<AssetBundleImageKey>? result;
286

287
    chosenBundle.loadStructuredData<Map<String, List<String>>?>(_kAssetManifestFileName, manifestParser).then<void>(
288
      (Map<String, List<String>>? manifest) {
289
        final String chosenName = _chooseVariant(
290
          keyName,
291
          configuration,
292
          manifest == null ? null : manifest[keyName],
293
        )!;
294
        final double chosenScale = _parseScale(chosenName);
295
        final AssetBundleImageKey key = AssetBundleImageKey(
296 297
          bundle: chosenBundle,
          name: chosenName,
298
          scale: chosenScale,
299 300 301 302
        );
        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
303
          // future is what we returned.
304 305 306 307 308 309
          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.
310
          result = SynchronousFuture<AssetBundleImageKey>(key);
311
        }
312
      },
313
    ).catchError((Object error, StackTrace stack) {
314 315 316 317
      // We had an error. (This guarantees we weren't called synchronously.)
      // Forward the error to the caller.
      assert(completer != null);
      assert(result == null);
318
      completer!.completeError(error, stack);
319 320 321 322
    });
    if (result != null) {
      // The code above ran synchronously, and came up with an answer.
      // Return the SynchronousFuture that we created above.
323
      return result!;
324 325 326
    }
    // The code above hasn't yet run its "then" handler yet. Let's prepare a
    // completer for it to use when it does run.
327
    completer = Completer<AssetBundleImageKey>();
328 329 330
    return completer.future;
  }

331 332 333
  /// Parses the asset manifest string into a strongly-typed map.
  @visibleForTesting
  static Future<Map<String, List<String>>?> manifestParser(String? jsonData) {
334
    if (jsonData == null) {
335
      return SynchronousFuture<Map<String, List<String>>?>(null);
336
    }
337
    // TODO(ianh): JSON decoding really shouldn't be on the main thread.
338
    final Map<String, dynamic> parsedJson = json.decode(jsonData) as Map<String, dynamic>;
339
    final Iterable<String> keys = parsedJson.keys;
340 341 342
    final Map<String, List<String>> parsedManifest = <String, List<String>> {
      for (final String key in keys) key: List<String>.from(parsedJson[key] as List<dynamic>),
    };
343
    // TODO(ianh): convert that data structure to the right types.
344
    return SynchronousFuture<Map<String, List<String>>?>(parsedManifest);
345 346
  }

347
  String? _chooseVariant(String main, ImageConfiguration config, List<String>? candidates) {
348
    if (config.devicePixelRatio == null || candidates == null || candidates.isEmpty) {
349
      return main;
350
    }
351
    // TODO(ianh): Consider moving this parsing logic into _manifestParser.
352
    final SplayTreeMap<double, String> mapping = SplayTreeMap<double, String>();
353
    for (final String candidate in candidates) {
354
      mapping[_parseScale(candidate)] = candidate;
355
    }
Ian Hickson's avatar
Ian Hickson committed
356 357 358
    // TODO(ianh): implement support for config.locale, config.textDirection,
    // config.size, config.platform (then document this over in the Image.asset
    // docs)
359
    return _findBestVariant(mapping, config.devicePixelRatio!);
360 361
  }

362
  // Returns the "best" asset variant amongst the available `candidates`.
363 364 365 366 367 368 369
  //
  // 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.
370
  // - If the screen has low device pixel ratio, choose the variant with the
371 372 373 374
  //   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) {
375
    if (candidates.containsKey(value)) {
376
      return candidates[value]!;
377
    }
378 379
    final double? lower = candidates.lastKeyBefore(value);
    final double? upper = candidates.firstKeyAfter(value);
380
    if (lower == null) {
381
      return candidates[upper];
382 383
    }
    if (upper == null) {
384
      return candidates[lower];
385
    }
386 387 388 389 390

    // 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.
391
    if (value < _kLowDprLimit || value > (lower + upper) / 2) {
392
      return candidates[upper];
393
    } else {
394
      return candidates[lower];
395
    }
396 397
  }

398
  static final RegExp _extractRatioRegExp = RegExp(r'/?(\d+(\.\d*)?)x$');
399 400

  double _parseScale(String key) {
401
    if (key == assetName) {
402 403 404
      return _naturalResolution;
    }

405 406 407 408 409
    final Uri assetUri = Uri.parse(key);
    String directoryPath = '';
    if (assetUri.pathSegments.length > 1) {
      directoryPath = assetUri.pathSegments[assetUri.pathSegments.length - 2];
    }
410

411
    final Match? match = _extractRatioRegExp.firstMatch(directoryPath);
412
    if (match != null && match.groupCount > 0) {
413
      return double.parse(match.group(1)!);
414
    }
415
    return _naturalResolution; // i.e. default to 1.0x
416 417 418
  }

  @override
419
  bool operator ==(Object other) {
420
    if (other.runtimeType != runtimeType) {
421
      return false;
422
    }
423 424 425
    return other is AssetImage
        && other.keyName == keyName
        && other.bundle == bundle;
426 427 428
  }

  @override
429
  int get hashCode => Object.hash(keyName, bundle);
430 431

  @override
432
  String toString() => '${objectRuntimeType(this, 'AssetImage')}(bundle: $bundle, name: "$keyName")';
433
}