// 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); } }