Unverified Commit 06d0cd51 authored by Dan Field's avatar Dan Field Committed by GitHub

Allow detection of images using more memory than necessary (#59877)

parent fd7a72ee
// 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:convert' show jsonEncode;
import 'dart:developer' as developer;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/painting.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:vm_service/vm_service.dart';
import 'package:vm_service/vm_service_io.dart';
void main() {
VmService vmService;
LiveTestWidgetsFlutterBinding binding;
setUpAll(() async {
final developer.ServiceProtocolInfo info =
await developer.Service.getInfo();
if (info.serverUri == null) {
fail('This test _must_ be run with --enable-vmservice.');
}
vmService = await vmServiceConnectUri('ws://localhost:${info.serverUri.port}${info.serverUri.path}ws');
await vmService.streamListen(EventStreams.kExtension);
// Initialize bindings
binding = LiveTestWidgetsFlutterBinding();
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
binding.attachRootWidget(const SizedBox.expand());
expect(binding.framesEnabled, true);
// Pump two frames to make sure we clear out any inter-frame comparisons.
await binding.endOfFrame;
await binding.endOfFrame;
});
test('Image painting events - deduplicates across frames', () async {
final Completer<Event> completer = Completer<Event>();
vmService.onExtensionEvent.first.then(completer.complete);
const TestImage image = TestImage(width: 300, height: 300);
final TestCanvas canvas = TestCanvas();
paintImage(
canvas: canvas,
rect: const Rect.fromLTWH(50.0, 75.0, 200.0, 100.0),
image: image,
debugImageLabel: 'test.png',
);
// Make sure that we don't report an identical image size info if we
// redraw in the next frame.
await binding.endOfFrame;
paintImage(
canvas: canvas,
rect: const Rect.fromLTWH(50.0, 75.0, 200.0, 100.0),
image: image,
debugImageLabel: 'test.png',
);
await binding.endOfFrame;
final Event event = await completer.future;
expect(event.extensionKind, 'Flutter.ImageSizesForFrame');
expect(
jsonEncode(event.extensionData.data),
'{"test.png":{"source":"test.png","displaySize":{"width":200.0,"height":100.0},"imageSize":{"width":300.0,"height":300.0},"displaySizeInBytes":106666,"decodedSizeInBytes":480000}}',
);
}, skip: isBrowser); // uses dart:isolate and io
test('Image painting events - deduplicates across frames', () async {
final Completer<Event> completer = Completer<Event>();
vmService.onExtensionEvent.first.then(completer.complete);
const TestImage image = TestImage(width: 300, height: 300);
final TestCanvas canvas = TestCanvas();
paintImage(
canvas: canvas,
rect: const Rect.fromLTWH(50.0, 75.0, 200.0, 100.0),
image: image,
debugImageLabel: 'test.png',
);
paintImage(
canvas: canvas,
rect: const Rect.fromLTWH(50.0, 75.0, 300.0, 300.0),
image: image,
debugImageLabel: 'test.png',
);
await binding.endOfFrame;
final Event event = await completer.future;
expect(event.extensionKind, 'Flutter.ImageSizesForFrame');
expect(
jsonEncode(event.extensionData.data),
'{"test.png":{"source":"test.png","displaySize":{"width":300.0,"height":300.0},"imageSize":{"width":300.0,"height":300.0},"displaySizeInBytes":480000,"decodedSizeInBytes":480000}}',
);
}, skip: isBrowser); // uses dart:isolate and io
}
class TestImage implements ui.Image {
const TestImage({this.height = 0, this.width = 0});
@override
final int height;
@override
final int width;
@override
void dispose() {}
@override
Future<ByteData> toByteData(
{ui.ImageByteFormat format = ui.ImageByteFormat.rawRgba}) {
throw UnimplementedError();
}
}
class TestCanvas implements Canvas {
@override
void noSuchMethod(Invocation invocation) {}
}
...@@ -51,6 +51,7 @@ class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkIm ...@@ -51,6 +51,7 @@ class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkIm
codec: _loadAsync(key as NetworkImage, chunkEvents, decode), codec: _loadAsync(key as NetworkImage, chunkEvents, decode),
chunkEvents: chunkEvents.stream, chunkEvents: chunkEvents.stream,
scale: key.scale, scale: key.scale,
debugLabel: key.url,
informationCollector: () { informationCollector: () {
return <DiagnosticsNode>[ return <DiagnosticsNode>[
DiagnosticsProperty<image_provider.ImageProvider>('Image provider', this), DiagnosticsProperty<image_provider.ImageProvider>('Image provider', this),
......
...@@ -54,6 +54,7 @@ class NetworkImage ...@@ -54,6 +54,7 @@ class NetworkImage
chunkEvents: chunkEvents.stream, chunkEvents: chunkEvents.stream,
codec: _loadAsync(key as NetworkImage, decode, chunkEvents), codec: _loadAsync(key as NetworkImage, decode, chunkEvents),
scale: key.scale, scale: key.scale,
debugLabel: key.url,
informationCollector: _imageStreamInformationCollector(key)); informationCollector: _imageStreamInformationCollector(key));
} }
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
// @dart = 2.8 // @dart = 2.8
import 'dart:io'; import 'dart:io';
import 'dart:ui' show Size, hashValues;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
...@@ -31,6 +32,101 @@ typedef HttpClientProvider = HttpClient Function(); ...@@ -31,6 +32,101 @@ typedef HttpClientProvider = HttpClient Function();
/// This value is ignored in non-debug builds. /// This value is ignored in non-debug builds.
HttpClientProvider debugNetworkImageHttpClientProvider; HttpClientProvider debugNetworkImageHttpClientProvider;
typedef PaintImageCallback = void Function(ImageSizeInfo);
/// Tracks the bytes used by a [ui.Image] compared to the bytes needed to paint
/// that image without scaling it.
@immutable
class ImageSizeInfo {
/// Creates an object to track the backing size of a [ui.Image] compared to
/// its display size on a [Canvas].
///
/// This class is used by the framework when it paints an image to a canvas
/// to report to `dart:developer`'s [postEvent], as well as to the
/// [debugOnPaintImage] callback if it is set.
const ImageSizeInfo({this.source, this.displaySize, this.imageSize});
/// A unique identifier for this image, for example its asset path or network
/// URL.
final String source;
/// The size of the area the image will be rendered in.
final Size displaySize;
/// The size the image has been decoded to.
final Size imageSize;
/// The number of bytes needed to render the image without scaling it.
int get displaySizeInBytes => _sizeToBytes(displaySize);
/// The number of bytes used by the image in memory.
int get decodedSizeInBytes => _sizeToBytes(imageSize);
int _sizeToBytes(Size size) {
// Assume 4 bytes per pixel and that mipmapping will be used, which adds
// 4/3.
return (size.width * size.height * 4 * (4/3)).toInt();
}
/// Returns a JSON encodable representation of this object.
Map<String, Object> toJson() {
return <String, Object>{
'source': source,
'displaySize': <String, double>{
'width': displaySize.width,
'height': displaySize.height,
},
'imageSize': <String, double>{
'width': imageSize.width,
'height': imageSize.height,
},
'displaySizeInBytes': displaySizeInBytes,
'decodedSizeInBytes': decodedSizeInBytes,
};
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) {
return false;
}
return other is ImageSizeInfo
&& other.source == source
&& other.imageSize == imageSize
&& other.displaySize == displaySize;
}
@override
int get hashCode => hashValues(source, displaySize, imageSize);
@override
String toString() => 'ImageSizeInfo($source, imageSize: $imageSize, displaySize: $displaySize)';
}
/// If not null, called when the framework is about to paint an [Image] to a
/// [Canvas] with an [ImageSizeInfo] that contains the decoded size of the
/// image as well as its output size.
///
/// A test can use this callback to detect if images under test are being
/// rendered with the appropriate cache dimensions.
///
/// For example, if a 100x100 image is decoded it takes roughly 53kb in memory
/// (including mipmapping overhead). If it is only ever displayed at 50x50, it
/// would take only 13kb if the cacheHeight/cacheWidth parameters had been
/// specified at that size. This problem becomes more serious for larger
/// images, such as a high resolution image from a 12MP camera, which would be
/// 64mb when decoded.
///
/// When using this callback, developers should consider whether the image will
/// be panned or scaled up in the application, how many images are being
/// displayed, and whether the application will run on multiple devices with
/// different resolutions and memory capacities. For example, it should be fine
/// to have an image that animates from thumbnail size to full screen be at
/// a higher resolution while animating, but it would be problematic to have
/// a grid or list of such thumbnails all be at the full resolution at the same
/// time.
PaintImageCallback debugOnPaintImage;
/// Returns true if none of the painting library debug variables have been changed. /// Returns true if none of the painting library debug variables have been changed.
/// ///
/// This function is used by the test framework to ensure that debug variables /// This function is used by the test framework to ensure that debug variables
...@@ -45,7 +141,8 @@ HttpClientProvider debugNetworkImageHttpClientProvider; ...@@ -45,7 +141,8 @@ HttpClientProvider debugNetworkImageHttpClientProvider;
bool debugAssertAllPaintingVarsUnset(String reason, { bool debugDisableShadowsOverride = false }) { bool debugAssertAllPaintingVarsUnset(String reason, { bool debugDisableShadowsOverride = false }) {
assert(() { assert(() {
if (debugDisableShadows != debugDisableShadowsOverride || if (debugDisableShadows != debugDisableShadowsOverride ||
debugNetworkImageHttpClientProvider != null) { debugNetworkImageHttpClientProvider != null ||
debugOnPaintImage != null) {
throw FlutterError(reason); throw FlutterError(reason);
} }
return true; return true;
......
...@@ -4,14 +4,17 @@ ...@@ -4,14 +4,17 @@
// @dart = 2.8 // @dart = 2.8
import 'dart:developer' as developer;
import 'dart:ui' as ui show Image; import 'dart:ui' as ui show Image;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'alignment.dart'; import 'alignment.dart';
import 'basic_types.dart'; import 'basic_types.dart';
import 'borders.dart'; import 'borders.dart';
import 'box_fit.dart'; import 'box_fit.dart';
import 'debug.dart';
import 'image_provider.dart'; import 'image_provider.dart';
import 'image_stream.dart'; import 'image_stream.dart';
...@@ -275,6 +278,7 @@ class DecorationImagePainter { ...@@ -275,6 +278,7 @@ class DecorationImagePainter {
canvas: canvas, canvas: canvas,
rect: rect, rect: rect,
image: _image.image, image: _image.image,
debugImageLabel: _image.debugLabel,
scale: _details.scale * _image.scale, scale: _details.scale * _image.scale,
colorFilter: _details.colorFilter, colorFilter: _details.colorFilter,
fit: _details.fit, fit: _details.fit,
...@@ -317,6 +321,25 @@ class DecorationImagePainter { ...@@ -317,6 +321,25 @@ class DecorationImagePainter {
} }
} }
/// Used by [paintImage] to report image sizes drawn at the end of the frame.
Map<String, ImageSizeInfo> _pendingImageSizeInfo = <String, ImageSizeInfo>{};
/// [ImageSizeInfo]s that were reported on the last frame.
///
/// Used to prevent duplicative reports from frame to frame.
Set<ImageSizeInfo> _lastFrameImageSizeInfo = <ImageSizeInfo>{};
/// Flushes inter-frame tracking of image size information from [paintImage].
///
/// Has no effect if asserts are disabled.
@visibleForTesting
void debugFlushLastFrameImageSizeInfo() {
assert(() {
_lastFrameImageSizeInfo = <ImageSizeInfo>{};
return true;
}());
}
/// Paints an image into the given rectangle on the canvas. /// Paints an image into the given rectangle on the canvas.
/// ///
/// The arguments have the following meanings: /// The arguments have the following meanings:
...@@ -389,6 +412,7 @@ void paintImage({ ...@@ -389,6 +412,7 @@ void paintImage({
@required Canvas canvas, @required Canvas canvas,
@required Rect rect, @required Rect rect,
@required ui.Image image, @required ui.Image image,
String debugImageLabel,
double scale = 1.0, double scale = 1.0,
ColorFilter colorFilter, ColorFilter colorFilter,
BoxFit fit, BoxFit fit,
...@@ -431,6 +455,43 @@ void paintImage({ ...@@ -431,6 +455,43 @@ void paintImage({
// as we apply a nine-patch stretch. // as we apply a nine-patch stretch.
assert(sourceSize == inputSize, 'centerSlice was used with a BoxFit that does not guarantee that the image is fully visible.'); assert(sourceSize == inputSize, 'centerSlice was used with a BoxFit that does not guarantee that the image is fully visible.');
} }
// Output size is fully calculated.
if (!kReleaseMode) {
final ImageSizeInfo sizeInfo = ImageSizeInfo(
// Some ImageProvider implementations may not have given this.
source: debugImageLabel ?? '<Unknown Image(${image.width}×${image.height})>',
imageSize: Size(image.width.toDouble(), image.height.toDouble()),
displaySize: outputSize,
);
// Avoid emitting events that are the same as those emitted in the last frame.
if (!_lastFrameImageSizeInfo.contains(sizeInfo)) {
final ImageSizeInfo existingSizeInfo = _pendingImageSizeInfo[sizeInfo.source];
if (existingSizeInfo == null || existingSizeInfo.displaySizeInBytes < sizeInfo.displaySizeInBytes) {
_pendingImageSizeInfo[sizeInfo.source] = sizeInfo;
}
// _pendingImageSizeInfo.add(sizeInfo);
if (debugOnPaintImage != null) {
debugOnPaintImage(sizeInfo);
}
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
_lastFrameImageSizeInfo = _pendingImageSizeInfo.values.toSet();
if (_pendingImageSizeInfo.isEmpty) {
return;
}
developer.postEvent(
'Flutter.ImageSizesForFrame',
<Object, Object>{
for (ImageSizeInfo imageSizeInfo in _pendingImageSizeInfo.values)
imageSizeInfo.source: imageSizeInfo.toJson()
},
);
_pendingImageSizeInfo = <String, ImageSizeInfo>{};
});
}
}
if (repeat != ImageRepeat.noRepeat && destinationSize == outputSize) { if (repeat != ImageRepeat.noRepeat && destinationSize == outputSize) {
// There's no need to repeat the image because we're exactly filling the // There's no need to repeat the image because we're exactly filling the
// output rect with the image. // output rect with the image.
......
...@@ -651,6 +651,7 @@ abstract class AssetBundleImageProvider extends ImageProvider<AssetBundleImageKe ...@@ -651,6 +651,7 @@ abstract class AssetBundleImageProvider extends ImageProvider<AssetBundleImageKe
return MultiFrameImageStreamCompleter( return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode), codec: _loadAsync(key, decode),
scale: key.scale, scale: key.scale,
debugLabel: key.name,
informationCollector: collector informationCollector: collector
); );
} }
...@@ -764,7 +765,11 @@ class ResizeImage extends ImageProvider<_SizeAwareCacheKey> { ...@@ -764,7 +765,11 @@ class ResizeImage extends ImageProvider<_SizeAwareCacheKey> {
); );
return decode(bytes, cacheWidth: width, cacheHeight: height, allowUpscaling: this.allowUpscaling); return decode(bytes, cacheWidth: width, cacheHeight: height, allowUpscaling: this.allowUpscaling);
}; };
return imageProvider.load(key.providerCacheKey, decodeResize); final ImageStreamCompleter completer = imageProvider.load(key.providerCacheKey, decodeResize);
if (!kReleaseMode) {
completer.debugLabel = '${completer.debugLabel} - Resized(${key.width}×${key.height})';
}
return completer;
} }
@override @override
...@@ -860,6 +865,7 @@ class FileImage extends ImageProvider<FileImage> { ...@@ -860,6 +865,7 @@ class FileImage extends ImageProvider<FileImage> {
return MultiFrameImageStreamCompleter( return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode), codec: _loadAsync(key, decode),
scale: key.scale, scale: key.scale,
debugLabel: key.file.path,
informationCollector: () sync* { informationCollector: () sync* {
yield ErrorDescription('Path: ${file?.path}'); yield ErrorDescription('Path: ${file?.path}');
}, },
...@@ -933,6 +939,7 @@ class MemoryImage extends ImageProvider<MemoryImage> { ...@@ -933,6 +939,7 @@ class MemoryImage extends ImageProvider<MemoryImage> {
return MultiFrameImageStreamCompleter( return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode), codec: _loadAsync(key, decode),
scale: key.scale, scale: key.scale,
debugLabel: 'MemoryImage(${describeIdentity(key.bytes)})',
); );
} }
......
...@@ -20,7 +20,9 @@ class ImageInfo { ...@@ -20,7 +20,9 @@ class ImageInfo {
/// Creates an [ImageInfo] object for the given [image] and [scale]. /// Creates an [ImageInfo] object for the given [image] and [scale].
/// ///
/// Both the image and the scale must not be null. /// Both the image and the scale must not be null.
const ImageInfo({ @required this.image, this.scale = 1.0 }) ///
/// The tag may be used to identify the source of this image.
const ImageInfo({ @required this.image, this.scale = 1.0, this.debugLabel })
: assert(image != null), : assert(image != null),
assert(scale != null); assert(scale != null);
...@@ -42,11 +44,14 @@ class ImageInfo { ...@@ -42,11 +44,14 @@ class ImageInfo {
/// (e.g. in the arguments given to [Canvas.drawImage]). /// (e.g. in the arguments given to [Canvas.drawImage]).
final double scale; final double scale;
/// A string used for debugging purpopses to identify the source of this image.
final String debugLabel;
@override @override
String toString() => '$image @ ${debugFormatDouble(scale)}x'; String toString() => '${debugLabel != null ? '$debugLabel ' : ''}$image @ ${debugFormatDouble(scale)}x';
@override @override
int get hashCode => hashValues(image, scale); int get hashCode => hashValues(image, scale, debugLabel);
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
...@@ -54,7 +59,8 @@ class ImageInfo { ...@@ -54,7 +59,8 @@ class ImageInfo {
return false; return false;
return other is ImageInfo return other is ImageInfo
&& other.image == image && other.image == image
&& other.scale == scale; && other.scale == scale
&& other.debugLabel == debugLabel;
} }
} }
...@@ -331,6 +337,9 @@ abstract class ImageStreamCompleter with Diagnosticable { ...@@ -331,6 +337,9 @@ abstract class ImageStreamCompleter with Diagnosticable {
ImageInfo _currentImage; ImageInfo _currentImage;
FlutterErrorDetails _currentError; FlutterErrorDetails _currentError;
/// A string identifying the source of the underlying image.
String debugLabel;
/// Whether any listeners are currently registered. /// Whether any listeners are currently registered.
/// ///
/// Clients should not depend on this value for their behavior, because having /// Clients should not depend on this value for their behavior, because having
...@@ -623,6 +632,9 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter { ...@@ -623,6 +632,9 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter {
/// The `scale` parameter is the linear scale factor for drawing this frames /// The `scale` parameter is the linear scale factor for drawing this frames
/// of this image at their intended size. /// of this image at their intended size.
/// ///
/// The `tag` parameter is passed on to created [ImageInfo] objects to
/// help identify the source of the image.
///
/// The `chunkEvents` parameter is an optional stream of notifications about /// The `chunkEvents` parameter is an optional stream of notifications about
/// the loading progress of the image. If this stream is provided, the events /// the loading progress of the image. If this stream is provided, the events
/// produced by the stream will be delivered to registered [ImageChunkListener]s /// produced by the stream will be delivered to registered [ImageChunkListener]s
...@@ -630,11 +642,13 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter { ...@@ -630,11 +642,13 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter {
MultiFrameImageStreamCompleter({ MultiFrameImageStreamCompleter({
@required Future<ui.Codec> codec, @required Future<ui.Codec> codec,
@required double scale, @required double scale,
String debugLabel,
Stream<ImageChunkEvent> chunkEvents, Stream<ImageChunkEvent> chunkEvents,
InformationCollector informationCollector, InformationCollector informationCollector,
}) : assert(codec != null), }) : assert(codec != null),
_informationCollector = informationCollector, _informationCollector = informationCollector,
_scale = scale { _scale = scale {
this.debugLabel = debugLabel;
codec.then<void>(_handleCodecReady, onError: (dynamic error, StackTrace stack) { codec.then<void>(_handleCodecReady, onError: (dynamic error, StackTrace stack) {
reportError( reportError(
context: ErrorDescription('resolving an image codec'), context: ErrorDescription('resolving an image codec'),
...@@ -688,7 +702,7 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter { ...@@ -688,7 +702,7 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter {
if (!hasListeners) if (!hasListeners)
return; return;
if (_isFirstFrame() || _hasFrameDurationPassed(timestamp)) { if (_isFirstFrame() || _hasFrameDurationPassed(timestamp)) {
_emitFrame(ImageInfo(image: _nextFrame.image, scale: _scale)); _emitFrame(ImageInfo(image: _nextFrame.image, scale: _scale, debugLabel: debugLabel));
_shownTimestamp = timestamp; _shownTimestamp = timestamp;
_frameDuration = _nextFrame.duration; _frameDuration = _nextFrame.duration;
_nextFrame = null; _nextFrame = null;
...@@ -729,7 +743,7 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter { ...@@ -729,7 +743,7 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter {
if (_codec.frameCount == 1) { if (_codec.frameCount == 1) {
// This is not an animated image, just return it and don't schedule more // This is not an animated image, just return it and don't schedule more
// frames. // frames.
_emitFrame(ImageInfo(image: _nextFrame.image, scale: _scale)); _emitFrame(ImageInfo(image: _nextFrame.image, scale: _scale, debugLabel: debugLabel));
return; return;
} }
_scheduleAppFrame(); _scheduleAppFrame();
......
...@@ -28,6 +28,7 @@ class RenderImage extends RenderBox { ...@@ -28,6 +28,7 @@ class RenderImage extends RenderBox {
/// [alignment] will need resolving or if [matchTextDirection] is true. /// [alignment] will need resolving or if [matchTextDirection] is true.
RenderImage({ RenderImage({
ui.Image image, ui.Image image,
this.debugImageLabel,
double width, double width,
double height, double height,
double scale = 1.0, double scale = 1.0,
...@@ -94,6 +95,9 @@ class RenderImage extends RenderBox { ...@@ -94,6 +95,9 @@ class RenderImage extends RenderBox {
markNeedsLayout(); markNeedsLayout();
} }
/// A string used to identify the source of the image.
String debugImageLabel;
/// If non-null, requires the image to have this width. /// If non-null, requires the image to have this width.
/// ///
/// If null, the image will pick a size that best preserves its intrinsic /// If null, the image will pick a size that best preserves its intrinsic
...@@ -377,6 +381,7 @@ class RenderImage extends RenderBox { ...@@ -377,6 +381,7 @@ class RenderImage extends RenderBox {
canvas: context.canvas, canvas: context.canvas,
rect: offset & size, rect: offset & size,
image: _image, image: _image,
debugImageLabel: debugImageLabel,
scale: _scale, scale: _scale,
colorFilter: _colorFilter, colorFilter: _colorFilter,
fit: _fit, fit: _fit,
......
...@@ -5301,6 +5301,7 @@ class RawImage extends LeafRenderObjectWidget { ...@@ -5301,6 +5301,7 @@ class RawImage extends LeafRenderObjectWidget {
const RawImage({ const RawImage({
Key key, Key key,
this.image, this.image,
this.debugImageLabel,
this.width, this.width,
this.height, this.height,
this.scale = 1.0, this.scale = 1.0,
...@@ -5324,6 +5325,9 @@ class RawImage extends LeafRenderObjectWidget { ...@@ -5324,6 +5325,9 @@ class RawImage extends LeafRenderObjectWidget {
/// The image to display. /// The image to display.
final ui.Image image; final ui.Image image;
/// A string identifying the source of the image.
final String debugImageLabel;
/// If non-null, require the image to have this width. /// If non-null, require the image to have this width.
/// ///
/// If null, the image will pick a size that best preserves its intrinsic /// If null, the image will pick a size that best preserves its intrinsic
...@@ -5443,6 +5447,7 @@ class RawImage extends LeafRenderObjectWidget { ...@@ -5443,6 +5447,7 @@ class RawImage extends LeafRenderObjectWidget {
assert((!matchTextDirection && alignment is Alignment) || debugCheckHasDirectionality(context)); assert((!matchTextDirection && alignment is Alignment) || debugCheckHasDirectionality(context));
return RenderImage( return RenderImage(
image: image, image: image,
debugImageLabel: debugImageLabel,
width: width, width: width,
height: height, height: height,
scale: scale, scale: scale,
...@@ -5464,6 +5469,7 @@ class RawImage extends LeafRenderObjectWidget { ...@@ -5464,6 +5469,7 @@ class RawImage extends LeafRenderObjectWidget {
void updateRenderObject(BuildContext context, RenderImage renderObject) { void updateRenderObject(BuildContext context, RenderImage renderObject) {
renderObject renderObject
..image = image ..image = image
..debugImageLabel = debugImageLabel
..width = width ..width = width
..height = height ..height = height
..scale = scale ..scale = scale
......
...@@ -1205,6 +1205,7 @@ class _ImageState extends State<Image> with WidgetsBindingObserver { ...@@ -1205,6 +1205,7 @@ class _ImageState extends State<Image> with WidgetsBindingObserver {
Widget result = RawImage( Widget result = RawImage(
image: _imageInfo?.image, image: _imageInfo?.image,
debugImageLabel: _imageInfo?.debugLabel,
width: widget.width, width: widget.width,
height: widget.height, height: widget.height,
scale: _imageInfo?.scale ?? 1.0, scale: _imageInfo?.scale ?? 1.0,
......
...@@ -8,6 +8,7 @@ import 'dart:async'; ...@@ -8,6 +8,7 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:ui' show Codec, FrameInfo;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
...@@ -219,8 +220,68 @@ void main() { ...@@ -219,8 +220,68 @@ void main() {
debugNetworkImageHttpClientProvider = null; debugNetworkImageHttpClientProvider = null;
}, skip: isBrowser); // Browser does not resolve images this way. }, skip: isBrowser); // Browser does not resolve images this way.
Future<Codec> _decoder(Uint8List bytes, {int cacheWidth, int cacheHeight, bool allowUpscaling}) async {
return FakeCodec();
}
test('Network image sets tag', () async {
const String url = 'http://test.png';
const int chunkSize = 8;
final List<Uint8List> chunks = <Uint8List>[
for (int offset = 0; offset < kTransparentImage.length; offset += chunkSize)
Uint8List.fromList(kTransparentImage.skip(offset).take(chunkSize).toList()),
];
final _MockHttpClientRequest request = _MockHttpClientRequest();
final _MockHttpClientResponse response = _MockHttpClientResponse();
when(httpClient.getUrl(any)).thenAnswer((_) => Future<HttpClientRequest>.value(request));
when(request.close()).thenAnswer((_) => Future<HttpClientResponse>.value(response));
when(response.statusCode).thenReturn(HttpStatus.ok);
when(response.contentLength).thenReturn(kTransparentImage.length);
when(response.listen(
any,
onDone: anyNamed('onDone'),
onError: anyNamed('onError'),
cancelOnError: anyNamed('cancelOnError'),
)).thenAnswer((Invocation invocation) {
final void Function(List<int>) onData = invocation.positionalArguments[0] as void Function(List<int>);
final void Function(Object) onError = invocation.namedArguments[#onError] as void Function(Object);
final VoidCallback onDone = invocation.namedArguments[#onDone] as VoidCallback;
final bool cancelOnError = invocation.namedArguments[#cancelOnError] as bool;
return Stream<Uint8List>.fromIterable(chunks).listen(
onData,
onDone: onDone,
onError: onError,
cancelOnError: cancelOnError,
);
});
const NetworkImage provider = NetworkImage(url);
final MultiFrameImageStreamCompleter completer = provider.load(provider, _decoder) as MultiFrameImageStreamCompleter;
expect(completer.debugLabel, url);
});
} }
class _MockHttpClient extends Mock implements HttpClient {} class _MockHttpClient extends Mock implements HttpClient {}
class _MockHttpClientRequest extends Mock implements HttpClientRequest {} class _MockHttpClientRequest extends Mock implements HttpClientRequest {}
class _MockHttpClientResponse extends Mock implements HttpClientResponse {} class _MockHttpClientResponse extends Mock implements HttpClientResponse {}
class FakeCodec implements Codec {
@override
void dispose() {}
@override
int get frameCount => throw UnimplementedError();
@override
Future<FrameInfo> getNextFrame() {
throw UnimplementedError();
}
@override
int get repetitionCount => throw UnimplementedError();
}
...@@ -6,13 +6,17 @@ ...@@ -6,13 +6,17 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'dart:ui';
import 'package:file/memory.dart'; import 'package:file/memory.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../rendering/rendering_tester.dart'; import '../rendering/rendering_tester.dart';
import 'image_data.dart';
import 'mocks_for_image_cache.dart'; import 'mocks_for_image_cache.dart';
void main() { void main() {
...@@ -136,4 +140,70 @@ void main() { ...@@ -136,4 +140,70 @@ void main() {
expect(await error.future, isStateError); expect(await error.future, isStateError);
}); });
Future<Codec> _decoder(Uint8List bytes, {int cacheWidth, int cacheHeight, bool allowUpscaling}) async {
return FakeCodec();
}
test('File image sets tag', () async {
final MemoryFileSystem fs = MemoryFileSystem();
final File file = fs.file('/blue.png')..createSync(recursive: true)..writeAsBytesSync(kBlueRectPng);
final FileImage provider = FileImage(file);
final MultiFrameImageStreamCompleter completer = provider.load(provider, _decoder) as MultiFrameImageStreamCompleter;
expect(completer.debugLabel, file.path);
});
test('Memory image sets tag', () async {
final Uint8List bytes = Uint8List.fromList(kBlueRectPng);
final MemoryImage provider = MemoryImage(bytes);
final MultiFrameImageStreamCompleter completer = provider.load(provider, _decoder) as MultiFrameImageStreamCompleter;
expect(completer.debugLabel, 'MemoryImage(${describeIdentity(bytes)})');
});
test('Asset image sets tag', () async {
const String asset = 'images/blue.png';
final ExactAssetImage provider = ExactAssetImage(asset, bundle: _TestAssetBundle());
final AssetBundleImageKey key = await provider.obtainKey(ImageConfiguration.empty);
final MultiFrameImageStreamCompleter completer = provider.load(key, _decoder) as MultiFrameImageStreamCompleter;
expect(completer.debugLabel, asset);
});
test('Resize image sets tag', () async {
final Uint8List bytes = Uint8List.fromList(kBlueRectPng);
final ResizeImage provider = ResizeImage(MemoryImage(bytes), width: 40, height: 40);
final MultiFrameImageStreamCompleter completer = provider.load(
await provider.obtainKey(ImageConfiguration.empty),
_decoder,
) as MultiFrameImageStreamCompleter;
expect(completer.debugLabel, 'MemoryImage(${describeIdentity(bytes)}) - Resized(40×40)');
});
}
class FakeCodec implements Codec {
@override
void dispose() {}
@override
int get frameCount => throw UnimplementedError();
@override
Future<FrameInfo> getNextFrame() {
throw UnimplementedError();
}
@override
int get repetitionCount => throw UnimplementedError();
}
class _TestAssetBundle extends CachingAssetBundle {
@override
Future<ByteData> load(String key) async {
return Uint8List.fromList(kBlueRectPng).buffer.asByteData();
}
} }
...@@ -13,7 +13,7 @@ import 'package:flutter/foundation.dart'; ...@@ -13,7 +13,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
class TestImageInfo implements ImageInfo { class TestImageInfo implements ImageInfo {
const TestImageInfo(this.value, { this.image, this.scale = 1.0 }); const TestImageInfo(this.value, { this.image, this.scale = 1.0, this.debugLabel });
@override @override
final ui.Image image; final ui.Image image;
...@@ -21,6 +21,9 @@ class TestImageInfo implements ImageInfo { ...@@ -21,6 +21,9 @@ class TestImageInfo implements ImageInfo {
@override @override
final double scale; final double scale;
@override
final String debugLabel;
final int value; final int value;
@override @override
......
...@@ -8,10 +8,9 @@ import 'dart:async'; ...@@ -8,10 +8,9 @@ import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import '../flutter_test_alternative.dart';
class TestImage implements ui.Image { class TestImage implements ui.Image {
TestImage({ this.width, this.height }); TestImage({ this.width, this.height });
...@@ -40,6 +39,10 @@ class TestCanvas implements Canvas { ...@@ -40,6 +39,10 @@ class TestCanvas implements Canvas {
} }
void main() { void main() {
setUp(() {
debugFlushLastFrameImageSizeInfo();
});
test('Cover and align', () { test('Cover and align', () {
final TestImage image = TestImage(width: 300, height: 300); final TestImage image = TestImage(width: 300, height: 300);
final TestCanvas canvas = TestCanvas(); final TestCanvas canvas = TestCanvas();
...@@ -61,5 +64,114 @@ void main() { ...@@ -61,5 +64,114 @@ void main() {
expect(command.positionalArguments[2], equals(const Rect.fromLTWH(50.0, 75.0, 200.0, 100.0))); expect(command.positionalArguments[2], equals(const Rect.fromLTWH(50.0, 75.0, 200.0, 100.0)));
}); });
testWidgets('Reports Image painting', (WidgetTester tester) async {
ImageSizeInfo imageSizeInfo;
int count = 0;
debugOnPaintImage = (ImageSizeInfo info) {
count += 1;
imageSizeInfo = info;
};
final TestImage image = TestImage(width: 300, height: 300);
final TestCanvas canvas = TestCanvas();
paintImage(
canvas: canvas,
rect: const Rect.fromLTWH(50.0, 75.0, 200.0, 100.0),
image: image,
debugImageLabel: 'test.png',
);
expect(count, 1);
expect(imageSizeInfo, isNotNull);
expect(imageSizeInfo.source, 'test.png');
expect(imageSizeInfo.imageSize, const Size(300, 300));
expect(imageSizeInfo.displaySize, const Size(200, 100));
// Make sure that we don't report an identical image size info if we
// redraw in the next frame.
tester.binding.scheduleForcedFrame();
await tester.pump();
paintImage(
canvas: canvas,
rect: const Rect.fromLTWH(50.0, 75.0, 200.0, 100.0),
image: image,
debugImageLabel: 'test.png',
);
expect(count, 1);
debugOnPaintImage = null;
});
testWidgets('Reports Image painting - change per frame', (WidgetTester tester) async {
ImageSizeInfo imageSizeInfo;
int count = 0;
debugOnPaintImage = (ImageSizeInfo info) {
count += 1;
imageSizeInfo = info;
};
final TestImage image = TestImage(width: 300, height: 300);
final TestCanvas canvas = TestCanvas();
paintImage(
canvas: canvas,
rect: const Rect.fromLTWH(50.0, 75.0, 200.0, 100.0),
image: image,
debugImageLabel: 'test.png',
);
expect(count, 1);
expect(imageSizeInfo, isNotNull);
expect(imageSizeInfo.source, 'test.png');
expect(imageSizeInfo.imageSize, const Size(300, 300));
expect(imageSizeInfo.displaySize, const Size(200, 100));
// Make sure that we don't report an identical image size info if we
// redraw in the next frame.
tester.binding.scheduleForcedFrame();
await tester.pump();
paintImage(
canvas: canvas,
rect: const Rect.fromLTWH(50.0, 75.0, 200.0, 150.0),
image: image,
debugImageLabel: 'test.png',
);
expect(count, 2);
expect(imageSizeInfo, isNotNull);
expect(imageSizeInfo.source, 'test.png');
expect(imageSizeInfo.imageSize, const Size(300, 300));
expect(imageSizeInfo.displaySize, const Size(200, 150));
debugOnPaintImage = null;
});
testWidgets('Reports Image painting - no debug label', (WidgetTester tester) async {
ImageSizeInfo imageSizeInfo;
int count = 0;
debugOnPaintImage = (ImageSizeInfo info) {
count += 1;
imageSizeInfo = info;
};
final TestImage image = TestImage(width: 300, height: 200);
final TestCanvas canvas = TestCanvas();
paintImage(
canvas: canvas,
rect: const Rect.fromLTWH(50.0, 75.0, 200.0, 100.0),
image: image,
);
expect(count, 1);
expect(imageSizeInfo, isNotNull);
expect(imageSizeInfo.source, '<Unknown Image(300×200)>');
expect(imageSizeInfo.imageSize, const Size(300, 200));
expect(imageSizeInfo.displaySize, const Size(200, 100));
debugOnPaintImage = null;
});
// See also the DecorationImage tests in: decoration_test.dart // See also the DecorationImage tests in: decoration_test.dart
} }
...@@ -1731,6 +1731,47 @@ void main() { ...@@ -1731,6 +1731,47 @@ void main() {
// See https://github.com/flutter/flutter/issues/54292. // See https://github.com/flutter/flutter/issues/54292.
skip: kIsWeb, skip: kIsWeb,
); );
testWidgets('Reports image size when painted', (WidgetTester tester) async {
ImageSizeInfo imageSizeInfo;
int count = 0;
debugOnPaintImage = (ImageSizeInfo info) {
count += 1;
imageSizeInfo = info;
};
final ui.Image image = await tester.runAsync(() => createTestImage(kBlueRectPng));
final TestImageStreamCompleter streamCompleter = TestImageStreamCompleter(
ImageInfo(
image: image,
scale: 1.0,
debugLabel: 'test.png',
),
);
final TestImageProvider imageProvider = TestImageProvider(streamCompleter: streamCompleter);
await tester.pumpWidget(
Center(
child: SizedBox(
height: 50,
width: 50,
child: Image(image: imageProvider),
),
),
);
expect(count, 1);
expect(
imageSizeInfo,
const ImageSizeInfo(
source: 'test.png',
imageSize: Size(100, 100),
displaySize: Size(50, 50),
),
);
debugOnPaintImage = null;
});
} }
class ImagePainter extends CustomPainter { class ImagePainter extends CustomPainter {
......
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