Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for ImageStreamListener.onChunk() #33092

Merged
merged 5 commits into from
May 21, 2019
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 40 additions & 18 deletions packages/flutter/lib/src/painting/image_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -525,23 +534,36 @@ 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) {
tvolkert marked this conversation as resolved.
Show resolved Hide resolved
chunkEvents.add(ImageChunkEvent(cumulative, 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
Expand Down
95 changes: 93 additions & 2 deletions packages/flutter/lib/src/painting/image_stream.dart
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class ImageStreamListener {
/// The [onImage] parameter must not be null.
const ImageStreamListener(
this.onImage, {
this.onChunk,
this.onError,
}) : assert(onImage != null);

Expand All @@ -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
Expand Down Expand Up @@ -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(this.cumulativeBytesLoaded, this.expectedTotalBytes)
: assert(cumulativeBytesLoaded >= 0),
assert(expectedTotalBytes >= -1);

/// 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 -1 if the number is not known in advance. In this way,
tvolkert marked this conversation as resolved.
Show resolved Hide resolved
/// this value matches the semantics of the `Content-Length` HTTP response
/// header.
///
/// When this value is -1, 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
Expand Down Expand Up @@ -504,13 +565,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
/// `codec` is a future for an initialized [ui.Codec] that will be used to
tvolkert marked this conversation as resolved.
Show resolved Hide resolved
/// decode the image.
/// [scale] is the linear scale factor for drawing this frames of this image
///
/// `scale` 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,
Expand All @@ -524,6 +592,29 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter {
silent: true,
);
});
if (chunkEvents != null) {
chunkEvents.listen(
(ImageChunkEvent event) {
if (hasListeners) {
final List<ImageChunkListener> localListeners = _listeners
.map<ImageChunkListener>((ImageStreamListener listener) => listener.onChunk)
.where((ImageChunkListener chunkListener) => chunkListener != null)
.toList();
tvolkert marked this conversation as resolved.
Show resolved Hide resolved
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;
Expand Down
57 changes: 57 additions & 0 deletions packages/flutter/test/painting/image_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';

import 'package:flutter/foundation.dart';
Expand Down Expand Up @@ -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 {}
79 changes: 79 additions & 0 deletions packages/flutter/test/painting/image_stream_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(1, 3));
tvolkert marked this conversation as resolved.
Show resolved Hide resolved
streamController.add(const ImageChunkEvent(2, 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(1, 3));
await tester.idle();
imageStream.addListener(ImageStreamListener(
(ImageInfo image, bool synchronousCall) { },
onChunk: (ImageChunkEvent event) {
chunkEvents.add(event);
},
));
streamController.add(const ImageChunkEvent(2, 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(2, 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;
Expand Down