Skip to content

Commit 1602be6

Browse files
authored
Live image cache (flutter#50318)
Track images available on screen
1 parent ea4d969 commit 1602be6

File tree

10 files changed

+688
-50
lines changed

10 files changed

+688
-50
lines changed

dev/tracing_tests/test/image_cache_tracing_test.dart

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ void main() {
2020
final developer.ServiceProtocolInfo info = await developer.Service.getInfo();
2121

2222
if (info.serverUri == null) {
23-
throw TestFailure('This test _must_ be run with --enable-vmservice.');
23+
fail('This test _must_ be run with --enable-vmservice.');
2424
}
2525
await timelineObtainer.connect(info.serverUri);
2626
await timelineObtainer.setDartFlags();
@@ -58,7 +58,8 @@ void main() {
5858
'name': 'ImageCache.clear',
5959
'args': <String, dynamic>{
6060
'pendingImages': 1,
61-
'cachedImages': 0,
61+
'keepAliveImages': 0,
62+
'liveImages': 1,
6263
'currentSizeInBytes': 0,
6364
'isolateId': isolateId,
6465
}
@@ -153,7 +154,7 @@ class TimelineObtainer {
153154

154155
Future<void> close() async {
155156
expect(_completers, isEmpty);
156-
await _observatorySocket.close();
157+
await _observatorySocket?.close();
157158
}
158159
}
159160

packages/flutter/lib/src/painting/binding.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ mixin PaintingBinding on BindingBase, ServicesBinding {
9696
void evict(String asset) {
9797
super.evict(asset);
9898
imageCache.clear();
99+
imageCache.clearLiveImages();
99100
}
100101

101102
/// Listenable that notifies when the available fonts on the system have

packages/flutter/lib/src/painting/image_cache.dart

Lines changed: 195 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// found in the LICENSE file.
44

55
import 'dart:developer';
6+
import 'dart:ui' show hashValues;
67

78
import 'package:flutter/foundation.dart';
89

@@ -15,18 +16,24 @@ const int _kDefaultSizeBytes = 100 << 20; // 100 MiB
1516
///
1617
/// Implements a least-recently-used cache of up to 1000 images, and up to 100
1718
/// MB. The maximum size can be adjusted using [maximumSize] and
18-
/// [maximumSizeBytes]. Images that are actively in use (i.e. to which the
19-
/// application is holding references, either via [ImageStream] objects,
20-
/// [ImageStreamCompleter] objects, [ImageInfo] objects, or raw [dart:ui.Image]
21-
/// objects) may get evicted from the cache (and thus need to be refetched from
22-
/// the network if they are referenced in the [putIfAbsent] method), but the raw
23-
/// bits are kept in memory for as long as the application is using them.
19+
/// [maximumSizeBytes].
20+
///
21+
/// The cache also holds a list of "live" references. An image is considered
22+
/// live if its [ImageStreamCompleter]'s listener count has never dropped to
23+
/// zero after adding at least one listener. The cache uses
24+
/// [ImageStreamCompleter.addOnLastListenerRemovedCallback] to determine when
25+
/// this has happened.
2426
///
2527
/// The [putIfAbsent] method is the main entry-point to the cache API. It
2628
/// returns the previously cached [ImageStreamCompleter] for the given key, if
2729
/// available; if not, it calls the given callback to obtain it first. In either
2830
/// case, the key is moved to the "most recently used" position.
2931
///
32+
/// A caller can determine whether an image is already in the cache by using
33+
/// [containsKey], which will return true if the image is tracked by the cache
34+
/// in a pending or compelted state. More fine grained information is available
35+
/// by using the [statusForKey] method.
36+
///
3037
/// Generally this class is not used directly. The [ImageProvider] class and its
3138
/// subclasses automatically handle the caching of images.
3239
///
@@ -71,6 +78,11 @@ const int _kDefaultSizeBytes = 100 << 20; // 100 MiB
7178
class ImageCache {
7279
final Map<Object, _PendingImage> _pendingImages = <Object, _PendingImage>{};
7380
final Map<Object, _CachedImage> _cache = <Object, _CachedImage>{};
81+
/// ImageStreamCompleters with at least one listener. These images may or may
82+
/// not fit into the _pendingImages or _cache objects.
83+
///
84+
/// Unlike _cache, the [_CachedImage] for this may have a null byte size.
85+
final Map<Object, _CachedImage> _liveImages = <Object, _CachedImage>{};
7486

7587
/// Maximum number of entries to store in the cache.
7688
///
@@ -163,7 +175,8 @@ class ImageCache {
163175
'ImageCache.clear',
164176
arguments: <String, dynamic>{
165177
'pendingImages': _pendingImages.length,
166-
'cachedImages': _cache.length,
178+
'keepAliveImages': _cache.length,
179+
'liveImages': _liveImages.length,
167180
'currentSizeInBytes': _currentSizeBytes,
168181
},
169182
);
@@ -204,7 +217,7 @@ class ImageCache {
204217
if (image != null) {
205218
if (!kReleaseMode) {
206219
Timeline.instantSync('ImageCache.evict', arguments: <String, dynamic>{
207-
'type': 'completed',
220+
'type': 'keepAlive',
208221
'sizeiInBytes': image.sizeBytes,
209222
});
210223
}
@@ -219,6 +232,36 @@ class ImageCache {
219232
return false;
220233
}
221234

235+
/// Updates the least recently used image cache with this image, if it is
236+
/// less than the [maximumSizeBytes] of this cache.
237+
///
238+
/// Resizes the cache as appropriate to maintain the constraints of
239+
/// [maximumSize] and [maximumSizeBytes].
240+
void _touch(Object key, _CachedImage image, TimelineTask timelineTask) {
241+
assert(timelineTask != null);
242+
if (image.sizeBytes != null && image.sizeBytes <= maximumSizeBytes) {
243+
_currentSizeBytes += image.sizeBytes;
244+
_cache[key] = image;
245+
_checkCacheSize(timelineTask);
246+
}
247+
}
248+
249+
void _trackLiveImage(Object key, _CachedImage image) {
250+
// Avoid adding unnecessary callbacks to the completer.
251+
if (_liveImages.containsKey(key)) {
252+
assert(identical(_liveImages[key].completer, image.completer));
253+
return;
254+
}
255+
_liveImages[key] = image;
256+
// Even if no callers to ImageProvider.resolve have listened to the stream,
257+
// the cache is listening to the stream and will remove itself once the
258+
// image completes to move it from pending to keepAlive.
259+
// Even if the cache size is 0, we still add this listener.
260+
image.completer.addOnLastListenerRemovedCallback(() {
261+
_liveImages.remove(key);
262+
});
263+
}
264+
222265
/// Returns the previously cached [ImageStream] for the given key, if available;
223266
/// if not, calls the given callback to obtain it first. In either case, the
224267
/// key is moved to the "most recently used" position.
@@ -255,14 +298,27 @@ class ImageCache {
255298
final _CachedImage image = _cache.remove(key);
256299
if (image != null) {
257300
if (!kReleaseMode) {
258-
timelineTask.finish(arguments: <String, dynamic>{'result': 'completed'});
301+
timelineTask.finish(arguments: <String, dynamic>{'result': 'keepAlive'});
259302
}
303+
// The image might have been keptAlive but had no listeners. We should
304+
// track it as live again until it has no listeners again.
305+
_trackLiveImage(key, image);
260306
_cache[key] = image;
261307
return image.completer;
262308
}
263309

310+
final _CachedImage liveImage = _liveImages[key];
311+
if (liveImage != null) {
312+
_touch(key, liveImage, timelineTask);
313+
if (!kReleaseMode) {
314+
timelineTask.finish(arguments: <String, dynamic>{'result': 'keepAlive'});
315+
}
316+
return liveImage.completer;
317+
}
318+
264319
try {
265320
result = loader();
321+
_trackLiveImage(key, _CachedImage(result, null));
266322
} catch (error, stackTrace) {
267323
if (!kReleaseMode) {
268324
timelineTask.finish(arguments: <String, dynamic>{
@@ -282,21 +338,37 @@ class ImageCache {
282338
if (!kReleaseMode) {
283339
listenerTask = TimelineTask(parent: timelineTask)..start('listener');
284340
}
341+
// If we're doing tracing, we need to make sure that we don't try to finish
342+
// the trace entry multiple times if we get re-entrant calls from a multi-
343+
// frame provider here.
285344
bool listenedOnce = false;
345+
346+
// We shouldn't use the _pendingImages map if the cache is disabled, but we
347+
// will have to listen to the image at least once so we don't leak it in
348+
// the live image tracking.
349+
// If the cache is disabled, this variable will be set.
350+
_PendingImage untrackedPendingImage;
286351
void listener(ImageInfo info, bool syncCall) {
287352
// Images that fail to load don't contribute to cache size.
288353
final int imageSize = info?.image == null ? 0 : info.image.height * info.image.width * 4;
354+
289355
final _CachedImage image = _CachedImage(result, imageSize);
290-
final _PendingImage pendingImage = _pendingImages.remove(key);
356+
if (!_liveImages.containsKey(key)) {
357+
assert(syncCall);
358+
result.addOnLastListenerRemovedCallback(() {
359+
_liveImages.remove(key);
360+
});
361+
}
362+
_liveImages[key] = image;
363+
final _PendingImage pendingImage = untrackedPendingImage ?? _pendingImages.remove(key);
291364
if (pendingImage != null) {
292365
pendingImage.removeListener();
293366
}
294-
295-
if (imageSize <= maximumSizeBytes) {
296-
_currentSizeBytes += imageSize;
297-
_cache[key] = image;
298-
_checkCacheSize(listenerTask);
367+
// Only touch if the cache was enabled when resolve was initially called.
368+
if (untrackedPendingImage == null) {
369+
_touch(key, image, listenerTask);
299370
}
371+
300372
if (!kReleaseMode && !listenedOnce) {
301373
listenerTask.finish(arguments: <String, dynamic>{
302374
'syncCall': syncCall,
@@ -309,20 +381,58 @@ class ImageCache {
309381
}
310382
listenedOnce = true;
311383
}
384+
385+
final ImageStreamListener streamListener = ImageStreamListener(listener);
312386
if (maximumSize > 0 && maximumSizeBytes > 0) {
313-
final ImageStreamListener streamListener = ImageStreamListener(listener);
314387
_pendingImages[key] = _PendingImage(result, streamListener);
315-
// Listener is removed in [_PendingImage.removeListener].
316-
result.addListener(streamListener);
388+
} else {
389+
untrackedPendingImage = _PendingImage(result, streamListener);
317390
}
391+
// Listener is removed in [_PendingImage.removeListener].
392+
result.addListener(streamListener);
393+
318394
return result;
319395
}
320396

397+
/// The [ImageCacheStatus] information for the given `key`.
398+
ImageCacheStatus statusForKey(Object key) {
399+
return ImageCacheStatus._(
400+
pending: _pendingImages.containsKey(key),
401+
keepAlive: _cache.containsKey(key),
402+
live: _liveImages.containsKey(key),
403+
);
404+
}
405+
321406
/// Returns whether this `key` has been previously added by [putIfAbsent].
322407
bool containsKey(Object key) {
323408
return _pendingImages[key] != null || _cache[key] != null;
324409
}
325410

411+
/// The number of live images being held by the [ImageCache].
412+
///
413+
/// Compare with [ImageCache.currentSize] for keepAlive images.
414+
int get liveImageCount => _liveImages.length;
415+
416+
/// The number of images being tracked as pending in the [ImageCache].
417+
///
418+
/// Compare with [ImageCache.currentSize] for keepAlive images.
419+
int get pendingImageCount => _pendingImages.length;
420+
421+
/// Clears any live references to images in this cache.
422+
///
423+
/// An image is considered live if its [ImageStreamCompleter] has never hit
424+
/// zero listeners after adding at least one listener. The
425+
/// [ImageStreamCompleter.addOnLastListenerRemovedCallback] is used to
426+
/// determine when this has happened.
427+
///
428+
/// This is called after a hot reload to evict any stale references to image
429+
/// data for assets that have changed. Calling this method does not relieve
430+
/// memory pressure, since the live image caching only tracks image instances
431+
/// that are also being held by at least one other object.
432+
void clearLiveImages() {
433+
_liveImages.clear();
434+
}
435+
326436
// Remove images from the cache until both the length and bytes are below
327437
// maximum, or the cache is empty.
328438
void _checkCacheSize(TimelineTask timelineTask) {
@@ -354,6 +464,73 @@ class ImageCache {
354464
}
355465
}
356466

467+
/// Information about how the [ImageCache] is tracking an image.
468+
///
469+
/// A [pending] image is one that has not completed yet. It may also be tracked
470+
/// as [live] because something is listening to it.
471+
///
472+
/// A [keepAlive] image is being held in the cache, which uses Least Recently
473+
/// Used semantics to determine when to evict an image. These images are subject
474+
/// to eviction based on [ImageCache.maximumSizeBytes] and
475+
/// [ImageCache.maximumSize]. It may be [live], but not [pending].
476+
///
477+
/// A [live] image is being held until its [ImageStreamCompleter] has no more
478+
/// listeners. It may also be [pending] or [keepAlive].
479+
///
480+
/// An [untracked] image is not being cached.
481+
///
482+
/// To obtain an [ImageCacheStatus], use [ImageCache.statusForKey] or
483+
/// [ImageProvider.obtainCacheStatus].
484+
class ImageCacheStatus {
485+
const ImageCacheStatus._({
486+
this.pending = false,
487+
this.keepAlive = false,
488+
this.live = false,
489+
}) : assert(!pending || !keepAlive);
490+
491+
/// An image that has been submitted to [ImageCache.putIfAbsent], but
492+
/// not yet completed.
493+
final bool pending;
494+
495+
/// An image that has been submitted to [ImageCache.putIfAbsent], has
496+
/// completed, fits based on the sizing rules of the cache, and has not been
497+
/// evicted.
498+
///
499+
/// Such images will be kept alive even if [live] is false, as long
500+
/// as they have not been evicted from the cache based on its sizing rules.
501+
final bool keepAlive;
502+
503+
/// An image that has been submitted to [ImageCache.putIfAbsent] and has at
504+
/// least one listener on its [ImageStreamCompleter].
505+
///
506+
/// Such images may also be [keepAlive] if they fit in the cache based on its
507+
/// sizing rules. They may also be [pending] if they have not yet resolved.
508+
final bool live;
509+
510+
/// An image that is tracked in some way by the [ImageCache], whether
511+
/// [pending], [keepAlive], or [live].
512+
bool get tracked => pending || keepAlive || live;
513+
514+
/// An image that either has not been submitted to
515+
/// [ImageCache.putIfAbsent] or has otherwise been evicted from the
516+
/// [keepAlive] and [live] caches.
517+
bool get untracked => !pending && !keepAlive && !live;
518+
519+
@override
520+
bool operator ==(Object other) {
521+
if (other.runtimeType != runtimeType) {
522+
return false;
523+
}
524+
return other is ImageCacheStatus
525+
&& other.pending == pending
526+
&& other.keepAlive == keepAlive
527+
&& other.live == live;
528+
}
529+
530+
@override
531+
int get hashCode => hashValues(pending, keepAlive, live);
532+
}
533+
357534
class _CachedImage {
358535
_CachedImage(this.completer, this.sizeBytes);
359536

0 commit comments

Comments
 (0)