Use separate isolate for image loading. (#34188)

* Use separate isolate for image loading. Use TransferableTypedData to const-cost receive bytes from that isolate.
This commit is contained in:
Alexander Aprelev 2019-09-18 20:38:54 -07:00 committed by GitHub
parent 3cf88fed6d
commit b12bdd0ea1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 520 additions and 191 deletions

View file

@ -5,6 +5,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data'; import 'dart:typed_data';
/// Signature for getting notified when chunks of bytes are received while /// Signature for getting notified when chunks of bytes are received while
@ -22,11 +23,11 @@ import 'dart:typed_data';
/// returned to the client and the total size of the response may not be known /// returned to the client and the total size of the response may not be known
/// until the request has been fully processed). /// until the request has been fully processed).
/// ///
/// This is used in [consolidateHttpClientResponseBytes]. /// This is used in [getHttpClientResponseBytes].
typedef BytesReceivedCallback = void Function(int cumulative, int total); typedef BytesReceivedCallback = void Function(int cumulative, int total);
/// Efficiently converts the response body of an [HttpClientResponse] into a /// Efficiently converts the response body of an [HttpClientResponse] into a
/// [Uint8List]. /// [TransferableTypedData].
/// ///
/// The future returned will forward any error emitted by `response`. /// The future returned will forward any error emitted by `response`.
/// ///
@ -43,13 +44,13 @@ typedef BytesReceivedCallback = void Function(int cumulative, int total);
/// bytes from this method (assuming the response is sending compressed bytes), /// bytes from this method (assuming the response is sending compressed bytes),
/// set both [HttpClient.autoUncompress] to false and the `autoUncompress` /// set both [HttpClient.autoUncompress] to false and the `autoUncompress`
/// parameter to false. /// parameter to false.
Future<Uint8List> consolidateHttpClientResponseBytes( Future<TransferableTypedData> getHttpClientResponseBytes(
HttpClientResponse response, { HttpClientResponse response, {
bool autoUncompress = true, bool autoUncompress = true,
BytesReceivedCallback onBytesReceived, BytesReceivedCallback onBytesReceived,
}) { }) {
assert(autoUncompress != null); assert(autoUncompress != null);
final Completer<Uint8List> completer = Completer<Uint8List>.sync(); final Completer<TransferableTypedData> completer = Completer<TransferableTypedData>.sync();
final _OutputBuffer output = _OutputBuffer(); final _OutputBuffer output = _OutputBuffer();
ByteConversionSink sink = output; ByteConversionSink sink = output;
@ -89,41 +90,54 @@ Future<Uint8List> consolidateHttpClientResponseBytes(
} }
}, onDone: () { }, onDone: () {
sink.close(); sink.close();
completer.complete(output.bytes); completer.complete(TransferableTypedData.fromList(output.chunks));
}, onError: completer.completeError, cancelOnError: true); }, onError: completer.completeError, cancelOnError: true);
return completer.future; return completer.future;
} }
/// Efficiently converts the response body of an [HttpClientResponse] into a
/// [Uint8List].
///
/// (This method is deprecated - use [getHttpClientResponseBytes] instead.)
///
/// The future returned will forward any error emitted by `response`.
///
/// The `onBytesReceived` callback, if specified, will be invoked for every
/// chunk of bytes that is received while consolidating the response bytes.
/// If the callback throws an error, processing of the response will halt, and
/// the returned future will complete with the error that was thrown by the
/// callback. For more information on how to interpret the parameters to the
/// callback, see the documentation on [BytesReceivedCallback].
///
/// If the `response` is gzipped and the `autoUncompress` parameter is true,
/// this will automatically un-compress the bytes in the returned list if it
/// hasn't already been done via [HttpClient.autoUncompress]. To get compressed
/// bytes from this method (assuming the response is sending compressed bytes),
/// set both [HttpClient.autoUncompress] to false and the `autoUncompress`
/// parameter to false.
@Deprecated('Use getHttpClientResponseBytes instead')
Future<Uint8List> consolidateHttpClientResponseBytes(
HttpClientResponse response, {
bool autoUncompress = true,
BytesReceivedCallback onBytesReceived,
}) async {
final TransferableTypedData bytes = await getHttpClientResponseBytes(
response,
autoUncompress: autoUncompress,
onBytesReceived: onBytesReceived,
);
return bytes.materialize().asUint8List();
}
class _OutputBuffer extends ByteConversionSinkBase { class _OutputBuffer extends ByteConversionSinkBase {
List<List<int>> _chunks = <List<int>>[]; final List<Uint8List> chunks = <Uint8List>[];
int _contentLength = 0;
Uint8List _bytes;
@override @override
void add(List<int> chunk) { void add(List<int> chunk) {
assert(_bytes == null); chunks.add(chunk);
_chunks.add(chunk);
_contentLength += chunk.length;
} }
@override @override
void close() { void close() {}
if (_bytes != null) {
// We've already been closed; this is a no-op
return;
}
_bytes = Uint8List(_contentLength);
int offset = 0;
for (List<int> chunk in _chunks) {
_bytes.setRange(offset, offset + chunk.length, chunk);
offset += chunk.length;
}
_chunks = null;
}
Uint8List get bytes {
assert(_bytes != null);
return _bytes;
}
} }

View file

@ -4,6 +4,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:ui' as ui; import 'dart:ui' as ui;
@ -14,7 +15,7 @@ import 'debug.dart';
import 'image_provider.dart' as image_provider; import 'image_provider.dart' as image_provider;
import 'image_stream.dart'; import 'image_stream.dart';
/// The dart:io implemenation of [image_provider.NetworkImage]. /// The dart:io implementation of [image_provider.NetworkImage].
class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkImage> implements image_provider.NetworkImage { class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkImage> implements image_provider.NetworkImage {
/// Creates an object that fetches the image at the given URL. /// Creates an object that fetches the image at the given URL.
/// ///
@ -39,9 +40,9 @@ class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkIm
@override @override
ImageStreamCompleter load(image_provider.NetworkImage key) { ImageStreamCompleter load(image_provider.NetworkImage key) {
// Ownership of this controller is handed off to [_loadAsync]; it is that // Ownership of this controller is handed off to [_loadAsync];
// method's responsibility to close the controller's stream when the image // it is that method's responsibility to close the controller's stream when
// has been loaded or an error is thrown. // the image has been loaded or an error is thrown.
final StreamController<ImageChunkEvent> chunkEvents = StreamController<ImageChunkEvent>(); final StreamController<ImageChunkEvent> chunkEvents = StreamController<ImageChunkEvent>();
return MultiFrameImageStreamCompleter( return MultiFrameImageStreamCompleter(
@ -57,63 +58,138 @@ class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkIm
); );
} }
// Do not access this field directly; use [_httpClient] instead. // For [_pendingLoader] we don't need the value(worker isolate), just the future
// We set `autoUncompress` to false to ensure that we can trust the value of // itself that is used as an indicator that successive load requests should be
// the `Content-Length` HTTP header. We automatically uncompress the content // added to the list of pending load requests [_pendingLoadRequests].
// in our call to [consolidateHttpClientResponseBytes]. static Future<void> _pendingLoader;
static final HttpClient _sharedHttpClient = HttpClient()..autoUncompress = false; static RawReceivePort _loaderErrorHandler;
static List<_DownloadRequest> _pendingLoadRequests;
static HttpClient get _httpClient { static SendPort _requestPort;
HttpClient client = _sharedHttpClient;
assert(() {
if (debugNetworkImageHttpClientProvider != null)
client = debugNetworkImageHttpClientProvider();
return true;
}());
return client;
}
Future<ui.Codec> _loadAsync( Future<ui.Codec> _loadAsync(
NetworkImage key, NetworkImage key,
StreamController<ImageChunkEvent> chunkEvents, StreamController<ImageChunkEvent> chunkEvents,
) async { ) async {
RawReceivePort downloadResponseHandler;
try { try {
assert(key == this); assert(key == this);
final Uri resolved = Uri.base.resolve(key.url); 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 image_provider.NetworkImageLoadException(statusCode: response.statusCode, uri: resolved);
final Uint8List bytes = await consolidateHttpClientResponseBytes( final Completer<TransferableTypedData> bytesCompleter = Completer<TransferableTypedData>();
response, downloadResponseHandler = RawReceivePort((_DownloadResponse response) {
onBytesReceived: (int cumulative, int total) { if (response.bytes != null) {
chunkEvents.add(ImageChunkEvent( if (bytesCompleter.isCompleted) {
cumulativeBytesLoaded: cumulative, // If an uncaught error occurred in the worker isolate, we'll have
expectedTotalBytes: total, // already completed our bytes completer.
)); return;
}, }
bytesCompleter.complete(response.bytes);
} else if (response.chunkEvent != null) {
chunkEvents.add(response.chunkEvent);
} else if (response.error != null) {
bytesCompleter.completeError(response.error);
} else {
assert(false);
}
});
// This will keep references to [debugNetworkImageHttpClientProvider] tree-shaken
// out of release builds.
HttpClientProvider httpClientProvider;
assert(() { httpClientProvider = debugNetworkImageHttpClientProvider; return true; }());
final _DownloadRequest downloadRequest = _DownloadRequest(
downloadResponseHandler.sendPort,
resolved,
headers,
httpClientProvider,
); );
if (bytes.lengthInBytes == 0) if (_requestPort != null) {
// If worker isolate is properly set up ([_requestPort] is holding
// initialized [SendPort]), then just send download request down to it.
_requestPort.send(downloadRequest);
} else {
if (_pendingLoader == null) {
// If worker isolate creation was not started, start creation now.
_spawnAndSetupIsolate();
}
// Record download request so it can either send a request when isolate is ready or handle errors.
_pendingLoadRequests.add(downloadRequest);
}
final TransferableTypedData transferable = await bytesCompleter.future;
final Uint8List bytes = transferable.materialize().asUint8List();
if (bytes.isEmpty)
throw Exception('NetworkImage is an empty file: $resolved'); throw Exception('NetworkImage is an empty file: $resolved');
return PaintingBinding.instance.instantiateImageCodec(bytes); return PaintingBinding.instance.instantiateImageCodec(bytes);
} finally { } finally {
chunkEvents.close(); chunkEvents.close();
downloadResponseHandler?.close();
} }
} }
void _spawnAndSetupIsolate() {
assert(_pendingLoadRequests == null);
assert(_loaderErrorHandler == null);
assert(_pendingLoader == null);
_pendingLoadRequests = <_DownloadRequest>[];
_pendingLoader = _spawnIsolate()..then((Isolate isolate) {
_loaderErrorHandler = RawReceivePort((List<dynamic> errorAndStackTrace) {
_cleanupDueToError(errorAndStackTrace[0]);
});
isolate.addErrorListener(_loaderErrorHandler.sendPort);
isolate.resume(isolate.pauseCapability);
}).catchError((dynamic error, StackTrace stackTrace) {
_cleanupDueToError(error);
});
}
void _cleanupDueToError(dynamic error) {
for (_DownloadRequest request in _pendingLoadRequests) {
request.handleError(error);
}
_pendingLoadRequests = null;
_pendingLoader = null;
_loaderErrorHandler.close();
_loaderErrorHandler = null;
}
Future<Isolate> _spawnIsolate() {
// Once worker isolate is up and running it sends it's [sendPort] over
// [communicationBootstrapHandler] receive port.
// If [sendPort] is [null], it indicates that worker isolate exited after
// being idle.
final RawReceivePort communicationBootstrapHandler = RawReceivePort((SendPort sendPort) {
_requestPort = sendPort;
if (sendPort == null) {
assert(_pendingLoadRequests.isEmpty);
_pendingLoader = null;
_pendingLoadRequests = null;
_loaderErrorHandler.close();
_loaderErrorHandler = null;
return;
}
// When we received [SendPort] for the worker isolate, we send all
// pending requests that were accumulated before worker isolate provided
// it's port (before [_requestPort] was populated).
_pendingLoadRequests.forEach(sendPort.send);
_pendingLoadRequests.clear();
});
return Isolate.spawn<SendPort>(_initializeWorkerIsolate,
communicationBootstrapHandler.sendPort, paused: true);
}
@override @override
bool operator ==(dynamic other) { bool operator ==(dynamic other) {
if (other.runtimeType != runtimeType) if (other.runtimeType != runtimeType)
return false; return false;
final NetworkImage typedOther = other; final NetworkImage typedOther = other;
return url == typedOther.url return url == typedOther.url && scale == typedOther.scale;
&& scale == typedOther.scale;
} }
@override @override
@ -122,3 +198,95 @@ class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkIm
@override @override
String toString() => '$runtimeType("$url", scale: $scale)'; String toString() => '$runtimeType("$url", scale: $scale)';
} }
@immutable
class _DownloadResponse {
const _DownloadResponse.bytes(this.bytes) : assert(bytes != null), chunkEvent = null, error = null;
const _DownloadResponse.chunkEvent(this.chunkEvent) : assert(chunkEvent != null), bytes = null, error = null;
const _DownloadResponse.error(this.error) : assert(error != null), bytes = null, chunkEvent = null;
final TransferableTypedData bytes;
final ImageChunkEvent chunkEvent;
final dynamic error;
}
@immutable
class _DownloadRequest {
const _DownloadRequest(this.sendPort, this.uri, this.headers, this.httpClientProvider) :
assert(sendPort != null), assert(uri != null);
final SendPort sendPort;
final Uri uri;
final Map<String, String> headers;
final HttpClientProvider httpClientProvider;
void handleError(dynamic error) { sendPort.send(_DownloadResponse.error(error)); }
}
// 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 [getHttpClientResponseBytes].
final HttpClient _sharedHttpClient = HttpClient()..autoUncompress = false;
const Duration _idleDuration = Duration(seconds: 60);
/// Sets up the worker isolate to listen for incoming [_DownloadRequest]s from
/// the main isolate.
///
/// This method runs on a worker isolate.
///
/// The `handshakeSendPort` argument is this worker isolate's communications
/// link back to the main isolate. It is used to set-up the channel with which
/// the main isolate sends download requests to the worker isolate.
void _initializeWorkerIsolate(SendPort handshakeSendPort) {
int ongoingRequests = 0;
Timer idleTimer;
RawReceivePort downloadRequestHandler;
// Sets up a handler that processes download requests messages.
downloadRequestHandler = RawReceivePort((_DownloadRequest downloadRequest) async {
ongoingRequests++;
idleTimer?.cancel();
final HttpClient httpClient = downloadRequest.httpClientProvider != null
? downloadRequest.httpClientProvider()
: _sharedHttpClient;
try {
final HttpClientRequest request = await httpClient.getUrl(downloadRequest.uri);
downloadRequest.headers?.forEach((String name, String value) {
request.headers.add(name, value);
});
final HttpClientResponse response = await request.close();
if (response.statusCode != HttpStatus.ok) {
throw image_provider.NetworkImageLoadException(
statusCode: response?.statusCode,
uri: downloadRequest.uri,
);
}
final TransferableTypedData transferable = await getHttpClientResponseBytes(
response,
onBytesReceived: (int cumulative, int total) {
downloadRequest.sendPort.send(_DownloadResponse.chunkEvent(
ImageChunkEvent(
cumulativeBytesLoaded: cumulative,
expectedTotalBytes: total,
),
));
},
);
downloadRequest.sendPort.send(_DownloadResponse.bytes(transferable));
} catch (error) {
downloadRequest.sendPort.send(_DownloadResponse.error(error));
}
ongoingRequests--;
if (ongoingRequests == 0) {
idleTimer = Timer(_idleDuration, () {
assert(ongoingRequests == 0);
// [null] indicates that worker is going down.
handshakeSendPort.send(null);
downloadRequestHandler.close();
});
}
});
handshakeSendPort.send(downloadRequestHandler.sendPort);
}

View file

@ -5,6 +5,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -120,8 +121,8 @@ class NetworkAssetBundle extends AssetBundle {
'Unable to load asset: $key\n' 'Unable to load asset: $key\n'
'HTTP status code: ${response.statusCode}' 'HTTP status code: ${response.statusCode}'
); );
final Uint8List bytes = await consolidateHttpClientResponseBytes(response); final TransferableTypedData transferable = await getHttpClientResponseBytes(response);
return bytes.buffer.asByteData(); return transferable.materialize().asByteData();
} }
/// Retrieve a string from the asset bundle, parse it with the given function, /// Retrieve a string from the asset bundle, parse it with the given function,

View file

@ -6,6 +6,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -14,7 +15,7 @@ import 'package:mockito/mockito.dart';
import '../flutter_test_alternative.dart'; import '../flutter_test_alternative.dart';
void main() { void main() {
group(consolidateHttpClientResponseBytes, () { group(getHttpClientResponseBytes, () {
final Uint8List chunkOne = Uint8List.fromList(<int>[0, 1, 2, 3, 4, 5]); final Uint8List chunkOne = Uint8List.fromList(<int>[0, 1, 2, 3, 4, 5]);
final Uint8List chunkTwo = Uint8List.fromList(<int>[6, 7, 8, 9, 10]); final Uint8List chunkTwo = Uint8List.fromList(<int>[6, 7, 8, 9, 10]);
MockHttpClientResponse response; MockHttpClientResponse response;
@ -46,24 +47,24 @@ void main() {
test('Converts an HttpClientResponse with contentLength to bytes', () async { test('Converts an HttpClientResponse with contentLength to bytes', () async {
when(response.contentLength) when(response.contentLength)
.thenReturn(chunkOne.length + chunkTwo.length); .thenReturn(chunkOne.length + chunkTwo.length);
final List<int> bytes = final List<int> bytes = (await getHttpClientResponseBytes(response))
await consolidateHttpClientResponseBytes(response); .materialize().asUint8List();
expect(bytes, <int>[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); expect(bytes, <int>[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
}); });
test('Converts a compressed HttpClientResponse with contentLength to bytes', () async { test('Converts a compressed HttpClientResponse with contentLength to bytes', () async {
when(response.contentLength).thenReturn(chunkOne.length); when(response.contentLength).thenReturn(chunkOne.length);
final List<int> bytes = final List<int> bytes = (await getHttpClientResponseBytes(response))
await consolidateHttpClientResponseBytes(response); .materialize().asUint8List();
expect(bytes, <int>[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); expect(bytes, <int>[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
}); });
test('Converts an HttpClientResponse without contentLength to bytes', () async { test('Converts an HttpClientResponse without contentLength to bytes', () async {
when(response.contentLength).thenReturn(-1); when(response.contentLength).thenReturn(-1);
final List<int> bytes = final List<int> bytes = (await getHttpClientResponseBytes(response))
await consolidateHttpClientResponseBytes(response); .materialize().asUint8List();
expect(bytes, <int>[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); expect(bytes, <int>[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
}); });
@ -72,7 +73,7 @@ void main() {
final int syntheticTotal = (chunkOne.length + chunkTwo.length) * 2; final int syntheticTotal = (chunkOne.length + chunkTwo.length) * 2;
when(response.contentLength).thenReturn(syntheticTotal); when(response.contentLength).thenReturn(syntheticTotal);
final List<int> records = <int>[]; final List<int> records = <int>[];
await consolidateHttpClientResponseBytes( await getHttpClientResponseBytes(
response, response,
onBytesReceived: (int cumulative, int total) { onBytesReceived: (int cumulative, int total) {
records.addAll(<int>[cumulative, total]); records.addAll(<int>[cumulative, total]);
@ -110,13 +111,13 @@ void main() {
}); });
when(response.contentLength).thenReturn(-1); when(response.contentLength).thenReturn(-1);
expect(consolidateHttpClientResponseBytes(response), expect(getHttpClientResponseBytes(response),
throwsA(isInstanceOf<Exception>())); throwsA(isInstanceOf<Exception>()));
}); });
test('Propagates error to Future return value if onBytesReceived throws', () async { test('Propagates error to Future return value if onBytesReceived throws', () async {
when(response.contentLength).thenReturn(-1); when(response.contentLength).thenReturn(-1);
final Future<List<int>> result = consolidateHttpClientResponseBytes( final Future<TransferableTypedData> result = getHttpClientResponseBytes(
response, response,
onBytesReceived: (int cumulative, int total) { onBytesReceived: (int cumulative, int total) {
throw 'misbehaving callback'; throw 'misbehaving callback';
@ -157,14 +158,14 @@ void main() {
test('Uncompresses GZIP bytes if autoUncompress is true and response.compressionState is compressed', () async { test('Uncompresses GZIP bytes if autoUncompress is true and response.compressionState is compressed', () async {
when(response.compressionState).thenReturn(HttpClientResponseCompressionState.compressed); when(response.compressionState).thenReturn(HttpClientResponseCompressionState.compressed);
when(response.contentLength).thenReturn(gzipped.length); when(response.contentLength).thenReturn(gzipped.length);
final List<int> bytes = await consolidateHttpClientResponseBytes(response); final List<int> bytes = (await getHttpClientResponseBytes(response)).materialize().asUint8List();
expect(bytes, <int>[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); expect(bytes, <int>[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
}); });
test('returns gzipped bytes if autoUncompress is false and response.compressionState is compressed', () async { test('returns gzipped bytes if autoUncompress is false and response.compressionState is compressed', () async {
when(response.compressionState).thenReturn(HttpClientResponseCompressionState.compressed); when(response.compressionState).thenReturn(HttpClientResponseCompressionState.compressed);
when(response.contentLength).thenReturn(gzipped.length); when(response.contentLength).thenReturn(gzipped.length);
final List<int> bytes = await consolidateHttpClientResponseBytes(response, autoUncompress: false); final List<int> bytes = (await getHttpClientResponseBytes(response, autoUncompress: false)).materialize().asUint8List();
expect(bytes, gzipped); expect(bytes, gzipped);
}); });
@ -172,7 +173,7 @@ void main() {
when(response.compressionState).thenReturn(HttpClientResponseCompressionState.compressed); when(response.compressionState).thenReturn(HttpClientResponseCompressionState.compressed);
when(response.contentLength).thenReturn(gzipped.length); when(response.contentLength).thenReturn(gzipped.length);
final List<int> records = <int>[]; final List<int> records = <int>[];
await consolidateHttpClientResponseBytes( await getHttpClientResponseBytes(
response, response,
onBytesReceived: (int cumulative, int total) { onBytesReceived: (int cumulative, int total) {
records.addAll(<int>[cumulative, total]); records.addAll(<int>[cumulative, total]);
@ -192,7 +193,7 @@ void main() {
when(response.compressionState).thenReturn(HttpClientResponseCompressionState.decompressed); when(response.compressionState).thenReturn(HttpClientResponseCompressionState.decompressed);
when(response.contentLength).thenReturn(syntheticTotal); when(response.contentLength).thenReturn(syntheticTotal);
final List<int> records = <int>[]; final List<int> records = <int>[];
await consolidateHttpClientResponseBytes( await getHttpClientResponseBytes(
response, response,
onBytesReceived: (int cumulative, int total) { onBytesReceived: (int cumulative, int total) {
records.addAll(<int>[cumulative, total]); records.addAll(<int>[cumulative, total]);

View file

@ -144,26 +144,11 @@ void main() {
}); });
group(NetworkImage, () { group(NetworkImage, () {
MockHttpClient httpClient;
setUp(() {
httpClient = MockHttpClient();
debugNetworkImageHttpClientProvider = () => httpClient;
});
tearDown(() {
debugNetworkImageHttpClientProvider = null;
});
test('Expect thrown exception with statusCode', () async { test('Expect thrown exception with statusCode', () async {
final int errorStatusCode = HttpStatus.notFound; final int errorStatusCode = HttpStatus.notFound;
const String requestUrl = 'foo-url'; const String requestUrl = 'foo-url';
final MockHttpClientRequest request = MockHttpClientRequest(); debugNetworkImageHttpClientProvider = returnErrorStatusCode;
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(errorStatusCode);
final Completer<dynamic> caughtError = Completer<dynamic>(); final Completer<dynamic> caughtError = Completer<dynamic>();
@ -188,32 +173,40 @@ void main() {
}); });
test('Uses the HttpClient provided by debugNetworkImageHttpClientProvider if set', () async { test('Uses the HttpClient provided by debugNetworkImageHttpClientProvider if set', () async {
when(httpClient.getUrl(any)).thenThrow('client1'); debugNetworkImageHttpClientProvider = throwOnAnyClient1;
final List<dynamic> capturedErrors = <dynamic>[]; final List<dynamic> capturedErrors = <dynamic>[];
Future<void> loadNetworkImage() async { Future<void> loadNetworkImage() async {
final NetworkImage networkImage = NetworkImage(nonconst('foo')); final NetworkImage networkImage = NetworkImage(nonconst('foo'));
final ImageStreamCompleter completer = networkImage.load(networkImage); final Completer<bool> completer = Completer<bool>();
completer.addListener(ImageStreamListener( networkImage.load(networkImage).addListener(ImageStreamListener(
(ImageInfo image, bool synchronousCall) { }, (ImageInfo image, bool synchronousCall) {
completer.complete(true);
},
onError: (dynamic error, StackTrace stackTrace) { onError: (dynamic error, StackTrace stackTrace) {
capturedErrors.add(error); capturedErrors.add(error);
completer.complete(false);
}, },
)); ));
await Future<void>.value(); await completer.future;
} }
await loadNetworkImage(); await loadNetworkImage();
expect(capturedErrors, <dynamic>['client1']); expect(capturedErrors, isNotNull);
final MockHttpClient client2 = MockHttpClient(); expect(capturedErrors.length, 1);
when(client2.getUrl(any)).thenThrow('client2'); expect(capturedErrors[0], equals('client1'));
debugNetworkImageHttpClientProvider = () => client2;
debugNetworkImageHttpClientProvider = throwOnAnyClient2;
await loadNetworkImage(); await loadNetworkImage();
expect(capturedErrors, <dynamic>['client1', 'client2']); expect(capturedErrors, isNotNull);
expect(capturedErrors.length, 2);
expect(capturedErrors[0], equals('client1'));
expect(capturedErrors[1], equals('client2'));
}, skip: isBrowser); }, skip: isBrowser);
test('Propagates http client errors during resolve()', () async { test('Propagates http client errors during resolve()', () async {
when(httpClient.getUrl(any)).thenThrow(Error()); debugNetworkImageHttpClientProvider = throwErrorOnAny;
bool uncaught = false; bool uncaught = false;
await runZoned(() async { await runZoned(() async {
@ -237,40 +230,16 @@ void main() {
}); });
test('Notifies listeners of chunk events', () async { test('Notifies listeners of chunk events', () async {
debugNetworkImageHttpClientProvider = respondOnAny;
const int chunkSize = 8; const int chunkSize = 8;
final List<Uint8List> chunks = <Uint8List>[ final List<Uint8List> chunks = createChunks(chunkSize);
for (int offset = 0; offset < kTransparentImage.length; offset += chunkSize)
Uint8List.fromList(kTransparentImage.skip(offset).take(chunkSize).toList()),
];
final Completer<void> imageAvailable = Completer<void>(); 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<Uint8List>.fromIterable(chunks).listen(
onData,
onDone: onDone,
onError: onError,
cancelOnError: cancelOnError,
);
});
final ImageProvider imageProvider = NetworkImage(nonconst('foo')); final ImageProvider imageProvider = NetworkImage(nonconst('foo'));
final ImageStream result = imageProvider.resolve(ImageConfiguration.empty); final ImageStream result = imageProvider.resolve(ImageConfiguration.empty);
final List<ImageChunkEvent> events = <ImageChunkEvent>[]; final List<ImageChunkEvent> events = <ImageChunkEvent>[];
result.addListener(ImageStreamListener( result.addListener(ImageStreamListener(
(ImageInfo image, bool synchronousCall) { (ImageInfo image, bool synchronousCall) {
imageAvailable.complete(); imageAvailable.complete();
@ -289,6 +258,69 @@ void main() {
expect(events[i].expectedTotalBytes, kTransparentImage.length); expect(events[i].expectedTotalBytes, kTransparentImage.length);
} }
}, skip: isBrowser); }, skip: isBrowser);
test('Uses http request headers', () async {
debugNetworkImageHttpClientProvider = respondOnAnyWithHeaders;
final Completer<bool> imageAvailable = Completer<bool>();
final ImageProvider imageProvider = NetworkImage(nonconst('foo'),
headers: const <String, String>{'flutter': 'flutter'},
);
final ImageStream result = imageProvider.resolve(ImageConfiguration.empty);
result.addListener(ImageStreamListener(
(ImageInfo image, bool synchronousCall) {
imageAvailable.complete(true);
},
onError: (dynamic error, StackTrace stackTrace) {
imageAvailable.completeError(error, stackTrace);
},
));
expect(await imageAvailable.future, isTrue);
}, skip: isBrowser);
test('Handles http stream errors', () async {
debugNetworkImageHttpClientProvider = respondErrorOnAny;
final Completer<String> imageAvailable = Completer<String>();
final ImageProvider imageProvider = NetworkImage(nonconst('bar'));
final ImageStream result = imageProvider.resolve(ImageConfiguration.empty);
final List<ImageChunkEvent> events = <ImageChunkEvent>[];
result.addListener(ImageStreamListener(
(ImageInfo image, bool synchronousCall) {
imageAvailable.complete(null);
},
onChunk: (ImageChunkEvent event) {
events.add(event);
},
onError: (dynamic error, StackTrace stackTrace) {
imageAvailable.complete(error);
},
));
final String error = await imageAvailable.future;
expect(error, 'failed chunk');
}, skip: isBrowser);
test('Handles http connection errors', () async {
debugNetworkImageHttpClientProvider = respondErrorOnConnection;
final Completer<dynamic> imageAvailable = Completer<dynamic>();
final ImageProvider imageProvider = NetworkImage(nonconst('baz'));
final ImageStream result = imageProvider.resolve(ImageConfiguration.empty);
result.addListener(ImageStreamListener(
(ImageInfo image, bool synchronousCall) {
imageAvailable.complete(null);
},
onError: (dynamic error, StackTrace stackTrace) {
imageAvailable.complete(error);
},
));
final dynamic err = await imageAvailable.future;
expect(err, const TypeMatcher<NetworkImageLoadException>()
.having((NetworkImageLoadException e) => e.toString(), 'e', startsWith('HTTP request failed'))
.having((NetworkImageLoadException e) => e.statusCode, 'statusCode', HttpStatus.badGateway)
.having((NetworkImageLoadException e) => e.uri.toString(), 'uri', endsWith('/baz')));
}, skip: isBrowser);
}); });
}); });
} }
@ -296,3 +328,169 @@ void main() {
class MockHttpClient extends Mock implements HttpClient {} class MockHttpClient extends Mock implements HttpClient {}
class MockHttpClientRequest extends Mock implements HttpClientRequest {} class MockHttpClientRequest extends Mock implements HttpClientRequest {}
class MockHttpClientResponse extends Mock implements HttpClientResponse {} class MockHttpClientResponse extends Mock implements HttpClientResponse {}
class MockHttpHeaders extends Mock implements HttpHeaders {}
HttpClient returnErrorStatusCode() {
final int errorStatusCode = HttpStatus.notFound;
debugNetworkImageHttpClientProvider = returnErrorStatusCode;
final MockHttpClientRequest request = MockHttpClientRequest();
final MockHttpClientResponse response = MockHttpClientResponse();
final MockHttpClient httpClient = MockHttpClient();
when(httpClient.getUrl(any)).thenAnswer((_) => Future<HttpClientRequest>.value(request));
when(request.close()).thenAnswer((_) => Future<HttpClientResponse>.value(response));
when(response.statusCode).thenReturn(errorStatusCode);
return httpClient;
}
HttpClient throwOnAnyClient1() {
final MockHttpClient httpClient = MockHttpClient();
when(httpClient.getUrl(any)).thenThrow('client1');
return httpClient;
}
HttpClient throwOnAnyClient2() {
final MockHttpClient httpClient = MockHttpClient();
when(httpClient.getUrl(any)).thenThrow('client2');
return httpClient;
}
HttpClient throwErrorOnAny() {
final MockHttpClient httpClient = MockHttpClient();
when(httpClient.getUrl(any)).thenThrow(Exception());
return httpClient;
}
HttpClient respondOnAny() {
const int chunkSize = 8;
final List<Uint8List> chunks = createChunks(chunkSize);
final MockHttpClientRequest request = MockHttpClientRequest();
final MockHttpClientResponse response = MockHttpClientResponse();
final MockHttpClient httpClient = MockHttpClient();
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(Uint8List) 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<Uint8List>.fromIterable(chunks).listen(
onData,
onDone: onDone,
onError: onError,
cancelOnError: cancelOnError,
);
});
return httpClient;
}
HttpClient respondOnAnyWithHeaders() {
final List<Invocation> invocations = <Invocation>[];
const int chunkSize = 8;
final List<Uint8List> chunks = createChunks(chunkSize);
final MockHttpClientRequest request = MockHttpClientRequest();
final MockHttpClientResponse response = MockHttpClientResponse();
final MockHttpClient httpClient = MockHttpClient();
final MockHttpHeaders headers = MockHttpHeaders();
when(httpClient.getUrl(any)).thenAnswer((_) => Future<HttpClientRequest>.value(request));
when(request.headers).thenReturn(headers);
when(headers.add(any, any)).thenAnswer((Invocation invocation) {
invocations.add(invocation);
});
when(request.close()).thenAnswer((Invocation invocation) {
if (invocations.length == 1 &&
invocations[0].positionalArguments.length == 2 &&
invocations[0].positionalArguments[0] == 'flutter' &&
invocations[0].positionalArguments[1] == 'flutter') {
return Future<HttpClientResponse>.value(response);
} else {
return Future<HttpClientResponse>.value(null);
}
});
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(Uint8List) 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<Uint8List>.fromIterable(chunks).listen(
onData,
onDone: onDone,
onError: onError,
cancelOnError: cancelOnError,
);
});
return httpClient;
}
HttpClient respondErrorOnConnection() {
final MockHttpClientRequest request = MockHttpClientRequest();
final MockHttpClientResponse response = MockHttpClientResponse();
final MockHttpClient httpClient = MockHttpClient();
when(httpClient.getUrl(any)).thenAnswer((_) => Future<HttpClientRequest>.value(request));
when(request.close()).thenAnswer((_) => Future<HttpClientResponse>.value(response));
when(response.statusCode).thenReturn(HttpStatus.badGateway);
return httpClient;
}
HttpClient respondErrorOnAny() {
const int chunkSize = 8;
final MockHttpClientRequest request = MockHttpClientRequest();
final MockHttpClientResponse response = MockHttpClientResponse();
final MockHttpClient httpClient = MockHttpClient();
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(Uint8List) 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 createRottenChunks(chunkSize).listen(
onData,
onDone: onDone,
onError: onError,
cancelOnError: cancelOnError,
);
});
return httpClient;
}
List<Uint8List> createChunks(int chunkSize) {
final List<Uint8List> chunks = <Uint8List>[
for (int offset = 0; offset < kTransparentImage.length; offset += chunkSize)
Uint8List.fromList(kTransparentImage.skip(offset).take(chunkSize).toList()),
];
return chunks;
}
Stream<Uint8List> createRottenChunks(int chunkSize) async* {
yield Uint8List.fromList(kTransparentImage.take(chunkSize).toList());
throw 'failed chunk';
}

View file

@ -1,53 +0,0 @@
// Copyright 2017 The Chromium 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 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import '../painting/image_data.dart';
void main() {
final MockHttpClient client = MockHttpClient();
final MockHttpClientRequest request = MockHttpClientRequest();
final MockHttpClientResponse response = MockHttpClientResponse();
final MockHttpHeaders headers = MockHttpHeaders();
testWidgets('Headers', (WidgetTester tester) async {
HttpOverrides.runZoned<Future<void>>(() async {
await tester.pumpWidget(Image.network(
'https://www.example.com/images/frame.png',
headers: const <String, String>{'flutter': 'flutter'},
));
verify(headers.add('flutter', 'flutter')).called(1);
}, createHttpClient: (SecurityContext _) {
when(client.getUrl(any)).thenAnswer((_) => Future<HttpClientRequest>.value(request));
when(request.headers).thenReturn(headers);
when(request.close()).thenAnswer((_) => Future<HttpClientResponse>.value(response));
when(response.contentLength).thenReturn(kTransparentImage.length);
when(response.statusCode).thenReturn(HttpStatus.ok);
when(response.listen(any)).thenAnswer((Invocation invocation) {
final void Function(List<int>) onData = invocation.positionalArguments[0];
final void Function() onDone = invocation.namedArguments[#onDone];
final void Function(Object, [ StackTrace ]) onError = invocation.namedArguments[#onError];
final bool cancelOnError = invocation.namedArguments[#cancelOnError];
return Stream<List<int>>.fromIterable(<List<int>>[kTransparentImage]).listen(onData, onDone: onDone, onError: onError, cancelOnError: cancelOnError);
});
return client;
});
}, skip: isBrowser);
}
class MockHttpClient extends Mock implements HttpClient {}
class MockHttpClientRequest extends Mock implements HttpClientRequest {}
class MockHttpClientResponse extends Mock implements HttpClientResponse {}
class MockHttpHeaders extends Mock implements HttpHeaders {}