mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
Live image cache (#51485)
* Reland "Live image cache" (#51441)
This reverts commit 419a2853a8
.
* Make evict safer
This commit is contained in:
parent
1a79592b4a
commit
c75db98337
@ -20,7 +20,7 @@ void main() {
|
||||
final developer.ServiceProtocolInfo info = await developer.Service.getInfo();
|
||||
|
||||
if (info.serverUri == null) {
|
||||
throw TestFailure('This test _must_ be run with --enable-vmservice.');
|
||||
fail('This test _must_ be run with --enable-vmservice.');
|
||||
}
|
||||
await timelineObtainer.connect(info.serverUri);
|
||||
await timelineObtainer.setDartFlags();
|
||||
@ -58,7 +58,8 @@ void main() {
|
||||
'name': 'ImageCache.clear',
|
||||
'args': <String, dynamic>{
|
||||
'pendingImages': 1,
|
||||
'cachedImages': 0,
|
||||
'keepAliveImages': 0,
|
||||
'liveImages': 1,
|
||||
'currentSizeInBytes': 0,
|
||||
'isolateId': isolateId,
|
||||
}
|
||||
@ -149,7 +150,7 @@ class TimelineObtainer {
|
||||
|
||||
Future<void> close() async {
|
||||
expect(_completers, isEmpty);
|
||||
await _observatorySocket.close();
|
||||
await _observatorySocket?.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -96,6 +96,7 @@ mixin PaintingBinding on BindingBase, ServicesBinding {
|
||||
void evict(String asset) {
|
||||
super.evict(asset);
|
||||
imageCache.clear();
|
||||
imageCache.clearLiveImages();
|
||||
}
|
||||
|
||||
/// Listenable that notifies when the available fonts on the system have
|
||||
|
@ -3,6 +3,7 @@
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:developer';
|
||||
import 'dart:ui' show hashValues;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@ -15,18 +16,24 @@ const int _kDefaultSizeBytes = 100 << 20; // 100 MiB
|
||||
///
|
||||
/// Implements a least-recently-used cache of up to 1000 images, and up to 100
|
||||
/// MB. The maximum size can be adjusted using [maximumSize] and
|
||||
/// [maximumSizeBytes]. Images that are actively in use (i.e. to which the
|
||||
/// application is holding references, either via [ImageStream] objects,
|
||||
/// [ImageStreamCompleter] objects, [ImageInfo] objects, or raw [dart:ui.Image]
|
||||
/// objects) may get evicted from the cache (and thus need to be refetched from
|
||||
/// the network if they are referenced in the [putIfAbsent] method), but the raw
|
||||
/// bits are kept in memory for as long as the application is using them.
|
||||
/// [maximumSizeBytes].
|
||||
///
|
||||
/// The cache also holds a list of "live" references. An image is considered
|
||||
/// live if its [ImageStreamCompleter]'s listener count has never dropped to
|
||||
/// zero after adding at least one listener. The cache uses
|
||||
/// [ImageStreamCompleter.addOnLastListenerRemovedCallback] to determine when
|
||||
/// this has happened.
|
||||
///
|
||||
/// The [putIfAbsent] method is the main entry-point to the cache API. It
|
||||
/// returns the previously cached [ImageStreamCompleter] for the given key, if
|
||||
/// available; if not, it calls the given callback to obtain it first. In either
|
||||
/// case, the key is moved to the "most recently used" position.
|
||||
///
|
||||
/// A caller can determine whether an image is already in the cache by using
|
||||
/// [containsKey], which will return true if the image is tracked by the cache
|
||||
/// in a pending or compelted state. More fine grained information is available
|
||||
/// by using the [statusForKey] method.
|
||||
///
|
||||
/// Generally this class is not used directly. The [ImageProvider] class and its
|
||||
/// subclasses automatically handle the caching of images.
|
||||
///
|
||||
@ -71,6 +78,11 @@ const int _kDefaultSizeBytes = 100 << 20; // 100 MiB
|
||||
class ImageCache {
|
||||
final Map<Object, _PendingImage> _pendingImages = <Object, _PendingImage>{};
|
||||
final Map<Object, _CachedImage> _cache = <Object, _CachedImage>{};
|
||||
/// ImageStreamCompleters with at least one listener. These images may or may
|
||||
/// not fit into the _pendingImages or _cache objects.
|
||||
///
|
||||
/// Unlike _cache, the [_CachedImage] for this may have a null byte size.
|
||||
final Map<Object, _CachedImage> _liveImages = <Object, _CachedImage>{};
|
||||
|
||||
/// Maximum number of entries to store in the cache.
|
||||
///
|
||||
@ -150,20 +162,27 @@ class ImageCache {
|
||||
int get currentSizeBytes => _currentSizeBytes;
|
||||
int _currentSizeBytes = 0;
|
||||
|
||||
/// Evicts all entries from the cache.
|
||||
/// Evicts all pending and keepAlive entries from the cache.
|
||||
///
|
||||
/// This is useful if, for instance, the root asset bundle has been updated
|
||||
/// and therefore new images must be obtained.
|
||||
///
|
||||
/// Images which have not finished loading yet will not be removed from the
|
||||
/// cache, and when they complete they will be inserted as normal.
|
||||
///
|
||||
/// This method does not clear live references to images, since clearing those
|
||||
/// would not reduce memory pressure. Such images still have listeners in the
|
||||
/// application code, and will still remain resident in memory.
|
||||
///
|
||||
/// To clear live references, use [clearLiveImages].
|
||||
void clear() {
|
||||
if (!kReleaseMode) {
|
||||
Timeline.instantSync(
|
||||
'ImageCache.clear',
|
||||
arguments: <String, dynamic>{
|
||||
'pendingImages': _pendingImages.length,
|
||||
'cachedImages': _cache.length,
|
||||
'keepAliveImages': _cache.length,
|
||||
'liveImages': _liveImages.length,
|
||||
'currentSizeInBytes': _currentSizeBytes,
|
||||
},
|
||||
);
|
||||
@ -174,11 +193,23 @@ class ImageCache {
|
||||
}
|
||||
|
||||
/// Evicts a single entry from the cache, returning true if successful.
|
||||
/// Pending images waiting for completion are removed as well, returning true
|
||||
/// if successful.
|
||||
///
|
||||
/// When a pending image is removed the listener on it is removed as well to
|
||||
/// prevent it from adding itself to the cache if it eventually completes.
|
||||
/// Pending images waiting for completion are removed as well, returning true
|
||||
/// if successful. When a pending image is removed the listener on it is
|
||||
/// removed as well to prevent it from adding itself to the cache if it
|
||||
/// eventually completes.
|
||||
///
|
||||
/// If this method removes a pending image, it will also remove
|
||||
/// the corresponding live tracking of the image, since it is no longer clear
|
||||
/// if the image will ever complete or have any listeners, and failing to
|
||||
/// remove the live reference could leave the cache in a state where all
|
||||
/// subsequent calls to [putIfAbsent] will return an [ImageStreamCompleter]
|
||||
/// that will never complete.
|
||||
///
|
||||
/// If this method removes a completed image, it will _not_ remove the live
|
||||
/// reference to the image, which will only be cleared when the listener
|
||||
/// count on the completer drops to zero. To clear live image references,
|
||||
/// whether completed or not, use [clearLiveImages].
|
||||
///
|
||||
/// The `key` must be equal to an object used to cache an image in
|
||||
/// [ImageCache.putIfAbsent].
|
||||
@ -186,10 +217,28 @@ class ImageCache {
|
||||
/// If the key is not immediately available, as is common, consider using
|
||||
/// [ImageProvider.evict] to call this method indirectly instead.
|
||||
///
|
||||
/// The `includeLive` argument determines whether images that still have
|
||||
/// listeners in the tree should be evicted as well. This parameter should be
|
||||
/// set to true in cases where the image may be corrupted and needs to be
|
||||
/// completely discarded by the cache. It should be set to false when calls
|
||||
/// to evict are trying to relieve memory pressure, since an image with a
|
||||
/// listener will not actually be evicted from memory, and subsequent attempts
|
||||
/// to load it will end up allocating more memory for the image again. The
|
||||
/// argument must not be null.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [ImageProvider], for providing images to the [Image] widget.
|
||||
bool evict(Object key) {
|
||||
bool evict(Object key, { bool includeLive = true }) {
|
||||
assert(includeLive != null);
|
||||
if (includeLive) {
|
||||
// Remove from live images - the cache will not be able to mark
|
||||
// it as complete, and it might be getting evicted because it
|
||||
// will never complete, e.g. it was loaded in a FakeAsync zone.
|
||||
// In such a case, we need to make sure subsequent calls to
|
||||
// putIfAbsent don't return this image that may never complete.
|
||||
_liveImages.remove(key);
|
||||
}
|
||||
final _PendingImage pendingImage = _pendingImages.remove(key);
|
||||
if (pendingImage != null) {
|
||||
if (!kReleaseMode) {
|
||||
@ -204,7 +253,7 @@ class ImageCache {
|
||||
if (image != null) {
|
||||
if (!kReleaseMode) {
|
||||
Timeline.instantSync('ImageCache.evict', arguments: <String, dynamic>{
|
||||
'type': 'completed',
|
||||
'type': 'keepAlive',
|
||||
'sizeiInBytes': image.sizeBytes,
|
||||
});
|
||||
}
|
||||
@ -219,6 +268,37 @@ class ImageCache {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Updates the least recently used image cache with this image, if it is
|
||||
/// less than the [maximumSizeBytes] of this cache.
|
||||
///
|
||||
/// Resizes the cache as appropriate to maintain the constraints of
|
||||
/// [maximumSize] and [maximumSizeBytes].
|
||||
void _touch(Object key, _CachedImage image, TimelineTask timelineTask) {
|
||||
// TODO(dnfield): Some customers test in release mode with asserts enabled.
|
||||
// This is bound to cause problems, b/150295238 is tracking that. For now,
|
||||
// avoid this being a point of failure.
|
||||
assert(kReleaseMode || timelineTask != null);
|
||||
if (image.sizeBytes != null && image.sizeBytes <= maximumSizeBytes) {
|
||||
_currentSizeBytes += image.sizeBytes;
|
||||
_cache[key] = image;
|
||||
_checkCacheSize(timelineTask);
|
||||
}
|
||||
}
|
||||
|
||||
void _trackLiveImage(Object key, _CachedImage image) {
|
||||
// Avoid adding unnecessary callbacks to the completer.
|
||||
_liveImages.putIfAbsent(key, () {
|
||||
// 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
|
||||
// image completes to move it from pending to keepAlive.
|
||||
// Even if the cache size is 0, we still add this listener.
|
||||
image.completer.addOnLastListenerRemovedCallback(() {
|
||||
_liveImages.remove(key);
|
||||
});
|
||||
return image;
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns the previously cached [ImageStream] for the given key, if available;
|
||||
/// if not, calls the given callback to obtain it first. In either case, the
|
||||
/// key is moved to the "most recently used" position.
|
||||
@ -252,17 +332,32 @@ class ImageCache {
|
||||
}
|
||||
// Remove the provider from the list so that we can move it to the
|
||||
// recently used position below.
|
||||
// Don't use _touch here, which would trigger a check on cache size that is
|
||||
// not needed since this is just moving an existing cache entry to the head.
|
||||
final _CachedImage image = _cache.remove(key);
|
||||
if (image != null) {
|
||||
if (!kReleaseMode) {
|
||||
timelineTask.finish(arguments: <String, dynamic>{'result': 'completed'});
|
||||
timelineTask.finish(arguments: <String, dynamic>{'result': 'keepAlive'});
|
||||
}
|
||||
// The image might have been keptAlive but had no listeners (so not live).
|
||||
// Make sure the cache starts tracking it as live again.
|
||||
_trackLiveImage(key, image);
|
||||
_cache[key] = image;
|
||||
return image.completer;
|
||||
}
|
||||
|
||||
final _CachedImage liveImage = _liveImages[key];
|
||||
if (liveImage != null) {
|
||||
_touch(key, liveImage, timelineTask);
|
||||
if (!kReleaseMode) {
|
||||
timelineTask.finish(arguments: <String, dynamic>{'result': 'keepAlive'});
|
||||
}
|
||||
return liveImage.completer;
|
||||
}
|
||||
|
||||
try {
|
||||
result = loader();
|
||||
_trackLiveImage(key, _CachedImage(result, null));
|
||||
} catch (error, stackTrace) {
|
||||
if (!kReleaseMode) {
|
||||
timelineTask.finish(arguments: <String, dynamic>{
|
||||
@ -282,21 +377,37 @@ class ImageCache {
|
||||
if (!kReleaseMode) {
|
||||
listenerTask = TimelineTask(parent: timelineTask)..start('listener');
|
||||
}
|
||||
// If we're doing tracing, we need to make sure that we don't try to finish
|
||||
// the trace entry multiple times if we get re-entrant calls from a multi-
|
||||
// frame provider here.
|
||||
bool listenedOnce = false;
|
||||
|
||||
// We shouldn't use the _pendingImages map if the cache is disabled, but we
|
||||
// will have to listen to the image at least once so we don't leak it in
|
||||
// the live image tracking.
|
||||
// If the cache is disabled, this variable will be set.
|
||||
_PendingImage untrackedPendingImage;
|
||||
void listener(ImageInfo info, bool syncCall) {
|
||||
// Images that fail to load don't contribute to cache size.
|
||||
final int imageSize = info?.image == null ? 0 : info.image.height * info.image.width * 4;
|
||||
|
||||
final _CachedImage image = _CachedImage(result, imageSize);
|
||||
final _PendingImage pendingImage = _pendingImages.remove(key);
|
||||
if (!_liveImages.containsKey(key)) {
|
||||
assert(syncCall);
|
||||
result.addOnLastListenerRemovedCallback(() {
|
||||
_liveImages.remove(key);
|
||||
});
|
||||
}
|
||||
_liveImages[key] = image;
|
||||
final _PendingImage pendingImage = untrackedPendingImage ?? _pendingImages.remove(key);
|
||||
if (pendingImage != null) {
|
||||
pendingImage.removeListener();
|
||||
}
|
||||
|
||||
if (imageSize <= maximumSizeBytes) {
|
||||
_currentSizeBytes += imageSize;
|
||||
_cache[key] = image;
|
||||
_checkCacheSize(listenerTask);
|
||||
// Only touch if the cache was enabled when resolve was initially called.
|
||||
if (untrackedPendingImage == null) {
|
||||
_touch(key, image, listenerTask);
|
||||
}
|
||||
|
||||
if (!kReleaseMode && !listenedOnce) {
|
||||
listenerTask.finish(arguments: <String, dynamic>{
|
||||
'syncCall': syncCall,
|
||||
@ -309,20 +420,58 @@ class ImageCache {
|
||||
}
|
||||
listenedOnce = true;
|
||||
}
|
||||
|
||||
final ImageStreamListener streamListener = ImageStreamListener(listener);
|
||||
if (maximumSize > 0 && maximumSizeBytes > 0) {
|
||||
final ImageStreamListener streamListener = ImageStreamListener(listener);
|
||||
_pendingImages[key] = _PendingImage(result, streamListener);
|
||||
// Listener is removed in [_PendingImage.removeListener].
|
||||
result.addListener(streamListener);
|
||||
} else {
|
||||
untrackedPendingImage = _PendingImage(result, streamListener);
|
||||
}
|
||||
// Listener is removed in [_PendingImage.removeListener].
|
||||
result.addListener(streamListener);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// The [ImageCacheStatus] information for the given `key`.
|
||||
ImageCacheStatus statusForKey(Object key) {
|
||||
return ImageCacheStatus._(
|
||||
pending: _pendingImages.containsKey(key),
|
||||
keepAlive: _cache.containsKey(key),
|
||||
live: _liveImages.containsKey(key),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns whether this `key` has been previously added by [putIfAbsent].
|
||||
bool containsKey(Object key) {
|
||||
return _pendingImages[key] != null || _cache[key] != null;
|
||||
}
|
||||
|
||||
/// The number of live images being held by the [ImageCache].
|
||||
///
|
||||
/// Compare with [ImageCache.currentSize] for keepAlive images.
|
||||
int get liveImageCount => _liveImages.length;
|
||||
|
||||
/// The number of images being tracked as pending in the [ImageCache].
|
||||
///
|
||||
/// Compare with [ImageCache.currentSize] for keepAlive images.
|
||||
int get pendingImageCount => _pendingImages.length;
|
||||
|
||||
/// Clears any live references to images in this cache.
|
||||
///
|
||||
/// An image is considered live if its [ImageStreamCompleter] has never hit
|
||||
/// zero listeners after adding at least one listener. The
|
||||
/// [ImageStreamCompleter.addOnLastListenerRemovedCallback] is used to
|
||||
/// determine when this has happened.
|
||||
///
|
||||
/// This is called after a hot reload to evict any stale references to image
|
||||
/// data for assets that have changed. Calling this method does not relieve
|
||||
/// memory pressure, since the live image caching only tracks image instances
|
||||
/// that are also being held by at least one other object.
|
||||
void clearLiveImages() {
|
||||
_liveImages.clear();
|
||||
}
|
||||
|
||||
// Remove images from the cache until both the length and bytes are below
|
||||
// maximum, or the cache is empty.
|
||||
void _checkCacheSize(TimelineTask timelineTask) {
|
||||
@ -354,6 +503,76 @@ class ImageCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about how the [ImageCache] is tracking an image.
|
||||
///
|
||||
/// A [pending] image is one that has not completed yet. It may also be tracked
|
||||
/// as [live] because something is listening to it.
|
||||
///
|
||||
/// A [keepAlive] image is being held in the cache, which uses Least Recently
|
||||
/// Used semantics to determine when to evict an image. These images are subject
|
||||
/// to eviction based on [ImageCache.maximumSizeBytes] and
|
||||
/// [ImageCache.maximumSize]. It may be [live], but not [pending].
|
||||
///
|
||||
/// A [live] image is being held until its [ImageStreamCompleter] has no more
|
||||
/// listeners. It may also be [pending] or [keepAlive].
|
||||
///
|
||||
/// An [untracked] image is not being cached.
|
||||
///
|
||||
/// To obtain an [ImageCacheStatus], use [ImageCache.statusForKey] or
|
||||
/// [ImageProvider.obtainCacheStatus].
|
||||
class ImageCacheStatus {
|
||||
const ImageCacheStatus._({
|
||||
this.pending = false,
|
||||
this.keepAlive = false,
|
||||
this.live = false,
|
||||
}) : assert(!pending || !keepAlive);
|
||||
|
||||
/// An image that has been submitted to [ImageCache.putIfAbsent], but
|
||||
/// not yet completed.
|
||||
final bool pending;
|
||||
|
||||
/// An image that has been submitted to [ImageCache.putIfAbsent], has
|
||||
/// completed, fits based on the sizing rules of the cache, and has not been
|
||||
/// evicted.
|
||||
///
|
||||
/// Such images will be kept alive even if [live] is false, as long
|
||||
/// as they have not been evicted from the cache based on its sizing rules.
|
||||
final bool keepAlive;
|
||||
|
||||
/// An image that has been submitted to [ImageCache.putIfAbsent] and has at
|
||||
/// least one listener on its [ImageStreamCompleter].
|
||||
///
|
||||
/// Such images may also be [keepAlive] if they fit in the cache based on its
|
||||
/// sizing rules. They may also be [pending] if they have not yet resolved.
|
||||
final bool live;
|
||||
|
||||
/// An image that is tracked in some way by the [ImageCache], whether
|
||||
/// [pending], [keepAlive], or [live].
|
||||
bool get tracked => pending || keepAlive || live;
|
||||
|
||||
/// An image that either has not been submitted to
|
||||
/// [ImageCache.putIfAbsent] or has otherwise been evicted from the
|
||||
/// [keepAlive] and [live] caches.
|
||||
bool get untracked => !pending && !keepAlive && !live;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
return other is ImageCacheStatus
|
||||
&& other.pending == pending
|
||||
&& other.keepAlive == keepAlive
|
||||
&& other.live == live;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(pending, keepAlive, live);
|
||||
|
||||
@override
|
||||
String toString() => '${objectRuntimeType(this, 'ImageCacheStatus')}(pending: $pending, live: $live, keepAlive: $keepAlive)';
|
||||
}
|
||||
|
||||
class _CachedImage {
|
||||
_CachedImage(this.completer, this.sizeBytes);
|
||||
|
||||
|
@ -17,6 +17,12 @@ import 'binding.dart';
|
||||
import 'image_cache.dart';
|
||||
import 'image_stream.dart';
|
||||
|
||||
/// Signature for the callback taken by [_createErrorHandlerAndKey].
|
||||
typedef _KeyAndErrorHandlerCallback<T> = void Function(T key, ImageErrorListener handleError);
|
||||
|
||||
/// Signature used for error handling by [_createErrorHandlerAndKey].
|
||||
typedef _AsyncKeyErrorHandler<T> = Future<void> Function(T key, dynamic exception, StackTrace stack);
|
||||
|
||||
/// Configuration information passed to the [ImageProvider.resolve] method to
|
||||
/// select a specific image.
|
||||
///
|
||||
@ -318,7 +324,28 @@ abstract class ImageProvider<T> {
|
||||
final ImageStream stream = createStream(configuration);
|
||||
// Load the key (potentially asynchronously), set up an error handling zone,
|
||||
// and call resolveStreamForKey.
|
||||
_createErrorHandlerAndKey(configuration, stream);
|
||||
_createErrorHandlerAndKey(
|
||||
configuration,
|
||||
(T key, ImageErrorListener errorHandler) {
|
||||
resolveStreamForKey(configuration, stream, key, errorHandler);
|
||||
},
|
||||
(T key, dynamic exception, StackTrace stack) async {
|
||||
await null; // wait an event turn in case a listener has been added to the image stream.
|
||||
final _ErrorImageCompleter imageCompleter = _ErrorImageCompleter();
|
||||
stream.setCompleter(imageCompleter);
|
||||
imageCompleter.setError(
|
||||
exception: exception,
|
||||
stack: stack,
|
||||
context: ErrorDescription('while resolving an image'),
|
||||
silent: true, // could be a network error or whatnot
|
||||
informationCollector: () sync* {
|
||||
yield DiagnosticsProperty<ImageProvider>('Image provider', this);
|
||||
yield DiagnosticsProperty<ImageConfiguration>('Image configuration', configuration);
|
||||
yield DiagnosticsProperty<T>('Image key', key, defaultValue: null);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
return stream;
|
||||
}
|
||||
|
||||
@ -332,30 +359,66 @@ abstract class ImageProvider<T> {
|
||||
return ImageStream();
|
||||
}
|
||||
|
||||
void _createErrorHandlerAndKey(ImageConfiguration configuration, ImageStream stream) {
|
||||
/// Returns the cache location for the key that this [ImageProvider] creates.
|
||||
///
|
||||
/// The location may be [ImageCacheStatus.untracked], indicating that this
|
||||
/// image provider's key is not available in the [ImageCache].
|
||||
///
|
||||
/// The `cache` and `configuration` parameters must not be null. If the
|
||||
/// `handleError` parameter is null, errors will be reported to
|
||||
/// [FlutterError.onError], and the method will return null.
|
||||
///
|
||||
/// A completed return value of null indicates that an error has occurred.
|
||||
Future<ImageCacheStatus> obtainCacheStatus({
|
||||
@required ImageConfiguration configuration,
|
||||
ImageErrorListener handleError,
|
||||
}) {
|
||||
assert(configuration != null);
|
||||
assert(stream != null);
|
||||
final Completer<ImageCacheStatus> completer = Completer<ImageCacheStatus>();
|
||||
_createErrorHandlerAndKey(
|
||||
configuration,
|
||||
(T key, ImageErrorListener innerHandleError) {
|
||||
completer.complete(PaintingBinding.instance.imageCache.statusForKey(key));
|
||||
},
|
||||
(T key, dynamic exception, StackTrace stack) async {
|
||||
if (handleError != null) {
|
||||
handleError(exception, stack);
|
||||
} else {
|
||||
FlutterError.onError(FlutterErrorDetails(
|
||||
context: ErrorDescription('while checking the cache location of an image'),
|
||||
informationCollector: () sync* {
|
||||
yield DiagnosticsProperty<ImageProvider>('Image provider', this);
|
||||
yield DiagnosticsProperty<ImageConfiguration>('Image configuration', configuration);
|
||||
yield DiagnosticsProperty<T>('Image key', key, defaultValue: null);
|
||||
},
|
||||
exception: exception,
|
||||
stack: stack,
|
||||
));
|
||||
completer.complete(null);
|
||||
}
|
||||
},
|
||||
);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// This method is used by both [resolve] and [obtainCacheStatus] to ensure
|
||||
/// that errors thrown during key creation are handled whether synchronous or
|
||||
/// asynchronous.
|
||||
void _createErrorHandlerAndKey(
|
||||
ImageConfiguration configuration,
|
||||
_KeyAndErrorHandlerCallback<T> successCallback,
|
||||
_AsyncKeyErrorHandler<T> errorCallback,
|
||||
) {
|
||||
T obtainedKey;
|
||||
bool didError = false;
|
||||
Future<void> handleError(dynamic exception, StackTrace stack) async {
|
||||
if (didError) {
|
||||
return;
|
||||
}
|
||||
if (!didError) {
|
||||
errorCallback(obtainedKey, exception, stack);
|
||||
}
|
||||
didError = true;
|
||||
await null; // wait an event turn in case a listener has been added to the image stream.
|
||||
final _ErrorImageCompleter imageCompleter = _ErrorImageCompleter();
|
||||
stream.setCompleter(imageCompleter);
|
||||
imageCompleter.setError(
|
||||
exception: exception,
|
||||
stack: stack,
|
||||
context: ErrorDescription('while resolving an image'),
|
||||
silent: true, // could be a network error or whatnot
|
||||
informationCollector: () sync* {
|
||||
yield DiagnosticsProperty<ImageProvider>('Image provider', this);
|
||||
yield DiagnosticsProperty<ImageConfiguration>('Image configuration', configuration);
|
||||
yield DiagnosticsProperty<T>('Image key', obtainedKey, defaultValue: null);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// If an error is added to a synchronous completer before a listener has been
|
||||
@ -384,7 +447,7 @@ abstract class ImageProvider<T> {
|
||||
key.then<void>((T key) {
|
||||
obtainedKey = key;
|
||||
try {
|
||||
resolveStreamForKey(configuration, stream, key, handleError);
|
||||
successCallback(key, handleError);
|
||||
} catch (error, stackTrace) {
|
||||
handleError(error, stackTrace);
|
||||
}
|
||||
|
@ -203,6 +203,11 @@ class ImageChunkEvent extends Diagnosticable {
|
||||
///
|
||||
/// ImageStream objects are backed by [ImageStreamCompleter] objects.
|
||||
///
|
||||
/// The [ImageCache] will consider an image to be live until the listener count
|
||||
/// drops to zero after adding at least one listener. The
|
||||
/// [addOnLastListenerRemovedCallback] method is used for tracking this
|
||||
/// information.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [ImageProvider], which has an example that includes the use of an
|
||||
@ -392,6 +397,23 @@ abstract class ImageStreamCompleter extends Diagnosticable {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (_listeners.isEmpty) {
|
||||
for (final VoidCallback callback in _onLastListenerRemovedCallbacks) {
|
||||
callback();
|
||||
}
|
||||
_onLastListenerRemovedCallbacks.clear();
|
||||
}
|
||||
}
|
||||
|
||||
final List<VoidCallback> _onLastListenerRemovedCallbacks = <VoidCallback>[];
|
||||
|
||||
/// Adds a callback to call when [removeListener] results in an empty
|
||||
/// list of listeners.
|
||||
///
|
||||
/// This callback will never fire if [removeListener] is never called.
|
||||
void addOnLastListenerRemovedCallback(VoidCallback callback) {
|
||||
assert(callback != null);
|
||||
_onLastListenerRemovedCallbacks.add(callback);
|
||||
}
|
||||
|
||||
/// Calls all the registered listeners to notify them of a new image.
|
||||
|
@ -8,6 +8,7 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/semantics.dart';
|
||||
|
||||
@ -65,7 +66,30 @@ ImageConfiguration createLocalImageConfiguration(BuildContext context, { Size si
|
||||
/// If the image is later used by an [Image] or [BoxDecoration] or [FadeInImage],
|
||||
/// it will probably be loaded faster. The consumer of the image does not need
|
||||
/// to use the same [ImageProvider] instance. The [ImageCache] will find the image
|
||||
/// as long as both images share the same key.
|
||||
/// as long as both images share the same key, and the image is held by the
|
||||
/// cache.
|
||||
///
|
||||
/// The cache may refuse to hold the image if it is disabled, the image is too
|
||||
/// large, or some other criteria implemented by a custom [ImageCache]
|
||||
/// implementation.
|
||||
///
|
||||
/// The [ImageCache] holds a reference to all images passed to [putIfAbsent] as
|
||||
/// long as their [ImageStreamCompleter] has at least one listener. This method
|
||||
/// will wait until the end of the frame after its future completes before
|
||||
/// releasing its own listener. This gives callers a chance to listen to the
|
||||
/// stream if necessary. A caller can determine if the image ended up in the
|
||||
/// cache by calling [ImageProvider.obtainCacheStatus]. If it is only held as
|
||||
/// [ImageCacheStatus.live], and the caller wishes to keep the resolved
|
||||
/// image in memory, the caller should immediately call `provider.resolve` and
|
||||
/// add a listener to the returned [ImageStream]. The image will remain pinned
|
||||
/// in memory at least until the caller removes its listener from the stream,
|
||||
/// even if it would not otherwise fit into the cache.
|
||||
///
|
||||
/// Callers should be cautious about pinning large images or a large number of
|
||||
/// images in memory, as this can result in running out of memory and being
|
||||
/// killed by the operating system. The lower the avilable physical memory, the
|
||||
/// more susceptible callers will be to running into OOM issues. These issues
|
||||
/// manifest as immediate process death, sometimes with no other error messages.
|
||||
///
|
||||
/// The [BuildContext] and [Size] are used to select an image configuration
|
||||
/// (see [createLocalImageConfiguration]).
|
||||
@ -91,7 +115,12 @@ Future<void> precacheImage(
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
stream.removeListener(listener);
|
||||
// Give callers until at least the end of the frame to subscribe to the
|
||||
// image stream.
|
||||
// See ImageCache._liveImages
|
||||
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
|
||||
stream.removeListener(listener);
|
||||
});
|
||||
},
|
||||
onError: (dynamic exception, StackTrace stackTrace) {
|
||||
if (!completer.isCompleted) {
|
||||
|
@ -3,7 +3,10 @@
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:typed_data' show Uint8List;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
@ -24,4 +27,88 @@ void main() {
|
||||
});
|
||||
expect(binding.instantiateImageCodecCalledCount, 1);
|
||||
});
|
||||
|
||||
test('evict clears live references', () async {
|
||||
final TestPaintingBinding binding = TestPaintingBinding();
|
||||
expect(binding.imageCache.clearCount, 0);
|
||||
expect(binding.imageCache.liveClearCount, 0);
|
||||
|
||||
binding.evict('/path/to/asset.png');
|
||||
expect(binding.imageCache.clearCount, 1);
|
||||
expect(binding.imageCache.liveClearCount, 1);
|
||||
});
|
||||
}
|
||||
|
||||
class TestBindingBase implements BindingBase {
|
||||
@override
|
||||
void initInstances() {}
|
||||
|
||||
@override
|
||||
void initServiceExtensions() {}
|
||||
|
||||
@override
|
||||
Future<void> lockEvents(Future<void> Function() callback) async {}
|
||||
|
||||
@override
|
||||
bool get locked => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<void> performReassemble() {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
void postEvent(String eventKind, Map<String, dynamic> eventData) {}
|
||||
|
||||
@override
|
||||
Future<void> reassembleApplication() {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
void registerBoolServiceExtension({String name, AsyncValueGetter<bool> getter, AsyncValueSetter<bool> setter}) {}
|
||||
|
||||
@override
|
||||
void registerNumericServiceExtension({String name, AsyncValueGetter<double> getter, AsyncValueSetter<double> setter}) {}
|
||||
|
||||
@override
|
||||
void registerServiceExtension({String name, ServiceExtensionCallback callback}) {}
|
||||
|
||||
@override
|
||||
void registerSignalServiceExtension({String name, AsyncCallback callback}) {}
|
||||
|
||||
@override
|
||||
void registerStringServiceExtension({String name, AsyncValueGetter<String> getter, AsyncValueSetter<String> setter}) {}
|
||||
|
||||
@override
|
||||
void unlocked() {}
|
||||
|
||||
@override
|
||||
Window get window => throw UnimplementedError();
|
||||
}
|
||||
|
||||
class TestPaintingBinding extends TestBindingBase with ServicesBinding, PaintingBinding {
|
||||
|
||||
@override
|
||||
final FakeImageCache imageCache = FakeImageCache();
|
||||
|
||||
@override
|
||||
ImageCache createImageCache() => imageCache;
|
||||
}
|
||||
|
||||
class FakeImageCache extends ImageCache {
|
||||
int clearCount = 0;
|
||||
int liveClearCount = 0;
|
||||
|
||||
@override
|
||||
void clear() {
|
||||
clearCount += 1;
|
||||
super.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
void clearLiveImages() {
|
||||
liveClearCount += 1;
|
||||
super.clearLiveImages();
|
||||
}
|
||||
}
|
@ -9,13 +9,14 @@ import '../rendering/rendering_tester.dart';
|
||||
import 'mocks_for_image_cache.dart';
|
||||
|
||||
void main() {
|
||||
group(ImageCache, () {
|
||||
group('ImageCache', () {
|
||||
setUpAll(() {
|
||||
TestRenderingFlutterBinding(); // initializes the imageCache
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
imageCache.clear();
|
||||
imageCache.clearLiveImages();
|
||||
imageCache.maximumSize = 1000;
|
||||
imageCache.maximumSizeBytes = 10485760;
|
||||
});
|
||||
@ -169,7 +170,14 @@ void main() {
|
||||
return completer1;
|
||||
}) as TestImageStreamCompleter;
|
||||
|
||||
expect(imageCache.statusForKey(testImage).pending, true);
|
||||
expect(imageCache.statusForKey(testImage).live, true);
|
||||
imageCache.clear();
|
||||
expect(imageCache.statusForKey(testImage).pending, false);
|
||||
expect(imageCache.statusForKey(testImage).live, true);
|
||||
imageCache.clearLiveImages();
|
||||
expect(imageCache.statusForKey(testImage).pending, false);
|
||||
expect(imageCache.statusForKey(testImage).live, false);
|
||||
|
||||
final TestImageStreamCompleter resultingCompleter2 = imageCache.putIfAbsent(testImage, () {
|
||||
return completer2;
|
||||
@ -240,7 +248,155 @@ void main() {
|
||||
|
||||
expect(resultingCompleter1, completer1);
|
||||
expect(imageCache.containsKey(testImage), true);
|
||||
});
|
||||
|
||||
test('putIfAbsent updates LRU properties of a live image', () async {
|
||||
imageCache.maximumSize = 1;
|
||||
const TestImage testImage = TestImage(width: 8, height: 8);
|
||||
const TestImage testImage2 = TestImage(width: 10, height: 10);
|
||||
|
||||
final TestImageStreamCompleter completer1 = TestImageStreamCompleter()..testSetImage(testImage);
|
||||
final TestImageStreamCompleter completer2 = TestImageStreamCompleter()..testSetImage(testImage2);
|
||||
|
||||
completer1.addListener(ImageStreamListener((ImageInfo info, bool syncCall) {}));
|
||||
|
||||
final TestImageStreamCompleter resultingCompleter1 = imageCache.putIfAbsent(testImage, () {
|
||||
return completer1;
|
||||
}) as TestImageStreamCompleter;
|
||||
|
||||
expect(imageCache.statusForKey(testImage).pending, false);
|
||||
expect(imageCache.statusForKey(testImage).keepAlive, true);
|
||||
expect(imageCache.statusForKey(testImage).live, true);
|
||||
expect(imageCache.statusForKey(testImage2).untracked, true);
|
||||
final TestImageStreamCompleter resultingCompleter2 = imageCache.putIfAbsent(testImage2, () {
|
||||
return completer2;
|
||||
}) as TestImageStreamCompleter;
|
||||
|
||||
|
||||
expect(imageCache.statusForKey(testImage).pending, false);
|
||||
expect(imageCache.statusForKey(testImage).keepAlive, false); // evicted
|
||||
expect(imageCache.statusForKey(testImage).live, true);
|
||||
expect(imageCache.statusForKey(testImage2).pending, false);
|
||||
expect(imageCache.statusForKey(testImage2).keepAlive, true); // took the LRU spot.
|
||||
expect(imageCache.statusForKey(testImage2).live, false); // no listeners
|
||||
|
||||
expect(resultingCompleter1, completer1);
|
||||
expect(resultingCompleter2, completer2);
|
||||
});
|
||||
|
||||
test('Live image cache avoids leaks of unlistened streams', () async {
|
||||
imageCache.maximumSize = 3;
|
||||
|
||||
const TestImageProvider(1, 1)..resolve(ImageConfiguration.empty);
|
||||
const TestImageProvider(2, 2)..resolve(ImageConfiguration.empty);
|
||||
const TestImageProvider(3, 3)..resolve(ImageConfiguration.empty);
|
||||
const TestImageProvider(4, 4)..resolve(ImageConfiguration.empty);
|
||||
const TestImageProvider(5, 5)..resolve(ImageConfiguration.empty);
|
||||
const TestImageProvider(6, 6)..resolve(ImageConfiguration.empty);
|
||||
|
||||
// wait an event loop to let image resolution process.
|
||||
await null;
|
||||
|
||||
expect(imageCache.currentSize, 3);
|
||||
expect(imageCache.liveImageCount, 0);
|
||||
});
|
||||
|
||||
test('Disabled image cache does not leak live images', () async {
|
||||
imageCache.maximumSize = 0;
|
||||
|
||||
const TestImageProvider(1, 1)..resolve(ImageConfiguration.empty);
|
||||
const TestImageProvider(2, 2)..resolve(ImageConfiguration.empty);
|
||||
const TestImageProvider(3, 3)..resolve(ImageConfiguration.empty);
|
||||
const TestImageProvider(4, 4)..resolve(ImageConfiguration.empty);
|
||||
const TestImageProvider(5, 5)..resolve(ImageConfiguration.empty);
|
||||
const TestImageProvider(6, 6)..resolve(ImageConfiguration.empty);
|
||||
|
||||
// wait an event loop to let image resolution process.
|
||||
await null;
|
||||
|
||||
expect(imageCache.currentSize, 0);
|
||||
expect(imageCache.liveImageCount, 0);
|
||||
});
|
||||
|
||||
test('Evicting a pending image clears the live image by default', () async {
|
||||
const TestImage testImage = TestImage(width: 8, height: 8);
|
||||
|
||||
final TestImageStreamCompleter completer1 = TestImageStreamCompleter();
|
||||
|
||||
imageCache.putIfAbsent(testImage, () => completer1);
|
||||
expect(imageCache.statusForKey(testImage).pending, true);
|
||||
expect(imageCache.statusForKey(testImage).live, true);
|
||||
expect(imageCache.statusForKey(testImage).keepAlive, false);
|
||||
|
||||
imageCache.evict(testImage);
|
||||
expect(imageCache.statusForKey(testImage).untracked, true);
|
||||
});
|
||||
|
||||
test('Evicting a pending image does clear the live image when includeLive is false and only cache listening', () async {
|
||||
const TestImage testImage = TestImage(width: 8, height: 8);
|
||||
|
||||
final TestImageStreamCompleter completer1 = TestImageStreamCompleter();
|
||||
|
||||
imageCache.putIfAbsent(testImage, () => completer1);
|
||||
expect(imageCache.statusForKey(testImage).pending, true);
|
||||
expect(imageCache.statusForKey(testImage).live, true);
|
||||
expect(imageCache.statusForKey(testImage).keepAlive, false);
|
||||
|
||||
imageCache.evict(testImage, includeLive: false);
|
||||
expect(imageCache.statusForKey(testImage).pending, false);
|
||||
expect(imageCache.statusForKey(testImage).live, false);
|
||||
expect(imageCache.statusForKey(testImage).keepAlive, false);
|
||||
});
|
||||
|
||||
test('Evicting a pending image does clear the live image when includeLive is false and some other listener', () async {
|
||||
const TestImage testImage = TestImage(width: 8, height: 8);
|
||||
|
||||
final TestImageStreamCompleter completer1 = TestImageStreamCompleter();
|
||||
|
||||
imageCache.putIfAbsent(testImage, () => completer1);
|
||||
expect(imageCache.statusForKey(testImage).pending, true);
|
||||
expect(imageCache.statusForKey(testImage).live, true);
|
||||
expect(imageCache.statusForKey(testImage).keepAlive, false);
|
||||
|
||||
completer1.addListener(ImageStreamListener((_, __) {}));
|
||||
imageCache.evict(testImage, includeLive: false);
|
||||
expect(imageCache.statusForKey(testImage).pending, false);
|
||||
expect(imageCache.statusForKey(testImage).live, true);
|
||||
expect(imageCache.statusForKey(testImage).keepAlive, false);
|
||||
});
|
||||
|
||||
test('Evicting a completed image does clear the live image by default', () async {
|
||||
const TestImage testImage = TestImage(width: 8, height: 8);
|
||||
|
||||
final TestImageStreamCompleter completer1 = TestImageStreamCompleter()
|
||||
..testSetImage(testImage)
|
||||
..addListener(ImageStreamListener((ImageInfo info, bool syncCall) {}));
|
||||
|
||||
imageCache.putIfAbsent(testImage, () => completer1);
|
||||
expect(imageCache.statusForKey(testImage).pending, false);
|
||||
expect(imageCache.statusForKey(testImage).live, true);
|
||||
expect(imageCache.statusForKey(testImage).keepAlive, true);
|
||||
|
||||
imageCache.evict(testImage);
|
||||
expect(imageCache.statusForKey(testImage).untracked, true);
|
||||
});
|
||||
|
||||
test('Evicting a completed image does not clear the live image when includeLive is set to false', () async {
|
||||
const TestImage testImage = TestImage(width: 8, height: 8);
|
||||
|
||||
final TestImageStreamCompleter completer1 = TestImageStreamCompleter()
|
||||
..testSetImage(testImage)
|
||||
..addListener(ImageStreamListener((ImageInfo info, bool syncCall) {}));
|
||||
|
||||
imageCache.putIfAbsent(testImage, () => completer1);
|
||||
expect(imageCache.statusForKey(testImage).pending, false);
|
||||
expect(imageCache.statusForKey(testImage).live, true);
|
||||
expect(imageCache.statusForKey(testImage).keepAlive, true);
|
||||
|
||||
imageCache.evict(testImage, includeLive: false);
|
||||
expect(imageCache.statusForKey(testImage).pending, false);
|
||||
expect(imageCache.statusForKey(testImage).live, true);
|
||||
expect(imageCache.statusForKey(testImage).keepAlive, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -36,6 +36,7 @@ void main() {
|
||||
tearDown(() {
|
||||
FlutterError.onError = oldError;
|
||||
PaintingBinding.instance.imageCache.clear();
|
||||
PaintingBinding.instance.imageCache.clearLiveImages();
|
||||
});
|
||||
|
||||
group('ImageProvider', () {
|
||||
@ -52,15 +53,18 @@ void main() {
|
||||
|
||||
const ImageProvider provider = ExactAssetImage('does-not-exist');
|
||||
final Object key = await provider.obtainKey(ImageConfiguration.empty);
|
||||
expect(imageCache.containsKey(provider), false);
|
||||
expect(imageCache.statusForKey(provider).untracked, true);
|
||||
expect(imageCache.pendingImageCount, 0);
|
||||
|
||||
provider.resolve(ImageConfiguration.empty);
|
||||
|
||||
expect(imageCache.containsKey(key), true);
|
||||
expect(imageCache.statusForKey(key).pending, true);
|
||||
expect(imageCache.pendingImageCount, 1);
|
||||
|
||||
await error.future;
|
||||
|
||||
expect(imageCache.containsKey(provider), false);
|
||||
expect(imageCache.statusForKey(provider).untracked, true);
|
||||
expect(imageCache.pendingImageCount, 0);
|
||||
}, skip: isBrowser);
|
||||
|
||||
test('AssetImageProvider - evicts on null load', () async {
|
||||
@ -71,15 +75,18 @@ void main() {
|
||||
|
||||
final ImageProvider provider = ExactAssetImage('does-not-exist', bundle: TestAssetBundle());
|
||||
final Object key = await provider.obtainKey(ImageConfiguration.empty);
|
||||
expect(imageCache.containsKey(key), false);
|
||||
expect(imageCache.statusForKey(provider).untracked, true);
|
||||
expect(imageCache.pendingImageCount, 0);
|
||||
|
||||
provider.resolve(ImageConfiguration.empty);
|
||||
|
||||
expect(imageCache.containsKey(key), true);
|
||||
expect(imageCache.statusForKey(key).pending, true);
|
||||
expect(imageCache.pendingImageCount, 1);
|
||||
|
||||
await error.future;
|
||||
|
||||
expect(imageCache.containsKey(key), false);
|
||||
expect(imageCache.statusForKey(provider).untracked, true);
|
||||
expect(imageCache.pendingImageCount, 0);
|
||||
}, skip: isBrowser);
|
||||
|
||||
test('ImageProvider can evict images', () async {
|
||||
@ -151,6 +158,17 @@ void main() {
|
||||
expect(await caughtError.future, true);
|
||||
});
|
||||
|
||||
test('obtainKey errors will be caught - check location', () async {
|
||||
final ImageProvider imageProvider = ObtainKeyErrorImageProvider();
|
||||
final Completer<bool> caughtError = Completer<bool>();
|
||||
FlutterError.onError = (FlutterErrorDetails details) {
|
||||
caughtError.complete(true);
|
||||
};
|
||||
await imageProvider.obtainCacheStatus(configuration: ImageConfiguration.empty);
|
||||
|
||||
expect(await caughtError.future, true);
|
||||
});
|
||||
|
||||
test('resolve sync errors will be caught', () async {
|
||||
bool uncaught = false;
|
||||
final Zone testZone = Zone.current.fork(specification: ZoneSpecification(
|
||||
@ -200,24 +218,26 @@ void main() {
|
||||
test('File image with empty file throws expected error and evicts from cache', () async {
|
||||
final Completer<StateError> error = Completer<StateError>();
|
||||
FlutterError.onError = (FlutterErrorDetails details) {
|
||||
print(details.exception);
|
||||
error.complete(details.exception as StateError);
|
||||
};
|
||||
final MemoryFileSystem fs = MemoryFileSystem();
|
||||
final File file = fs.file('/empty.png')..createSync(recursive: true);
|
||||
final FileImage provider = FileImage(file);
|
||||
|
||||
expect(imageCache.containsKey(provider), false);
|
||||
expect(imageCache.statusForKey(provider).untracked, true);
|
||||
expect(imageCache.pendingImageCount, 0);
|
||||
|
||||
provider.resolve(ImageConfiguration.empty);
|
||||
|
||||
expect(imageCache.containsKey(provider), true);
|
||||
expect(imageCache.statusForKey(provider).pending, true);
|
||||
expect(imageCache.pendingImageCount, 1);
|
||||
|
||||
expect(await error.future, isStateError);
|
||||
expect(imageCache.containsKey(provider), false);
|
||||
expect(imageCache.statusForKey(provider).untracked, true);
|
||||
expect(imageCache.pendingImageCount, 0);
|
||||
});
|
||||
|
||||
group(NetworkImage, () {
|
||||
group('NetworkImage', () {
|
||||
MockHttpClient httpClient;
|
||||
|
||||
setUp(() {
|
||||
@ -242,11 +262,13 @@ void main() {
|
||||
final Completer<dynamic> caughtError = Completer<dynamic>();
|
||||
|
||||
final ImageProvider imageProvider = NetworkImage(nonconst(requestUrl));
|
||||
expect(imageCache.containsKey(imageProvider), false);
|
||||
expect(imageCache.pendingImageCount, 0);
|
||||
expect(imageCache.statusForKey(imageProvider).untracked, true);
|
||||
|
||||
final ImageStream result = imageProvider.resolve(ImageConfiguration.empty);
|
||||
|
||||
expect(imageCache.containsKey(imageProvider), true);
|
||||
expect(imageCache.pendingImageCount, 1);
|
||||
expect(imageCache.statusForKey(imageProvider).pending, true);
|
||||
|
||||
result.addListener(ImageStreamListener((ImageInfo info, bool syncCall) {
|
||||
}, onError: (dynamic error, StackTrace stackTrace) {
|
||||
@ -255,7 +277,8 @@ void main() {
|
||||
|
||||
final dynamic err = await caughtError.future;
|
||||
|
||||
expect(imageCache.containsKey(imageProvider), false);
|
||||
expect(imageCache.pendingImageCount, 0);
|
||||
expect(imageCache.statusForKey(imageProvider).untracked, true);
|
||||
|
||||
expect(
|
||||
err,
|
||||
|
@ -8,6 +8,7 @@ import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@ -23,6 +24,18 @@ Future<ui.Image> createTestImage([List<int> bytes = kTransparentImage]) async {
|
||||
}
|
||||
|
||||
void main() {
|
||||
int originalCacheSize;
|
||||
|
||||
setUp(() {
|
||||
originalCacheSize = imageCache.maximumSize;
|
||||
imageCache.clear();
|
||||
imageCache.clearLiveImages();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
imageCache.maximumSize = originalCacheSize;
|
||||
});
|
||||
|
||||
testWidgets('Verify Image resets its RenderImage when changing providers', (WidgetTester tester) async {
|
||||
final GlobalKey key = GlobalKey();
|
||||
final TestImageProvider imageProvider1 = TestImageProvider();
|
||||
@ -763,7 +776,7 @@ void main() {
|
||||
expect(isSync, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('Precache remove listeners immediately after future completes, does not crash on successive calls #25143', (WidgetTester tester) async {
|
||||
testWidgets('Precache removes original listener immediately after future completes, does not crash on successive calls #25143', (WidgetTester tester) async {
|
||||
final TestImageStreamCompleter imageStreamCompleter = TestImageStreamCompleter();
|
||||
final TestImageProvider provider = TestImageProvider(streamCompleter: imageStreamCompleter);
|
||||
|
||||
@ -1364,6 +1377,197 @@ void main() {
|
||||
expect(imageProviders.skip(309 - 15).every(loadCalled), true);
|
||||
expect(imageProviders.take(309 - 15).every(loadNotCalled), true);
|
||||
});
|
||||
|
||||
testWidgets('Same image provider in multiple parts of the tree, no cache room left', (WidgetTester tester) async {
|
||||
imageCache.maximumSize = 0;
|
||||
|
||||
final ui.Image image = await tester.runAsync(createTestImage);
|
||||
final TestImageProvider provider1 = TestImageProvider();
|
||||
final TestImageProvider provider2 = TestImageProvider();
|
||||
|
||||
expect(provider1.loadCallCount, 0);
|
||||
expect(provider2.loadCallCount, 0);
|
||||
expect(imageCache.liveImageCount, 0);
|
||||
|
||||
await tester.pumpWidget(Column(
|
||||
children: <Widget>[
|
||||
Image(image: provider1),
|
||||
Image(image: provider2),
|
||||
Image(image: provider1),
|
||||
Image(image: provider1),
|
||||
Image(image: provider2),
|
||||
],
|
||||
));
|
||||
|
||||
expect(imageCache.liveImageCount, 2);
|
||||
expect(imageCache.statusForKey(provider1).live, true);
|
||||
expect(imageCache.statusForKey(provider1).pending, false);
|
||||
expect(imageCache.statusForKey(provider1).keepAlive, false);
|
||||
expect(imageCache.statusForKey(provider2).live, true);
|
||||
expect(imageCache.statusForKey(provider2).pending, false);
|
||||
expect(imageCache.statusForKey(provider2).keepAlive, false);
|
||||
|
||||
expect(provider1.loadCallCount, 1);
|
||||
expect(provider2.loadCallCount, 1);
|
||||
|
||||
provider1.complete(image);
|
||||
await tester.idle();
|
||||
|
||||
provider2.complete(image);
|
||||
await tester.idle();
|
||||
|
||||
expect(imageCache.liveImageCount, 2);
|
||||
expect(imageCache.currentSize, 0);
|
||||
|
||||
await tester.pumpWidget(Image(image: provider2));
|
||||
await tester.idle();
|
||||
expect(imageCache.statusForKey(provider1).untracked, true);
|
||||
expect(imageCache.statusForKey(provider2).live, true);
|
||||
expect(imageCache.statusForKey(provider2).pending, false);
|
||||
expect(imageCache.statusForKey(provider2).keepAlive, false);
|
||||
expect(imageCache.liveImageCount, 1);
|
||||
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.idle();
|
||||
expect(provider1.loadCallCount, 1);
|
||||
expect(provider2.loadCallCount, 1);
|
||||
expect(imageCache.liveImageCount, 0);
|
||||
});
|
||||
|
||||
testWidgets('precacheImage does not hold weak ref for more than a frame', (WidgetTester tester) async {
|
||||
imageCache.maximumSize = 0;
|
||||
final TestImageProvider provider = TestImageProvider();
|
||||
Future<void> precache;
|
||||
await tester.pumpWidget(
|
||||
Builder(
|
||||
builder: (BuildContext context) {
|
||||
precache = precacheImage(provider, context);
|
||||
return Container();
|
||||
}
|
||||
)
|
||||
);
|
||||
provider.complete();
|
||||
await precache;
|
||||
|
||||
// Should have ended up with only a weak ref, not in cache because cache size is 0
|
||||
expect(imageCache.liveImageCount, 1);
|
||||
expect(imageCache.containsKey(provider), false);
|
||||
|
||||
final ImageCacheStatus providerLocation = await provider.obtainCacheStatus(configuration: ImageConfiguration.empty);
|
||||
|
||||
expect(providerLocation, isNotNull);
|
||||
expect(providerLocation.live, true);
|
||||
expect(providerLocation.keepAlive, false);
|
||||
expect(providerLocation.pending, false);
|
||||
|
||||
// Check that a second resolve of the same image is synchronous.
|
||||
expect(provider._lastResolvedConfiguration, isNotNull);
|
||||
final ImageStream stream = provider.resolve(provider._lastResolvedConfiguration);
|
||||
bool isSync;
|
||||
final ImageStreamListener listener = ImageStreamListener((ImageInfo image, bool syncCall) { isSync = syncCall; });
|
||||
|
||||
// Still have live ref because frame has not pumped yet.
|
||||
await tester.pump();
|
||||
expect(imageCache.liveImageCount, 1);
|
||||
|
||||
SchedulerBinding.instance.scheduleFrame();
|
||||
await tester.pump();
|
||||
// Live ref should be gone - we didn't listen to the stream.
|
||||
expect(imageCache.liveImageCount, 0);
|
||||
expect(imageCache.currentSize, 0);
|
||||
|
||||
stream.addListener(listener);
|
||||
expect(isSync, true); // because the stream still has the image.
|
||||
|
||||
expect(imageCache.liveImageCount, 0);
|
||||
expect(imageCache.currentSize, 0);
|
||||
|
||||
expect(provider.loadCallCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('precacheImage allows time to take over weak refernce', (WidgetTester tester) async {
|
||||
final TestImageProvider provider = TestImageProvider();
|
||||
Future<void> precache;
|
||||
await tester.pumpWidget(
|
||||
Builder(
|
||||
builder: (BuildContext context) {
|
||||
precache = precacheImage(provider, context);
|
||||
return Container();
|
||||
}
|
||||
)
|
||||
);
|
||||
provider.complete();
|
||||
await precache;
|
||||
|
||||
// Should have ended up in the cache and have a weak reference.
|
||||
expect(imageCache.liveImageCount, 1);
|
||||
expect(imageCache.currentSize, 1);
|
||||
expect(imageCache.containsKey(provider), true);
|
||||
|
||||
// Check that a second resolve of the same image is synchronous.
|
||||
expect(provider._lastResolvedConfiguration, isNotNull);
|
||||
final ImageStream stream = provider.resolve(provider._lastResolvedConfiguration);
|
||||
bool isSync;
|
||||
final ImageStreamListener listener = ImageStreamListener((ImageInfo image, bool syncCall) { isSync = syncCall; });
|
||||
|
||||
// Should have ended up in the cache and still have a weak reference.
|
||||
expect(imageCache.liveImageCount, 1);
|
||||
expect(imageCache.currentSize, 1);
|
||||
expect(imageCache.containsKey(provider), true);
|
||||
|
||||
stream.addListener(listener);
|
||||
expect(isSync, true);
|
||||
|
||||
expect(imageCache.liveImageCount, 1);
|
||||
expect(imageCache.currentSize, 1);
|
||||
expect(imageCache.containsKey(provider), true);
|
||||
|
||||
SchedulerBinding.instance.scheduleFrame();
|
||||
await tester.pump();
|
||||
|
||||
expect(imageCache.liveImageCount, 1);
|
||||
expect(imageCache.currentSize, 1);
|
||||
expect(imageCache.containsKey(provider), true);
|
||||
stream.removeListener(listener);
|
||||
|
||||
expect(imageCache.liveImageCount, 0);
|
||||
expect(imageCache.currentSize, 1);
|
||||
expect(imageCache.containsKey(provider), true);
|
||||
expect(provider.loadCallCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('evict an image during precache', (WidgetTester tester) async {
|
||||
// This test checks that the live image tracking does not hold on to a
|
||||
// pending image that will never complete because it has been evicted from
|
||||
// the cache.
|
||||
// The scenario may arise in a test harness that is trying to load real
|
||||
// images using `tester.runAsync()`, and wants to make sure that widgets
|
||||
// under test have not also tried to resolve the image in a FakeAsync zone.
|
||||
// The image loaded in the FakeAsync zone will never complete, and the
|
||||
// runAsync call wants to make sure it gets a load attempt from the correct
|
||||
// zone.
|
||||
final Uint8List bytes = Uint8List.fromList(kTransparentImage);
|
||||
final MemoryImage provider = MemoryImage(bytes);
|
||||
|
||||
await tester.runAsync(() async {
|
||||
final List<Future<void>> futures = <Future<void>>[];
|
||||
await tester.pumpWidget(Builder(builder: (BuildContext context) {
|
||||
futures.add(precacheImage(provider, context));
|
||||
imageCache.evict(provider);
|
||||
futures.add(precacheImage(provider, context));
|
||||
return const SizedBox.expand();
|
||||
}));
|
||||
await Future.wait<void>(futures);
|
||||
expect(imageCache.statusForKey(provider).keepAlive, true);
|
||||
expect(imageCache.statusForKey(provider).live, true);
|
||||
|
||||
// Schedule a frame to get precacheImage to stop listening.
|
||||
SchedulerBinding.instance.scheduleFrame();
|
||||
await tester.pump();
|
||||
expect(imageCache.statusForKey(provider).keepAlive, true);
|
||||
expect(imageCache.statusForKey(provider).live, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class ConfigurationAwareKey {
|
||||
@ -1405,8 +1609,9 @@ class TestImageProvider extends ImageProvider<Object> {
|
||||
ImageStreamCompleter _streamCompleter;
|
||||
ImageConfiguration _lastResolvedConfiguration;
|
||||
|
||||
bool get loadCalled => _loadCalled;
|
||||
bool _loadCalled = false;
|
||||
bool get loadCalled => _loadCallCount > 0;
|
||||
int get loadCallCount => _loadCallCount;
|
||||
int _loadCallCount = 0;
|
||||
|
||||
@override
|
||||
Future<Object> obtainKey(ImageConfiguration configuration) {
|
||||
@ -1421,12 +1626,13 @@ class TestImageProvider extends ImageProvider<Object> {
|
||||
|
||||
@override
|
||||
ImageStreamCompleter load(Object key, DecoderCallback decode) {
|
||||
_loadCalled = true;
|
||||
_loadCallCount += 1;
|
||||
return _streamCompleter;
|
||||
}
|
||||
|
||||
void complete() {
|
||||
_completer.complete(ImageInfo(image: TestImage()));
|
||||
void complete([ui.Image image]) {
|
||||
image ??= TestImage();
|
||||
_completer.complete(ImageInfo(image: image));
|
||||
}
|
||||
|
||||
void fail(dynamic exception, StackTrace stackTrace) {
|
||||
|
Loading…
Reference in New Issue
Block a user