Unverified Commit 27d3c2fc authored by Todd Volkert's avatar Todd Volkert Committed by GitHub

Add support for ImageStreamListener.onChunk() (#33092)

This is another step towards supporting image loading
progress notification at the widgets layer.

This adds an `ImageChunkEvent` class along with associated
`ImageChunkListener` callback signature and an `onChunk`
property to `ImageStreamListener`. The events serve to
notify registered listeners when byte chunks are received
while loading an image.

https://github.com/flutter/flutter/issues/32374
parent ca4ad6dc
......@@ -16,7 +16,7 @@ import 'dart:typed_data';
///
/// The `total` parameter will contain the _expected_ total number of bytes to
/// be received across the wire (extracted from the value of the
/// `Content-Length` HTTP response header), or -1 if the size of the response
/// `Content-Length` HTTP response header), or null if the size of the response
/// body is not known in advance (this is common for HTTP chunked transfer
/// encoding, which itself is common when a large amount of data is being
/// returned to the client and the total size of the response may not be known
......@@ -56,11 +56,13 @@ Future<Uint8List> consolidateHttpClientResponseBytes(
final _OutputBuffer output = _OutputBuffer();
ByteConversionSink sink = output;
int expectedContentLength = response.contentLength;
if (expectedContentLength == -1)
expectedContentLength = null;
if (response.headers?.value(HttpHeaders.contentEncodingHeader) == 'gzip') {
if (client?.autoUncompress ?? true) {
// response.contentLength will not match our bytes stream, so we declare
// that we don't know the expected content length.
expectedContentLength = -1;
expectedContentLength = null;
} else if (autoUncompress) {
// We need to un-compress the bytes as they come in.
sink = gzip.decoder.startChunkedConversion(output);
......
......@@ -502,8 +502,14 @@ class NetworkImage extends ImageProvider<NetworkImage> {
@override
ImageStreamCompleter load(NetworkImage key) {
// Ownership of this controller is handed off to [_loadAsync]; it is that
// method's responsibility to close the controller's stream when the image
// has been loaded or an error is thrown.
final StreamController<ImageChunkEvent> chunkEvents = StreamController<ImageChunkEvent>();
return MultiFrameImageStreamCompleter(
codec: _loadAsync(key),
codec: _loadAsync(key, chunkEvents),
chunkEvents: chunkEvents.stream,
scale: key.scale,
informationCollector: () sync* {
yield DiagnosticsProperty<ImageProvider>('Image provider', this);
......@@ -513,7 +519,10 @@ class NetworkImage extends ImageProvider<NetworkImage> {
}
// Do not access this field directly; use [_httpClient] instead.
static final HttpClient _sharedHttpClient = HttpClient();
// We set `autoUncompress` to false to ensure that we can trust the value of
// the `Content-Length` HTTP header. We automatically uncompress the content
// in our call to [consolidateHttpClientResponseBytes].
static final HttpClient _sharedHttpClient = HttpClient()..autoUncompress = false;
static HttpClient get _httpClient {
HttpClient client = _sharedHttpClient;
......@@ -525,23 +534,39 @@ class NetworkImage extends ImageProvider<NetworkImage> {
return client;
}
Future<ui.Codec> _loadAsync(NetworkImage key) async {
assert(key == this);
final Uri resolved = Uri.base.resolve(key.url);
final HttpClientRequest request = await _httpClient.getUrl(resolved);
headers?.forEach((String name, String value) {
request.headers.add(name, value);
});
final HttpClientResponse response = await request.close();
if (response.statusCode != HttpStatus.ok)
throw Exception('HTTP request failed, statusCode: ${response?.statusCode}, $resolved');
final Uint8List bytes = await consolidateHttpClientResponseBytes(response);
if (bytes.lengthInBytes == 0)
throw Exception('NetworkImage is an empty file: $resolved');
Future<ui.Codec> _loadAsync(
NetworkImage key,
StreamController<ImageChunkEvent> chunkEvents,
) async {
try {
assert(key == this);
final Uri resolved = Uri.base.resolve(key.url);
final HttpClientRequest request = await _httpClient.getUrl(resolved);
headers?.forEach((String name, String value) {
request.headers.add(name, value);
});
final HttpClientResponse response = await request.close();
if (response.statusCode != HttpStatus.ok)
throw Exception('HTTP request failed, statusCode: ${response?.statusCode}, $resolved');
final Uint8List bytes = await consolidateHttpClientResponseBytes(
response,
client: _httpClient,
onBytesReceived: (int cumulative, int total) {
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: cumulative,
expectedTotalBytes: total,
));
},
);
if (bytes.lengthInBytes == 0)
throw Exception('NetworkImage is an empty file: $resolved');
return PaintingBinding.instance.instantiateImageCodec(bytes);
return PaintingBinding.instance.instantiateImageCodec(bytes);
} finally {
chunkEvents.close();
}
}
@override
......
......@@ -73,6 +73,7 @@ class ImageStreamListener {
/// The [onImage] parameter must not be null.
const ImageStreamListener(
this.onImage, {
this.onChunk,
this.onError,
}) : assert(onImage != null);
......@@ -92,6 +93,19 @@ class ImageStreamListener {
/// during loading.
final ImageListener onImage;
/// Callback for getting notified when a chunk of bytes has been received
/// during the loading of the image.
///
/// This callback may fire many times (e.g. when used with a [NetworkImage],
/// where the image bytes are loaded incrementally over the wire) or not at
/// all (e.g. when used with a [MemoryImage], where the image bytes are
/// already available in memory).
///
/// This callback may also continue to fire after the [onImage] callback has
/// fired (e.g. for multi-frame images that continue to load after the first
/// frame is available).
final ImageChunkListener onChunk;
/// Callback for getting notified when an error occurs while loading an image.
///
/// If an error occurs during loading, [onError] will be called instead of
......@@ -123,12 +137,59 @@ class ImageStreamListener {
/// same stack frame as the call to [ImageStream.addListener]).
typedef ImageListener = void Function(ImageInfo image, bool synchronousCall);
/// Signature for listening to [ImageChunkEvent] events.
///
/// Used in [ImageStreamListener].
typedef ImageChunkListener = void Function(ImageChunkEvent event);
/// Signature for reporting errors when resolving images.
///
/// Used in [ImageStreamListener], as well as by [ImageCache.putIfAbsent] and
/// [precacheImage], to report errors.
typedef ImageErrorListener = void Function(dynamic exception, StackTrace stackTrace);
/// An immutable notification of image bytes that have been incrementally loaded.
///
/// Chunk events represent progress notifications while an image is being
/// loaded (e.g. from disk or over the network).
///
/// See also:
///
/// * [ImageChunkListener], the means by which callers get notified of
/// these events.
@immutable
class ImageChunkEvent extends Diagnosticable {
/// Creates a new chunk event.
const ImageChunkEvent({
@required this.cumulativeBytesLoaded,
@required this.expectedTotalBytes,
}) : assert(cumulativeBytesLoaded >= 0),
assert(expectedTotalBytes == null || expectedTotalBytes >= 0);
/// The number of bytes that have been received across the wire thus far.
final int cumulativeBytesLoaded;
/// The expected number of bytes that need to be received to finish loading
/// the image.
///
/// This value is not necessarily equal to the expected _size_ of the image
/// in bytes, as the bytes required to load the image may be compressed.
///
/// This value will be null if the number is not known in advance.
///
/// When this value is null, the chunk event may still be useful as an
/// indication that data is loading (and how much), but it cannot represent a
/// loading completion percentage.
final int expectedTotalBytes;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(IntProperty('cumulativeBytesLoaded', cumulativeBytesLoaded));
properties.add(IntProperty('expectedTotalBytes', expectedTotalBytes));
}
}
/// A handle to an image resource.
///
/// ImageStream represents a handle to a [dart:ui.Image] object and its scale
......@@ -337,6 +398,7 @@ abstract class ImageStreamCompleter extends Diagnosticable {
_currentImage = image;
if (_listeners.isEmpty)
return;
// Make a copy to allow for concurrent modification.
final List<ImageStreamListener> localListeners =
List<ImageStreamListener>.from(_listeners);
for (ImageStreamListener listener in localListeners) {
......@@ -397,6 +459,7 @@ abstract class ImageStreamCompleter extends Diagnosticable {
silent: silent,
);
// Make a copy to allow for concurrent modification.
final List<ImageErrorListener> localErrorListeners = _listeners
.map<ImageErrorListener>((ImageStreamListener listener) => listener.onError)
.where((ImageErrorListener errorListener) => errorListener != null)
......@@ -504,13 +567,20 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter {
///
/// Immediately starts decoding the first image frame when the codec is ready.
///
/// [codec] is a future for an initialized [ui.Codec] that will be used to
/// decode the image.
/// [scale] is the linear scale factor for drawing this frames of this image
/// at their intended size.
/// The `codec` parameter is a future for an initialized [ui.Codec] that will
/// be used to decode the image.
///
/// The `scale` parameter is the linear scale factor for drawing this frames
/// of this image at their intended size.
///
/// The `chunkEvents` parameter is an optional stream of notifications about
/// the loading progress of the image. If this stream is provided, the events
/// produced by the stream will be delivered to registered [ImageChunkListener]s
/// (see [addListener]).
MultiFrameImageStreamCompleter({
@required Future<ui.Codec> codec,
@required double scale,
Stream<ImageChunkEvent> chunkEvents,
InformationCollector informationCollector,
}) : assert(codec != null),
_informationCollector = informationCollector,
......@@ -524,6 +594,30 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter {
silent: true,
);
});
if (chunkEvents != null) {
chunkEvents.listen(
(ImageChunkEvent event) {
if (hasListeners) {
// Make a copy to allow for concurrent modification.
final List<ImageChunkListener> localListeners = _listeners
.map<ImageChunkListener>((ImageStreamListener listener) => listener.onChunk)
.where((ImageChunkListener chunkListener) => chunkListener != null)
.toList();
for (ImageChunkListener listener in localListeners) {
listener(event);
}
}
}, onError: (dynamic error, StackTrace stack) {
reportError(
context: ErrorDescription('loading an image'),
exception: error,
stack: stack,
informationCollector: informationCollector,
silent: true,
);
},
);
}
}
ui.Codec _codec;
......
......@@ -207,9 +207,9 @@ void main() {
expect(records, <int>[
gzippedChunkOne.length,
-1,
null,
gzipped.length,
-1,
null,
]);
});
});
......
......@@ -4,6 +4,7 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
......@@ -199,8 +200,64 @@ void main() {
));
expect(uncaught, false);
});
test('Notifies listeners of chunk events', () async {
final List<List<int>> chunks = <List<int>>[];
const int chunkSize = 8;
for (int offset = 0; offset < kTransparentImage.length; offset += chunkSize) {
chunks.add(kTransparentImage.skip(offset).take(chunkSize).toList());
}
final Completer<void> imageAvailable = Completer<void>();
final MockHttpClientRequest request = MockHttpClientRequest();
final MockHttpClientResponse response = MockHttpClientResponse();
when(httpClient.getUrl(any)).thenAnswer((_) => Future<HttpClientRequest>.value(request));
when(request.close()).thenAnswer((_) => Future<HttpClientResponse>.value(response));
when(response.statusCode).thenReturn(HttpStatus.ok);
when(response.contentLength).thenReturn(kTransparentImage.length);
when(response.listen(
any,
onDone: anyNamed('onDone'),
onError: anyNamed('onError'),
cancelOnError: anyNamed('cancelOnError'),
)).thenAnswer((Invocation invocation) {
final void Function(List<int>) onData = invocation.positionalArguments[0];
final void Function(Object) onError = invocation.namedArguments[#onError];
final void Function() onDone = invocation.namedArguments[#onDone];
final bool cancelOnError = invocation.namedArguments[#cancelOnError];
return Stream<List<int>>.fromIterable(chunks).listen(
onData,
onDone: onDone,
onError: onError,
cancelOnError: cancelOnError,
);
});
final ImageProvider imageProvider = NetworkImage(nonconst('foo'));
final ImageStream result = imageProvider.resolve(ImageConfiguration.empty);
final List<ImageChunkEvent> events = <ImageChunkEvent>[];
result.addListener(ImageStreamListener(
(ImageInfo image, bool synchronousCall) {
imageAvailable.complete();
},
onChunk: (ImageChunkEvent event) {
events.add(event);
},
onError: (dynamic error, StackTrace stackTrace) {
imageAvailable.completeError(error, stackTrace);
},
));
await imageAvailable.future;
expect(events.length, chunks.length);
for (int i = 0; i < events.length; i++) {
expect(events[i].cumulativeBytesLoaded, math.min((i + 1) * chunkSize, kTransparentImage.length));
expect(events[i].expectedTotalBytes, kTransparentImage.length);
}
});
});
});
}
class MockHttpClient extends Mock implements HttpClient {}
class MockHttpClientRequest extends Mock implements HttpClientRequest {}
class MockHttpClientResponse extends Mock implements HttpClientResponse {}
......@@ -127,6 +127,85 @@ void main() {
expect(mockCodec.numFramesAsked, 1);
});
testWidgets('Chunk events are delivered', (WidgetTester tester) async {
final List<ImageChunkEvent> chunkEvents = <ImageChunkEvent>[];
final Completer<Codec> completer = Completer<Codec>();
final StreamController<ImageChunkEvent> streamController = StreamController<ImageChunkEvent>();
final ImageStreamCompleter imageStream = MultiFrameImageStreamCompleter(
codec: completer.future,
chunkEvents: streamController.stream,
scale: 1.0,
);
imageStream.addListener(ImageStreamListener(
(ImageInfo image, bool synchronousCall) { },
onChunk: (ImageChunkEvent event) {
chunkEvents.add(event);
},
));
streamController.add(const ImageChunkEvent(cumulativeBytesLoaded: 1, expectedTotalBytes: 3));
streamController.add(const ImageChunkEvent(cumulativeBytesLoaded: 2, expectedTotalBytes: 3));
await tester.idle();
expect(chunkEvents.length, 2);
expect(chunkEvents[0].cumulativeBytesLoaded, 1);
expect(chunkEvents[0].expectedTotalBytes, 3);
expect(chunkEvents[1].cumulativeBytesLoaded, 2);
expect(chunkEvents[1].expectedTotalBytes, 3);
});
testWidgets('Chunk events are not buffered before listener registration', (WidgetTester tester) async {
final List<ImageChunkEvent> chunkEvents = <ImageChunkEvent>[];
final Completer<Codec> completer = Completer<Codec>();
final StreamController<ImageChunkEvent> streamController = StreamController<ImageChunkEvent>();
final ImageStreamCompleter imageStream = MultiFrameImageStreamCompleter(
codec: completer.future,
chunkEvents: streamController.stream,
scale: 1.0,
);
streamController.add(const ImageChunkEvent(cumulativeBytesLoaded: 1, expectedTotalBytes: 3));
await tester.idle();
imageStream.addListener(ImageStreamListener(
(ImageInfo image, bool synchronousCall) { },
onChunk: (ImageChunkEvent event) {
chunkEvents.add(event);
},
));
streamController.add(const ImageChunkEvent(cumulativeBytesLoaded: 2, expectedTotalBytes: 3));
await tester.idle();
expect(chunkEvents.length, 1);
expect(chunkEvents[0].cumulativeBytesLoaded, 2);
expect(chunkEvents[0].expectedTotalBytes, 3);
});
testWidgets('Chunk errors are reported', (WidgetTester tester) async {
final List<ImageChunkEvent> chunkEvents = <ImageChunkEvent>[];
final Completer<Codec> completer = Completer<Codec>();
final StreamController<ImageChunkEvent> streamController = StreamController<ImageChunkEvent>();
final ImageStreamCompleter imageStream = MultiFrameImageStreamCompleter(
codec: completer.future,
chunkEvents: streamController.stream,
scale: 1.0,
);
imageStream.addListener(ImageStreamListener(
(ImageInfo image, bool synchronousCall) { },
onChunk: (ImageChunkEvent event) {
chunkEvents.add(event);
},
));
streamController.addError(Error());
streamController.add(const ImageChunkEvent(cumulativeBytesLoaded: 2, expectedTotalBytes: 3));
await tester.idle();
expect(tester.takeException(), isNotNull);
expect(chunkEvents.length, 1);
expect(chunkEvents[0].cumulativeBytesLoaded, 2);
expect(chunkEvents[0].expectedTotalBytes, 3);
});
testWidgets('getNextFrame future fails', (WidgetTester tester) async {
final MockCodec mockCodec = MockCodec();
mockCodec.frameCount = 1;
......
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