Live image cache (#51485)

* Reland "Live image cache" (#51441)

This reverts commit 419a2853a8e4ac846524afeb49b866d4cdaa7d9d.

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