Unverified Commit 496efca1 authored by Yegor's avatar Yegor Committed by GitHub

choose higher-res images on low-DPR screens (#69799)

parent e8efde6a
...@@ -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:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:convert'; import 'dart:convert';
...@@ -15,6 +14,11 @@ import 'image_provider.dart'; ...@@ -15,6 +14,11 @@ import 'image_provider.dart';
const String _kAssetManifestFileName = 'AssetManifest.json'; 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 /// Fetches an image from an [AssetBundle], having determined the exact image to
/// use based on the context. /// use based on the context.
/// ///
...@@ -34,18 +38,52 @@ const String _kAssetManifestFileName = 'AssetManifest.json'; ...@@ -34,18 +38,52 @@ const String _kAssetManifestFileName = 'AssetManifest.json';
/// ///
/// For example, suppose an application wants to use an icon named /// 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 /// "heart.png". This icon has representations at 1.0 (the main icon), as well
/// as 1.5 and 2.0 pixel ratios (variants). The asset bundle should then contain /// as 2.0 and 4.0 pixel ratios (variants). The asset bundle should then
/// the following assets: /// contain the following assets:
/// ///
/// ``` /// ```
/// heart.png /// heart.png
/// 1.5x/heart.png
/// 2.0x/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 /// On a device with a 1.0 device pixel ratio, the image chosen would be
/// heart.png; on a device with a 1.3 device pixel ratio, the image chosen /// heart.png; on a device with a 2.0 device pixel ratio, the image chosen
/// would be 1.5x/heart.png. /// 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 /// 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 /// at the equivalent level; that is, the following is also a valid bundle
...@@ -240,11 +278,22 @@ class AssetImage extends AssetBundleImageProvider { ...@@ -240,11 +278,22 @@ class AssetImage extends AssetBundleImageProvider {
// 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 _findNearest(mapping, config.devicePixelRatio!); return _findBestVariant(mapping, config.devicePixelRatio!);
} }
// Return the value for the key in a [SplayTreeMap] nearest the provided key. // Returns the "best" asset variant amongst the availabe `candidates`.
String? _findNearest(SplayTreeMap<double, String> candidates, double value) { //
// 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, chosse 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)) if (candidates.containsKey(value))
return candidates[value]!; return candidates[value]!;
final double? lower = candidates.lastKeyBefore(value); final double? lower = candidates.lastKeyBefore(value);
...@@ -253,7 +302,12 @@ class AssetImage extends AssetBundleImageProvider { ...@@ -253,7 +302,12 @@ class AssetImage extends AssetBundleImageProvider {
return candidates[upper]; return candidates[upper];
if (upper == null) if (upper == null)
return candidates[lower]; return candidates[lower];
if (value > (lower + upper) / 2)
// 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 candidates[upper];
else else
return candidates[lower]; return candidates[lower];
......
...@@ -215,16 +215,19 @@ void main() { ...@@ -215,16 +215,19 @@ void main() {
expect(getRenderImage(tester, key).scale, 1.5); expect(getRenderImage(tester, key).scale, 1.5);
}); });
// A 1.75 DPR screen is typically a low-resolution screen, such that physical
// pixels are visible to the user. For such screens we prefer to pick the
// higher resolution image, if available.
testWidgets('Image for device pixel ratio 1.75', (WidgetTester tester) async { testWidgets('Image for device pixel ratio 1.75', (WidgetTester tester) async {
const double ratio = 1.75; const double ratio = 1.75;
Key key = GlobalKey(); Key key = GlobalKey();
await pumpTreeToLayout(tester, buildImageAtRatio(image, key, ratio, false, images)); await pumpTreeToLayout(tester, buildImageAtRatio(image, key, ratio, false, images));
expect(getRenderImage(tester, key).size, const Size(200.0, 200.0)); expect(getRenderImage(tester, key).size, const Size(200.0, 200.0));
expect(getRenderImage(tester, key).scale, 1.5); expect(getRenderImage(tester, key).scale, 2.0);
key = GlobalKey(); key = GlobalKey();
await pumpTreeToLayout(tester, buildImageAtRatio(image, key, ratio, true, images)); await pumpTreeToLayout(tester, buildImageAtRatio(image, key, ratio, true, images));
expect(getRenderImage(tester, key).size, const Size(48.0, 48.0)); expect(getRenderImage(tester, key).size, const Size(48.0, 48.0));
expect(getRenderImage(tester, key).scale, 1.5); expect(getRenderImage(tester, key).scale, 2.0);
}); });
testWidgets('Image for device pixel ratio 2.3', (WidgetTester tester) async { testWidgets('Image for device pixel ratio 2.3', (WidgetTester tester) async {
...@@ -336,4 +339,50 @@ void main() { ...@@ -336,4 +339,50 @@ void main() {
expect(getRenderImage(tester, key).size, const Size(5.0, 5.0)); expect(getRenderImage(tester, key).size, const Size(5.0, 5.0));
}); });
// For low-resolution screens we prefer higher-resolution images due to
// visible physical pixel size (see the test for 1.75 DPR above). However,
// if higher resolution assets are not available we will pick the best
// available.
testWidgets('Low-resolution assets', (WidgetTester tester) async {
final AssetBundle bundle = TestAssetBundle(manifest: '''
{
"assets/image.png" : [
"assets/image.png",
"assets/1.5x/image.png"
]
}
''');
Future<void> testRatio({required double ratio, required double expectedScale}) async {
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, expectedScale);
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, expectedScale);
}
// Choose higher resolution image as it's the lowest available.
await testRatio(ratio: 0.25, expectedScale: 1.0);
await testRatio(ratio: 0.5, expectedScale: 1.0);
await testRatio(ratio: 0.75, expectedScale: 1.0);
await testRatio(ratio: 1.0, expectedScale: 1.0);
// Choose higher resolution image even though a lower resolution
// image is closer.
await testRatio(ratio: 1.20, expectedScale: 1.5);
// Choose higher resolution image because it's closer.
await testRatio(ratio: 1.25, expectedScale: 1.5);
await testRatio(ratio: 1.5, expectedScale: 1.5);
// Choose lower resolution image because no higher resolution assets
// are not available.
await testRatio(ratio: 1.75, expectedScale: 1.5);
await testRatio(ratio: 2.0, expectedScale: 1.5);
await testRatio(ratio: 2.25, expectedScale: 1.5);
await testRatio(ratio: 10.0, expectedScale: 1.5);
});
} }
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