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();
|
final developer.ServiceProtocolInfo info = await developer.Service.getInfo();
|
||||||
|
|
||||||
if (info.serverUri == null) {
|
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.connect(info.serverUri);
|
||||||
await timelineObtainer.setDartFlags();
|
await timelineObtainer.setDartFlags();
|
||||||
@ -58,7 +58,8 @@ void main() {
|
|||||||
'name': 'ImageCache.clear',
|
'name': 'ImageCache.clear',
|
||||||
'args': <String, dynamic>{
|
'args': <String, dynamic>{
|
||||||
'pendingImages': 1,
|
'pendingImages': 1,
|
||||||
'cachedImages': 0,
|
'keepAliveImages': 0,
|
||||||
|
'liveImages': 1,
|
||||||
'currentSizeInBytes': 0,
|
'currentSizeInBytes': 0,
|
||||||
'isolateId': isolateId,
|
'isolateId': isolateId,
|
||||||
}
|
}
|
||||||
@ -149,7 +150,7 @@ class TimelineObtainer {
|
|||||||
|
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
expect(_completers, isEmpty);
|
expect(_completers, isEmpty);
|
||||||
await _observatorySocket.close();
|
await _observatorySocket?.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -96,6 +96,7 @@ mixin PaintingBinding on BindingBase, ServicesBinding {
|
|||||||
void evict(String asset) {
|
void evict(String asset) {
|
||||||
super.evict(asset);
|
super.evict(asset);
|
||||||
imageCache.clear();
|
imageCache.clear();
|
||||||
|
imageCache.clearLiveImages();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Listenable that notifies when the available fonts on the system have
|
/// Listenable that notifies when the available fonts on the system have
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
// found in the LICENSE file.
|
// found in the LICENSE file.
|
||||||
|
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
import 'dart:ui' show hashValues;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
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
|
/// 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
|
/// MB. The maximum size can be adjusted using [maximumSize] and
|
||||||
/// [maximumSizeBytes]. Images that are actively in use (i.e. to which the
|
/// [maximumSizeBytes].
|
||||||
/// application is holding references, either via [ImageStream] objects,
|
///
|
||||||
/// [ImageStreamCompleter] objects, [ImageInfo] objects, or raw [dart:ui.Image]
|
/// The cache also holds a list of "live" references. An image is considered
|
||||||
/// objects) may get evicted from the cache (and thus need to be refetched from
|
/// live if its [ImageStreamCompleter]'s listener count has never dropped to
|
||||||
/// the network if they are referenced in the [putIfAbsent] method), but the raw
|
/// zero after adding at least one listener. The cache uses
|
||||||
/// bits are kept in memory for as long as the application is using them.
|
/// [ImageStreamCompleter.addOnLastListenerRemovedCallback] to determine when
|
||||||
|
/// this has happened.
|
||||||
///
|
///
|
||||||
/// The [putIfAbsent] method is the main entry-point to the cache API. It
|
/// The [putIfAbsent] method is the main entry-point to the cache API. It
|
||||||
/// returns the previously cached [ImageStreamCompleter] for the given key, if
|
/// returns the previously cached [ImageStreamCompleter] for the given key, if
|
||||||
/// available; if not, it calls the given callback to obtain it first. In either
|
/// 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.
|
/// 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
|
/// Generally this class is not used directly. The [ImageProvider] class and its
|
||||||
/// subclasses automatically handle the caching of images.
|
/// subclasses automatically handle the caching of images.
|
||||||
///
|
///
|
||||||
@ -71,6 +78,11 @@ const int _kDefaultSizeBytes = 100 << 20; // 100 MiB
|
|||||||
class ImageCache {
|
class ImageCache {
|
||||||
final Map<Object, _PendingImage> _pendingImages = <Object, _PendingImage>{};
|
final Map<Object, _PendingImage> _pendingImages = <Object, _PendingImage>{};
|
||||||
final Map<Object, _CachedImage> _cache = <Object, _CachedImage>{};
|
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.
|
/// Maximum number of entries to store in the cache.
|
||||||
///
|
///
|
||||||
@ -150,20 +162,27 @@ class ImageCache {
|
|||||||
int get currentSizeBytes => _currentSizeBytes;
|
int get currentSizeBytes => _currentSizeBytes;
|
||||||
int _currentSizeBytes = 0;
|
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
|
/// This is useful if, for instance, the root asset bundle has been updated
|
||||||
/// and therefore new images must be obtained.
|
/// and therefore new images must be obtained.
|
||||||
///
|
///
|
||||||
/// Images which have not finished loading yet will not be removed from the
|
/// Images which have not finished loading yet will not be removed from the
|
||||||
/// cache, and when they complete they will be inserted as normal.
|
/// 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() {
|
void clear() {
|
||||||
if (!kReleaseMode) {
|
if (!kReleaseMode) {
|
||||||
Timeline.instantSync(
|
Timeline.instantSync(
|
||||||
'ImageCache.clear',
|
'ImageCache.clear',
|
||||||
arguments: <String, dynamic>{
|
arguments: <String, dynamic>{
|
||||||
'pendingImages': _pendingImages.length,
|
'pendingImages': _pendingImages.length,
|
||||||
'cachedImages': _cache.length,
|
'keepAliveImages': _cache.length,
|
||||||
|
'liveImages': _liveImages.length,
|
||||||
'currentSizeInBytes': _currentSizeBytes,
|
'currentSizeInBytes': _currentSizeBytes,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -174,11 +193,23 @@ class ImageCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Evicts a single entry from the cache, returning true if successful.
|
/// 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
|
/// Pending images waiting for completion are removed as well, returning true
|
||||||
/// prevent it from adding itself to the cache if it eventually completes.
|
/// 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
|
/// The `key` must be equal to an object used to cache an image in
|
||||||
/// [ImageCache.putIfAbsent].
|
/// [ImageCache.putIfAbsent].
|
||||||
@ -186,10 +217,28 @@ class ImageCache {
|
|||||||
/// If the key is not immediately available, as is common, consider using
|
/// If the key is not immediately available, as is common, consider using
|
||||||
/// [ImageProvider.evict] to call this method indirectly instead.
|
/// [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:
|
/// See also:
|
||||||
///
|
///
|
||||||
/// * [ImageProvider], for providing images to the [Image] widget.
|
/// * [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);
|
final _PendingImage pendingImage = _pendingImages.remove(key);
|
||||||
if (pendingImage != null) {
|
if (pendingImage != null) {
|
||||||
if (!kReleaseMode) {
|
if (!kReleaseMode) {
|
||||||
@ -204,7 +253,7 @@ class ImageCache {
|
|||||||
if (image != null) {
|
if (image != null) {
|
||||||
if (!kReleaseMode) {
|
if (!kReleaseMode) {
|
||||||
Timeline.instantSync('ImageCache.evict', arguments: <String, dynamic>{
|
Timeline.instantSync('ImageCache.evict', arguments: <String, dynamic>{
|
||||||
'type': 'completed',
|
'type': 'keepAlive',
|
||||||
'sizeiInBytes': image.sizeBytes,
|
'sizeiInBytes': image.sizeBytes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -219,6 +268,37 @@ class ImageCache {
|
|||||||
return false;
|
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;
|
/// 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
|
/// if not, calls the given callback to obtain it first. In either case, the
|
||||||
/// key is moved to the "most recently used" position.
|
/// 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
|
// Remove the provider from the list so that we can move it to the
|
||||||
// recently used position below.
|
// 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);
|
final _CachedImage image = _cache.remove(key);
|
||||||
if (image != null) {
|
if (image != null) {
|
||||||
if (!kReleaseMode) {
|
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;
|
_cache[key] = image;
|
||||||
return image.completer;
|
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 {
|
try {
|
||||||
result = loader();
|
result = loader();
|
||||||
|
_trackLiveImage(key, _CachedImage(result, null));
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
if (!kReleaseMode) {
|
if (!kReleaseMode) {
|
||||||
timelineTask.finish(arguments: <String, dynamic>{
|
timelineTask.finish(arguments: <String, dynamic>{
|
||||||
@ -282,21 +377,37 @@ class ImageCache {
|
|||||||
if (!kReleaseMode) {
|
if (!kReleaseMode) {
|
||||||
listenerTask = TimelineTask(parent: timelineTask)..start('listener');
|
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;
|
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) {
|
void listener(ImageInfo info, bool syncCall) {
|
||||||
// Images that fail to load don't contribute to cache size.
|
// 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 int imageSize = info?.image == null ? 0 : info.image.height * info.image.width * 4;
|
||||||
|
|
||||||
final _CachedImage image = _CachedImage(result, imageSize);
|
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) {
|
if (pendingImage != null) {
|
||||||
pendingImage.removeListener();
|
pendingImage.removeListener();
|
||||||
}
|
}
|
||||||
|
// Only touch if the cache was enabled when resolve was initially called.
|
||||||
if (imageSize <= maximumSizeBytes) {
|
if (untrackedPendingImage == null) {
|
||||||
_currentSizeBytes += imageSize;
|
_touch(key, image, listenerTask);
|
||||||
_cache[key] = image;
|
|
||||||
_checkCacheSize(listenerTask);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!kReleaseMode && !listenedOnce) {
|
if (!kReleaseMode && !listenedOnce) {
|
||||||
listenerTask.finish(arguments: <String, dynamic>{
|
listenerTask.finish(arguments: <String, dynamic>{
|
||||||
'syncCall': syncCall,
|
'syncCall': syncCall,
|
||||||
@ -309,20 +420,58 @@ class ImageCache {
|
|||||||
}
|
}
|
||||||
listenedOnce = true;
|
listenedOnce = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final ImageStreamListener streamListener = ImageStreamListener(listener);
|
||||||
if (maximumSize > 0 && maximumSizeBytes > 0) {
|
if (maximumSize > 0 && maximumSizeBytes > 0) {
|
||||||
final ImageStreamListener streamListener = ImageStreamListener(listener);
|
|
||||||
_pendingImages[key] = _PendingImage(result, streamListener);
|
_pendingImages[key] = _PendingImage(result, streamListener);
|
||||||
// Listener is removed in [_PendingImage.removeListener].
|
} else {
|
||||||
result.addListener(streamListener);
|
untrackedPendingImage = _PendingImage(result, streamListener);
|
||||||
}
|
}
|
||||||
|
// Listener is removed in [_PendingImage.removeListener].
|
||||||
|
result.addListener(streamListener);
|
||||||
|
|
||||||
return result;
|
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].
|
/// Returns whether this `key` has been previously added by [putIfAbsent].
|
||||||
bool containsKey(Object key) {
|
bool containsKey(Object key) {
|
||||||
return _pendingImages[key] != null || _cache[key] != null;
|
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
|
// Remove images from the cache until both the length and bytes are below
|
||||||
// maximum, or the cache is empty.
|
// maximum, or the cache is empty.
|
||||||
void _checkCacheSize(TimelineTask timelineTask) {
|
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 {
|
class _CachedImage {
|
||||||
_CachedImage(this.completer, this.sizeBytes);
|
_CachedImage(this.completer, this.sizeBytes);
|
||||||
|
|
||||||
|
@ -17,6 +17,12 @@ import 'binding.dart';
|
|||||||
import 'image_cache.dart';
|
import 'image_cache.dart';
|
||||||
import 'image_stream.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
|
/// Configuration information passed to the [ImageProvider.resolve] method to
|
||||||
/// select a specific image.
|
/// select a specific image.
|
||||||
///
|
///
|
||||||
@ -318,7 +324,28 @@ abstract class ImageProvider<T> {
|
|||||||
final ImageStream stream = createStream(configuration);
|
final ImageStream stream = createStream(configuration);
|
||||||
// Load the key (potentially asynchronously), set up an error handling zone,
|
// Load the key (potentially asynchronously), set up an error handling zone,
|
||||||
// and call resolveStreamForKey.
|
// 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;
|
return stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -332,30 +359,66 @@ abstract class ImageProvider<T> {
|
|||||||
return ImageStream();
|
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(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;
|
T obtainedKey;
|
||||||
bool didError = false;
|
bool didError = false;
|
||||||
Future<void> handleError(dynamic exception, StackTrace stack) async {
|
Future<void> handleError(dynamic exception, StackTrace stack) async {
|
||||||
if (didError) {
|
if (didError) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!didError) {
|
||||||
|
errorCallback(obtainedKey, exception, stack);
|
||||||
|
}
|
||||||
didError = true;
|
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
|
// 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) {
|
key.then<void>((T key) {
|
||||||
obtainedKey = key;
|
obtainedKey = key;
|
||||||
try {
|
try {
|
||||||
resolveStreamForKey(configuration, stream, key, handleError);
|
successCallback(key, handleError);
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
handleError(error, stackTrace);
|
handleError(error, stackTrace);
|
||||||
}
|
}
|
||||||
|
@ -203,6 +203,11 @@ class ImageChunkEvent extends Diagnosticable {
|
|||||||
///
|
///
|
||||||
/// ImageStream objects are backed by [ImageStreamCompleter] objects.
|
/// 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:
|
/// See also:
|
||||||
///
|
///
|
||||||
/// * [ImageProvider], which has an example that includes the use of an
|
/// * [ImageProvider], which has an example that includes the use of an
|
||||||
@ -392,6 +397,23 @@ abstract class ImageStreamCompleter extends Diagnosticable {
|
|||||||
break;
|
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.
|
/// 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/foundation.dart';
|
||||||
import 'package:flutter/painting.dart';
|
import 'package:flutter/painting.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/semantics.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],
|
/// 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
|
/// 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
|
/// 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
|
/// The [BuildContext] and [Size] are used to select an image configuration
|
||||||
/// (see [createLocalImageConfiguration]).
|
/// (see [createLocalImageConfiguration]).
|
||||||
@ -91,7 +115,12 @@ Future<void> precacheImage(
|
|||||||
if (!completer.isCompleted) {
|
if (!completer.isCompleted) {
|
||||||
completer.complete();
|
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) {
|
onError: (dynamic exception, StackTrace stackTrace) {
|
||||||
if (!completer.isCompleted) {
|
if (!completer.isCompleted) {
|
||||||
|
@ -3,7 +3,10 @@
|
|||||||
// found in the LICENSE file.
|
// found in the LICENSE file.
|
||||||
|
|
||||||
import 'dart:typed_data' show Uint8List;
|
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_test/flutter_test.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter/painting.dart';
|
import 'package:flutter/painting.dart';
|
||||||
@ -24,4 +27,88 @@ void main() {
|
|||||||
});
|
});
|
||||||
expect(binding.instantiateImageCodecCalledCount, 1);
|
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';
|
import 'mocks_for_image_cache.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group(ImageCache, () {
|
group('ImageCache', () {
|
||||||
setUpAll(() {
|
setUpAll(() {
|
||||||
TestRenderingFlutterBinding(); // initializes the imageCache
|
TestRenderingFlutterBinding(); // initializes the imageCache
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() {
|
tearDown(() {
|
||||||
imageCache.clear();
|
imageCache.clear();
|
||||||
|
imageCache.clearLiveImages();
|
||||||
imageCache.maximumSize = 1000;
|
imageCache.maximumSize = 1000;
|
||||||
imageCache.maximumSizeBytes = 10485760;
|
imageCache.maximumSizeBytes = 10485760;
|
||||||
});
|
});
|
||||||
@ -169,7 +170,14 @@ void main() {
|
|||||||
return completer1;
|
return completer1;
|
||||||
}) as TestImageStreamCompleter;
|
}) as TestImageStreamCompleter;
|
||||||
|
|
||||||
|
expect(imageCache.statusForKey(testImage).pending, true);
|
||||||
|
expect(imageCache.statusForKey(testImage).live, true);
|
||||||
imageCache.clear();
|
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, () {
|
final TestImageStreamCompleter resultingCompleter2 = imageCache.putIfAbsent(testImage, () {
|
||||||
return completer2;
|
return completer2;
|
||||||
@ -240,7 +248,155 @@ void main() {
|
|||||||
|
|
||||||
expect(resultingCompleter1, completer1);
|
expect(resultingCompleter1, completer1);
|
||||||
expect(imageCache.containsKey(testImage), true);
|
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(() {
|
tearDown(() {
|
||||||
FlutterError.onError = oldError;
|
FlutterError.onError = oldError;
|
||||||
PaintingBinding.instance.imageCache.clear();
|
PaintingBinding.instance.imageCache.clear();
|
||||||
|
PaintingBinding.instance.imageCache.clearLiveImages();
|
||||||
});
|
});
|
||||||
|
|
||||||
group('ImageProvider', () {
|
group('ImageProvider', () {
|
||||||
@ -52,15 +53,18 @@ void main() {
|
|||||||
|
|
||||||
const ImageProvider provider = ExactAssetImage('does-not-exist');
|
const ImageProvider provider = ExactAssetImage('does-not-exist');
|
||||||
final Object key = await provider.obtainKey(ImageConfiguration.empty);
|
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);
|
provider.resolve(ImageConfiguration.empty);
|
||||||
|
|
||||||
expect(imageCache.containsKey(key), true);
|
expect(imageCache.statusForKey(key).pending, true);
|
||||||
|
expect(imageCache.pendingImageCount, 1);
|
||||||
|
|
||||||
await error.future;
|
await error.future;
|
||||||
|
|
||||||
expect(imageCache.containsKey(provider), false);
|
expect(imageCache.statusForKey(provider).untracked, true);
|
||||||
|
expect(imageCache.pendingImageCount, 0);
|
||||||
}, skip: isBrowser);
|
}, skip: isBrowser);
|
||||||
|
|
||||||
test('AssetImageProvider - evicts on null load', () async {
|
test('AssetImageProvider - evicts on null load', () async {
|
||||||
@ -71,15 +75,18 @@ void main() {
|
|||||||
|
|
||||||
final ImageProvider provider = ExactAssetImage('does-not-exist', bundle: TestAssetBundle());
|
final ImageProvider provider = ExactAssetImage('does-not-exist', bundle: TestAssetBundle());
|
||||||
final Object key = await provider.obtainKey(ImageConfiguration.empty);
|
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);
|
provider.resolve(ImageConfiguration.empty);
|
||||||
|
|
||||||
expect(imageCache.containsKey(key), true);
|
expect(imageCache.statusForKey(key).pending, true);
|
||||||
|
expect(imageCache.pendingImageCount, 1);
|
||||||
|
|
||||||
await error.future;
|
await error.future;
|
||||||
|
|
||||||
expect(imageCache.containsKey(key), false);
|
expect(imageCache.statusForKey(provider).untracked, true);
|
||||||
|
expect(imageCache.pendingImageCount, 0);
|
||||||
}, skip: isBrowser);
|
}, skip: isBrowser);
|
||||||
|
|
||||||
test('ImageProvider can evict images', () async {
|
test('ImageProvider can evict images', () async {
|
||||||
@ -151,6 +158,17 @@ void main() {
|
|||||||
expect(await caughtError.future, true);
|
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 {
|
test('resolve sync errors will be caught', () async {
|
||||||
bool uncaught = false;
|
bool uncaught = false;
|
||||||
final Zone testZone = Zone.current.fork(specification: ZoneSpecification(
|
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 {
|
test('File image with empty file throws expected error and evicts from cache', () async {
|
||||||
final Completer<StateError> error = Completer<StateError>();
|
final Completer<StateError> error = Completer<StateError>();
|
||||||
FlutterError.onError = (FlutterErrorDetails details) {
|
FlutterError.onError = (FlutterErrorDetails details) {
|
||||||
print(details.exception);
|
|
||||||
error.complete(details.exception as StateError);
|
error.complete(details.exception as StateError);
|
||||||
};
|
};
|
||||||
final MemoryFileSystem fs = MemoryFileSystem();
|
final MemoryFileSystem fs = MemoryFileSystem();
|
||||||
final File file = fs.file('/empty.png')..createSync(recursive: true);
|
final File file = fs.file('/empty.png')..createSync(recursive: true);
|
||||||
final FileImage provider = FileImage(file);
|
final FileImage provider = FileImage(file);
|
||||||
|
|
||||||
expect(imageCache.containsKey(provider), false);
|
expect(imageCache.statusForKey(provider).untracked, true);
|
||||||
|
expect(imageCache.pendingImageCount, 0);
|
||||||
|
|
||||||
provider.resolve(ImageConfiguration.empty);
|
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(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;
|
MockHttpClient httpClient;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
@ -242,11 +262,13 @@ void main() {
|
|||||||
final Completer<dynamic> caughtError = Completer<dynamic>();
|
final Completer<dynamic> caughtError = Completer<dynamic>();
|
||||||
|
|
||||||
final ImageProvider imageProvider = NetworkImage(nonconst(requestUrl));
|
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);
|
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) {
|
result.addListener(ImageStreamListener((ImageInfo info, bool syncCall) {
|
||||||
}, onError: (dynamic error, StackTrace stackTrace) {
|
}, onError: (dynamic error, StackTrace stackTrace) {
|
||||||
@ -255,7 +277,8 @@ void main() {
|
|||||||
|
|
||||||
final dynamic err = await caughtError.future;
|
final dynamic err = await caughtError.future;
|
||||||
|
|
||||||
expect(imageCache.containsKey(imageProvider), false);
|
expect(imageCache.pendingImageCount, 0);
|
||||||
|
expect(imageCache.statusForKey(imageProvider).untracked, true);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
err,
|
err,
|
||||||
|
@ -8,6 +8,7 @@ import 'dart:ui' as ui;
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
@ -23,6 +24,18 @@ Future<ui.Image> createTestImage([List<int> bytes = kTransparentImage]) async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
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 {
|
testWidgets('Verify Image resets its RenderImage when changing providers', (WidgetTester tester) async {
|
||||||
final GlobalKey key = GlobalKey();
|
final GlobalKey key = GlobalKey();
|
||||||
final TestImageProvider imageProvider1 = TestImageProvider();
|
final TestImageProvider imageProvider1 = TestImageProvider();
|
||||||
@ -763,7 +776,7 @@ void main() {
|
|||||||
expect(isSync, isTrue);
|
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 TestImageStreamCompleter imageStreamCompleter = TestImageStreamCompleter();
|
||||||
final TestImageProvider provider = TestImageProvider(streamCompleter: imageStreamCompleter);
|
final TestImageProvider provider = TestImageProvider(streamCompleter: imageStreamCompleter);
|
||||||
|
|
||||||
@ -1364,6 +1377,197 @@ void main() {
|
|||||||
expect(imageProviders.skip(309 - 15).every(loadCalled), true);
|
expect(imageProviders.skip(309 - 15).every(loadCalled), true);
|
||||||
expect(imageProviders.take(309 - 15).every(loadNotCalled), 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 {
|
class ConfigurationAwareKey {
|
||||||
@ -1405,8 +1609,9 @@ class TestImageProvider extends ImageProvider<Object> {
|
|||||||
ImageStreamCompleter _streamCompleter;
|
ImageStreamCompleter _streamCompleter;
|
||||||
ImageConfiguration _lastResolvedConfiguration;
|
ImageConfiguration _lastResolvedConfiguration;
|
||||||
|
|
||||||
bool get loadCalled => _loadCalled;
|
bool get loadCalled => _loadCallCount > 0;
|
||||||
bool _loadCalled = false;
|
int get loadCallCount => _loadCallCount;
|
||||||
|
int _loadCallCount = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Object> obtainKey(ImageConfiguration configuration) {
|
Future<Object> obtainKey(ImageConfiguration configuration) {
|
||||||
@ -1421,12 +1626,13 @@ class TestImageProvider extends ImageProvider<Object> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
ImageStreamCompleter load(Object key, DecoderCallback decode) {
|
ImageStreamCompleter load(Object key, DecoderCallback decode) {
|
||||||
_loadCalled = true;
|
_loadCallCount += 1;
|
||||||
return _streamCompleter;
|
return _streamCompleter;
|
||||||
}
|
}
|
||||||
|
|
||||||
void complete() {
|
void complete([ui.Image image]) {
|
||||||
_completer.complete(ImageInfo(image: TestImage()));
|
image ??= TestImage();
|
||||||
|
_completer.complete(ImageInfo(image: image));
|
||||||
}
|
}
|
||||||
|
|
||||||
void fail(dynamic exception, StackTrace stackTrace) {
|
void fail(dynamic exception, StackTrace stackTrace) {
|
||||||
|
Loading…
Reference in New Issue
Block a user