Unverified Commit 169529c3 authored by Dan Field's avatar Dan Field Committed by GitHub

Defer image decoding when scrolling fast (#49389)

parent 2bb290c2
......@@ -227,6 +227,11 @@ class ImageCache {
return result;
}
/// Returns whether this `key` has been previously added by [putIfAbsent].
bool containsKey(Object key) {
return _pendingImages[key] != null || _cache[key] != null;
}
// Remove images from the cache until both the length and bytes are below
// maximum, or the cache is empty.
void _checkCacheSize() {
......
......@@ -182,6 +182,43 @@ typedef DecoderCallback = Future<ui.Codec> Function(Uint8List bytes, {int cacheW
///
/// The following image formats are supported: {@macro flutter.dart:ui.imageFormats}
///
/// ## Lifecycle of resolving an image
///
/// The [ImageProvider] goes through the following lifecycle to resolve an
/// image, once the [resolve] method is called:
///
/// 1. Create an [ImageStream] using [createStream] to return to the caller.
/// This stream will be used to communicate back to the caller when the
/// image is decoded and ready to display, or when an error occurs.
/// 2. Obtain the key for the image using [obtainKey].
/// Calling this method can throw exceptions into the zone asynchronously
/// or into the callstack synchronously. To handle that, an error handler
/// is created that catches both synchronous and asynchronous errors, to
/// make sure errors can be routed to the correct consumers.
/// The error handler is passed on to [resolveStreamForKey] and the
/// [ImageCache].
/// 3. If the key is successfully obtained, schedule resolution of the image
/// using that key. This is handled by [resolveStreamForKey]. That method
/// may fizzle if it determines the image is no longer necessary, use the
/// provided [ImageErrorListener] to report an error, set the completer
/// from the cache if possible, or call [load] to fetch the encoded image
/// bytes and schedule decoding.
/// 4. The [load] method is responsible for both fetching the encoded bytes
/// and decoding them using the provided [DecoderCallback]. It is called
/// in a context that uses the [ImageErrorListener] to report errors back.
///
/// Subclasses normally only have to implement the [load] and [obtainKey]
/// methods. A subclass that needs finer grained control over the [ImageStream]
/// type must override [createStream]. A subclass that needs finer grained
/// control over the resolution, such as delaying calling [load], must override
/// [resolveStreamForKey].
///
/// The [resolve] method is marked as [nonVirtual] so that [ImageProvider]s can
/// be properly composed, and so that the base class can properly set up error
/// handling for subsequent methods.
///
/// ## Using an [ImageProvider]
///
/// {@tool snippet}
///
/// The following shows the code required to write a widget that fully conforms
......@@ -270,10 +307,34 @@ abstract class ImageProvider<T> {
/// This is the public entry-point of the [ImageProvider] class hierarchy.
///
/// Subclasses should implement [obtainKey] and [load], which are used by this
/// method.
/// method. If they need to change the implementation of [ImageStream] used,
/// they should override [createStream]. If they need to manage the actual
/// resolution of the image, they should override [resolveStreamForKey].
///
/// See the Lifecycle documentation on [ImageProvider] for more information.
@nonVirtual
ImageStream resolve(ImageConfiguration configuration) {
assert(configuration != null);
final ImageStream stream = ImageStream();
final ImageStream stream = createStream(configuration);
// Load the key (potentially asynchronously), set up an error handling zone,
// and call resolveStreamForKey.
_createErrorHandlerAndKey(configuration, stream);
return stream;
}
/// Called by [resolve] to create the [ImageStream] it returns.
///
/// Subclasses should override this instead of [resolve] if they need to
/// return some subclass of [ImageStream]. The stream created here will be
/// passed to [resolveStreamForKey].
@protected
ImageStream createStream(ImageConfiguration configuration) {
return ImageStream();
}
void _createErrorHandlerAndKey(ImageConfiguration configuration, ImageStream stream) {
assert(configuration != null);
assert(stream != null);
T obtainedKey;
bool didError = false;
Future<void> handleError(dynamic exception, StackTrace stack) async {
......@@ -322,17 +383,42 @@ abstract class ImageProvider<T> {
}
key.then<void>((T key) {
obtainedKey = key;
final ImageStreamCompleter completer = PaintingBinding.instance.imageCache.putIfAbsent(
key,
() => load(key, PaintingBinding.instance.instantiateImageCodec),
onError: handleError,
);
if (completer != null) {
stream.setCompleter(completer);
try {
resolveStreamForKey(configuration, stream, key, handleError);
} catch (error, stackTrace) {
handleError(error, stackTrace);
}
}).catchError(handleError);
});
return stream;
}
/// Called by [resolve] with the key returned by [obtainKey].
///
/// Subclasses should override this method rather than calling [obtainKey] if
/// they need to use a key directly. The [resolve] method installs appropriate
/// error handling guards so that errors will bubble up to the right places in
/// the framework, and passes those guards along to this method via the
/// [handleError] parameter.
///
/// It is safe for the implementation of this method to call [handleError]
/// multiple times if multiple errors occur, or if an error is thrown both
/// synchronously into the current part of the stack and thrown into the
/// enclosing [Zone].
///
/// The default implementation uses the key to interact with the [ImageCache],
/// calling [ImageCache.putIfAbsent] and notifying listeners of the [stream].
/// Implementers that do not call super are expected to correctly use the
/// [ImageCache].
@protected
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, T key, ImageErrorListener handleError) {
final ImageStreamCompleter completer = PaintingBinding.instance.imageCache.putIfAbsent(
key,
() => load(key, PaintingBinding.instance.instantiateImageCodec),
onError: handleError,
);
if (completer != null) {
stream.setCompleter(completer);
}
}
/// Evicts an entry from the image cache.
......
......@@ -340,6 +340,7 @@ abstract class ImageStreamCompleter extends Diagnosticable {
/// is false after calling `super.removeListener()`, and if so, stopping that
/// same work.
@protected
@visibleForTesting
bool get hasListeners => _listeners.isNotEmpty;
/// Adds a listener callback that is called whenever a new concrete [ImageInfo]
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'framework.dart';
/// Provides non-leaking access to a [BuildContext].
///
/// A [BuildContext] is only valid if it is pointing to an active [Element].
/// Once the [Element.dispose] method is called, the [BuildContext] should not
/// be accessed further. This class makes it possible for a [StatefulWidget] to
/// share its build context safely with other objects.
///
/// Creators of this object must guarantee the following:
///
/// 1. They create this object at or after [State.initState] but before
/// [State.dispose]. In particular, do not attempt to create this from the
/// constructor of a state.
/// 2. They call [dispose] from [State.dispose].
///
/// This object will not hold on to the [State] after disposal.
@optionalTypeArgs
class DisposableBuildContext<T extends State> {
/// Creates an object that provides access to a [BuildContext] without leaking
/// a [State].
///
/// Creators must call [dispose] when the [State] is disposed.
///
/// The [State] must not be null, and [State.mounted] must be true.
DisposableBuildContext(this._state)
: assert(_state != null),
assert(_state.mounted, 'A DisposableBuildContext was given a BuildContext for an Element that is not mounted.');
T _state;
/// Provides safe access to the build context.
///
/// If [dispose] has been called, will return null.
///
/// Otherwise, asserts the [_state] is still mounted and returns its context.
BuildContext get context {
assert(_debugValidate());
if (_state == null) {
return null;
}
return _state.context;
}
/// Called from asserts or tests to determine whether this object is in a
/// valid state.
///
/// Always returns true, but will assert if [dispose] has not been called
/// but the state this is tracking is unmounted.
bool _debugValidate() {
assert(
_state == null || _state.mounted,
'A DisposableBuildContext tried to access the BuildContext of a disposed '
'State object. This can happen when the creator of this '
'DisposableBuildContext fails to call dispose when it is disposed.',
);
return true;
}
/// Marks the [BuildContext] as disposed.
///
/// Creators of this object must call [dispose] when their [Element] is
/// unmounted, i.e. when [State.dispose] is called.
void dispose() {
_state = null;
}
}
......@@ -13,9 +13,11 @@ import 'package:flutter/semantics.dart';
import 'basic.dart';
import 'binding.dart';
import 'disposable_build_context.dart';
import 'framework.dart';
import 'localizations.dart';
import 'media_query.dart';
import 'scroll_aware_image_provider.dart';
import 'ticker_provider.dart';
export 'package:flutter/painting.dart' show
......@@ -946,11 +948,13 @@ class _ImageState extends State<Image> with WidgetsBindingObserver {
bool _invertColors;
int _frameNumber;
bool _wasSynchronouslyLoaded;
DisposableBuildContext<State<Image>> _scrollAwareContext;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_scrollAwareContext = DisposableBuildContext<State<Image>>(this);
}
@override
......@@ -958,6 +962,7 @@ class _ImageState extends State<Image> with WidgetsBindingObserver {
assert(_imageStream != null);
WidgetsBinding.instance.removeObserver(this);
_stopListeningToStream();
_scrollAwareContext.dispose();
super.dispose();
}
......@@ -1006,8 +1011,12 @@ class _ImageState extends State<Image> with WidgetsBindingObserver {
}
void _resolveImage() {
final ScrollAwareImageProvider provider = ScrollAwareImageProvider<dynamic>(
context: _scrollAwareContext,
imageProvider: widget.image,
);
final ImageStream newStream =
widget.image.resolve(createLocalImageConfiguration(
provider.resolve(createLocalImageConfiguration(
context,
size: widget.width != null && widget.height != null ? Size(widget.width, widget.height) : null,
));
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'package:flutter/painting.dart';
import 'package:flutter/scheduler.dart';
import 'disposable_build_context.dart';
import 'framework.dart';
import 'scrollable.dart';
/// An [ImageProvider] that makes use of
/// [Scollable.recommendDeferredLoadingForContext] to avoid loading images when
/// rapidly scrolling.
///
/// This provider assumes that its wrapped [imageProvider] correctly uses the
/// [ImageCache], and does not attempt to re-acquire or decode images in the
/// cache.
///
/// Calling [resolve] on this provider will cause it to obtain the image key
/// and then check the following:
///
/// 1. If the returned [ImageStream] has been completed, end. This can happen
/// if the caller sets the completer on the stream.
/// 2. If the [ImageCache] has a completer for the key for this image, ask the
/// wrapped provider to resolve.
/// This can happen if the image was precached, or another [ImageProvider]
/// already resolved the same image.
/// 3. If the [context] has been disposed, end. This can happen if the caller
/// has been disposed and is no longer interested in resolving the image.
/// 4. If the widget is scrolling with high velocity at this point in time,
/// wait until the beginning of the next frame and go back to step 1.
/// 5. Delegate loading the image to the wrapped provider and finish.
///
/// If the cycle ends at steps 1 or 3, the [ImageStream] will never be marked as
/// complete and listeners will not be notified.
///
/// The [Image] widget wraps its incoming providers with this provider to avoid
/// overutilization of resources for images that would never appear on screen or
/// only be visible for a very brief period.
@optionalTypeArgs
class ScrollAwareImageProvider<T> extends ImageProvider<T> {
/// Creates a [ScrollingAwareImageProvider].
///
/// The [context] object is the [BuildContext] of the [State] using this
/// provider. It is used to determine scrolling velocity during [resolve]. It
/// must not be null.
///
/// The [imageProvider] is used to create a key and load the image. It must
/// not be null, and is assumed to interact with the cache in the normal way
/// that [ImageProvider.resolveStreamForKey] does.
const ScrollAwareImageProvider({
@required this.context,
@required this.imageProvider,
}) : assert(context != null),
assert(imageProvider != null);
/// The context that may or may not be enclosed by a [Scrollable].
///
/// Once [DisposableBuildContext.dispose] is called on this context,
/// the provider will stop trying to resolve the image if it has not already
/// been resolved.
final DisposableBuildContext context;
/// The wrapped image provider to delegate [obtainKey] and [load] to.
final ImageProvider<T> imageProvider;
@override
void resolveStreamForKey(
ImageConfiguration configuration,
ImageStream stream,
T key,
ImageErrorListener handleError,
) {
// Something managed to complete the stream. Nothing left to do.
if (stream.completer != null) {
return;
}
// Something else got this image into the cache. Return it.
if (PaintingBinding.instance.imageCache.containsKey(key)) {
imageProvider.resolveStreamForKey(configuration, stream, key, handleError);
}
// The context has gone out of the tree - ignore it.
if (context.context == null) {
return;
}
// Something still wants this image, but check if the context is scrolling
// too fast before scheduling work that might never show on screen.
// Try to get to end of the frame callbacks of the next frame, and then
// check again.
if (Scrollable.recommendDeferredLoadingForContext(context.context)) {
SchedulerBinding.instance.scheduleFrameCallback((_) {
scheduleMicrotask(() => resolveStreamForKey(configuration, stream, key, handleError));
});
return;
}
// We are in the tree, we're not scrolling too fast, the cache doens't
// have our image, and no one has otherwise completed the stream. Go.
imageProvider.resolveStreamForKey(configuration, stream, key, handleError);
}
@override
ImageStreamCompleter load(T key, DecoderCallback decode) => imageProvider.load(key, decode);
@override
Future<T> obtainKey(ImageConfiguration configuration) => imageProvider.obtainKey(configuration);
}
......@@ -31,6 +31,7 @@ export 'src/widgets/color_filter.dart';
export 'src/widgets/container.dart';
export 'src/widgets/debug.dart';
export 'src/widgets/dismissible.dart';
export 'src/widgets/disposable_build_context.dart';
export 'src/widgets/drag_target.dart';
export 'src/widgets/draggable_scrollable_sheet.dart';
export 'src/widgets/editable_text.dart';
......@@ -78,6 +79,7 @@ export 'src/widgets/raw_keyboard_listener.dart';
export 'src/widgets/routes.dart';
export 'src/widgets/safe_area.dart';
export 'src/widgets/scroll_activity.dart';
export 'src/widgets/scroll_aware_image_provider.dart';
export 'src/widgets/scroll_configuration.dart';
export 'src/widgets/scroll_context.dart';
export 'src/widgets/scroll_controller.dart';
......
......@@ -212,5 +212,35 @@ void main() {
},
));
});
test('containsKey - pending', () async {
const TestImage testImage = TestImage(width: 8, height: 8);
final TestImageStreamCompleter completer1 = TestImageStreamCompleter();
final TestImageStreamCompleter resultingCompleter1 = imageCache.putIfAbsent(testImage, () {
return completer1;
}) as TestImageStreamCompleter;
expect(resultingCompleter1, completer1);
expect(imageCache.containsKey(testImage), true);
});
test('containsKey - completed', () async {
const TestImage testImage = TestImage(width: 8, height: 8);
final TestImageStreamCompleter completer1 = TestImageStreamCompleter();
final TestImageStreamCompleter resultingCompleter1 = imageCache.putIfAbsent(testImage, () {
return completer1;
}) as TestImageStreamCompleter;
// Mark as complete
completer1.testSetImage(testImage);
expect(resultingCompleter1, completer1);
expect(imageCache.containsKey(testImage), true);
});
});
}
......@@ -25,9 +25,9 @@ class TestImageProvider extends ImageProvider<TestImageProvider> {
}
@override
ImageStream resolve(ImageConfiguration config) {
void resolveStreamForKey(ImageConfiguration config, ImageStream stream, TestImageProvider key, ImageErrorListener handleError) {
configuration = config;
return super.resolve(configuration);
super.resolveStreamForKey(config, stream, key, handleError);
}
@override
......
......@@ -133,4 +133,8 @@ class LoadErrorCompleterImageProvider extends ImageProvider<LoadErrorCompleterIm
}
}
class TestImageStreamCompleter extends ImageStreamCompleter {}
class TestImageStreamCompleter extends ImageStreamCompleter {
void testSetImage(TestImage image) {
setImage(ImageInfo(image: image, scale: 1.0));
}
}
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/widgets.dart';
void main() {
testWidgets('DisposableBuildContext asserts on disposed state', (WidgetTester tester) async {
final GlobalKey<TestWidgetState> key = GlobalKey<TestWidgetState>();
await tester.pumpWidget(TestWidget(key));
final TestWidgetState state = key.currentState;
expect(state.mounted, true);
final DisposableBuildContext context = DisposableBuildContext(state);
expect(context.context, state.context);
await tester.pumpWidget(const TestWidget(null));
expect(state.mounted, false);
expect(() => context.context, throwsAssertionError);
context.dispose();
expect(context.context, state.context);
expect(() => DisposableBuildContext(state), throwsAssertionError);
});
}
class TestWidget extends StatefulWidget {
const TestWidget(Key key) : super(key: key);
@override
State<TestWidget> createState() => TestWidgetState();
}
class TestWidgetState extends State<TestWidget> {
@override
Widget build(BuildContext context) => const SizedBox(height: 50);
}
......@@ -1175,6 +1175,54 @@ void main() {
streamCompleter.setData(chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100));
expect(tester.binding.hasScheduledFrame, isFalse);
}, skip: isBrowser);
testWidgets('Image defers loading while fast scrolling', (WidgetTester tester) async {
const int gridCells = 1000;
final List<TestImageProvider> imageProviders = <TestImageProvider>[];
final ScrollController controller = ScrollController();
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: GridView.builder(
controller: controller,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3),
itemCount: gridCells,
itemBuilder: (_, int index) {
final TestImageProvider provider = TestImageProvider();
imageProviders.add(provider);
return SizedBox(
height: 250,
width: 250,
child: Image(
image: provider,
semanticLabel: index.toString(),
),
);
},
),
));
final bool Function(TestImageProvider) loadCalled = (TestImageProvider provider) => provider.loadCalled;
final bool Function(TestImageProvider) loadNotCalled = (TestImageProvider provider) => !provider.loadCalled;
expect(find.bySemanticsLabel('5'), findsOneWidget);
expect(imageProviders.length, 12);
expect(imageProviders.every(loadCalled), true);
imageProviders.clear();
// Simulate a very fast fling.
controller.animateTo(
30000,
duration: const Duration(seconds: 2),
curve: Curves.linear,
);
await tester.pumpAndSettle();
// The last 15 images on screen have loaded because the scrolling settled there.
// The rest have not loaded.
expect(imageProviders.length, 309);
expect(imageProviders.skip(309 - 15).every(loadCalled), true);
expect(imageProviders.take(309 - 15).every(loadNotCalled), true);
});
}
class TestImageProvider extends ImageProvider<TestImageProvider> {
......@@ -1187,19 +1235,25 @@ class TestImageProvider extends ImageProvider<TestImageProvider> {
ImageStreamCompleter _streamCompleter;
ImageConfiguration _lastResolvedConfiguration;
bool get loadCalled => _loadCalled;
bool _loadCalled = false;
@override
Future<TestImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture<TestImageProvider>(this);
}
@override
ImageStream resolve(ImageConfiguration configuration) {
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, TestImageProvider key, ImageErrorListener handleError) {
_lastResolvedConfiguration = configuration;
return super.resolve(configuration);
super.resolveStreamForKey(configuration, stream, key, handleError);
}
@override
ImageStreamCompleter load(TestImageProvider key, DecoderCallback decode) => _streamCompleter;
ImageStreamCompleter load(TestImageProvider key, DecoderCallback decode) {
_loadCalled = true;
return _streamCompleter;
}
void complete() {
_completer.complete(ImageInfo(image: TestImage()));
......
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