blob: 37b3bc6601af8b891fbc512b4f2bf15905dae103 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'image_stream.dart';
const int _kDefaultSize = 1000;
const int _kDefaultSizeBytes = 100 << 20; // 100 MiB
/// Class for caching images.
///
/// 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].
///
/// 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 completed 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.
///
/// A shared instance of this cache is retained by [PaintingBinding] and can be
/// obtained via the [imageCache] top-level property in the [painting] library.
///
/// {@tool snippet}
///
/// This sample shows how to supply your own caching logic and replace the
/// global [imageCache] variable.
///
/// ```dart
/// /// This is the custom implementation of [ImageCache] where we can override
/// /// the logic.
/// class MyImageCache extends ImageCache {
/// @override
/// void clear() {
/// print('Clearing cache!');
/// super.clear();
/// }
/// }
///
/// class MyWidgetsBinding extends WidgetsFlutterBinding {
/// @override
/// ImageCache createImageCache() => MyImageCache();
/// }
///
/// void main() {
/// // The constructor sets global variables.
/// MyWidgetsBinding();
/// runApp(const MyApp());
/// }
///
/// class MyApp extends StatelessWidget {
/// const MyApp({super.key});
///
/// @override
/// Widget build(BuildContext context) {
/// return Container();
/// }
/// }
/// ```
/// {@end-tool}
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, _LiveImage> _liveImages = <Object, _LiveImage>{};
/// Maximum number of entries to store in the cache.
///
/// Once this many entries have been cached, the least-recently-used entry is
/// evicted when adding a new entry.
int get maximumSize => _maximumSize;
int _maximumSize = _kDefaultSize;
/// Changes the maximum cache size.
///
/// If the new size is smaller than the current number of elements, the
/// extraneous elements are evicted immediately. Setting this to zero and then
/// returning it to its original value will therefore immediately clear the
/// cache.
set maximumSize(int value) {
assert(value != null);
assert(value >= 0);
if (value == maximumSize) {
return;
}
TimelineTask? timelineTask;
if (!kReleaseMode) {
timelineTask = TimelineTask()..start(
'ImageCache.setMaximumSize',
arguments: <String, dynamic>{'value': value},
);
}
_maximumSize = value;
if (maximumSize == 0) {
clear();
} else {
_checkCacheSize(timelineTask);
}
if (!kReleaseMode) {
timelineTask!.finish();
}
}
/// The current number of cached entries.
int get currentSize => _cache.length;
/// Maximum size of entries to store in the cache in bytes.
///
/// Once more than this amount of bytes have been cached, the
/// least-recently-used entry is evicted until there are fewer than the
/// maximum bytes.
int get maximumSizeBytes => _maximumSizeBytes;
int _maximumSizeBytes = _kDefaultSizeBytes;
/// Changes the maximum cache bytes.
///
/// If the new size is smaller than the current size in bytes, the
/// extraneous elements are evicted immediately. Setting this to zero and then
/// returning it to its original value will therefore immediately clear the
/// cache.
set maximumSizeBytes(int value) {
assert(value != null);
assert(value >= 0);
if (value == _maximumSizeBytes) {
return;
}
TimelineTask? timelineTask;
if (!kReleaseMode) {
timelineTask = TimelineTask()..start(
'ImageCache.setMaximumSizeBytes',
arguments: <String, dynamic>{'value': value},
);
}
_maximumSizeBytes = value;
if (_maximumSizeBytes == 0) {
clear();
} else {
_checkCacheSize(timelineTask);
}
if (!kReleaseMode) {
timelineTask!.finish();
}
}
/// The current size of cached entries in bytes.
int get currentSizeBytes => _currentSizeBytes;
int _currentSizeBytes = 0;
/// 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,
'keepAliveImages': _cache.length,
'liveImages': _liveImages.length,
'currentSizeInBytes': _currentSizeBytes,
},
);
}
for (final _CachedImage image in _cache.values) {
image.dispose();
}
_cache.clear();
for (final _PendingImage pendingImage in _pendingImages.values) {
pendingImage.removeListener();
}
_pendingImages.clear();
_currentSizeBytes = 0;
}
/// 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.
///
/// 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].
///
/// 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 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.
final _LiveImage? image = _liveImages.remove(key);
image?.dispose();
}
final _PendingImage? pendingImage = _pendingImages.remove(key);
if (pendingImage != null) {
if (!kReleaseMode) {
Timeline.instantSync('ImageCache.evict', arguments: <String, dynamic>{
'type': 'pending',
});
}
pendingImage.removeListener();
return true;
}
final _CachedImage? image = _cache.remove(key);
if (image != null) {
if (!kReleaseMode) {
Timeline.instantSync('ImageCache.evict', arguments: <String, dynamic>{
'type': 'keepAlive',
'sizeInBytes': image.sizeBytes,
});
}
_currentSizeBytes -= image.sizeBytes!;
image.dispose();
return true;
}
if (!kReleaseMode) {
Timeline.instantSync('ImageCache.evict', arguments: <String, dynamic>{
'type': 'miss',
});
}
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) {
assert(timelineTask != null);
if (image.sizeBytes != null && image.sizeBytes! <= maximumSizeBytes && maximumSize > 0) {
_currentSizeBytes += image.sizeBytes!;
_cache[key] = image;
_checkCacheSize(timelineTask);
} else {
image.dispose();
}
}
void _trackLiveImage(Object key, ImageStreamCompleter completer, int? sizeBytes) {
// 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 tracker, which will add
// a keep alive handle to the stream.
return _LiveImage(
completer,
() {
_liveImages.remove(key);
},
);
}).sizeBytes ??= sizeBytes;
}
/// 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.
///
/// The arguments must not be null. The `loader` cannot return null.
///
/// In the event that the loader throws an exception, it will be caught only if
/// `onError` is also provided. When an exception is caught resolving an image,
/// no completers are cached and `null` is returned instead of a new
/// completer.
ImageStreamCompleter? putIfAbsent(Object key, ImageStreamCompleter Function() loader, { ImageErrorListener? onError }) {
assert(key != null);
assert(loader != null);
TimelineTask? timelineTask;
TimelineTask? listenerTask;
if (!kReleaseMode) {
timelineTask = TimelineTask()..start(
'ImageCache.putIfAbsent',
arguments: <String, dynamic>{
'key': key.toString(),
},
);
}
ImageStreamCompleter? result = _pendingImages[key]?.completer;
// Nothing needs to be done because the image hasn't loaded yet.
if (result != null) {
if (!kReleaseMode) {
timelineTask!.finish(arguments: <String, dynamic>{'result': 'pending'});
}
return result;
}
// 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': '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.completer,
image.sizeBytes,
);
_cache[key] = image;
return image.completer;
}
final _LiveImage? liveImage = _liveImages[key];
if (liveImage != null) {
_touch(
key,
_CachedImage(
liveImage.completer,
sizeBytes: liveImage.sizeBytes,
),
timelineTask,
);
if (!kReleaseMode) {
timelineTask!.finish(arguments: <String, dynamic>{'result': 'keepAlive'});
}
return liveImage.completer;
}
try {
result = loader();
_trackLiveImage(key, result, null);
} catch (error, stackTrace) {
if (!kReleaseMode) {
timelineTask!.finish(arguments: <String, dynamic>{
'result': 'error',
'error': error.toString(),
'stackTrace': stackTrace.toString(),
});
}
if (onError != null) {
onError(error, stackTrace);
return null;
} else {
rethrow;
}
}
if (!kReleaseMode) {
listenerTask = TimelineTask(parent: timelineTask)..start('listener');
}
// A multi-frame provider may call the listener more than once. We need do make
// sure that some cleanup works won't run multiple times, such as finishing the
// tracing task or removing the listeners
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.
final bool trackPendingImage = maximumSize > 0 && maximumSizeBytes > 0;
late _PendingImage pendingImage;
void listener(ImageInfo? info, bool syncCall) {
int? sizeBytes;
if (info != null) {
sizeBytes = info.sizeBytes;
info.dispose();
}
final _CachedImage image = _CachedImage(
result!,
sizeBytes: sizeBytes,
);
_trackLiveImage(key, result, sizeBytes);
// Only touch if the cache was enabled when resolve was initially called.
if (trackPendingImage) {
_touch(key, image, listenerTask);
} else {
image.dispose();
}
_pendingImages.remove(key);
if (!listenedOnce) {
pendingImage.removeListener();
}
if (!kReleaseMode && !listenedOnce) {
listenerTask!.finish(arguments: <String, dynamic>{
'syncCall': syncCall,
'sizeInBytes': sizeBytes,
});
timelineTask!.finish(arguments: <String, dynamic>{
'currentSizeBytes': currentSizeBytes,
'currentSize': currentSize,
});
}
listenedOnce = true;
}
final ImageStreamListener streamListener = ImageStreamListener(listener);
pendingImage = _PendingImage(result, streamListener);
if (trackPendingImage) {
_pendingImages[key] = pendingImage;
}
// 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() {
for (final _LiveImage image in _liveImages.values) {
image.dispose();
}
_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) {
final Map<String, dynamic> finishArgs = <String, dynamic>{};
TimelineTask? checkCacheTask;
if (!kReleaseMode) {
checkCacheTask = TimelineTask(parent: timelineTask)..start('checkCacheSize');
finishArgs['evictedKeys'] = <String>[];
finishArgs['currentSize'] = currentSize;
finishArgs['currentSizeBytes'] = currentSizeBytes;
}
while (_currentSizeBytes > _maximumSizeBytes || _cache.length > _maximumSize) {
final Object key = _cache.keys.first;
final _CachedImage image = _cache[key]!;
_currentSizeBytes -= image.sizeBytes!;
image.dispose();
_cache.remove(key);
if (!kReleaseMode) {
(finishArgs['evictedKeys'] as List<String>).add(key.toString());
}
}
if (!kReleaseMode) {
finishArgs['endSize'] = currentSize;
finishArgs['endSizeBytes'] = currentSizeBytes;
checkCacheTask!.finish(arguments: finishArgs);
}
assert(_currentSizeBytes >= 0);
assert(_cache.length <= maximumSize);
assert(_currentSizeBytes <= maximumSizeBytes);
}
}
/// 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].
@immutable
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 => Object.hash(pending, keepAlive, live);
@override
String toString() => '${objectRuntimeType(this, 'ImageCacheStatus')}(pending: $pending, live: $live, keepAlive: $keepAlive)';
}
/// Base class for [_CachedImage] and [_LiveImage].
///
/// Exists primarily so that a [_LiveImage] cannot be added to the
/// [ImageCache._cache].
abstract class _CachedImageBase {
_CachedImageBase(
this.completer, {
this.sizeBytes,
}) : assert(completer != null),
handle = completer.keepAlive();
final ImageStreamCompleter completer;
int? sizeBytes;
ImageStreamCompleterHandle? handle;
@mustCallSuper
void dispose() {
assert(handle != null);
// Give any interested parties a chance to listen to the stream before we
// potentially dispose it.
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
assert(handle != null);
handle?.dispose();
handle = null;
});
}
}
class _CachedImage extends _CachedImageBase {
_CachedImage(super.completer, {super.sizeBytes});
}
class _LiveImage extends _CachedImageBase {
_LiveImage(ImageStreamCompleter completer, VoidCallback handleRemove, {int? sizeBytes})
: super(completer, sizeBytes: sizeBytes) {
_handleRemove = () {
handleRemove();
dispose();
};
completer.addOnLastListenerRemovedCallback(_handleRemove);
}
late VoidCallback _handleRemove;
@override
void dispose() {
completer.removeOnLastListenerRemovedCallback(_handleRemove);
super.dispose();
}
@override
String toString() => describeIdentity(this);
}
class _PendingImage {
_PendingImage(this.completer, this.listener);
final ImageStreamCompleter completer;
final ImageStreamListener listener;
void removeListener() {
completer.removeListener(listener);
}
}