Migrates to null safety (#22)

This commit is contained in:
Ascênio
2021-05-12 12:15:38 -03:00
committed by GitHub
parent 0ec88d71f5
commit d7e59a10ce
4 changed files with 217 additions and 191 deletions

View File

@ -1,5 +1,10 @@
# Changelog # Changelog
## 4.0.0
- Migrates to null safety
- **Breaking change**: `NetworkImageWithRetry.load` now throws a `FetchFailure` if the fetched image data is zero bytes.
## 3.0.0 ## 3.0.0
* **Breaking change**. Updates for Flutter 1.10.15. * **Breaking change**. Updates for Flutter 1.10.15.

View File

@ -29,10 +29,11 @@ class NetworkImageWithRetry extends ImageProvider<NetworkImageWithRetry> {
/// Creates an object that fetches the image at the given [url]. /// Creates an object that fetches the image at the given [url].
/// ///
/// The arguments must not be null. /// The arguments must not be null.
const NetworkImageWithRetry(this.url, { this.scale = 1.0, this.fetchStrategy = defaultFetchStrategy }) const NetworkImageWithRetry(
: assert(url != null), this.url, {
assert(scale != null), this.scale = 1.0,
assert(fetchStrategy != null); this.fetchStrategy = defaultFetchStrategy,
});
/// The HTTP client used to download images. /// The HTTP client used to download images.
static final io.HttpClient _client = io.HttpClient(); static final io.HttpClient _client = io.HttpClient();
@ -57,10 +58,12 @@ class NetworkImageWithRetry extends ImageProvider<NetworkImageWithRetry> {
/// This indirection is necessary because [defaultFetchStrategy] is used as /// This indirection is necessary because [defaultFetchStrategy] is used as
/// the default constructor argument value, which requires that it be a const /// the default constructor argument value, which requires that it be a const
/// expression. /// expression.
static final FetchStrategy _defaultFetchStrategyFunction = const FetchStrategyBuilder().build(); static final FetchStrategy _defaultFetchStrategyFunction =
const FetchStrategyBuilder().build();
/// The [FetchStrategy] that [NetworkImageWithRetry] uses by default. /// The [FetchStrategy] that [NetworkImageWithRetry] uses by default.
static Future<FetchInstructions> defaultFetchStrategy(Uri uri, FetchFailure failure) { static Future<FetchInstructions> defaultFetchStrategy(
Uri uri, FetchFailure? failure) {
return _defaultFetchStrategyFunction(uri, failure); return _defaultFetchStrategyFunction(uri, failure);
} }
@ -71,37 +74,34 @@ class NetworkImageWithRetry extends ImageProvider<NetworkImageWithRetry> {
@override @override
ImageStreamCompleter load(NetworkImageWithRetry key, DecoderCallback decode) { ImageStreamCompleter load(NetworkImageWithRetry key, DecoderCallback decode) {
return OneFrameImageStreamCompleter( return OneFrameImageStreamCompleter(_loadWithRetry(key, decode),
_loadWithRetry(key, decode),
informationCollector: () sync* { informationCollector: () sync* {
yield ErrorDescription('Image provider: $this'); yield ErrorDescription('Image provider: $this');
yield ErrorDescription('Image key: $key'); yield ErrorDescription('Image key: $key');
} });
);
} }
void _debugCheckInstructions(FetchInstructions instructions) { void _debugCheckInstructions(FetchInstructions? instructions) {
assert(() { assert(() {
if (instructions == null) { if (instructions == null) {
if (fetchStrategy == defaultFetchStrategy) { if (fetchStrategy == defaultFetchStrategy) {
throw StateError( throw StateError(
'The default FetchStrategy returned null FetchInstructions. This\n' 'The default FetchStrategy returned null FetchInstructions. This\n'
'is likely a bug in $runtimeType. Please file a bug at\n' 'is likely a bug in $runtimeType. Please file a bug at\n'
'https://github.com/flutter/flutter/issues.' 'https://github.com/flutter/flutter/issues.');
);
} else { } else {
throw StateError( throw StateError(
'The custom FetchStrategy used to fetch $url returned null\n' 'The custom FetchStrategy used to fetch $url returned null\n'
'FetchInstructions. FetchInstructions must never be null, but\n' 'FetchInstructions. FetchInstructions must never be null, but\n'
'instead instruct to either make another fetch attempt or give up.' 'instead instruct to either make another fetch attempt or give up.');
);
} }
} }
return true; return true;
}()); }());
} }
Future<ImageInfo> _loadWithRetry(NetworkImageWithRetry key, DecoderCallback decode) async { Future<ImageInfo> _loadWithRetry(
NetworkImageWithRetry key, DecoderCallback decode) async {
assert(key == this); assert(key == this);
final Stopwatch stopwatch = Stopwatch()..start(); final Stopwatch stopwatch = Stopwatch()..start();
@ -109,16 +109,19 @@ class NetworkImageWithRetry extends ImageProvider<NetworkImageWithRetry> {
FetchInstructions instructions = await fetchStrategy(resolved, null); FetchInstructions instructions = await fetchStrategy(resolved, null);
_debugCheckInstructions(instructions); _debugCheckInstructions(instructions);
int attemptCount = 0; int attemptCount = 0;
FetchFailure lastFailure; FetchFailure? lastFailure;
while (!instructions.shouldGiveUp) { while (!instructions.shouldGiveUp) {
attemptCount += 1; attemptCount += 1;
io.HttpClientRequest request; io.HttpClientRequest? request;
try { try {
request = await _client.getUrl(instructions.uri).timeout(instructions.timeout); request = await _client
final io.HttpClientResponse response = await request.close().timeout(instructions.timeout); .getUrl(instructions.uri)
.timeout(instructions.timeout);
final io.HttpClientResponse response =
await request.close().timeout(instructions.timeout);
if (response == null || response.statusCode != 200) { if (response.statusCode != 200) {
throw FetchFailure._( throw FetchFailure._(
totalDuration: stopwatch.elapsed, totalDuration: stopwatch.elapsed,
attemptCount: attemptCount, attemptCount: attemptCount,
@ -126,21 +129,25 @@ class NetworkImageWithRetry extends ImageProvider<NetworkImageWithRetry> {
); );
} }
final _Uint8ListBuilder builder = await response.fold( final _Uint8ListBuilder builder = await response
_Uint8ListBuilder(), .fold(
_Uint8ListBuilder(),
(_Uint8ListBuilder buffer, List<int> bytes) => buffer..add(bytes), (_Uint8ListBuilder buffer, List<int> bytes) => buffer..add(bytes),
).timeout(instructions.timeout); )
.timeout(instructions.timeout);
final Uint8List bytes = builder.data; final Uint8List bytes = builder.data;
if (bytes.lengthInBytes == 0) if (bytes.lengthInBytes == 0) {
return null; throw FetchFailure._(
totalDuration: stopwatch.elapsed,
attemptCount: attemptCount,
httpStatusCode: response.statusCode,
);
}
final ui.Codec codec = await decode(bytes); final ui.Codec codec = await decode(bytes);
final ui.Image image = (await codec.getNextFrame()).image; final ui.Image image = (await codec.getNextFrame()).image;
if (image == null)
return null;
return ImageInfo( return ImageInfo(
image: image, image: image,
scale: key.scale, scale: key.scale,
@ -159,27 +166,31 @@ class NetworkImageWithRetry extends ImageProvider<NetworkImageWithRetry> {
} }
} }
if (instructions.alternativeImage != null) if (instructions.alternativeImage != null) {
return instructions.alternativeImage; return instructions.alternativeImage!;
}
assert(lastFailure != null); assert(lastFailure != null);
FlutterError.onError(FlutterErrorDetails( if (FlutterError.onError != null) {
exception: lastFailure, FlutterError.onError!(FlutterErrorDetails(
library: 'package:flutter_image', exception: lastFailure!,
context: ErrorDescription('$runtimeType failed to load ${instructions.uri}'), library: 'package:flutter_image',
)); context:
ErrorDescription('$runtimeType failed to load ${instructions.uri}'),
));
}
return null; throw lastFailure!;
} }
@override @override
bool operator ==(dynamic other) { bool operator ==(dynamic other) {
if (other.runtimeType != runtimeType) if (other.runtimeType != runtimeType) {
return false; return false;
}
final NetworkImageWithRetry typedOther = other; final NetworkImageWithRetry typedOther = other;
return url == typedOther.url return url == typedOther.url && scale == typedOther.scale;
&& scale == typedOther.scale;
} }
@override @override
@ -207,26 +218,26 @@ class NetworkImageWithRetry extends ImageProvider<NetworkImageWithRetry> {
/// [NetworkImageWithRetry] to try again. /// [NetworkImageWithRetry] to try again.
/// ///
/// See [NetworkImageWithRetry.defaultFetchStrategy] for an example. /// See [NetworkImageWithRetry.defaultFetchStrategy] for an example.
typedef FetchStrategy = Future<FetchInstructions> Function(Uri uri, FetchFailure failure); typedef FetchStrategy = Future<FetchInstructions> Function(
Uri uri, FetchFailure? failure);
/// Instructions [NetworkImageWithRetry] uses to fetch the image. /// Instructions [NetworkImageWithRetry] uses to fetch the image.
@immutable @immutable
class FetchInstructions { class FetchInstructions {
/// Instructs [NetworkImageWithRetry] to give up trying to download the image. /// Instructs [NetworkImageWithRetry] to give up trying to download the image.
const FetchInstructions.giveUp({ const FetchInstructions.giveUp({
@required this.uri, required this.uri,
this.alternativeImage, this.alternativeImage,
}) }) : shouldGiveUp = true,
: shouldGiveUp = true, timeout = Duration.zero;
timeout = null;
/// Instructs [NetworkImageWithRetry] to attempt to download the image from /// Instructs [NetworkImageWithRetry] to attempt to download the image from
/// the given [uri] and [timeout] if it takes too long. /// the given [uri] and [timeout] if it takes too long.
const FetchInstructions.attempt({ const FetchInstructions.attempt({
@required this.uri, required this.uri,
@required this.timeout, required this.timeout,
}) : shouldGiveUp = false, }) : shouldGiveUp = false,
alternativeImage = null; alternativeImage = null;
/// Instructs to give up trying. /// Instructs to give up trying.
/// ///
@ -241,16 +252,16 @@ class FetchInstructions {
final Uri uri; final Uri uri;
/// Instructs to give up and use this image instead. /// Instructs to give up and use this image instead.
final Future<ImageInfo> alternativeImage; final Future<ImageInfo>? alternativeImage;
@override @override
String toString() { String toString() {
return '$runtimeType(\n' return '$runtimeType(\n'
' shouldGiveUp: $shouldGiveUp\n' ' shouldGiveUp: $shouldGiveUp\n'
' timeout: $timeout\n' ' timeout: $timeout\n'
' uri: $uri\n' ' uri: $uri\n'
' alternativeImage?: ${alternativeImage != null ? 'yes' : 'no'}\n' ' alternativeImage?: ${alternativeImage != null ? 'yes' : 'no'}\n'
')'; ')';
} }
} }
@ -258,12 +269,11 @@ class FetchInstructions {
@immutable @immutable
class FetchFailure implements Exception { class FetchFailure implements Exception {
const FetchFailure._({ const FetchFailure._({
@required this.totalDuration, required this.totalDuration,
@required this.attemptCount, required this.attemptCount,
this.httpStatusCode, this.httpStatusCode,
this.originalException, this.originalException,
}) : assert(totalDuration != null), }) : assert(attemptCount > 0);
assert(attemptCount > 0);
/// The total amount of time it has taken so far to download the image. /// The total amount of time it has taken so far to download the image.
final Duration totalDuration; final Duration totalDuration;
@ -276,7 +286,7 @@ class FetchFailure implements Exception {
final int attemptCount; final int attemptCount;
/// HTTP status code, such as 500. /// HTTP status code, such as 500.
final int httpStatusCode; final int? httpStatusCode;
/// The exception that caused the fetch failure. /// The exception that caused the fetch failure.
final dynamic originalException; final dynamic originalException;
@ -294,7 +304,7 @@ class FetchFailure implements Exception {
/// An indefinitely growing builder of a [Uint8List]. /// An indefinitely growing builder of a [Uint8List].
class _Uint8ListBuilder { class _Uint8ListBuilder {
static const int _kInitialSize = 100000; // 100KB-ish static const int _kInitialSize = 100000; // 100KB-ish
int _usedLength = 0; int _usedLength = 0;
Uint8List _buffer = Uint8List(_kInitialSize); Uint8List _buffer = Uint8List(_kInitialSize);
@ -344,20 +354,16 @@ class FetchStrategyBuilder {
this.maxAttempts = 5, this.maxAttempts = 5,
this.initialPauseBetweenRetries = const Duration(seconds: 1), this.initialPauseBetweenRetries = const Duration(seconds: 1),
this.exponentialBackoffMultiplier = 2, this.exponentialBackoffMultiplier = 2,
this.transientHttpStatusCodePredicate = defaultTransientHttpStatusCodePredicate, this.transientHttpStatusCodePredicate =
}) : assert(timeout != null), defaultTransientHttpStatusCodePredicate,
assert(totalFetchTimeout != null), });
assert(maxAttempts != null),
assert(initialPauseBetweenRetries != null),
assert(exponentialBackoffMultiplier != null),
assert(transientHttpStatusCodePredicate != null);
/// A list of HTTP status codes that can generally be retried. /// 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 /// You may want to use a different list depending on the needs of your
/// application. /// application.
static const List<int> defaultTransientHttpStatusCodes = <int>[ static const List<int> defaultTransientHttpStatusCodes = <int>[
0, // Network error 0, // Network error
408, // Request timeout 408, // Request timeout
500, // Internal server error 500, // Internal server error
502, // Bad gateway 502, // Bad gateway
@ -395,7 +401,7 @@ class FetchStrategyBuilder {
/// Builds a [FetchStrategy] that operates using the properties of this /// Builds a [FetchStrategy] that operates using the properties of this
/// builder. /// builder.
FetchStrategy build() { FetchStrategy build() {
return (Uri uri, FetchFailure failure) async { return (Uri uri, FetchFailure? failure) async {
if (failure == null) { if (failure == null) {
// First attempt. Just load. // First attempt. Just load.
return FetchInstructions.attempt( return FetchInstructions.attempt(
@ -404,18 +410,21 @@ class FetchStrategyBuilder {
); );
} }
final bool isRetriableFailure = transientHttpStatusCodePredicate(failure.httpStatusCode) || final bool isRetriableFailure = (failure.httpStatusCode != null &&
transientHttpStatusCodePredicate(failure.httpStatusCode!)) ||
failure.originalException is io.SocketException; failure.originalException is io.SocketException;
// If cannot retry, give up. // If cannot retry, give up.
if (!isRetriableFailure || // retrying will not help if (!isRetriableFailure || // retrying will not help
failure.totalDuration > totalFetchTimeout || // taking too long failure.totalDuration > totalFetchTimeout || // taking too long
failure.attemptCount > maxAttempts) { // too many attempts failure.attemptCount > maxAttempts) {
// too many attempts
return FetchInstructions.giveUp(uri: uri); return FetchInstructions.giveUp(uri: uri);
} }
// Exponential back-off. // Exponential back-off.
final Duration pauseBetweenRetries = initialPauseBetweenRetries * math.pow(exponentialBackoffMultiplier, failure.attemptCount - 1); final Duration pauseBetweenRetries = initialPauseBetweenRetries *
math.pow(exponentialBackoffMultiplier, failure.attemptCount - 1);
await Future<void>.delayed(pauseBetweenRetries); await Future<void>.delayed(pauseBetweenRetries);
// Retry. // Retry.

View File

@ -1,5 +1,5 @@
name: flutter_image name: flutter_image
version: 3.0.0 version: 4.0.0
description: > description: >
Image utilities for Flutter: providers, effects, etc Image utilities for Flutter: providers, effects, etc
author: Flutter Team <flutter-dev@googlegroups.com> author: Flutter Team <flutter-dev@googlegroups.com>
@ -12,9 +12,9 @@ dependencies:
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
quiver: '>=0.24.0 <3.0.0' quiver: ^3.0.0
test: any test: any
environment: environment:
sdk: ">=2.0.0-dev.28.0 <3.0.0" sdk: ">=2.12.0 <3.0.0"
flutter: ">=1.10.15-pre.144" flutter: ">=1.10.15-pre.144"

View File

@ -19,122 +19,134 @@ void main() {
HttpOverrides.global = null; HttpOverrides.global = null;
group('NetworkImageWithRetry', () { group('NetworkImageWithRetry', () {
setUp(() { group('succeeds', () {
FlutterError.onError = (FlutterErrorDetails error) { setUp(() {
fail('$error'); FlutterError.onError = (FlutterErrorDetails error) {
}; fail('$error');
};
});
tearDown(() {
FlutterError.onError = FlutterError.dumpErrorToConsole;
});
test('loads image from network', () async {
final NetworkImageWithRetry subject = NetworkImageWithRetry(
_imageUrl('immediate_success.png'),
);
assertThatImageLoadingSucceeds(subject);
});
test('succeeds on successful retry', () async {
final NetworkImageWithRetry subject = NetworkImageWithRetry(
_imageUrl('error.png'),
fetchStrategy: (Uri uri, FetchFailure? failure) async {
if (failure == null) {
return FetchInstructions.attempt(
uri: uri,
timeout: const Duration(minutes: 1),
);
} else {
expect(failure.attemptCount, lessThan(2));
return FetchInstructions.attempt(
uri: Uri.parse(_imageUrl('immediate_success.png')),
timeout: const Duration(minutes: 1),
);
}
},
);
assertThatImageLoadingSucceeds(subject);
});
}); });
tearDown(() { group('fails', () {
FlutterError.onError = FlutterError.dumpErrorToConsole;
});
test('loads image from network', () async {
final NetworkImageWithRetry subject = NetworkImageWithRetry(
_imageUrl('immediate_success.png'),
);
subject.load(subject, PaintingBinding.instance.instantiateImageCodec).addListener(
ImageStreamListener(expectAsync2((ImageInfo image, bool synchronousCall) {
expect(image.image.height, 1);
expect(image.image.width, 1);
})),
);
});
test('retries 6 times then gives up', () async {
final List<FlutterErrorDetails> errorLog = <FlutterErrorDetails>[]; final List<FlutterErrorDetails> errorLog = <FlutterErrorDetails>[];
FlutterError.onError = errorLog.add; FakeAsync fakeAsync = FakeAsync();
final FakeAsync fakeAsync = FakeAsync(); setUp(() {
final dynamic maxAttemptCountReached = expectAsync0(() {}); FlutterError.onError = errorLog.add;
});
int attemptCount = 0; tearDown(() {
Future<void> onAttempt() async { fakeAsync = FakeAsync();
expect(attemptCount, lessThan(7)); errorLog.clear();
if (attemptCount == 6) { FlutterError.onError = FlutterError.dumpErrorToConsole;
maxAttemptCountReached(); });
}
await Future<void>.delayed(Duration.zero);
fakeAsync.elapse(const Duration(seconds: 60));
attemptCount++;
}
final NetworkImageWithRetry subject = NetworkImageWithRetry( test('retries 6 times then gives up', () async {
_imageUrl('error.png'), final dynamic maxAttemptCountReached = expectAsync0(() {});
fetchStrategy: (Uri uri, FetchFailure failure) {
Timer.run(onAttempt);
return fakeAsync.run((FakeAsync fakeAsync) {
return NetworkImageWithRetry.defaultFetchStrategy(uri, failure);
});
},
);
subject.load(subject, PaintingBinding.instance.instantiateImageCodec).addListener( int attemptCount = 0;
ImageStreamListener(expectAsync2((ImageInfo image, bool synchronousCall) { Future<void> onAttempt() async {
expect(errorLog.single.exception, isInstanceOf<FetchFailure>()); expect(attemptCount, lessThan(7));
expect(image, null); if (attemptCount == 6) {
})), maxAttemptCountReached();
);
});
test('gives up immediately on non-retriable errors (HTTP 404)', () async {
final List<FlutterErrorDetails> errorLog = <FlutterErrorDetails>[];
FlutterError.onError = errorLog.add;
final FakeAsync fakeAsync = FakeAsync();
int attemptCount = 0;
Future<void> onAttempt() async {
expect(attemptCount, lessThan(2));
await Future<void>.delayed(Duration.zero);
fakeAsync.elapse(const Duration(seconds: 60));
attemptCount++;
}
final NetworkImageWithRetry subject = NetworkImageWithRetry(
_imageUrl('does_not_exist.png'),
fetchStrategy: (Uri uri, FetchFailure failure) {
Timer.run(onAttempt);
return fakeAsync.run((FakeAsync fakeAsync) {
return NetworkImageWithRetry.defaultFetchStrategy(uri, failure);
});
},
);
subject.load(subject, PaintingBinding.instance.instantiateImageCodec).addListener(
ImageStreamListener(expectAsync2((ImageInfo image, bool synchronousCall) {
expect(errorLog.single.exception, isInstanceOf<FetchFailure>());
expect(image, null);
})),
);
});
test('succeeds on successful retry', () async {
final NetworkImageWithRetry subject = NetworkImageWithRetry(
_imageUrl('error.png'),
fetchStrategy: (Uri uri, FetchFailure failure) async {
if (failure == null) {
return FetchInstructions.attempt(
uri: uri,
timeout: const Duration(minutes: 1),
);
} else {
expect(failure.attemptCount, lessThan(2));
return FetchInstructions.attempt(
uri: Uri.parse(_imageUrl('immediate_success.png')),
timeout: const Duration(minutes: 1),
);
} }
}, await Future<void>.delayed(Duration.zero);
); fakeAsync.elapse(const Duration(seconds: 60));
attemptCount++;
}
subject.load(subject, PaintingBinding.instance.instantiateImageCodec).addListener( final NetworkImageWithRetry subject = NetworkImageWithRetry(
ImageStreamListener(expectAsync2((ImageInfo image, bool synchronousCall) { _imageUrl('error.png'),
expect(image.image.height, 1); fetchStrategy: (Uri uri, FetchFailure? failure) {
expect(image.image.width, 1); Timer.run(onAttempt);
})), return fakeAsync.run((FakeAsync fakeAsync) {
); return NetworkImageWithRetry.defaultFetchStrategy(uri, failure);
});
},
);
assertThatImageLoadingFails(subject, errorLog);
});
test('gives up immediately on non-retriable errors (HTTP 404)', () async {
int attemptCount = 0;
Future<void> onAttempt() async {
expect(attemptCount, lessThan(2));
await Future<void>.delayed(Duration.zero);
fakeAsync.elapse(const Duration(seconds: 60));
attemptCount++;
}
final NetworkImageWithRetry subject = NetworkImageWithRetry(
_imageUrl('does_not_exist.png'),
fetchStrategy: (Uri uri, FetchFailure? failure) {
Timer.run(onAttempt);
return fakeAsync.run((FakeAsync fakeAsync) {
return NetworkImageWithRetry.defaultFetchStrategy(uri, failure);
});
},
);
assertThatImageLoadingFails(subject, errorLog);
});
}); });
}); });
} }
void assertThatImageLoadingFails(
NetworkImageWithRetry subject, List<FlutterErrorDetails> errorLog) {
subject
.load(subject, PaintingBinding.instance!.instantiateImageCodec)
.addListener(ImageStreamListener(
(ImageInfo image, bool synchronousCall) {},
onError: expectAsync2((Object error, StackTrace? _) {
expect(errorLog.single.exception, isInstanceOf<FetchFailure>());
expect(error, isInstanceOf<FetchFailure>());
expect(error, equals(errorLog.single.exception));
}),
));
}
void assertThatImageLoadingSucceeds(NetworkImageWithRetry subject) {
subject
.load(subject, PaintingBinding.instance!.instantiateImageCodec)
.addListener(
ImageStreamListener(expectAsync2((ImageInfo image, bool synchronousCall) {
expect(image.image.height, 1);
expect(image.image.width, 1);
})),
);
}