Unverified Commit e3d9ecae authored by Jonah Williams's avatar Jonah Williams Committed by GitHub

Add ability to cache images by size (#18135)

parent dac2ebf0
......@@ -5,6 +5,7 @@
import 'image_stream.dart';
const int _kDefaultSize = 1000;
const int _kDefaultSizeBytes = 10485760; // 10 MiB
/// Class for the [imageCache] object.
///
......@@ -25,7 +26,8 @@ const int _kDefaultSize = 1000;
/// Generally this class is not used directly. The [ImageProvider] class and its
/// subclasses automatically handle the caching of images.
class ImageCache {
final Map<Object, ImageStreamCompleter> _cache = <Object, ImageStreamCompleter>{};
final Map<Object, ImageStreamCompleter> _pendingImages = <Object, ImageStreamCompleter>{};
final Map<Object, _CachedImage> _cache = <Object, _CachedImage>{};
/// Maximum number of entries to store in the cache.
///
......@@ -47,20 +49,76 @@ class ImageCache {
_maximumSize = value;
if (maximumSize == 0) {
_cache.clear();
_currentSizeBytes = 0;
} else {
while (_cache.length > maximumSize)
_cache.remove(_cache.keys.first);
_checkCacheSize();
}
}
/// 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;
_maximumSizeBytes = value;
if (_maximumSizeBytes == 0) {
_cache.clear();
_currentSizeBytes = 0;
} else {
_checkCacheSize();
}
}
/// The current size of cached entries in bytes.
int get currentSizeBytes => _currentSizeBytes;
int _currentSizeBytes = 0;
/// Evicts all entries from the cache.
///
/// This is useful if, for instance, the root asset bundle has been updated
/// and therefore new images must be obtained.
// TODO(ianh): Provide a way to target individual images. This is currently non-trivial
// because by the time we get to the imageCache, the keys we're using are opaque.
///
/// Images which have not finished loading yet will not be removed from the
/// cache, and when they complete they will be inserted as normal.
void clear() {
_cache.clear();
_currentSizeBytes = 0;
}
/// Evicts a single entry from the cache, returning true if successful.
///
/// 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.
///
/// See also:
///
/// * [ImageProvider], for providing images to the [Image] widget.
bool evict(Object key) {
final _CachedImage image = _cache.remove(key);
if (image != null) {
_currentSizeBytes -= image.sizeBytes;
return true;
}
return false;
}
/// Returns the previously cached [ImageStream] for the given key, if available;
......@@ -71,21 +129,53 @@ class ImageCache {
ImageStreamCompleter putIfAbsent(Object key, ImageStreamCompleter loader()) {
assert(key != null);
assert(loader != null);
ImageStreamCompleter result = _cache[key];
if (result != null) {
// Remove the provider from the list so that we can put it back in below
// and thus move it to the end of the list.
_cache.remove(key);
} else {
if (_cache.length == maximumSize && maximumSize > 0)
_cache.remove(_cache.keys.first);
result = loader();
ImageStreamCompleter result = _pendingImages[key];
// Nothing needs to be done because the image hasn't loaded yet.
if (result != null)
return result;
// Remove the provider from the list so that we can move it to the
// recently used position below.
final _CachedImage image = _cache.remove(key);
if (image != null) {
_cache[key] = image;
return image.completer;
}
if (maximumSize > 0) {
assert(_cache.length < maximumSize);
_cache[key] = result;
result = loader();
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 = new _CachedImage(result, imageSize);
_currentSizeBytes += imageSize;
_pendingImages.remove(key);
_cache[key] = image;
result.removeListener(listener);
_checkCacheSize();
}
if (maximumSize > 0 && maximumSizeBytes > 0) {
_pendingImages[key] = result;
result.addListener(listener);
}
assert(_cache.length <= maximumSize);
return result;
}
// Remove images from the cache until both the length and bytes are below
// maximum, or the cache is empty.
void _checkCacheSize() {
while (_currentSizeBytes > _maximumSizeBytes || _cache.length > _maximumSize) {
final Object key = _cache.keys.first;
final _CachedImage image = _cache[key];
_currentSizeBytes -= image.sizeBytes;
_cache.remove(key);
}
assert(_currentSizeBytes >= 0);
assert(_cache.length <= maximumSize);
assert(_currentSizeBytes <= maximumSizeBytes);
}
}
class _CachedImage {
_CachedImage(this.completer, this.sizeBytes);
final ImageStreamCompleter completer;
final int sizeBytes;
}
......@@ -12,6 +12,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'binding.dart';
import 'image_cache.dart';
import 'image_stream.dart';
/// Configuration information passed to the [ImageProvider.resolve] method to
......@@ -284,6 +285,49 @@ abstract class ImageProvider<T> {
return stream;
}
/// Evicts an entry from the image cache.
///
/// Returns a [Future] which indicates whether the value was successfully
/// removed.
///
/// The [ImageProvider] used does not need to be the same instance that was
/// passed to an [Image] widget, but it does need to create a key which is
/// equal to one.
///
/// The [cache] is optional and defaults to the global image cache.
///
/// The [configuration] is optional and defaults to
/// [ImageConfiguration.empty].
///
/// ## Sample code
///
/// The following sample code shows how an image loaded using the [Image]
/// widget can be evicted using a [NetworkImage] with a matching url.
///
/// ```dart
/// class MyWidget extends StatelessWidget {
/// final String url = '...';
///
/// @override
/// Widget build(BuildContext context) {
/// return new Image.network(url);
/// }
///
/// void evictImage() {
/// final NetworkImage provider = new NetworkImage(url);
/// provider.evict().then<void>((bool success) {
/// if (success)
/// debugPrint('removed image!');
/// });
/// }
/// }
/// ```
Future<bool> evict({ImageCache cache, ImageConfiguration configuration = ImageConfiguration.empty}) async {
cache ??= imageCache;
final T key = await obtainKey(configuration);
return cache.evict(key);
}
/// Converts an ImageProvider's settings plus an ImageConfiguration to a key
/// that describes the precise image to load.
///
......
......@@ -10,35 +10,70 @@ import 'mocks_for_image_cache.dart';
void main() {
new TestRenderingFlutterBinding(); // initializes the imageCache
group(ImageCache, () {
tearDown(() {
imageCache.clear();
imageCache.maximumSize = 1000;
imageCache.maximumSizeBytes = 10485760;
});
test('Image cache resizing', () async {
test('Image cache resizing based on count', () async {
imageCache.maximumSize = 2;
imageCache.maximumSize = 2;
final TestImageInfo a = await extractOneFrame(const TestImageProvider(1, 1).resolve(ImageConfiguration.empty));
final TestImageInfo b = await extractOneFrame(const TestImageProvider(2, 2).resolve(ImageConfiguration.empty));
final TestImageInfo c = await extractOneFrame(const TestImageProvider(3, 3).resolve(ImageConfiguration.empty));
final TestImageInfo d = await extractOneFrame(const TestImageProvider(1, 4).resolve(ImageConfiguration.empty));
expect(a.value, equals(1));
expect(b.value, equals(2));
expect(c.value, equals(3));
expect(d.value, equals(4));
final TestImageInfo a = await extractOneFrame(const TestImageProvider(1, 1).resolve(ImageConfiguration.empty));
final TestImageInfo b = await extractOneFrame(const TestImageProvider(2, 2).resolve(ImageConfiguration.empty));
final TestImageInfo c = await extractOneFrame(const TestImageProvider(3, 3).resolve(ImageConfiguration.empty));
final TestImageInfo d = await extractOneFrame(const TestImageProvider(1, 4).resolve(ImageConfiguration.empty));
expect(a.value, equals(1));
expect(b.value, equals(2));
expect(c.value, equals(3));
expect(d.value, equals(4));
imageCache.maximumSize = 0;
imageCache.maximumSize = 0;
final TestImageInfo e = await extractOneFrame(const TestImageProvider(1, 5).resolve(ImageConfiguration.empty));
expect(e.value, equals(5));
final TestImageInfo e = await extractOneFrame(const TestImageProvider(1, 5).resolve(ImageConfiguration.empty));
expect(e.value, equals(5));
final TestImageInfo f = await extractOneFrame(const TestImageProvider(1, 6).resolve(ImageConfiguration.empty));
expect(f.value, equals(6));
final TestImageInfo f = await extractOneFrame(const TestImageProvider(1, 6).resolve(ImageConfiguration.empty));
expect(f.value, equals(6));
imageCache.maximumSize = 3;
imageCache.maximumSize = 3;
final TestImageInfo g = await extractOneFrame(const TestImageProvider(1, 7).resolve(ImageConfiguration.empty));
expect(g.value, equals(7));
final TestImageInfo g = await extractOneFrame(const TestImageProvider(1, 7).resolve(ImageConfiguration.empty));
expect(g.value, equals(7));
final TestImageInfo h = await extractOneFrame(const TestImageProvider(1, 8).resolve(ImageConfiguration.empty));
expect(h.value, equals(7));
});
final TestImageInfo h = await extractOneFrame(const TestImageProvider(1, 8).resolve(ImageConfiguration.empty));
expect(h.value, equals(7));
test('Image cache resizing based on size', () async {
const TestImage testImage = const TestImage(width: 8, height: 8); // 256 B.
imageCache.maximumSizeBytes = 256 * 2;
final TestImageInfo a = await extractOneFrame(const TestImageProvider(1, 1, image: testImage).resolve(ImageConfiguration.empty));
final TestImageInfo b = await extractOneFrame(const TestImageProvider(2, 2, image: testImage).resolve(ImageConfiguration.empty));
final TestImageInfo c = await extractOneFrame(const TestImageProvider(3, 3, image: testImage).resolve(ImageConfiguration.empty));
final TestImageInfo d = await extractOneFrame(const TestImageProvider(1, 4, image: testImage).resolve(ImageConfiguration.empty));
expect(a.value, equals(1));
expect(b.value, equals(2));
expect(c.value, equals(3));
expect(d.value, equals(4));
imageCache.maximumSizeBytes = 0;
final TestImageInfo e = await extractOneFrame(const TestImageProvider(1, 5, image: testImage).resolve(ImageConfiguration.empty));
expect(e.value, equals(5));
final TestImageInfo f = await extractOneFrame(const TestImageProvider(1, 6, image: testImage).resolve(ImageConfiguration.empty));
expect(f.value, equals(6));
imageCache.maximumSizeBytes = 256 * 3;
final TestImageInfo g = await extractOneFrame(const TestImageProvider(1, 7, image: testImage).resolve(ImageConfiguration.empty));
expect(g.value, equals(7));
final TestImageInfo h = await extractOneFrame(const TestImageProvider(1, 8, image: testImage).resolve(ImageConfiguration.empty));
expect(h.value, equals(7));
});
});
}
......@@ -10,77 +10,113 @@ import 'mocks_for_image_cache.dart';
void main() {
new TestRenderingFlutterBinding(); // initializes the imageCache
group(ImageCache, () {
tearDown(() {
imageCache.clear();
imageCache.maximumSize = 1000;
imageCache.maximumSizeBytes = 10485760;
});
test('Image cache', () async {
test('maintains cache size', () async {
imageCache.maximumSize = 3;
imageCache.maximumSize = 3;
final TestImageInfo a = await extractOneFrame(const TestImageProvider(1, 1).resolve(ImageConfiguration.empty));
expect(a.value, equals(1));
final TestImageInfo b = await extractOneFrame(const TestImageProvider(1, 2).resolve(ImageConfiguration.empty));
expect(b.value, equals(1));
final TestImageInfo c = await extractOneFrame(const TestImageProvider(1, 3).resolve(ImageConfiguration.empty));
expect(c.value, equals(1));
final TestImageInfo d = await extractOneFrame(const TestImageProvider(1, 4).resolve(ImageConfiguration.empty));
expect(d.value, equals(1));
final TestImageInfo e = await extractOneFrame(const TestImageProvider(1, 5).resolve(ImageConfiguration.empty));
expect(e.value, equals(1));
final TestImageInfo f = await extractOneFrame(const TestImageProvider(1, 6).resolve(ImageConfiguration.empty));
expect(f.value, equals(1));
final TestImageInfo a = await extractOneFrame(const TestImageProvider(1, 1).resolve(ImageConfiguration.empty));
expect(a.value, equals(1));
final TestImageInfo b = await extractOneFrame(const TestImageProvider(1, 2).resolve(ImageConfiguration.empty));
expect(b.value, equals(1));
final TestImageInfo c = await extractOneFrame(const TestImageProvider(1, 3).resolve(ImageConfiguration.empty));
expect(c.value, equals(1));
final TestImageInfo d = await extractOneFrame(const TestImageProvider(1, 4).resolve(ImageConfiguration.empty));
expect(d.value, equals(1));
final TestImageInfo e = await extractOneFrame(const TestImageProvider(1, 5).resolve(ImageConfiguration.empty));
expect(e.value, equals(1));
final TestImageInfo f = await extractOneFrame(const TestImageProvider(1, 6).resolve(ImageConfiguration.empty));
expect(f.value, equals(1));
expect(f, equals(a));
expect(f, equals(a));
// cache still only has one entry in it: 1(1)
// cache still only has one entry in it: 1(1)
final TestImageInfo g = await extractOneFrame(const TestImageProvider(2, 7).resolve(ImageConfiguration.empty));
expect(g.value, equals(7));
final TestImageInfo g = await extractOneFrame(const TestImageProvider(2, 7).resolve(ImageConfiguration.empty));
expect(g.value, equals(7));
// cache has two entries in it: 1(1), 2(7)
// cache has two entries in it: 1(1), 2(7)
final TestImageInfo h = await extractOneFrame(const TestImageProvider(1, 8).resolve(ImageConfiguration.empty));
expect(h.value, equals(1));
final TestImageInfo h = await extractOneFrame(const TestImageProvider(1, 8).resolve(ImageConfiguration.empty));
expect(h.value, equals(1));
// cache still has two entries in it: 2(7), 1(1)
// cache still has two entries in it: 2(7), 1(1)
final TestImageInfo i = await extractOneFrame(const TestImageProvider(3, 9).resolve(ImageConfiguration.empty));
expect(i.value, equals(9));
final TestImageInfo i = await extractOneFrame(const TestImageProvider(3, 9).resolve(ImageConfiguration.empty));
expect(i.value, equals(9));
// cache has three entries in it: 2(7), 1(1), 3(9)
// cache has three entries in it: 2(7), 1(1), 3(9)
final TestImageInfo j = await extractOneFrame(const TestImageProvider(1, 10).resolve(ImageConfiguration.empty));
expect(j.value, equals(1));
final TestImageInfo j = await extractOneFrame(const TestImageProvider(1, 10).resolve(ImageConfiguration.empty));
expect(j.value, equals(1));
// cache still has three entries in it: 2(7), 3(9), 1(1)
// cache still has three entries in it: 2(7), 3(9), 1(1)
final TestImageInfo k = await extractOneFrame(const TestImageProvider(4, 11).resolve(ImageConfiguration.empty));
expect(k.value, equals(11));
final TestImageInfo k = await extractOneFrame(const TestImageProvider(4, 11).resolve(ImageConfiguration.empty));
expect(k.value, equals(11));
// cache has three entries: 3(9), 1(1), 4(11)
// cache has three entries: 3(9), 1(1), 4(11)
final TestImageInfo l = await extractOneFrame(const TestImageProvider(1, 12).resolve(ImageConfiguration.empty));
expect(l.value, equals(1));
final TestImageInfo l = await extractOneFrame(const TestImageProvider(1, 12).resolve(ImageConfiguration.empty));
expect(l.value, equals(1));
// cache has three entries: 3(9), 4(11), 1(1)
// cache has three entries: 3(9), 4(11), 1(1)
final TestImageInfo m = await extractOneFrame(const TestImageProvider(2, 13).resolve(ImageConfiguration.empty));
expect(m.value, equals(13));
final TestImageInfo m = await extractOneFrame(const TestImageProvider(2, 13).resolve(ImageConfiguration.empty));
expect(m.value, equals(13));
// cache has three entries: 4(11), 1(1), 2(13)
// cache has three entries: 4(11), 1(1), 2(13)
final TestImageInfo n = await extractOneFrame(const TestImageProvider(3, 14).resolve(ImageConfiguration.empty));
expect(n.value, equals(14));
final TestImageInfo n = await extractOneFrame(const TestImageProvider(3, 14).resolve(ImageConfiguration.empty));
expect(n.value, equals(14));
// cache has three entries: 1(1), 2(13), 3(14)
// cache has three entries: 1(1), 2(13), 3(14)
final TestImageInfo o = await extractOneFrame(const TestImageProvider(4, 15).resolve(ImageConfiguration.empty));
expect(o.value, equals(15));
final TestImageInfo o = await extractOneFrame(const TestImageProvider(4, 15).resolve(ImageConfiguration.empty));
expect(o.value, equals(15));
// cache has three entries: 2(13), 3(14), 4(15)
// cache has three entries: 2(13), 3(14), 4(15)
final TestImageInfo p = await extractOneFrame(const TestImageProvider(1, 16).resolve(ImageConfiguration.empty));
expect(p.value, equals(16));
final TestImageInfo p = await extractOneFrame(const TestImageProvider(1, 16).resolve(ImageConfiguration.empty));
expect(p.value, equals(16));
// cache has three entries: 3(14), 4(15), 1(16)
// cache has three entries: 3(14), 4(15), 1(16)
});
test('clear removes all images and resets cache size', () async {
const TestImage testImage = const TestImage(width: 8, height: 8);
expect(imageCache.currentSize, 0);
expect(imageCache.currentSizeBytes, 0);
await extractOneFrame(const TestImageProvider(1, 1, image: testImage).resolve(ImageConfiguration.empty));
await extractOneFrame(const TestImageProvider(2, 2, image: testImage).resolve(ImageConfiguration.empty));
expect(imageCache.currentSize, 2);
expect(imageCache.currentSizeBytes, 256 * 2);
imageCache.clear();
expect(imageCache.currentSize, 0);
expect(imageCache.currentSizeBytes, 0);
});
test('evicts individual images', () async {
const TestImage testImage = const TestImage(width: 8, height: 8);
await extractOneFrame(const TestImageProvider(1, 1, image: testImage).resolve(ImageConfiguration.empty));
await extractOneFrame(const TestImageProvider(2, 2, image: testImage).resolve(ImageConfiguration.empty));
expect(imageCache.currentSize, 2);
expect(imageCache.currentSizeBytes, 256 * 2);
expect(imageCache.evict(1), true);
expect(imageCache.currentSize, 1);
expect(imageCache.currentSizeBytes, 256);
});
});
}
......@@ -2,13 +2,56 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/painting.dart';
import 'package:flutter_test/flutter_test.dart';
import '../rendering/rendering_tester.dart';
import 'image_data.dart';
void main() {
test('NetworkImage non-null url test', () {
expect(() {
new NetworkImage(nonconst(null));
}, throwsAssertionError);
new TestRenderingFlutterBinding(); // initializes the imageCache
group(ImageProvider, () {
tearDown(() {
imageCache.clear();
});
test('NetworkImage non-null url test', () {
expect(() {
new NetworkImage(nonconst(null));
}, throwsAssertionError);
});
test('ImageProvider can evict images', () async {
final Uint8List bytes = new Uint8List.fromList(kTransparentImage);
final MemoryImage imageProvider = new MemoryImage(bytes);
final ImageStream stream = imageProvider.resolve(ImageConfiguration.empty);
final Completer<void> completer = new Completer<void>();
stream.addListener((ImageInfo info, bool syncCall) => completer.complete());
await completer.future;
expect(imageCache.currentSize, 1);
expect(await new MemoryImage(bytes).evict(), true);
expect(imageCache.currentSize, 0);
});
test('ImageProvider.evict respects the provided ImageCache', () async {
final ImageCache otherCache = new ImageCache();
final Uint8List bytes = new Uint8List.fromList(kTransparentImage);
final MemoryImage imageProvider = new MemoryImage(bytes);
otherCache.putIfAbsent(imageProvider, () => imageProvider.load(imageProvider));
final ImageStream stream = imageProvider.resolve(ImageConfiguration.empty);
final Completer<void> completer = new Completer<void>();
stream.addListener((ImageInfo info, bool syncCall) => completer.complete());
await completer.future;
expect(otherCache.currentSize, 1);
expect(imageCache.currentSize, 1);
expect(await imageProvider.evict(cache: otherCache), true);
expect(otherCache.currentSize, 0);
expect(imageCache.currentSize, 1);
});
});
}
......@@ -3,7 +3,9 @@
// found in the LICENSE file.
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui show Image;
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
......@@ -54,3 +56,19 @@ Future<ImageInfo> extractOneFrame(ImageStream stream) {
stream.addListener(listener);
return completer.future;
}
class TestImage implements ui.Image {
const TestImage({this.height = 0, this.width = 0});
@override
final int height;
@override
final int width;
@override
void dispose() {}
@override
Future<ByteData> toByteData({ImageByteFormat format = ImageByteFormat.rawRgba}) {
throw new UnimplementedError();
}
}
......@@ -295,7 +295,7 @@ void main() {
final TestImageProvider imageProvider = new TestImageProvider();
await tester.pumpWidget(new Image(image: imageProvider));
final State<Image> image = tester.state/*State<Image>*/(find.byType(Image));
expect(image.toString(), equalsIgnoringHashCodes('_ImageState#00000(stream: ImageStream#00000(OneFrameImageStreamCompleter#00000, unresolved, 1 listener), pixels: null)'));
expect(image.toString(), equalsIgnoringHashCodes('_ImageState#00000(stream: ImageStream#00000(OneFrameImageStreamCompleter#00000, unresolved, 2 listeners), pixels: null)'));
imageProvider.complete();
await tester.pump();
expect(image.toString(), equalsIgnoringHashCodes('_ImageState#00000(stream: ImageStream#00000(OneFrameImageStreamCompleter#00000, [100×100] @ 1.0x, 1 listener), pixels: [100×100] @ 1.0x)'));
......@@ -353,14 +353,14 @@ void main() {
child: image,
),
);
expect(imageStreamCompleter.listeners.length, 1);
expect(imageStreamCompleter.listeners.length, 2);
await tester.pumpWidget(
new TickerMode(
enabled: false,
child: image,
),
);
expect(imageStreamCompleter.listeners.length, 0);
expect(imageStreamCompleter.listeners.length, 1);
});
testWidgets('Verify Image shows correct RenderImage when changing to an already completed provider', (WidgetTester tester) async {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment