diff --git a/dev/tracing_tests/test/image_painting_event_test.dart b/dev/tracing_tests/test/image_painting_event_test.dart new file mode 100644 index 00000000000..ccc05fd181a --- /dev/null +++ b/dev/tracing_tests/test/image_painting_event_test.dart @@ -0,0 +1,124 @@ +// 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 completer = Completer(); + 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 completer = Completer(); + 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 toByteData( + {ui.ImageByteFormat format = ui.ImageByteFormat.rawRgba}) { + throw UnimplementedError(); + } +} + +class TestCanvas implements Canvas { + @override + void noSuchMethod(Invocation invocation) {} +} diff --git a/packages/flutter/lib/src/painting/_network_image_io.dart b/packages/flutter/lib/src/painting/_network_image_io.dart index b33684480bd..8cb99c82777 100644 --- a/packages/flutter/lib/src/painting/_network_image_io.dart +++ b/packages/flutter/lib/src/painting/_network_image_io.dart @@ -51,6 +51,7 @@ class NetworkImage extends image_provider.ImageProvider[ DiagnosticsProperty('Image provider', this), diff --git a/packages/flutter/lib/src/painting/_network_image_web.dart b/packages/flutter/lib/src/painting/_network_image_web.dart index 7a2845c44c3..7e55e7e7385 100644 --- a/packages/flutter/lib/src/painting/_network_image_web.dart +++ b/packages/flutter/lib/src/painting/_network_image_web.dart @@ -54,6 +54,7 @@ class NetworkImage chunkEvents: chunkEvents.stream, codec: _loadAsync(key as NetworkImage, decode, chunkEvents), scale: key.scale, + debugLabel: key.url, informationCollector: _imageStreamInformationCollector(key)); } diff --git a/packages/flutter/lib/src/painting/debug.dart b/packages/flutter/lib/src/painting/debug.dart index cd00303b9df..de0ff3d0314 100644 --- a/packages/flutter/lib/src/painting/debug.dart +++ b/packages/flutter/lib/src/painting/debug.dart @@ -5,6 +5,7 @@ // @dart = 2.8 import 'dart:io'; +import 'dart:ui' show Size, hashValues; import 'package:flutter/foundation.dart'; @@ -31,6 +32,101 @@ typedef HttpClientProvider = HttpClient Function(); /// This value is ignored in non-debug builds. 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 toJson() { + return { + 'source': source, + 'displaySize': { + 'width': displaySize.width, + 'height': displaySize.height, + }, + 'imageSize': { + '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. /// /// This function is used by the test framework to ensure that debug variables @@ -45,7 +141,8 @@ HttpClientProvider debugNetworkImageHttpClientProvider; bool debugAssertAllPaintingVarsUnset(String reason, { bool debugDisableShadowsOverride = false }) { assert(() { if (debugDisableShadows != debugDisableShadowsOverride || - debugNetworkImageHttpClientProvider != null) { + debugNetworkImageHttpClientProvider != null || + debugOnPaintImage != null) { throw FlutterError(reason); } return true; diff --git a/packages/flutter/lib/src/painting/decoration_image.dart b/packages/flutter/lib/src/painting/decoration_image.dart index 8f923578d32..11071992756 100644 --- a/packages/flutter/lib/src/painting/decoration_image.dart +++ b/packages/flutter/lib/src/painting/decoration_image.dart @@ -4,14 +4,17 @@ // @dart = 2.8 +import 'dart:developer' as developer; import 'dart:ui' as ui show Image; import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; import 'alignment.dart'; import 'basic_types.dart'; import 'borders.dart'; import 'box_fit.dart'; +import 'debug.dart'; import 'image_provider.dart'; import 'image_stream.dart'; @@ -275,6 +278,7 @@ class DecorationImagePainter { canvas: canvas, rect: rect, image: _image.image, + debugImageLabel: _image.debugLabel, scale: _details.scale * _image.scale, colorFilter: _details.colorFilter, fit: _details.fit, @@ -317,6 +321,25 @@ class DecorationImagePainter { } } +/// Used by [paintImage] to report image sizes drawn at the end of the frame. +Map _pendingImageSizeInfo = {}; + +/// [ImageSizeInfo]s that were reported on the last frame. +/// +/// Used to prevent duplicative reports from frame to frame. +Set _lastFrameImageSizeInfo = {}; + +/// Flushes inter-frame tracking of image size information from [paintImage]. +/// +/// Has no effect if asserts are disabled. +@visibleForTesting +void debugFlushLastFrameImageSizeInfo() { + assert(() { + _lastFrameImageSizeInfo = {}; + return true; + }()); +} + /// Paints an image into the given rectangle on the canvas. /// /// The arguments have the following meanings: @@ -389,6 +412,7 @@ void paintImage({ @required Canvas canvas, @required Rect rect, @required ui.Image image, + String debugImageLabel, double scale = 1.0, ColorFilter colorFilter, BoxFit fit, @@ -431,6 +455,43 @@ void paintImage({ // 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.'); } + + // Output size is fully calculated. + if (!kReleaseMode) { + final ImageSizeInfo sizeInfo = ImageSizeInfo( + // Some ImageProvider implementations may not have given this. + source: debugImageLabel ?? '', + 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', + { + for (ImageSizeInfo imageSizeInfo in _pendingImageSizeInfo.values) + imageSizeInfo.source: imageSizeInfo.toJson() + }, + ); + _pendingImageSizeInfo = {}; + }); + + } + } + if (repeat != ImageRepeat.noRepeat && destinationSize == outputSize) { // There's no need to repeat the image because we're exactly filling the // output rect with the image. diff --git a/packages/flutter/lib/src/painting/image_provider.dart b/packages/flutter/lib/src/painting/image_provider.dart index 0d7646f5fdd..9da69a8b0b2 100644 --- a/packages/flutter/lib/src/painting/image_provider.dart +++ b/packages/flutter/lib/src/painting/image_provider.dart @@ -651,6 +651,7 @@ abstract class AssetBundleImageProvider extends ImageProvider { ); 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 @@ -860,6 +865,7 @@ class FileImage extends ImageProvider { return MultiFrameImageStreamCompleter( codec: _loadAsync(key, decode), scale: key.scale, + debugLabel: key.file.path, informationCollector: () sync* { yield ErrorDescription('Path: ${file?.path}'); }, @@ -933,6 +939,7 @@ class MemoryImage extends ImageProvider { return MultiFrameImageStreamCompleter( codec: _loadAsync(key, decode), scale: key.scale, + debugLabel: 'MemoryImage(${describeIdentity(key.bytes)})', ); } diff --git a/packages/flutter/lib/src/painting/image_stream.dart b/packages/flutter/lib/src/painting/image_stream.dart index abf9b2941ce..92940626d6f 100644 --- a/packages/flutter/lib/src/painting/image_stream.dart +++ b/packages/flutter/lib/src/painting/image_stream.dart @@ -20,7 +20,9 @@ class ImageInfo { /// Creates an [ImageInfo] object for the given [image] and [scale]. /// /// 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(scale != null); @@ -42,11 +44,14 @@ class ImageInfo { /// (e.g. in the arguments given to [Canvas.drawImage]). final double scale; - @override - String toString() => '$image @ ${debugFormatDouble(scale)}x'; + /// A string used for debugging purpopses to identify the source of this image. + final String debugLabel; @override - int get hashCode => hashValues(image, scale); + String toString() => '${debugLabel != null ? '$debugLabel ' : ''}$image @ ${debugFormatDouble(scale)}x'; + + @override + int get hashCode => hashValues(image, scale, debugLabel); @override bool operator ==(Object other) { @@ -54,7 +59,8 @@ class ImageInfo { return false; return other is ImageInfo && other.image == image - && other.scale == scale; + && other.scale == scale + && other.debugLabel == debugLabel; } } @@ -331,6 +337,9 @@ abstract class ImageStreamCompleter with Diagnosticable { ImageInfo _currentImage; FlutterErrorDetails _currentError; + /// A string identifying the source of the underlying image. + String debugLabel; + /// Whether any listeners are currently registered. /// /// Clients should not depend on this value for their behavior, because having @@ -623,6 +632,9 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter { /// The `scale` parameter is the linear scale factor for drawing this frames /// 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 loading progress of the image. If this stream is provided, the events /// produced by the stream will be delivered to registered [ImageChunkListener]s @@ -630,11 +642,13 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter { MultiFrameImageStreamCompleter({ @required Future codec, @required double scale, + String debugLabel, Stream chunkEvents, InformationCollector informationCollector, }) : assert(codec != null), _informationCollector = informationCollector, _scale = scale { + this.debugLabel = debugLabel; codec.then(_handleCodecReady, onError: (dynamic error, StackTrace stack) { reportError( context: ErrorDescription('resolving an image codec'), @@ -688,7 +702,7 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter { if (!hasListeners) return; if (_isFirstFrame() || _hasFrameDurationPassed(timestamp)) { - _emitFrame(ImageInfo(image: _nextFrame.image, scale: _scale)); + _emitFrame(ImageInfo(image: _nextFrame.image, scale: _scale, debugLabel: debugLabel)); _shownTimestamp = timestamp; _frameDuration = _nextFrame.duration; _nextFrame = null; @@ -729,7 +743,7 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter { if (_codec.frameCount == 1) { // This is not an animated image, just return it and don't schedule more // frames. - _emitFrame(ImageInfo(image: _nextFrame.image, scale: _scale)); + _emitFrame(ImageInfo(image: _nextFrame.image, scale: _scale, debugLabel: debugLabel)); return; } _scheduleAppFrame(); diff --git a/packages/flutter/lib/src/rendering/image.dart b/packages/flutter/lib/src/rendering/image.dart index bc354c38d70..1f365199f1c 100644 --- a/packages/flutter/lib/src/rendering/image.dart +++ b/packages/flutter/lib/src/rendering/image.dart @@ -28,6 +28,7 @@ class RenderImage extends RenderBox { /// [alignment] will need resolving or if [matchTextDirection] is true. RenderImage({ ui.Image image, + this.debugImageLabel, double width, double height, double scale = 1.0, @@ -94,6 +95,9 @@ class RenderImage extends RenderBox { markNeedsLayout(); } + /// A string used to identify the source of the image. + String debugImageLabel; + /// If non-null, requires the image to have this width. /// /// If null, the image will pick a size that best preserves its intrinsic @@ -377,6 +381,7 @@ class RenderImage extends RenderBox { canvas: context.canvas, rect: offset & size, image: _image, + debugImageLabel: debugImageLabel, scale: _scale, colorFilter: _colorFilter, fit: _fit, diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index ebdc43eb851..bbdfe2d3210 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -5301,6 +5301,7 @@ class RawImage extends LeafRenderObjectWidget { const RawImage({ Key key, this.image, + this.debugImageLabel, this.width, this.height, this.scale = 1.0, @@ -5324,6 +5325,9 @@ class RawImage extends LeafRenderObjectWidget { /// The image to display. 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 null, the image will pick a size that best preserves its intrinsic @@ -5443,6 +5447,7 @@ class RawImage extends LeafRenderObjectWidget { assert((!matchTextDirection && alignment is Alignment) || debugCheckHasDirectionality(context)); return RenderImage( image: image, + debugImageLabel: debugImageLabel, width: width, height: height, scale: scale, @@ -5464,6 +5469,7 @@ class RawImage extends LeafRenderObjectWidget { void updateRenderObject(BuildContext context, RenderImage renderObject) { renderObject ..image = image + ..debugImageLabel = debugImageLabel ..width = width ..height = height ..scale = scale diff --git a/packages/flutter/lib/src/widgets/image.dart b/packages/flutter/lib/src/widgets/image.dart index 6efaa746f85..9712a5d270f 100644 --- a/packages/flutter/lib/src/widgets/image.dart +++ b/packages/flutter/lib/src/widgets/image.dart @@ -1205,6 +1205,7 @@ class _ImageState extends State with WidgetsBindingObserver { Widget result = RawImage( image: _imageInfo?.image, + debugImageLabel: _imageInfo?.debugLabel, width: widget.width, height: widget.height, scale: _imageInfo?.scale ?? 1.0, diff --git a/packages/flutter/test/painting/image_provider_network_image_test.dart b/packages/flutter/test/painting/image_provider_network_image_test.dart index 1b978d8d982..3a877cad982 100644 --- a/packages/flutter/test/painting/image_provider_network_image_test.dart +++ b/packages/flutter/test/painting/image_provider_network_image_test.dart @@ -8,6 +8,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:math' as math; import 'dart:typed_data'; +import 'dart:ui' show Codec, FrameInfo; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; @@ -219,8 +220,68 @@ void main() { debugNetworkImageHttpClientProvider = null; }, skip: isBrowser); // Browser does not resolve images this way. + + Future _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 chunks = [ + 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.value(request)); + when(request.close()).thenAnswer((_) => Future.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) onData = invocation.positionalArguments[0] as void Function(List); + 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.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 _MockHttpClientRequest extends Mock implements HttpClientRequest {} class _MockHttpClientResponse extends Mock implements HttpClientResponse {} + +class FakeCodec implements Codec { + @override + void dispose() {} + + @override + int get frameCount => throw UnimplementedError(); + + @override + Future getNextFrame() { + throw UnimplementedError(); + } + + @override + int get repetitionCount => throw UnimplementedError(); +} diff --git a/packages/flutter/test/painting/image_provider_test.dart b/packages/flutter/test/painting/image_provider_test.dart index 4e2fce5edad..ff64d63dd60 100644 --- a/packages/flutter/test/painting/image_provider_test.dart +++ b/packages/flutter/test/painting/image_provider_test.dart @@ -6,13 +6,17 @@ import 'dart:async'; import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui'; import 'package:file/memory.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import '../rendering/rendering_tester.dart'; +import 'image_data.dart'; import 'mocks_for_image_cache.dart'; void main() { @@ -136,4 +140,70 @@ void main() { expect(await error.future, isStateError); }); + + Future _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 getNextFrame() { + throw UnimplementedError(); + } + + @override + int get repetitionCount => throw UnimplementedError(); +} + +class _TestAssetBundle extends CachingAssetBundle { + @override + Future load(String key) async { + return Uint8List.fromList(kBlueRectPng).buffer.asByteData(); + } } diff --git a/packages/flutter/test/painting/mocks_for_image_cache.dart b/packages/flutter/test/painting/mocks_for_image_cache.dart index 8e588f7bde9..063bea282c4 100644 --- a/packages/flutter/test/painting/mocks_for_image_cache.dart +++ b/packages/flutter/test/painting/mocks_for_image_cache.dart @@ -13,7 +13,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; 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 final ui.Image image; @@ -21,6 +21,9 @@ class TestImageInfo implements ImageInfo { @override final double scale; + @override + final String debugLabel; + final int value; @override diff --git a/packages/flutter/test/painting/paint_image_test.dart b/packages/flutter/test/painting/paint_image_test.dart index e0e84463bd8..fbec016f84b 100644 --- a/packages/flutter/test/painting/paint_image_test.dart +++ b/packages/flutter/test/painting/paint_image_test.dart @@ -8,10 +8,9 @@ import 'dart:async'; import 'dart:typed_data'; import 'dart:ui' as ui; +import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/painting.dart'; -import '../flutter_test_alternative.dart'; - class TestImage implements ui.Image { TestImage({ this.width, this.height }); @@ -40,6 +39,10 @@ class TestCanvas implements Canvas { } void main() { + setUp(() { + debugFlushLastFrameImageSizeInfo(); + }); + test('Cover and align', () { final TestImage image = TestImage(width: 300, height: 300); final TestCanvas canvas = TestCanvas(); @@ -61,5 +64,114 @@ void main() { 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, ''); + 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 } diff --git a/packages/flutter/test/widgets/image_test.dart b/packages/flutter/test/widgets/image_test.dart index 472181cd674..b3266251a6d 100644 --- a/packages/flutter/test/widgets/image_test.dart +++ b/packages/flutter/test/widgets/image_test.dart @@ -1731,6 +1731,47 @@ void main() { // See https://github.com/flutter/flutter/issues/54292. 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 {