Unverified Commit 2cdec258 authored by Dan Field's avatar Dan Field Committed by GitHub

Reland dispose images when done (#67100) (#67177)

* Reland dispose images when done (#67100)

Changes since last time:

- Test for CanvasKit image rendering
  (https://github.com/flutter/flutter/pull/67176)
- Fix CanvasKit dispose impl
  (https://github.com/flutter/engine/pull/21555)
- Update internal google3 customer with a problematic ImageStream
  Listener impl (cl/335091311, cl/335459002)

This reverts commit 473358d9.
parent ece2f98e
...@@ -295,6 +295,11 @@ class DecorationImagePainter { ...@@ -295,6 +295,11 @@ class DecorationImagePainter {
void _handleImage(ImageInfo value, bool synchronousCall) { void _handleImage(ImageInfo value, bool synchronousCall) {
if (_image == value) if (_image == value)
return; return;
if (_image != null && _image!.isCloneOf(value)) {
value.dispose();
return;
}
_image?.dispose();
_image = value; _image = value;
assert(_onChanged != null); assert(_onChanged != null);
if (!synchronousCall) if (!synchronousCall)
...@@ -312,6 +317,8 @@ class DecorationImagePainter { ...@@ -312,6 +317,8 @@ class DecorationImagePainter {
_handleImage, _handleImage,
onError: _details.onError, onError: _details.onError,
)); ));
_image?.dispose();
_image = null;
} }
@override @override
...@@ -429,6 +436,12 @@ void paintImage({ ...@@ -429,6 +436,12 @@ void paintImage({
assert(repeat != null); assert(repeat != null);
assert(flipHorizontally != null); assert(flipHorizontally != null);
assert(isAntiAlias != null); assert(isAntiAlias != null);
assert(
image.debugGetOpenHandleStackTraces()?.isNotEmpty ?? true,
'Cannot paint an image that is disposed.\n'
'The caller of paintImage is expected to wait to dispose the image until '
'after painting has completed.'
);
if (rect.isEmpty) if (rect.isEmpty)
return; return;
Size outputSize = rect.size; Size outputSize = rect.size;
......
...@@ -6,6 +6,7 @@ import 'dart:developer'; ...@@ -6,6 +6,7 @@ import 'dart:developer';
import 'dart:ui' show hashValues; import 'dart:ui' show hashValues;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'image_stream.dart'; import 'image_stream.dart';
...@@ -238,7 +239,7 @@ class ImageCache { ...@@ -238,7 +239,7 @@ class ImageCache {
// In such a case, we need to make sure subsequent calls to // In such a case, we need to make sure subsequent calls to
// putIfAbsent don't return this image that may never complete. // putIfAbsent don't return this image that may never complete.
final _LiveImage? image = _liveImages.remove(key); final _LiveImage? image = _liveImages.remove(key);
image?.removeListener(); image?.dispose();
} }
final _PendingImage? pendingImage = _pendingImages.remove(key); final _PendingImage? pendingImage = _pendingImages.remove(key);
if (pendingImage != null) { if (pendingImage != null) {
...@@ -259,6 +260,7 @@ class ImageCache { ...@@ -259,6 +260,7 @@ class ImageCache {
}); });
} }
_currentSizeBytes -= image.sizeBytes!; _currentSizeBytes -= image.sizeBytes!;
image.dispose();
return true; return true;
} }
if (!kReleaseMode) { if (!kReleaseMode) {
...@@ -276,23 +278,30 @@ class ImageCache { ...@@ -276,23 +278,30 @@ class ImageCache {
/// [maximumSize] and [maximumSizeBytes]. /// [maximumSize] and [maximumSizeBytes].
void _touch(Object key, _CachedImage image, TimelineTask? timelineTask) { void _touch(Object key, _CachedImage image, TimelineTask? timelineTask) {
assert(timelineTask != null); assert(timelineTask != null);
if (image.sizeBytes != null && image.sizeBytes! <= maximumSizeBytes) { if (image.sizeBytes != null && image.sizeBytes! <= maximumSizeBytes && maximumSize > 0) {
_currentSizeBytes += image.sizeBytes!; _currentSizeBytes += image.sizeBytes!;
_cache[key] = image; _cache[key] = image;
_checkCacheSize(timelineTask); _checkCacheSize(timelineTask);
} else {
image.dispose();
} }
} }
void _trackLiveImage(Object key, _LiveImage image) { void _trackLiveImage(Object key, ImageStreamCompleter completer, int? sizeBytes) {
// Avoid adding unnecessary callbacks to the completer. // Avoid adding unnecessary callbacks to the completer.
_liveImages.putIfAbsent(key, () { _liveImages.putIfAbsent(key, () {
// Even if no callers to ImageProvider.resolve have listened to the stream, // Even if no callers to ImageProvider.resolve have listened to the stream,
// the cache is listening to the stream and will remove itself once the // the cache is listening to the stream and will remove itself once the
// image completes to move it from pending to keepAlive. // image completes to move it from pending to keepAlive.
// Even if the cache size is 0, we still add this listener. // Even if the cache size is 0, we still add this tracker, which will add
image.completer.addOnLastListenerRemovedCallback(image.handleRemove); // a keep alive handle to the stream.
return image; return _LiveImage(
}).sizeBytes ??= image.sizeBytes; completer,
() {
_liveImages.remove(key);
},
);
}).sizeBytes ??= sizeBytes;
} }
/// Returns the previously cached [ImageStream] for the given key, if available; /// Returns the previously cached [ImageStream] for the given key, if available;
...@@ -337,14 +346,25 @@ class ImageCache { ...@@ -337,14 +346,25 @@ class ImageCache {
} }
// The image might have been keptAlive but had no listeners (so not live). // The image might have been keptAlive but had no listeners (so not live).
// Make sure the cache starts tracking it as live again. // Make sure the cache starts tracking it as live again.
_trackLiveImage(key, _LiveImage(image.completer, image.sizeBytes, () => _liveImages.remove(key))); _trackLiveImage(
key,
image.completer,
image.sizeBytes,
);
_cache[key] = image; _cache[key] = image;
return image.completer; return image.completer;
} }
final _CachedImage? liveImage = _liveImages[key]; final _LiveImage? liveImage = _liveImages[key];
if (liveImage != null) { if (liveImage != null) {
_touch(key, liveImage, timelineTask); _touch(
key,
_CachedImage(
liveImage.completer,
sizeBytes: liveImage.sizeBytes,
),
timelineTask,
);
if (!kReleaseMode) { if (!kReleaseMode) {
timelineTask!.finish(arguments: <String, dynamic>{'result': 'keepAlive'}); timelineTask!.finish(arguments: <String, dynamic>{'result': 'keepAlive'});
} }
...@@ -353,7 +373,7 @@ class ImageCache { ...@@ -353,7 +373,7 @@ class ImageCache {
try { try {
result = loader(); result = loader();
_trackLiveImage(key, _LiveImage(result, null, () => _liveImages.remove(key))); _trackLiveImage(key, result, null);
} catch (error, stackTrace) { } catch (error, stackTrace) {
if (!kReleaseMode) { if (!kReleaseMode) {
timelineTask!.finish(arguments: <String, dynamic>{ timelineTask!.finish(arguments: <String, dynamic>{
...@@ -384,33 +404,33 @@ class ImageCache { ...@@ -384,33 +404,33 @@ class ImageCache {
// If the cache is disabled, this variable will be set. // If the cache is disabled, this variable will be set.
_PendingImage? untrackedPendingImage; _PendingImage? untrackedPendingImage;
void listener(ImageInfo? info, bool syncCall) { void listener(ImageInfo? info, bool syncCall) {
// Images that fail to load don't contribute to cache size. int? sizeBytes;
final int imageSize = info == null || info.image == null ? 0 : info.image.height * info.image.width * 4; if (info != null) {
sizeBytes = info.image.height * info.image.width * 4;
final _CachedImage image = _CachedImage(result!, imageSize); info.dispose();
}
_trackLiveImage( final _CachedImage image = _CachedImage(
key, result!,
_LiveImage( sizeBytes: sizeBytes,
result,
imageSize,
() => _liveImages.remove(key),
),
); );
final _PendingImage? pendingImage = untrackedPendingImage ?? _pendingImages.remove(key); _trackLiveImage(key, result, sizeBytes);
if (pendingImage != null) {
pendingImage.removeListener();
}
// Only touch if the cache was enabled when resolve was initially called. // Only touch if the cache was enabled when resolve was initially called.
if (untrackedPendingImage == null) { if (untrackedPendingImage == null) {
_touch(key, image, listenerTask); _touch(key, image, listenerTask);
} else {
image.dispose();
} }
final _PendingImage? pendingImage = untrackedPendingImage ?? _pendingImages.remove(key);
if (pendingImage != null) {
pendingImage.removeListener();
}
if (!kReleaseMode && !listenedOnce) { if (!kReleaseMode && !listenedOnce) {
listenerTask!.finish(arguments: <String, dynamic>{ listenerTask!.finish(arguments: <String, dynamic>{
'syncCall': syncCall, 'syncCall': syncCall,
'sizeInBytes': imageSize, 'sizeInBytes': sizeBytes,
}); });
timelineTask!.finish(arguments: <String, dynamic>{ timelineTask!.finish(arguments: <String, dynamic>{
'currentSizeBytes': currentSizeBytes, 'currentSizeBytes': currentSizeBytes,
...@@ -469,7 +489,7 @@ class ImageCache { ...@@ -469,7 +489,7 @@ class ImageCache {
/// that are also being held by at least one other object. /// that are also being held by at least one other object.
void clearLiveImages() { void clearLiveImages() {
for (final _LiveImage image in _liveImages.values) { for (final _LiveImage image in _liveImages.values) {
image.removeListener(); image.dispose();
} }
_liveImages.clear(); _liveImages.clear();
} }
...@@ -489,6 +509,7 @@ class ImageCache { ...@@ -489,6 +509,7 @@ class ImageCache {
final Object key = _cache.keys.first; final Object key = _cache.keys.first;
final _CachedImage image = _cache[key]!; final _CachedImage image = _cache[key]!;
_currentSizeBytes -= image.sizeBytes!; _currentSizeBytes -= image.sizeBytes!;
image.dispose();
_cache.remove(key); _cache.remove(key);
if (!kReleaseMode) { if (!kReleaseMode) {
finishArgs['evictedKeys'].add(key.toString()); finishArgs['evictedKeys'].add(key.toString());
...@@ -576,22 +597,59 @@ class ImageCacheStatus { ...@@ -576,22 +597,59 @@ class ImageCacheStatus {
String toString() => '${objectRuntimeType(this, 'ImageCacheStatus')}(pending: $pending, live: $live, keepAlive: $keepAlive)'; String toString() => '${objectRuntimeType(this, 'ImageCacheStatus')}(pending: $pending, live: $live, keepAlive: $keepAlive)';
} }
class _CachedImage { /// Base class for [_CachedImage] and [_LiveImage].
_CachedImage(this.completer, this.sizeBytes); ///
/// Exists primarily so that a [_LiveImage] cannot be added to the
/// [ImageCache._cache].
abstract class _CachedImageBase {
_CachedImageBase(
this.completer, {
this.sizeBytes,
}) : assert(completer != null),
handle = completer.keepAlive();
final ImageStreamCompleter completer; final ImageStreamCompleter completer;
int? sizeBytes; int? sizeBytes;
ImageStreamCompleterHandle? handle;
@mustCallSuper
void dispose() {
assert(handle != null);
// Give any interested parties a chance to listen to the stream before we
// potentially dispose it.
SchedulerBinding.instance!.addPostFrameCallback((Duration timeStamp) {
assert(handle != null);
handle?.dispose();
handle = null;
});
}
} }
class _LiveImage extends _CachedImage { class _CachedImage extends _CachedImageBase {
_LiveImage(ImageStreamCompleter completer, int? sizeBytes, this.handleRemove) _CachedImage(ImageStreamCompleter completer, {int? sizeBytes})
: super(completer, sizeBytes); : super(completer, sizeBytes: sizeBytes);
}
final VoidCallback handleRemove; class _LiveImage extends _CachedImageBase {
_LiveImage(ImageStreamCompleter completer, VoidCallback handleRemove, {int? sizeBytes})
: super(completer, sizeBytes: sizeBytes) {
_handleRemove = () {
handleRemove();
dispose();
};
completer.addOnLastListenerRemovedCallback(_handleRemove);
}
void removeListener() { late VoidCallback _handleRemove;
completer.removeOnLastListenerRemovedCallback(handleRemove);
@override
void dispose() {
completer.removeOnLastListenerRemovedCallback(_handleRemove);
super.dispose();
} }
@override
String toString() => describeIdentity(this);
} }
class _PendingImage { class _PendingImage {
......
...@@ -284,6 +284,7 @@ typedef DecoderCallback = Future<ui.Codec> Function(Uint8List bytes, {int? cache ...@@ -284,6 +284,7 @@ typedef DecoderCallback = Future<ui.Codec> Function(Uint8List bytes, {int? cache
/// void _updateImage(ImageInfo imageInfo, bool synchronousCall) { /// void _updateImage(ImageInfo imageInfo, bool synchronousCall) {
/// setState(() { /// setState(() {
/// // Trigger a build whenever the image changes. /// // Trigger a build whenever the image changes.
/// _imageInfo?.dispose();
/// _imageInfo = imageInfo; /// _imageInfo = imageInfo;
/// }); /// });
/// } /// }
...@@ -291,6 +292,8 @@ typedef DecoderCallback = Future<ui.Codec> Function(Uint8List bytes, {int? cache ...@@ -291,6 +292,8 @@ typedef DecoderCallback = Future<ui.Codec> Function(Uint8List bytes, {int? cache
/// @override /// @override
/// void dispose() { /// void dispose() {
/// _imageStream.removeListener(ImageStreamListener(_updateImage)); /// _imageStream.removeListener(ImageStreamListener(_updateImage));
/// _imageInfo?.dispose();
/// _imageInfo = null;
/// super.dispose(); /// super.dispose();
/// } /// }
/// ///
......
...@@ -85,8 +85,16 @@ class RenderImage extends RenderBox { ...@@ -85,8 +85,16 @@ class RenderImage extends RenderBox {
ui.Image? get image => _image; ui.Image? get image => _image;
ui.Image? _image; ui.Image? _image;
set image(ui.Image? value) { set image(ui.Image? value) {
if (value == _image) if (value == _image) {
return; return;
}
// If we get a clone of our image, it's the same underlying native data -
// dispose of the new clone and return early.
if (value != null && _image != null && value.isCloneOf(_image!)) {
value.dispose();
return;
}
_image?.dispose();
_image = value; _image = value;
markNeedsPaint(); markNeedsPaint();
if (_width == null || _height == null) if (_width == null || _height == null)
......
...@@ -5363,6 +5363,10 @@ class RichText extends MultiChildRenderObjectWidget { ...@@ -5363,6 +5363,10 @@ class RichText extends MultiChildRenderObjectWidget {
/// The image is painted using [paintImage], which describes the meanings of the /// The image is painted using [paintImage], which describes the meanings of the
/// various fields on this class in more detail. /// various fields on this class in more detail.
/// ///
/// The [image] is not disposed of by this widget. Creators of the widget are
/// expected to call [Image.dispose] on the [image] once the [RawImage] is no
/// longer buildable.
///
/// This widget is rarely used directly. Instead, consider using [Image]. /// This widget is rarely used directly. Instead, consider using [Image].
class RawImage extends LeafRenderObjectWidget { class RawImage extends LeafRenderObjectWidget {
/// Creates a widget that displays an image. /// Creates a widget that displays an image.
...@@ -5394,6 +5398,10 @@ class RawImage extends LeafRenderObjectWidget { ...@@ -5394,6 +5398,10 @@ class RawImage extends LeafRenderObjectWidget {
super(key: key); super(key: key);
/// The image to display. /// The image to display.
///
/// Since a [RawImage] is stateless, it does not ever dispose this image.
/// Creators of a [RawImage] are expected to call [Image.dispose] on this
/// image handle when the [RawImage] will no longer be needed.
final ui.Image? image; final ui.Image? image;
/// A string identifying the source of the image. /// A string identifying the source of the image.
...@@ -5516,8 +5524,13 @@ class RawImage extends LeafRenderObjectWidget { ...@@ -5516,8 +5524,13 @@ class RawImage extends LeafRenderObjectWidget {
@override @override
RenderImage createRenderObject(BuildContext context) { RenderImage createRenderObject(BuildContext context) {
assert((!matchTextDirection && alignment is Alignment) || debugCheckHasDirectionality(context)); assert((!matchTextDirection && alignment is Alignment) || debugCheckHasDirectionality(context));
assert(
image?.debugGetOpenHandleStackTraces()?.isNotEmpty ?? true,
'Creator of a RawImage disposed of the image when the RawImage still '
'needed it.'
);
return RenderImage( return RenderImage(
image: image, image: image?.clone(),
debugImageLabel: debugImageLabel, debugImageLabel: debugImageLabel,
width: width, width: width,
height: height, height: height,
...@@ -5538,8 +5551,13 @@ class RawImage extends LeafRenderObjectWidget { ...@@ -5538,8 +5551,13 @@ class RawImage extends LeafRenderObjectWidget {
@override @override
void updateRenderObject(BuildContext context, RenderImage renderObject) { void updateRenderObject(BuildContext context, RenderImage renderObject) {
assert(
image?.debugGetOpenHandleStackTraces()?.isNotEmpty ?? true,
'Creator of a RawImage disposed of the image when the RawImage still '
'needed it.'
);
renderObject renderObject
..image = image ..image = image?.clone()
..debugImageLabel = debugImageLabel ..debugImageLabel = debugImageLabel
..width = width ..width = width
..height = height ..height = height
...@@ -5556,6 +5574,12 @@ class RawImage extends LeafRenderObjectWidget { ...@@ -5556,6 +5574,12 @@ class RawImage extends LeafRenderObjectWidget {
..filterQuality = filterQuality; ..filterQuality = filterQuality;
} }
@override
void didUnmountRenderObject(RenderImage renderObject) {
// Have the render object dispose its image handle.
renderObject.image = null;
}
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);
......
...@@ -1084,6 +1084,7 @@ class _ImageState extends State<Image> with WidgetsBindingObserver { ...@@ -1084,6 +1084,7 @@ class _ImageState extends State<Image> with WidgetsBindingObserver {
late DisposableBuildContext<State<Image>> _scrollAwareContext; late DisposableBuildContext<State<Image>> _scrollAwareContext;
Object? _lastException; Object? _lastException;
StackTrace? _lastStack; StackTrace? _lastStack;
ImageStreamCompleterHandle? _completerHandle;
@override @override
void initState() { void initState() {
...@@ -1097,7 +1098,9 @@ class _ImageState extends State<Image> with WidgetsBindingObserver { ...@@ -1097,7 +1098,9 @@ class _ImageState extends State<Image> with WidgetsBindingObserver {
assert(_imageStream != null); assert(_imageStream != null);
WidgetsBinding.instance!.removeObserver(this); WidgetsBinding.instance!.removeObserver(this);
_stopListeningToStream(); _stopListeningToStream();
_completerHandle?.dispose();
_scrollAwareContext.dispose(); _scrollAwareContext.dispose();
_replaceImage(info: null);
super.dispose(); super.dispose();
} }
...@@ -1109,7 +1112,7 @@ class _ImageState extends State<Image> with WidgetsBindingObserver { ...@@ -1109,7 +1112,7 @@ class _ImageState extends State<Image> with WidgetsBindingObserver {
if (TickerMode.of(context)) if (TickerMode.of(context))
_listenToStream(); _listenToStream();
else else
_stopListeningToStream(); _stopListeningToStream(keepStreamAlive: true);
super.didChangeDependencies(); super.didChangeDependencies();
} }
...@@ -1119,8 +1122,9 @@ class _ImageState extends State<Image> with WidgetsBindingObserver { ...@@ -1119,8 +1122,9 @@ class _ImageState extends State<Image> with WidgetsBindingObserver {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (_isListeningToStream && if (_isListeningToStream &&
(widget.loadingBuilder == null) != (oldWidget.loadingBuilder == null)) { (widget.loadingBuilder == null) != (oldWidget.loadingBuilder == null)) {
_imageStream!.removeListener(_getListener()); final ImageStreamListener oldListener = _getListener();
_imageStream!.addListener(_getListener(recreateListener: true)); _imageStream!.addListener(_getListener(recreateListener: true));
_imageStream!.removeListener(oldListener);
} }
if (widget.image != oldWidget.image) if (widget.image != oldWidget.image)
_resolveImage(); _resolveImage();
...@@ -1182,7 +1186,7 @@ class _ImageState extends State<Image> with WidgetsBindingObserver { ...@@ -1182,7 +1186,7 @@ class _ImageState extends State<Image> with WidgetsBindingObserver {
void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) { void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) {
setState(() { setState(() {
_imageInfo = imageInfo; _replaceImage(info: imageInfo);
_loadingProgress = null; _loadingProgress = null;
_lastException = null; _lastException = null;
_lastStack = null; _lastStack = null;
...@@ -1200,6 +1204,11 @@ class _ImageState extends State<Image> with WidgetsBindingObserver { ...@@ -1200,6 +1204,11 @@ class _ImageState extends State<Image> with WidgetsBindingObserver {
}); });
} }
void _replaceImage({required ImageInfo? info}) {
_imageInfo?.dispose();
_imageInfo = info;
}
// Updates _imageStream to newStream, and moves the stream listener // Updates _imageStream to newStream, and moves the stream listener
// registration from the old stream to the new stream (if a listener was // registration from the old stream to the new stream (if a listener was
// registered). // registered).
...@@ -1211,7 +1220,7 @@ class _ImageState extends State<Image> with WidgetsBindingObserver { ...@@ -1211,7 +1220,7 @@ class _ImageState extends State<Image> with WidgetsBindingObserver {
_imageStream!.removeListener(_getListener()); _imageStream!.removeListener(_getListener());
if (!widget.gaplessPlayback) if (!widget.gaplessPlayback)
setState(() { _imageInfo = null; }); setState(() { _replaceImage(info: null); });
setState(() { setState(() {
_loadingProgress = null; _loadingProgress = null;
...@@ -1227,13 +1236,29 @@ class _ImageState extends State<Image> with WidgetsBindingObserver { ...@@ -1227,13 +1236,29 @@ class _ImageState extends State<Image> with WidgetsBindingObserver {
void _listenToStream() { void _listenToStream() {
if (_isListeningToStream) if (_isListeningToStream)
return; return;
_imageStream!.addListener(_getListener()); _imageStream!.addListener(_getListener());
_completerHandle?.dispose();
_completerHandle = null;
_isListeningToStream = true; _isListeningToStream = true;
} }
void _stopListeningToStream() { /// Stops listening to the image stream, if this state object has attached a
/// listener.
///
/// If the listener from this state is the last listener on the stream, the
/// stream will be disposed. To keep the stream alive, set `keepStreamAlive`
/// to true, which create [ImageStreamCompleterHandle] to keep the completer
/// alive and is compatible with the [TickerMode] being off.
void _stopListeningToStream({bool keepStreamAlive = false}) {
if (!_isListeningToStream) if (!_isListeningToStream)
return; return;
if (keepStreamAlive && _completerHandle == null && _imageStream?.completer != null) {
_completerHandle = _imageStream!.completer!.keepAlive();
}
_imageStream!.removeListener(_getListener()); _imageStream!.removeListener(_getListener());
_isListeningToStream = false; _isListeningToStream = false;
} }
...@@ -1246,6 +1271,10 @@ class _ImageState extends State<Image> with WidgetsBindingObserver { ...@@ -1246,6 +1271,10 @@ class _ImageState extends State<Image> with WidgetsBindingObserver {
} }
Widget result = RawImage( Widget result = RawImage(
// Do not clone the image, because RawImage is a stateless wrapper.
// The image will be disposed by this state object when it is not needed
// anymore, such as when it is unmounted or when the image stream pushes
// a new image.
image: _imageInfo?.image, image: _imageInfo?.image,
debugImageLabel: _imageInfo?.debugLabel, debugImageLabel: _imageInfo?.debugLabel,
width: widget.width, width: widget.width,
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
@TestOn('!chrome') @TestOn('!chrome')
import 'dart:async'; import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui show Image, ColorFilter; import 'dart:ui' as ui show Image, ColorFilter;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
...@@ -11,6 +12,7 @@ import 'package:flutter/painting.dart'; ...@@ -11,6 +12,7 @@ import 'package:flutter/painting.dart';
import 'package:fake_async/fake_async.dart'; import 'package:fake_async/fake_async.dart';
import '../flutter_test_alternative.dart'; import '../flutter_test_alternative.dart';
import '../image_data.dart';
import '../painting/mocks_for_image_cache.dart'; import '../painting/mocks_for_image_cache.dart';
import '../rendering/rendering_tester.dart'; import '../rendering/rendering_tester.dart';
...@@ -100,6 +102,31 @@ class DelayedImageProvider extends ImageProvider<DelayedImageProvider> { ...@@ -100,6 +102,31 @@ class DelayedImageProvider extends ImageProvider<DelayedImageProvider> {
String toString() => '${describeIdentity(this)}()'; String toString() => '${describeIdentity(this)}()';
} }
class MultiFrameImageProvider extends ImageProvider<MultiFrameImageProvider> {
MultiFrameImageProvider(this.completer);
final MultiImageCompleter completer;
@override
Future<MultiFrameImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture<MultiFrameImageProvider>(this);
}
@override
ImageStreamCompleter load(MultiFrameImageProvider key, DecoderCallback decode) {
return completer;
}
@override
String toString() => '${describeIdentity(this)}()';
}
class MultiImageCompleter extends ImageStreamCompleter {
void testSetImage(ImageInfo info) {
setImage(info);
}
}
void main() { void main() {
TestRenderingFlutterBinding(); // initializes the imageCache TestRenderingFlutterBinding(); // initializes the imageCache
...@@ -153,7 +180,7 @@ void main() { ...@@ -153,7 +180,7 @@ void main() {
test('BoxDecorationImageListenerAsync', () async { test('BoxDecorationImageListenerAsync', () async {
final ui.Image image = await createTestImage(width: 10, height: 10); final ui.Image image = await createTestImage(width: 10, height: 10);
FakeAsync().run((FakeAsync async) { FakeAsync().run((FakeAsync async) {
final ImageProvider imageProvider = AsyncTestImageProvider(image); final ImageProvider imageProvider = AsyncTestImageProvider(image);
final DecorationImage backgroundImage = DecorationImage(image: imageProvider); final DecorationImage backgroundImage = DecorationImage(image: imageProvider);
...@@ -174,6 +201,42 @@ void main() { ...@@ -174,6 +201,42 @@ void main() {
}); });
}); });
test('BoxDecorationImageListener does not change when image is clone', () async {
final ui.Image image1 = await createTestImage(width: 10, height: 10, cache: false);
final ui.Image image2 = await createTestImage(width: 10, height: 10, cache: false);
final MultiImageCompleter completer = MultiImageCompleter();
final MultiFrameImageProvider imageProvider = MultiFrameImageProvider(completer);
final DecorationImage backgroundImage = DecorationImage(image: imageProvider);
final BoxDecoration boxDecoration = BoxDecoration(image: backgroundImage);
bool onChangedCalled = false;
final BoxPainter boxPainter = boxDecoration.createBoxPainter(() {
onChangedCalled = true;
});
final TestCanvas canvas = TestCanvas();
const ImageConfiguration imageConfiguration = ImageConfiguration(size: Size.zero);
boxPainter.paint(canvas, Offset.zero, imageConfiguration);
// The onChanged callback should be invoked asynchronously.
expect(onChangedCalled, equals(false));
completer.testSetImage(ImageInfo(image: image1.clone()));
await null;
expect(onChangedCalled, equals(true));
onChangedCalled = false;
completer.testSetImage(ImageInfo(image: image1.clone()));
await null;
expect(onChangedCalled, equals(false));
completer.testSetImage(ImageInfo(image: image2.clone()));
await null;
expect(onChangedCalled, equals(true));
});
// Regression test for https://github.com/flutter/flutter/issues/7289. // Regression test for https://github.com/flutter/flutter/issues/7289.
// A reference test would be better. // A reference test would be better.
test('BoxDecoration backgroundImage clip', () async { test('BoxDecoration backgroundImage clip', () async {
...@@ -614,4 +677,30 @@ void main() { ...@@ -614,4 +677,30 @@ void main() {
expect(call.positionalArguments[2].size, const Size(25.0, 25.0)); expect(call.positionalArguments[2].size, const Size(25.0, 25.0));
expect(call.positionalArguments[2], const Rect.fromLTRB(0.0, 0.0, 25.0, 25.0)); expect(call.positionalArguments[2], const Rect.fromLTRB(0.0, 0.0, 25.0, 25.0));
}); });
test('DecorationImagePainter disposes of image when disposed', () async {
final ImageProvider provider = MemoryImage(Uint8List.fromList(kTransparentImage));
final ImageStream stream = provider.resolve(ImageConfiguration.empty);
final Completer<ImageInfo> infoCompleter = Completer<ImageInfo>();
void _listener(ImageInfo image, bool syncCall) {
assert(!infoCompleter.isCompleted);
infoCompleter.complete(image);
}
stream.addListener(ImageStreamListener(_listener));
final ImageInfo info = await infoCompleter.future;
final int baselineRefCount = info.image.debugGetOpenHandleStackTraces()!.length;
final DecorationImagePainter painter = DecorationImage(image: provider).createPainter(() {});
final Canvas canvas = TestCanvas();
painter.paint(canvas, Rect.zero, Path(), ImageConfiguration.empty);
expect(info.image.debugGetOpenHandleStackTraces()!.length, baselineRefCount + 1);
painter.dispose();
expect(info.image.debugGetOpenHandleStackTraces()!.length, baselineRefCount);
info.dispose();
});
} }
...@@ -6,9 +6,11 @@ ...@@ -6,9 +6,11 @@
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import '../flutter_test_alternative.dart'; import 'package:flutter/scheduler.dart';
import '../flutter_test_alternative.dart';
import '../rendering/rendering_tester.dart'; import '../rendering/rendering_tester.dart';
import 'mocks_for_image_cache.dart'; import 'mocks_for_image_cache.dart';
...@@ -456,7 +458,6 @@ void main() { ...@@ -456,7 +458,6 @@ void main() {
final TestImageStreamCompleter completer1 = TestImageStreamCompleter() final TestImageStreamCompleter completer1 = TestImageStreamCompleter()
..addListener(listener); ..addListener(listener);
imageCache.putIfAbsent(testImage, () => completer1); imageCache.putIfAbsent(testImage, () => completer1);
expect(imageCache.statusForKey(testImage).pending, true); expect(imageCache.statusForKey(testImage).pending, true);
expect(imageCache.statusForKey(testImage).live, true); expect(imageCache.statusForKey(testImage).live, true);
...@@ -484,4 +485,88 @@ void main() { ...@@ -484,4 +485,88 @@ void main() {
expect(imageCache.statusForKey(testImage).keepAlive, true); expect(imageCache.statusForKey(testImage).keepAlive, true);
expect(imageCache.currentSizeBytes, testImageSize); expect(imageCache.currentSizeBytes, testImageSize);
}); });
test('Image is obtained and disposed of properly for cache', () async {
const int key = 1;
final ui.Image testImage = await createTestImage(width: 8, height: 8, cache: false);
expect(testImage.debugGetOpenHandleStackTraces().length, 1);
ImageInfo imageInfo;
final ImageStreamListener listener = ImageStreamListener((ImageInfo info, bool syncCall) {
imageInfo = info;
});
final TestImageStreamCompleter completer = TestImageStreamCompleter();
completer.addListener(listener);
imageCache.putIfAbsent(key, () => completer);
expect(testImage.debugGetOpenHandleStackTraces().length, 1);
// This should cause keepAlive to be set to true.
completer.testSetImage(testImage);
expect(imageInfo, isNotNull);
// +1 ImageStreamCompleter
expect(testImage.debugGetOpenHandleStackTraces().length, 2);
completer.removeListener(listener);
// Force us to the end of the frame.
SchedulerBinding.instance.scheduleFrame();
await SchedulerBinding.instance.endOfFrame;
expect(testImage.debugGetOpenHandleStackTraces().length, 2);
expect(imageCache.evict(key), true);
// Force us to the end of the frame.
SchedulerBinding.instance.scheduleFrame();
await SchedulerBinding.instance.endOfFrame;
// -1 _CachedImage
// -1 ImageStreamCompleter
expect(testImage.debugGetOpenHandleStackTraces().length, 1);
imageInfo.dispose();
expect(testImage.debugGetOpenHandleStackTraces().length, 0);
}, skip: kIsWeb); // Web does not care about image handles.
test('Image is obtained and disposed of properly for cache when listener is still active', () async {
const int key = 1;
final ui.Image testImage = await createTestImage(width: 8, height: 8, cache: false);
expect(testImage.debugGetOpenHandleStackTraces().length, 1);
ImageInfo imageInfo;
final ImageStreamListener listener = ImageStreamListener((ImageInfo info, bool syncCall) {
imageInfo = info;
});
final TestImageStreamCompleter completer = TestImageStreamCompleter();
completer.addListener(listener);
imageCache.putIfAbsent(key, () => completer);
expect(testImage.debugGetOpenHandleStackTraces().length, 1);
// This should cause keepAlive to be set to true.
completer.testSetImage(testImage);
expect(imageInfo, isNotNull);
// Just our imageInfo and the completer.
expect(testImage.debugGetOpenHandleStackTraces().length, 2);
expect(imageCache.evict(key), true);
// Force us to the end of the frame.
SchedulerBinding.instance.scheduleFrame();
await SchedulerBinding.instance.endOfFrame;
// Live image still around since there's still a listener, and the listener
// should be holding a handle.
expect(testImage.debugGetOpenHandleStackTraces().length, 2);
completer.removeListener(listener);
expect(testImage.debugGetOpenHandleStackTraces().length, 1);
imageInfo.dispose();
expect(testImage.debugGetOpenHandleStackTraces().length, 0);
}, skip: kIsWeb); // Web does not care about open image handles.
} }
...@@ -8,13 +8,12 @@ import 'dart:async'; ...@@ -8,13 +8,12 @@ import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:flutter/scheduler.dart' show timeDilation; import 'package:flutter/scheduler.dart' show timeDilation, SchedulerBinding;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
class FakeFrameInfo implements FrameInfo { class FakeFrameInfo implements FrameInfo {
FakeFrameInfo(this._duration, this._image); const FakeFrameInfo(this._duration, this._image);
final Duration _duration; final Duration _duration;
final Image _image; final Image _image;
...@@ -24,6 +23,15 @@ class FakeFrameInfo implements FrameInfo { ...@@ -24,6 +23,15 @@ class FakeFrameInfo implements FrameInfo {
@override @override
Image get image => _image; Image get image => _image;
int get imageHandleCount => image.debugGetOpenHandleStackTraces().length;
FakeFrameInfo clone() {
return FakeFrameInfo(
_duration,
_image.clone(),
);
}
} }
class MockCodec implements Codec { class MockCodec implements Codec {
...@@ -72,7 +80,7 @@ class FakeEventReportingImageStreamCompleter extends ImageStreamCompleter { ...@@ -72,7 +80,7 @@ class FakeEventReportingImageStreamCompleter extends ImageStreamCompleter {
void main() { void main() {
Image image20x10; Image image20x10;
Image image200x100; Image image200x100;
setUpAll(() async { setUp(() async {
image20x10 = await createTestImage(width: 20, height: 10); image20x10 = await createTestImage(width: 20, height: 10);
image200x100 = await createTestImage(width: 200, height: 100); image200x100 = await createTestImage(width: 200, height: 100);
}); });
...@@ -299,7 +307,7 @@ void main() { ...@@ -299,7 +307,7 @@ void main() {
mockCodec.completeNextFrame(frame); mockCodec.completeNextFrame(frame);
await tester.idle(); await tester.idle();
expect(emittedImages, equals(<ImageInfo>[ImageInfo(image: frame.image)])); expect(emittedImages.every((ImageInfo info) => info.image.isCloneOf(frame.image)), true);
}); });
testWidgets('ImageStream emits frames (animated images)', (WidgetTester tester) async { testWidgets('ImageStream emits frames (animated images)', (WidgetTester tester) async {
...@@ -329,7 +337,7 @@ void main() { ...@@ -329,7 +337,7 @@ void main() {
expect(emittedImages.length, 0); expect(emittedImages.length, 0);
await tester.pump(); await tester.pump();
expect(emittedImages, equals(<ImageInfo>[ImageInfo(image: frame1.image)])); expect(emittedImages.single.image.isCloneOf(frame1.image), true);
final FrameInfo frame2 = FakeFrameInfo(const Duration(milliseconds: 400), image200x100); final FrameInfo frame2 = FakeFrameInfo(const Duration(milliseconds: 400), image200x100);
mockCodec.completeNextFrame(frame2); mockCodec.completeNextFrame(frame2);
...@@ -340,10 +348,8 @@ void main() { ...@@ -340,10 +348,8 @@ void main() {
expect(emittedImages.length, 1); expect(emittedImages.length, 1);
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
expect(emittedImages, equals(<ImageInfo>[ expect(emittedImages[0].image.isCloneOf(frame1.image), true);
ImageInfo(image: frame1.image), expect(emittedImages[1].image.isCloneOf(frame2.image), true);
ImageInfo(image: frame2.image),
]));
// Let the pending timer for the next frame to complete so we can cleanly // Let the pending timer for the next frame to complete so we can cleanly
// quit the test without pending timers. // quit the test without pending timers.
...@@ -369,24 +375,22 @@ void main() { ...@@ -369,24 +375,22 @@ void main() {
codecCompleter.complete(mockCodec); codecCompleter.complete(mockCodec);
await tester.idle(); await tester.idle();
final FrameInfo frame1 = FakeFrameInfo(const Duration(milliseconds: 200), image20x10); final FakeFrameInfo frame1 = FakeFrameInfo(const Duration(milliseconds: 200), image20x10);
final FrameInfo frame2 = FakeFrameInfo(const Duration(milliseconds: 400), image200x100); final FakeFrameInfo frame2 = FakeFrameInfo(const Duration(milliseconds: 400), image200x100);
mockCodec.completeNextFrame(frame1); mockCodec.completeNextFrame(frame1.clone());
await tester.idle(); // let nextFrameFuture complete await tester.idle(); // let nextFrameFuture complete
await tester.pump(); // first animation frame shows on first app frame. await tester.pump(); // first animation frame shows on first app frame.
mockCodec.completeNextFrame(frame2); mockCodec.completeNextFrame(frame2.clone());
await tester.idle(); // let nextFrameFuture complete await tester.idle(); // let nextFrameFuture complete
await tester.pump(const Duration(milliseconds: 200)); // emit 2nd frame. await tester.pump(const Duration(milliseconds: 200)); // emit 2nd frame.
mockCodec.completeNextFrame(frame1); mockCodec.completeNextFrame(frame1.clone());
await tester.idle(); // let nextFrameFuture complete await tester.idle(); // let nextFrameFuture complete
await tester.pump(const Duration(milliseconds: 400)); // emit 3rd frame await tester.pump(const Duration(milliseconds: 400)); // emit 3rd frame
expect(emittedImages, equals(<ImageInfo>[ expect(emittedImages[0].image.isCloneOf(frame1.image), true);
ImageInfo(image: frame1.image), expect(emittedImages[1].image.isCloneOf(frame2.image), true);
ImageInfo(image: frame2.image), expect(emittedImages[2].image.isCloneOf(frame1.image), true);
ImageInfo(image: frame1.image),
]));
// Let the pending timer for the next frame to complete so we can cleanly // Let the pending timer for the next frame to complete so we can cleanly
// quit the test without pending timers. // quit the test without pending timers.
...@@ -427,13 +431,11 @@ void main() { ...@@ -427,13 +431,11 @@ void main() {
await tester.idle(); await tester.idle();
await tester.pump(const Duration(milliseconds: 400)); await tester.pump(const Duration(milliseconds: 400));
expect(emittedImages, equals(<ImageInfo>[ expect(emittedImages[0].image.isCloneOf(frame1.image), true);
ImageInfo(image: frame1.image), expect(emittedImages[1].image.isCloneOf(frame2.image), true);
ImageInfo(image: frame2.image),
]));
}); });
testWidgets('frames are only decoded when there are active listeners', (WidgetTester tester) async { testWidgets('frames are only decoded when there are listeners', (WidgetTester tester) async {
final MockCodec mockCodec = MockCodec(); final MockCodec mockCodec = MockCodec();
mockCodec.frameCount = 2; mockCodec.frameCount = 2;
mockCodec.repetitionCount = -1; mockCodec.repetitionCount = -1;
...@@ -446,6 +448,7 @@ void main() { ...@@ -446,6 +448,7 @@ void main() {
final ImageListener listener = (ImageInfo image, bool synchronousCall) { }; final ImageListener listener = (ImageInfo image, bool synchronousCall) { };
imageStream.addListener(ImageStreamListener(listener)); imageStream.addListener(ImageStreamListener(listener));
final ImageStreamCompleterHandle handle = imageStream.keepAlive();
codecCompleter.complete(mockCodec); codecCompleter.complete(mockCodec);
await tester.idle(); await tester.idle();
...@@ -468,6 +471,8 @@ void main() { ...@@ -468,6 +471,8 @@ void main() {
imageStream.addListener(ImageStreamListener(listener)); imageStream.addListener(ImageStreamListener(listener));
await tester.idle(); // let nextFrameFuture complete await tester.idle(); // let nextFrameFuture complete
expect(mockCodec.numFramesAsked, 3); expect(mockCodec.numFramesAsked, 3);
handle.dispose();
}); });
testWidgets('multiple stream listeners', (WidgetTester tester) async { testWidgets('multiple stream listeners', (WidgetTester tester) async {
...@@ -501,8 +506,9 @@ void main() { ...@@ -501,8 +506,9 @@ void main() {
mockCodec.completeNextFrame(frame1); mockCodec.completeNextFrame(frame1);
await tester.idle(); // let nextFrameFuture complete await tester.idle(); // let nextFrameFuture complete
await tester.pump(); // first animation frame shows on first app frame. await tester.pump(); // first animation frame shows on first app frame.
expect(emittedImages1, equals(<ImageInfo>[ImageInfo(image: frame1.image)]));
expect(emittedImages2, equals(<ImageInfo>[ImageInfo(image: frame1.image)])); expect(emittedImages1.single.image.isCloneOf(frame1.image), true);
expect(emittedImages2.single.image.isCloneOf(frame1.image), true);
mockCodec.completeNextFrame(frame2); mockCodec.completeNextFrame(frame2);
await tester.idle(); // let nextFrameFuture complete await tester.idle(); // let nextFrameFuture complete
...@@ -510,11 +516,10 @@ void main() { ...@@ -510,11 +516,10 @@ void main() {
imageStream.removeListener(ImageStreamListener(listener1)); imageStream.removeListener(ImageStreamListener(listener1));
await tester.pump(const Duration(milliseconds: 400)); // emit 2nd frame. await tester.pump(const Duration(milliseconds: 400)); // emit 2nd frame.
expect(emittedImages1, equals(<ImageInfo>[ImageInfo(image: frame1.image)])); expect(emittedImages1.single.image.isCloneOf(frame1.image), true);
expect(emittedImages2, equals(<ImageInfo>[ expect(emittedImages2[0].image.isCloneOf(frame1.image), true);
ImageInfo(image: frame1.image), expect(emittedImages2[1].image.isCloneOf(frame2.image), true);
ImageInfo(image: frame2.image),
]));
}); });
testWidgets('timer is canceled when listeners are removed', (WidgetTester tester) async { testWidgets('timer is canceled when listeners are removed', (WidgetTester tester) async {
...@@ -639,8 +644,8 @@ void main() { ...@@ -639,8 +644,8 @@ void main() {
await tester.idle(); // let nextFrameFuture complete await tester.idle(); // let nextFrameFuture complete
imageStream.removeListener(ImageStreamListener(listener));
imageStream.addListener(ImageStreamListener(listener)); imageStream.addListener(ImageStreamListener(listener));
imageStream.removeListener(ImageStreamListener(listener));
final FrameInfo frame1 = FakeFrameInfo(const Duration(milliseconds: 200), image20x10); final FrameInfo frame1 = FakeFrameInfo(const Duration(milliseconds: 200), image20x10);
...@@ -684,6 +689,76 @@ void main() { ...@@ -684,6 +689,76 @@ void main() {
compare(onImage1: handleImage, onChunk1: handleChunk, onError1: handleError, onImage2: handleImage, onError2: handleError, areEqual: false); compare(onImage1: handleImage, onChunk1: handleChunk, onError1: handleError, onImage2: handleImage, onError2: handleError, areEqual: false);
}); });
testWidgets('Keep alive handles do not drive frames or prevent last listener callbacks', (WidgetTester tester) async {
final Image image10x10 = await tester.runAsync(() => createTestImage(width: 10, height: 10));
final MockCodec mockCodec = MockCodec();
mockCodec.frameCount = 2;
mockCodec.repetitionCount = -1;
final Completer<Codec> codecCompleter = Completer<Codec>();
final ImageStreamCompleter imageStream = MultiFrameImageStreamCompleter(
codec: codecCompleter.future,
scale: 1.0,
);
int onImageCount = 0;
final ImageListener activeListener = (ImageInfo image, bool synchronousCall) {
onImageCount += 1;
};
bool lastListenerDropped = false;
imageStream.addOnLastListenerRemovedCallback(() {
lastListenerDropped = true;
});
expect(lastListenerDropped, false);
final ImageStreamCompleterHandle handle = imageStream.keepAlive();
expect(lastListenerDropped, false);
SchedulerBinding.instance.debugAssertNoTransientCallbacks('Only passive listeners');
codecCompleter.complete(mockCodec);
await tester.idle();
expect(onImageCount, 0);
final FakeFrameInfo frame1 = FakeFrameInfo(Duration.zero, image20x10);
mockCodec.completeNextFrame(frame1);
await tester.idle();
SchedulerBinding.instance.debugAssertNoTransientCallbacks('Only passive listeners');
await tester.pump();
expect(onImageCount, 0);
imageStream.addListener(ImageStreamListener(activeListener));
final FakeFrameInfo frame2 = FakeFrameInfo(Duration.zero, image10x10);
mockCodec.completeNextFrame(frame2);
await tester.idle();
expect(SchedulerBinding.instance.transientCallbackCount, 1);
await tester.pump();
expect(onImageCount, 1);
imageStream.removeListener(ImageStreamListener(activeListener));
expect(lastListenerDropped, true);
mockCodec.completeNextFrame(frame1);
await tester.idle();
expect(SchedulerBinding.instance.transientCallbackCount, 1);
await tester.pump();
expect(onImageCount, 1);
SchedulerBinding.instance.debugAssertNoTransientCallbacks('Only passive listeners');
mockCodec.completeNextFrame(frame2);
await tester.idle();
SchedulerBinding.instance.debugAssertNoTransientCallbacks('Only passive listeners');
await tester.pump();
expect(onImageCount, 1);
handle.dispose();
});
// TODO(amirh): enable this once WidgetTester supports flushTimers. // TODO(amirh): enable this once WidgetTester supports flushTimers.
// https://github.com/flutter/flutter/issues/30344 // https://github.com/flutter/flutter/issues/30344
// testWidgets('remove and add listener before a delayed frame is scheduled', (WidgetTester tester) async { // testWidgets('remove and add listener before a delayed frame is scheduled', (WidgetTester tester) async {
......
...@@ -8,6 +8,7 @@ import 'dart:ui' as ui show Image; ...@@ -8,6 +8,7 @@ import 'dart:ui' as ui show Image;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
// ignore: must_be_immutable
class TestImageInfo implements ImageInfo { class TestImageInfo implements ImageInfo {
const TestImageInfo(this.value, { required this.image, this.scale = 1.0, this.debugLabel }); const TestImageInfo(this.value, { required this.image, this.scale = 1.0, this.debugLabel });
...@@ -24,6 +25,39 @@ class TestImageInfo implements ImageInfo { ...@@ -24,6 +25,39 @@ class TestImageInfo implements ImageInfo {
@override @override
String toString() => '$runtimeType($value)'; String toString() => '$runtimeType($value)';
@override
TestImageInfo clone() {
return TestImageInfo(value, image: image.clone(), scale: scale, debugLabel: debugLabel);
}
@override
bool isCloneOf(ImageInfo other) {
assert(other != null);
return other.image.isCloneOf(image)
&& scale == scale
&& other.debugLabel == debugLabel;
}
@override
void dispose() {
image.dispose();
}
@override
int get hashCode => hashValues(value, image, scale, debugLabel);
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType)
return false;
return other is TestImageInfo
&& other.value == value
&& other.image.isCloneOf(image)
&& other.scale == scale
&& other.debugLabel == debugLabel;
}
} }
class TestImageProvider extends ImageProvider<int> { class TestImageProvider extends ImageProvider<int> {
...@@ -42,7 +76,7 @@ class TestImageProvider extends ImageProvider<int> { ...@@ -42,7 +76,7 @@ class TestImageProvider extends ImageProvider<int> {
@override @override
ImageStreamCompleter load(int key, DecoderCallback decode) { ImageStreamCompleter load(int key, DecoderCallback decode) {
return OneFrameImageStreamCompleter( return OneFrameImageStreamCompleter(
SynchronousFuture<ImageInfo>(TestImageInfo(imageValue, image: image)) SynchronousFuture<ImageInfo>(TestImageInfo(imageValue, image: image.clone()))
); );
} }
......
...@@ -176,4 +176,38 @@ Future<void> main() async { ...@@ -176,4 +176,38 @@ Future<void> main() async {
image.colorBlendMode = BlendMode.color; image.colorBlendMode = BlendMode.color;
expect(image.colorBlendMode, BlendMode.color); expect(image.colorBlendMode, BlendMode.color);
}); });
test('Render image disposes its image', () async {
final ui.Image image = await createTestImage(width: 10, height: 10, cache: false);
expect(image.debugGetOpenHandleStackTraces().length, 1);
final RenderImage renderImage = RenderImage(image: image.clone());
expect(image.debugGetOpenHandleStackTraces().length, 2);
renderImage.image = image.clone();
expect(image.debugGetOpenHandleStackTraces().length, 2);
renderImage.image = null;
expect(image.debugGetOpenHandleStackTraces().length, 1);
image.dispose();
expect(image.debugGetOpenHandleStackTraces().length, 0);
}, skip: kIsWeb); // Web doesn't track open image handles.
test('Render image does not dispose its image if setting the same image twice', () async {
final ui.Image image = await createTestImage(width: 10, height: 10, cache: false);
expect(image.debugGetOpenHandleStackTraces().length, 1);
final RenderImage renderImage = RenderImage(image: image.clone());
expect(image.debugGetOpenHandleStackTraces().length, 2);
renderImage.image = renderImage.image;
expect(image.debugGetOpenHandleStackTraces().length, 2);
renderImage.image = null;
expect(image.debugGetOpenHandleStackTraces().length, 1);
image.dispose();
expect(image.debugGetOpenHandleStackTraces().length, 0);
}, skip: kIsWeb); // Web doesn't track open image handles.
} }
...@@ -1313,7 +1313,7 @@ class _DrawImagePaintPredicate extends _DrawCommandPaintPredicate { ...@@ -1313,7 +1313,7 @@ class _DrawImagePaintPredicate extends _DrawCommandPaintPredicate {
void verifyArguments(List<dynamic> arguments) { void verifyArguments(List<dynamic> arguments) {
super.verifyArguments(arguments); super.verifyArguments(arguments);
final ui.Image imageArgument = arguments[0] as ui.Image; final ui.Image imageArgument = arguments[0] as ui.Image;
if (image != null && imageArgument != image) if (image != null && !image!.isCloneOf(imageArgument))
throw 'It called $methodName with an image, $imageArgument, which was not exactly the expected image ($image).'; throw 'It called $methodName with an image, $imageArgument, which was not exactly the expected image ($image).';
final Offset pointArgument = arguments[0] as Offset; final Offset pointArgument = arguments[0] as Offset;
if (x != null && y != null) { if (x != null && y != null) {
...@@ -1357,7 +1357,7 @@ class _DrawImageRectPaintPredicate extends _DrawCommandPaintPredicate { ...@@ -1357,7 +1357,7 @@ class _DrawImageRectPaintPredicate extends _DrawCommandPaintPredicate {
void verifyArguments(List<dynamic> arguments) { void verifyArguments(List<dynamic> arguments) {
super.verifyArguments(arguments); super.verifyArguments(arguments);
final ui.Image imageArgument = arguments[0] as ui.Image; final ui.Image imageArgument = arguments[0] as ui.Image;
if (image != null && imageArgument != image) if (image != null && !image!.isCloneOf(imageArgument))
throw 'It called $methodName with an image, $imageArgument, which was not exactly the expected image ($image).'; throw 'It called $methodName with an image, $imageArgument, which was not exactly the expected image ($image).';
final Rect sourceArgument = arguments[1] as Rect; final Rect sourceArgument = arguments[1] as Rect;
if (source != null && sourceArgument != source) if (source != null && sourceArgument != source)
......
...@@ -132,15 +132,15 @@ Future<void> main() async { ...@@ -132,15 +132,15 @@ Future<void> main() async {
placeholderProvider.complete(); placeholderProvider.complete();
await tester.pump(); await tester.pump();
expect(findFadeInImage(tester).placeholder.rawImage.image, same(placeholderImage)); expect(findFadeInImage(tester).placeholder.rawImage.image.isCloneOf(placeholderImage), true);
expect(findFadeInImage(tester).target.rawImage.image, null); expect(findFadeInImage(tester).target.rawImage.image, null);
imageProvider.complete(); imageProvider.complete();
await tester.pump(); await tester.pump();
for (int i = 0; i < 5; i += 1) { for (int i = 0; i < 5; i += 1) {
final FadeInImageParts parts = findFadeInImage(tester); final FadeInImageParts parts = findFadeInImage(tester);
expect(parts.placeholder.rawImage.image, same(placeholderImage)); expect(parts.placeholder.rawImage.image.isCloneOf(placeholderImage), true);
expect(parts.target.rawImage.image, same(targetImage)); expect(parts.target.rawImage.image.isCloneOf(targetImage), true);
expect(parts.placeholder.opacity, moreOrLessEquals(1 - i / 5)); expect(parts.placeholder.opacity, moreOrLessEquals(1 - i / 5));
expect(parts.target.opacity, 0); expect(parts.target.opacity, 0);
await tester.pump(const Duration(milliseconds: 10)); await tester.pump(const Duration(milliseconds: 10));
...@@ -148,8 +148,8 @@ Future<void> main() async { ...@@ -148,8 +148,8 @@ Future<void> main() async {
for (int i = 0; i < 5; i += 1) { for (int i = 0; i < 5; i += 1) {
final FadeInImageParts parts = findFadeInImage(tester); final FadeInImageParts parts = findFadeInImage(tester);
expect(parts.placeholder.rawImage.image, same(placeholderImage)); expect(parts.placeholder.rawImage.image.isCloneOf(placeholderImage), true);
expect(parts.target.rawImage.image, same(targetImage)); expect(parts.target.rawImage.image.isCloneOf(targetImage), true);
expect(parts.placeholder.opacity, 0); expect(parts.placeholder.opacity, 0);
expect(parts.target.opacity, moreOrLessEquals(i / 5)); expect(parts.target.opacity, moreOrLessEquals(i / 5));
await tester.pump(const Duration(milliseconds: 10)); await tester.pump(const Duration(milliseconds: 10));
...@@ -159,7 +159,7 @@ Future<void> main() async { ...@@ -159,7 +159,7 @@ Future<void> main() async {
placeholder: placeholderProvider, placeholder: placeholderProvider,
image: imageProvider, image: imageProvider,
)); ));
expect(findFadeInImage(tester).target.rawImage.image, same(targetImage)); expect(findFadeInImage(tester).target.rawImage.image.isCloneOf(targetImage), true);
expect(findFadeInImage(tester).target.opacity, 1); expect(findFadeInImage(tester).target.opacity, 1);
}); });
...@@ -174,7 +174,7 @@ Future<void> main() async { ...@@ -174,7 +174,7 @@ Future<void> main() async {
image: imageProvider, image: imageProvider,
)); ));
expect(findFadeInImage(tester).target.rawImage.image, same(targetImage)); expect(findFadeInImage(tester).target.rawImage.image.isCloneOf(targetImage), true);
expect(findFadeInImage(tester).placeholder, isNull); expect(findFadeInImage(tester).placeholder, isNull);
expect(findFadeInImage(tester).target.opacity, 1); expect(findFadeInImage(tester).target.opacity, 1);
}); });
...@@ -195,7 +195,7 @@ Future<void> main() async { ...@@ -195,7 +195,7 @@ Future<void> main() async {
final State state = findFadeInImage(tester).state; final State state = findFadeInImage(tester).state;
placeholderProvider.complete(); placeholderProvider.complete();
await tester.pump(); await tester.pump();
expect(findFadeInImage(tester).placeholder.rawImage.image, same(placeholderImage)); expect(findFadeInImage(tester).placeholder.rawImage.image.isCloneOf(placeholderImage), true);
await tester.pumpWidget(FadeInImage( await tester.pumpWidget(FadeInImage(
placeholder: secondPlaceholderProvider, placeholder: secondPlaceholderProvider,
...@@ -207,7 +207,7 @@ Future<void> main() async { ...@@ -207,7 +207,7 @@ Future<void> main() async {
secondPlaceholderProvider.complete(); secondPlaceholderProvider.complete();
await tester.pump(); await tester.pump();
expect(findFadeInImage(tester).placeholder.rawImage.image, same(replacementImage)); expect(findFadeInImage(tester).placeholder.rawImage.image.isCloneOf(replacementImage), true);
expect(findFadeInImage(tester).state, same(state)); expect(findFadeInImage(tester).state, same(state));
}); });
...@@ -263,7 +263,7 @@ Future<void> main() async { ...@@ -263,7 +263,7 @@ Future<void> main() async {
secondImageProvider.complete(); secondImageProvider.complete();
await tester.pump(); await tester.pump();
expect(findFadeInImage(tester).target.rawImage.image, same(replacementImage)); expect(findFadeInImage(tester).target.rawImage.image.isCloneOf(replacementImage), true);
expect(findFadeInImage(tester).state, same(state)); expect(findFadeInImage(tester).state, same(state));
expect(findFadeInImage(tester).placeholder.opacity, moreOrLessEquals(1)); expect(findFadeInImage(tester).placeholder.opacity, moreOrLessEquals(1));
expect(findFadeInImage(tester).target.opacity, moreOrLessEquals(0)); expect(findFadeInImage(tester).target.opacity, moreOrLessEquals(0));
......
...@@ -472,7 +472,7 @@ void main() { ...@@ -472,7 +472,7 @@ void main() {
await tester.pump(); await tester.pump();
expect(image.toString(), equalsIgnoringHashCodes('_ImageState#00000(stream: ImageStream#00000(OneFrameImageStreamCompleter#00000, $imageString @ 1.0x, 1 listener), pixels: $imageString @ 1.0x, loadingProgress: null, frameNumber: 0, wasSynchronouslyLoaded: false)')); expect(image.toString(), equalsIgnoringHashCodes('_ImageState#00000(stream: ImageStream#00000(OneFrameImageStreamCompleter#00000, $imageString @ 1.0x, 1 listener), pixels: $imageString @ 1.0x, loadingProgress: null, frameNumber: 0, wasSynchronouslyLoaded: false)'));
await tester.pumpWidget(Container()); await tester.pumpWidget(Container());
expect(image.toString(), equalsIgnoringHashCodes('_ImageState#00000(lifecycle state: defunct, not mounted, stream: ImageStream#00000(OneFrameImageStreamCompleter#00000, $imageString @ 1.0x, 0 listeners), pixels: $imageString @ 1.0x, loadingProgress: null, frameNumber: 0, wasSynchronouslyLoaded: false)')); expect(image.toString(), equalsIgnoringHashCodes('_ImageState#00000(lifecycle state: defunct, not mounted, stream: ImageStream#00000(OneFrameImageStreamCompleter#00000, $imageString @ 1.0x, 0 listeners), pixels: null, loadingProgress: null, frameNumber: 0, wasSynchronouslyLoaded: false)'));
}); });
testWidgets('Stream completer errors can be listened to by attaching before resolving', (WidgetTester tester) async { testWidgets('Stream completer errors can be listened to by attaching before resolving', (WidgetTester tester) async {
...@@ -904,8 +904,8 @@ void main() { ...@@ -904,8 +904,8 @@ void main() {
}); });
testWidgets('Image State can be reconfigured to use another image', (WidgetTester tester) async { testWidgets('Image State can be reconfigured to use another image', (WidgetTester tester) async {
final Image image1 = Image(image: TestImageProvider()..complete(image10x10), width: 10.0, excludeFromSemantics: true); final Image image1 = Image(image: TestImageProvider()..complete(image10x10.clone()), width: 10.0, excludeFromSemantics: true);
final Image image2 = Image(image: TestImageProvider()..complete(image10x10), width: 20.0, excludeFromSemantics: true); final Image image2 = Image(image: TestImageProvider()..complete(image10x10.clone()), width: 20.0, excludeFromSemantics: true);
final Column column = Column(children: <Widget>[image1, image2]); final Column column = Column(children: <Widget>[image1, image2]);
await tester.pumpWidget(column, null, EnginePhase.layout); await tester.pumpWidget(column, null, EnginePhase.layout);
...@@ -1039,7 +1039,7 @@ void main() { ...@@ -1039,7 +1039,7 @@ void main() {
}); });
testWidgets('Image invokes frameBuilder with correct wasSynchronouslyLoaded=true', (WidgetTester tester) async { testWidgets('Image invokes frameBuilder with correct wasSynchronouslyLoaded=true', (WidgetTester tester) async {
final TestImageStreamCompleter streamCompleter = TestImageStreamCompleter(ImageInfo(image: image10x10)); final TestImageStreamCompleter streamCompleter = TestImageStreamCompleter(ImageInfo(image: image10x10.clone()));
final TestImageProvider imageProvider = TestImageProvider(streamCompleter: streamCompleter); final TestImageProvider imageProvider = TestImageProvider(streamCompleter: streamCompleter);
int lastFrame; int lastFrame;
bool lastFrameWasSync; bool lastFrameWasSync;
...@@ -1058,7 +1058,7 @@ void main() { ...@@ -1058,7 +1058,7 @@ void main() {
expect(lastFrame, 0); expect(lastFrame, 0);
expect(lastFrameWasSync, isTrue); expect(lastFrameWasSync, isTrue);
expect(find.byType(RawImage), findsOneWidget); expect(find.byType(RawImage), findsOneWidget);
streamCompleter.setData(imageInfo: ImageInfo(image: image10x10)); streamCompleter.setData(imageInfo: ImageInfo(image: image10x10.clone()));
await tester.pump(); await tester.pump();
expect(lastFrame, 1); expect(lastFrame, 1);
expect(lastFrameWasSync, isTrue); expect(lastFrameWasSync, isTrue);
...@@ -1474,10 +1474,10 @@ void main() { ...@@ -1474,10 +1474,10 @@ void main() {
expect(provider1.loadCallCount, 1); expect(provider1.loadCallCount, 1);
expect(provider2.loadCallCount, 1); expect(provider2.loadCallCount, 1);
provider1.complete(image10x10); provider1.complete(image10x10.clone());
await tester.idle(); await tester.idle();
provider2.complete(image10x10); provider2.complete(image10x10.clone());
await tester.idle(); await tester.idle();
expect(imageCache.liveImageCount, 2); expect(imageCache.liveImageCount, 2);
...@@ -1763,6 +1763,76 @@ void main() { ...@@ -1763,6 +1763,76 @@ void main() {
debugOnPaintImage = null; debugOnPaintImage = null;
}); });
testWidgets('Disposes image handle when disposed', (WidgetTester tester) async {
final ui.Image image = await tester.runAsync(() => createTestImage(width: 1, height: 1, cache: false));
expect(image.debugGetOpenHandleStackTraces().length, 1);
final ImageProvider provider = TestImageProvider(
streamCompleter: OneFrameImageStreamCompleter(
Future<ImageInfo>.value(
ImageInfo(
image: image,
scale: 1.0,
debugLabel: 'TestImage',
),
),
),
);
// creating the provider should not have changed anything, and the provider
// now owns the handle.
expect(image.debugGetOpenHandleStackTraces().length, 1);
await tester.pumpWidget(Image(image: provider));
// Image widget + 1, render object + 1
expect(image.debugGetOpenHandleStackTraces().length, 3);
await tester.pumpWidget(const SizedBox());
// Image widget and render object go away
expect(image.debugGetOpenHandleStackTraces().length, 1);
await provider.evict();
tester.binding.scheduleFrame();
await tester.pump();
// Image cache listener go away and Image stream listeners go away.
// Image is now at zero.
expect(image.debugGetOpenHandleStackTraces().length, 0);
}, skip: kIsWeb); // Web does not care about image handle/disposal.
testWidgets('Keeps stream alive when ticker mode is disabled', (WidgetTester tester) async {
imageCache.maximumSize = 0;
final ui.Image image = await tester.runAsync(() => createTestImage(width: 1, height: 1, cache: false));
final TestImageProvider provider = TestImageProvider();
provider.complete(image);
await tester.pumpWidget(
TickerMode(
enabled: true,
child: Image(image: provider),
),
);
expect(find.byType(Image), findsOneWidget);
await tester.pumpWidget(TickerMode(
enabled: false,
child: Image(image: provider),
),
);
expect(find.byType(Image), findsOneWidget);
await tester.pumpWidget(TickerMode(
enabled: true,
child: Image(image: provider),
),
);
expect(find.byType(Image), findsOneWidget);
});
testWidgets('Load a good image after a bad image was loaded should not call errorBuilder', (WidgetTester tester) async { testWidgets('Load a good image after a bad image was loaded should not call errorBuilder', (WidgetTester tester) async {
final UniqueKey errorKey = UniqueKey(); final UniqueKey errorKey = UniqueKey();
final ui.Image image = await tester.runAsync(() => createTestImage()); final ui.Image image = await tester.runAsync(() => createTestImage());
...@@ -1899,6 +1969,12 @@ class TestImageProvider extends ImageProvider<Object> { ...@@ -1899,6 +1969,12 @@ class TestImageProvider extends ImageProvider<Object> {
String toString() => '${describeIdentity(this)}()'; String toString() => '${describeIdentity(this)}()';
} }
class SimpleTestImageStreamCompleter extends ImageStreamCompleter {
void testSetImage(ui.Image image) {
setImage(ImageInfo(image: image, scale: 1.0));
}
}
class TestImageStreamCompleter extends ImageStreamCompleter { class TestImageStreamCompleter extends ImageStreamCompleter {
TestImageStreamCompleter([this._currentImage]); TestImageStreamCompleter([this._currentImage]);
...@@ -1909,7 +1985,7 @@ class TestImageStreamCompleter extends ImageStreamCompleter { ...@@ -1909,7 +1985,7 @@ class TestImageStreamCompleter extends ImageStreamCompleter {
void addListener(ImageStreamListener listener) { void addListener(ImageStreamListener listener) {
listeners.add(listener); listeners.add(listener);
if (_currentImage != null) { if (_currentImage != null) {
listener.onImage(_currentImage, true); listener.onImage(_currentImage.clone(), true);
} }
} }
...@@ -1923,12 +1999,13 @@ class TestImageStreamCompleter extends ImageStreamCompleter { ...@@ -1923,12 +1999,13 @@ class TestImageStreamCompleter extends ImageStreamCompleter {
ImageChunkEvent chunkEvent, ImageChunkEvent chunkEvent,
}) { }) {
if (imageInfo != null) { if (imageInfo != null) {
_currentImage?.dispose();
_currentImage = imageInfo; _currentImage = imageInfo;
} }
final List<ImageStreamListener> localListeners = listeners.toList(); final List<ImageStreamListener> localListeners = listeners.toList();
for (final ImageStreamListener listener in localListeners) { for (final ImageStreamListener listener in localListeners) {
if (imageInfo != null) { if (imageInfo != null) {
listener.onImage(imageInfo, false); listener.onImage(imageInfo.clone(), false);
} }
if (chunkEvent != null && listener.onChunk != null) { if (chunkEvent != null && listener.onChunk != null) {
listener.onChunk(chunkEvent); listener.onChunk(chunkEvent);
......
...@@ -166,7 +166,7 @@ class AnimationSheetBuilder { ...@@ -166,7 +166,7 @@ class AnimationSheetBuilder {
key: key, key: key,
cellSize: frameSize, cellSize: frameSize,
children: frames.map((ui.Image image) => RawImage( children: frames.map((ui.Image image) => RawImage(
image: image, image: image.clone(),
width: frameSize.width, width: frameSize.width,
height: frameSize.height, height: frameSize.height,
)).toList(), )).toList(),
......
...@@ -38,12 +38,12 @@ Future<ui.Image> createTestImage({ ...@@ -38,12 +38,12 @@ Future<ui.Image> createTestImage({
final int cacheKey = hashValues(width, height); final int cacheKey = hashValues(width, height);
if (cache && _cache.containsKey(cacheKey)) { if (cache && _cache.containsKey(cacheKey)) {
return _cache[cacheKey]!; return _cache[cacheKey]!.clone();
} }
final ui.Image image = await _createImage(width, height); final ui.Image image = await _createImage(width, height);
if (cache) { if (cache) {
_cache[cacheKey] = image; _cache[cacheKey] = image.clone();
} }
return image; return image;
}); });
......
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