blob: d66ac5222dca538312799aadfac264eda610d897 [file] [log] [blame]
// Copyright 2017, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// Utilities for loading images from the network.
///
/// This library expands the capabilities of the basic [Image.network] and
/// [NetworkImage] provided by Flutter core libraries, to include a retry
/// mechanism and connectivity detection.
library network;
import 'dart:async';
import 'dart:io' as io;
import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
/// Fetches the image from the given URL, associating it with the given scale.
///
/// If [fetchStrategy] is specified, uses it instead of the
/// [defaultFetchStrategy] to obtain instructions for fetching the URL.
///
/// The image will be cached regardless of cache headers from the server.
class NetworkImageWithRetry extends ImageProvider<NetworkImageWithRetry> {
/// Creates an object that fetches the image at the given [url].
///
/// The arguments must not be null.
const NetworkImageWithRetry(this.url, { this.scale: 1.0, this.fetchStrategy: defaultFetchStrategy })
: assert(url != null),
assert(scale != null),
assert(fetchStrategy != null);
/// The HTTP client used to download images.
static final io.HttpClient _client = new io.HttpClient();
/// The URL from which the image will be fetched.
final String url;
/// The scale to place in the [ImageInfo] object of the image.
final double scale;
/// The strategy used to fetch the [url] and retry when the fetch fails.
///
/// This function is called at least once and may be called multiple times.
/// The first time it is called, it is passed a null [FetchFailure], which
/// indicates that this is the first attempt to fetch the [url]. Subsequent
/// calls pass non-null [FetchFailure] values, which indicate that previous
/// fetch attempts failed.
final FetchStrategy fetchStrategy;
/// Used by [defaultFetchStrategy].
///
/// This indirection is necessary because [defaultFetchStrategy] is used as
/// the default constructor argument value, which requires that it be a const
/// expression.
static final FetchStrategy _defaultFetchStrategyFunction = const FetchStrategyBuilder().build();
/// The [FetchStrategy] that [NetworkImageWithRetry] uses by default.
static Future<FetchInstructions> defaultFetchStrategy(Uri uri, FetchFailure failure) {
return _defaultFetchStrategyFunction(uri, failure);
}
@override
Future<NetworkImageWithRetry> obtainKey(ImageConfiguration configuration) {
return new SynchronousFuture<NetworkImageWithRetry>(this);
}
@override
ImageStreamCompleter load(NetworkImageWithRetry key) {
return new OneFrameImageStreamCompleter(
_loadWithRetry(key),
informationCollector: (StringBuffer information) {
information.writeln('Image provider: $this');
information.write('Image key: $key');
}
);
}
void _debugCheckInstructions(FetchInstructions instructions) {
assert(() {
if (instructions == null) {
if (fetchStrategy == defaultFetchStrategy) {
throw new StateError(
'The default FetchStrategy returned null FetchInstructions. This\n'
'is likely a bug in $runtimeType. Please file a bug at\n'
'https://github.com/flutter/flutter/issues.'
);
} else {
throw new StateError(
'The custom FetchStrategy used to fetch $url returned null\n'
'FetchInstructions. FetchInstructions must never be null, but\n'
'instead instruct to either make another fetch attempt or give up.'
);
}
}
return true;
});
}
Future<ImageInfo> _loadWithRetry(NetworkImageWithRetry key) async {
assert(key == this);
final Stopwatch stopwatch = new Stopwatch()..start();
final Uri resolved = Uri.base.resolve(key.url);
FetchInstructions instructions = await fetchStrategy(resolved, null);
_debugCheckInstructions(instructions);
int attemptCount = 0;
FetchFailure lastFailure;
while (!instructions.shouldGiveUp) {
attemptCount += 1;
io.HttpClientRequest request;
try {
request = await _client.getUrl(instructions.uri).timeout(instructions.timeout);
final io.HttpClientResponse response = await request.close().timeout(instructions.timeout);
if (response == null || response.statusCode != 200) {
throw new FetchFailure._(
totalDuration: stopwatch.elapsed,
attemptCount: attemptCount,
httpStatusCode: response.statusCode,
);
}
final _Uint8ListBuilder builder = await response.fold(
new _Uint8ListBuilder(),
(_Uint8ListBuilder buffer, List<int> bytes) => buffer..add(bytes),
).timeout(instructions.timeout);
final Uint8List bytes = builder.data;
if (bytes.lengthInBytes == 0)
return null;
final ui.Image image = await decodeImageFromList(bytes);
if (image == null)
return null;
return new ImageInfo(
image: image,
scale: key.scale,
);
} catch (error) {
request?.close();
lastFailure = error is FetchFailure
? error
: new FetchFailure._(
totalDuration: stopwatch.elapsed,
attemptCount: attemptCount,
originalException: error,
);
instructions = await fetchStrategy(instructions.uri, lastFailure);
_debugCheckInstructions(instructions);
}
}
if (instructions.alternativeImage != null)
return instructions.alternativeImage;
assert(lastFailure != null);
FlutterError.onError(new FlutterErrorDetails(
exception: lastFailure,
library: 'package:flutter_image',
context: '$runtimeType failed to load ${instructions.uri}',
));
return null;
}
@override
bool operator ==(dynamic other) {
if (other.runtimeType != runtimeType)
return false;
final NetworkImageWithRetry typedOther = other;
return url == typedOther.url
&& scale == typedOther.scale;
}
@override
int get hashCode => hashValues(url, scale);
@override
String toString() => '$runtimeType("$url", scale: $scale)';
}
/// This function is called to get [FetchInstructions] to fetch an image.
///
/// The instructions are executed as soon as possible after the returned
/// [Future] resolves. If a delay in necessary between retries, use a delayed
/// [Future], such as [new Future.delayed]. This is useful for implementing
/// back-off strategies and for recovering from lack of connectivity.
///
/// [uri] is the last requested image URI. A [FetchStrategy] may choose to use
/// a different URI (see [FetchInstructions.uri]).
///
/// If [failure] is `null`, then this is the first attempt to fetch the image.
///
/// If the [failure] is not `null`, it contains the information about the
/// previous attempt to fetch the image. A [FetchStrategy] may attempt to
/// recover from the failure by returning [FetchInstructions] that instruct
/// [NetworkImageWithRetry] to try again.
///
/// See [NetworkImageWithRetry.defaultFetchStrategy] for an example.
typedef Future<FetchInstructions> FetchStrategy(Uri uri, FetchFailure failure);
/// Instructions [NetworkImageWithRetry] uses to fetch the image.
@immutable
class FetchInstructions {
/// Instructs [NetworkImageWithRetry] to give up trying to download the image.
const FetchInstructions.giveUp({
@required this.uri,
this.alternativeImage,
})
: shouldGiveUp = true,
timeout = null;
/// Instructs [NetworkImageWithRetry] to attempt to download the image from
/// the given [uri] and [timeout] if it takes too long.
const FetchInstructions.attempt({
@required this.uri,
@required this.timeout,
}) : shouldGiveUp = false,
alternativeImage = null;
/// Instructs to give up trying.
///
/// If [alternativeImage] is `null` reports the latest [FetchFailure] to
/// [FlutterError].
final bool shouldGiveUp;
/// Timeout for the next network call.
final Duration timeout;
/// The URI to use on the next attempt.
final Uri uri;
/// Instructs to give up and use this image instead.
final Future<ImageInfo> alternativeImage;
@override
String toString() {
return '$runtimeType(\n'
' shouldGiveUp: $shouldGiveUp\n'
' timeout: $timeout\n'
' uri: $uri\n'
' alternativeImage?: ${alternativeImage != null ? 'yes' : 'no'}\n'
')';
}
}
/// Contains information about a failed attempt to fetch an image.
@immutable
class FetchFailure implements Exception {
const FetchFailure._({
@required this.totalDuration,
@required this.attemptCount,
this.httpStatusCode,
this.originalException,
}) : assert(totalDuration != null),
assert(attemptCount > 0);
/// The total amount of time it has taken so far to download the image.
final Duration totalDuration;
/// The number of times [NetworkImageWithRetry] attempted to fetch the image
/// so far.
///
/// This value starts with 1 and grows by 1 with each attempt to fetch the
/// image.
final int attemptCount;
/// HTTP status code, such as 500.
final int httpStatusCode;
/// The exception that caused the fetch failure.
final dynamic originalException;
@override
String toString() {
return '$runtimeType(\n'
' attemptCount: $attemptCount\n'
' httpStatusCode: $httpStatusCode\n'
' totalDuration: $totalDuration\n'
' originalException: $originalException\n'
')';
}
}
/// An indefinitely growing builder of a [Uint8List].
class _Uint8ListBuilder {
static const int _kInitialSize = 100000; // 100KB-ish
int _usedLength = 0;
Uint8List _buffer = new Uint8List(_kInitialSize);
Uint8List get data => new Uint8List.view(_buffer.buffer, 0, _usedLength);
void add(List<int> bytes) {
_ensureCanAdd(bytes.length);
_buffer.setAll(_usedLength, bytes);
_usedLength += bytes.length;
}
void _ensureCanAdd(int byteCount) {
final int totalSpaceNeeded = _usedLength + byteCount;
int newLength = _buffer.length;
while (totalSpaceNeeded > newLength) {
newLength *= 2;
}
if (newLength != _buffer.length) {
final Uint8List newBuffer = new Uint8List(newLength);
newBuffer.setAll(0, _buffer);
newBuffer.setRange(0, _usedLength, _buffer);
_buffer = newBuffer;
}
}
}
/// Determines whether the given HTTP [statusCode] is transient.
typedef bool TransientHttpStatusCodePredicate(int statusCode);
/// Builds a [FetchStrategy] function that retries up to a certain amount of
/// times for up to a certain amount of time.
///
/// Pauses between retries with pauses growing exponentially (known as
/// exponential backoff). Each attempt is subject to a [timeout]. Retries only
/// those HTTP status codes considered transient by a
/// [transientHttpStatusCodePredicate] function.
class FetchStrategyBuilder {
/// A list of HTTP status codes that can generally be retried.
///
/// You may want to use a different list depending on the needs of your
/// application.
static const List<int> defaultTransientHttpStatusCodes = const <int>[
0, // Network error
408, // Request timeout
500, // Internal server error
502, // Bad gateway
503, // Service unavailable
504 // Gateway timeout
];
/// Creates a fetch strategy builder.
///
/// All parameters must be non-null.
const FetchStrategyBuilder({
this.timeout: const Duration(seconds: 30),
this.totalFetchTimeout: const Duration(minutes: 1),
this.maxAttempts: 5,
this.initialPauseBetweenRetries: const Duration(seconds: 1),
this.exponentialBackoffMultiplier: 2,
this.transientHttpStatusCodePredicate: defaultTransientHttpStatusCodePredicate,
}) : assert(timeout != null),
assert(totalFetchTimeout != null),
assert(maxAttempts != null),
assert(initialPauseBetweenRetries != null),
assert(exponentialBackoffMultiplier != null),
assert(transientHttpStatusCodePredicate != null);
/// Maximum amount of time a single fetch attempt is allowed to take.
final Duration timeout;
/// A strategy built by this builder will retry for up to this amount of time
/// before giving up.
final Duration totalFetchTimeout;
/// Maximum number of attempts a strategy will make before giving up.
final int maxAttempts;
/// Initial amount of time between retries.
final Duration initialPauseBetweenRetries;
/// The pause between retries is multiplied by this number with each attempt,
/// causing it to grow exponentially.
final num exponentialBackoffMultiplier;
/// A function that determines whether a given HTTP status code should be
/// retried.
final TransientHttpStatusCodePredicate transientHttpStatusCodePredicate;
/// Uses [defaultTransientHttpStatusCodes] to determine if the [statusCode] is
/// transient.
static bool defaultTransientHttpStatusCodePredicate(int statusCode) {
return defaultTransientHttpStatusCodes.contains(statusCode);
}
/// Builds a [FetchStrategy] that operates using the properties of this
/// builder.
FetchStrategy build() {
return (Uri uri, FetchFailure failure) async {
if (failure == null) {
// First attempt. Just load.
return new FetchInstructions.attempt(
uri: uri,
timeout: timeout,
);
}
final bool isRetriableFailure = transientHttpStatusCodePredicate(failure.httpStatusCode) ||
failure.originalException is io.SocketException;
// If cannot retry, give up.
if (!isRetriableFailure || // retrying will not help
failure.totalDuration > totalFetchTimeout || // taking too long
failure.attemptCount > maxAttempts) { // too many attempts
return new FetchInstructions.giveUp(uri: uri);
}
// Exponential back-off.
final Duration pauseBetweenRetries = initialPauseBetweenRetries * math.pow(exponentialBackoffMultiplier, failure.attemptCount - 1);
await new Future<Null>.delayed(pauseBetweenRetries);
// Retry.
return new FetchInstructions.attempt(
uri: uri,
timeout: timeout,
);
};
}
}