Skip to content

Add support for image loading progress - #32465

Closed
tvolkert wants to merge 9 commits into
flutter:masterfrom
tvolkert:images
Closed

tvolkert wants to merge 9 commits into
flutter:masterfrom
tvolkert:images

Conversation

@tvolkert

@tvolkert tvolkert commented May 10, 2019 •

Copy link
Copy Markdown
Contributor

Description

Add support for image loading progress.

This PR adds a imageLoadingBuilder property to the Image class. This is useful for images that are incrementally loaded (e.g. over a local file system or a network) and you want to give the user an indication of when the image will be displayed.

Related Issues

Fixes #32374

Tests

I added the following tests:

  • consolidateHttpClientResponseBytes notifies onBytesReceived for every chunk of bytes
  • NetworkImage notifies listeners of chunk events
  • ImageStreamCompleter delivers chunk events to listeners
  • ImageStreamCompleter drops chunks events on the floor (does not buffer the events) if there are no listeners
  • ImageStreamCompleter reports errors in the chunk event stream
  • Image invokes imageLoadingBuilder as the image loads
  • Image ignores chunk events if imageLoadingBuilder is null

Checklist

  • I read the [Contributor Guide] and followed the process outlined there for submitting PRs.
  • I signed the [CLA].
  • I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement].
  • I updated/added relevant documentation (doc comments with ///).
  • All existing and new tests are passing.
  • The analyzer (flutter analyze --flutter-repo) does not report any problems on my PR.
  • I am willing to follow-up on review comments in a timely manner.

Breaking Change

Does your PR require Flutter developers to manually update their apps to accommodate your change?

  • Yes, this is a breaking change (Please read [Handling breaking changes]). Replace this with a link to the e-mail where you asked for input on this proposed change.
  • No, this is not a breaking change.

@goderbauer goderbauer added the framework flutter/packages/flutter repository. See also f: labels. label May 10, 2019
@tvolkert tvolkert changed the title WIP: proof of concept image loading progress notification Add support for image loading progress May 11, 2019
Comment thread packages/flutter/lib/src/foundation/consolidate_response.dart
Comment thread packages/flutter/lib/src/foundation/consolidate_response.dart
/// The `total` parameter will contain the _expected_ total number of bytes to
/// be received (extracted from the value of the `Content-Length` HTTP response
/// header), or -1 if the size of the response body is not known in advance.
/// Even if the parameter reports a non-negative value, it is not 100%

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Content-Length is the size of the transferred data, whether or not it is compressed, at least according to the spec. So this should be a good proxy for the progress, and I'm not sure that the comment is needed. Do ask @Hixie, however, because he probably knows if the spec is implemented properly by most servers.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had added this comment to fix a bug previously.

Some servers in the wild won't set this properly. And ones that do set it properly will give you the compressed size, but dart:io automagically decompresses for you without telling you whether it's compressed. At one point I had patched this so that it would try to be smart and detect that, but that was abandoned since the header may not even be reliable.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be worth documenting here that total could be less than cumulative when GZIP is used.

Do you think we could use the content-type header to determine if the response is one of our supported image formats or a likely compression type?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated the code to better handle gzipped content. Now, consolidateHttpClientResponseBytes() will unzip the raw bytes itself if needed, which allowed us to turn off autoUncompress in the http client used by NetworkImage.

I also updated the docs.

import 'mocks_for_image_cache.dart';

/// A valid encoding of a 1x1 GIF.
final Uint8List _smallGif = Uint8List.fromList(<int>[

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't add your own, just use one from image_data.dart

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

// to the stream, so we manually count the bytes as they cross the wire.
final Completer<Uint8List> completer = Completer<Uint8List>.sync();
final List<List<int>> chunks = <List<int>>[];
final int expectedContentLength = response.contentLength;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will almost always be less than the actual content length - your progress bar will end up loading faster because the contentLength will usually be the gzip compressed length. You should be able to notice this if you test a network download of this with something from Wikimedia for example.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

the bytes manually, rather than relying on HttpClient.autoUncompress.

Correspondingly, change NetworkImage's HttpClient to turn off
autoUncompress, so as to get a more valid reading of the expected
content length.

(also, some minor doc updates)
@tvolkert

Copy link
Copy Markdown
Contributor Author

@gspencergoog @dnfield PTAL

Comment thread packages/flutter/lib/src/painting/image_stream.dart Outdated
Comment thread packages/flutter/lib/src/painting/image_stream.dart Outdated
Comment thread packages/flutter/lib/src/painting/image_stream.dart Outdated
Comment thread packages/flutter/lib/src/painting/image_stream.dart Outdated
Comment thread packages/flutter/lib/src/painting/image_stream.dart Outdated
Comment thread packages/flutter/lib/src/painting/image_stream.dart Outdated
Comment thread packages/flutter/lib/src/painting/image_stream.dart Outdated
/// progress.
///
/// This is useful for images that are incrementally loaded (e.g. over a local
/// file system or a network) and you want to give the user an indication of

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid 'you'.

@tvolkert tvolkert May 15, 2019 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can change it, but pourquoi (I see 152 files in flutter/lib and flutter_test/lib with "you" in the comment)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either way, done 😄

///
/// Once an image has fully loaded, this builder will no longer be consulted.
///
/// {@tool sample}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could make this a snippet pretty easily...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@dnfield dnfield left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

tvolkert added 3 commits May 14, 2019 21:46
… optional named parameter.

It turns out a lot of code outside Flutter is using that method, so it needs to be a non-breaking change to the signature.
Comment thread packages/flutter/lib/src/foundation/consolidate_response.dart
/// The `total` parameter will contain the _expected_ total number of bytes to
/// be received across the wire (extracted from the value of the
/// `Content-Length` HTTP response header), or -1 if the size of the response
/// body is not known in advance.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not known in advance (this is common for HTTP chunked transfer encoding, which itself is common when dealing with CGI scripts on HTTP servers) (or something. "CGI script" is 90s terminology, I don't know what the cool kids are calling it these days.)

/// Efficiently converts the response body of an [HttpClientResponse] into a
/// [Uint8List].
///
/// The future returned will forward all errors emitted by [response].

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it can only forward one error... maybe s/all errors/any error/?

// to the stream.
///
/// The [onBytesReceived] callback, if specified, will be invoked for every
/// chunk of bytes that are received while consolidating the response bytes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/are/is/

Comment thread packages/flutter/lib/src/foundation/consolidate_response.dart
expectedContentLength = -1;
} else {
// We need to un-compress the bytes as they come in.
sink = gzip.decoder.startChunkedConversion(output);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we want to override autoUncompress?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A search yielded no usages of people calling this method with a client that has autoUncompress off, so we have a chance to define the behavior, so I just defined it as "this method will always return uncompressed bytes". The use case is setting the client to not auto-uncompress so that we can get an accurate (or at least, more likely to be accurate) accounting of the expected content length. When the client has auto-uncompressed, we have no chance of knowing the expected content length.

An alternative would be to add a new autoUncompress parameter to this method so callers could control the behavior (it'd be a no-op if the client had already uncompressed). But since it looked like no one was using this with clients that kept the response bytes compressed, it seemed simpler to just define the method as always uncompressing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do you download a gzip with this API?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

heh fair enough - I'll add a parameter 😄

final _OutputBuffer output = _OutputBuffer();
ByteConversionSink sink = output;
int expectedContentLength = response.contentLength;
if (response.headers?.value(HttpHeaders.contentEncodingHeader) == 'gzip') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if it's "identity, gzip"?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wondered about making this more robust, but I opted for copying the exact behavior from HttpClient so as to not add confusion as to how we determine that unzipping is necessary.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please at a minimum file a bug and add a TODO

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the spec, "identity" is an invalid value in the Content-Encoding header.

identity The default (identity) encoding; the use of no transformation whatsoever. This content-coding is used only in the Accept- Encoding header, and SHOULD NOT be used in the Content-Encoding header.

So I think the current impl here is correct.

sink.add(chunk);
if (onBytesReceived != null) {
bytesReceived += chunk.length;
onBytesReceived(bytesReceived, expectedContentLength);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we document that exceptions from this callback will be uncaught and will cause the future never to complete?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(actually i guess they will be caught by listen?)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i guess since i'm confused about this, we should document it, whatever the behaviour is.

/// mock client that hasn't been affected by other tests.
///
/// This value is ignored in non-debug builds.
@visibleForTesting

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this one @visibleForTesting but not the others?

Seems like it would be reasonable for this one to be usable from non-test debug code too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wondered about that - I couldn't think of a concrete use case during debugging. But happy to remove the annotation - will do.

/// * [Image.imageLoadingBuilder], which allows callers to visually represent
/// the loading progress of an image at the widget layer.
class ImageChunkEvent extends Diagnosticable {
/// Creates a new chunk event.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can the arguments be null?

/// 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. When this is

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why -1 rather than null?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'cause it's a passthrough from the response.contentLength property, which uses -1. I could certainly see the argument for making this be null - wdyt?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah if it's just passing through an existing API, sounds good to me.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you haven't already, please reference the api you're forwarding in the docs

///
/// When used with [Image.network], this value comes from the `Content-Length`
/// HTTP response header, and as such is only as trustworthy as the server
/// that reported it. Callers are correspondingly advised to be defensive

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Content-Length is always accurate, if present. It has to be, there's no other signal of the end of the resource.

/// was added, even if it was added with a different `onError` than the
/// intended paired `addListener` call.
void addListener(ImageListener listener, { ImageErrorListener onError }) {
void addListener(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this api is already broken and this makes it more broken. i don't think we should go down this road.

See the docs for removeListener, for example. We'd have to update those if we went this path, and I don't think I could do that without shame.

filterQuality: widget.filterQuality,
);
if (widget.imageLoadingBuilder != null && _chunkEvent != null)
result = widget.imageLoadingBuilder(context, _chunkEvent, _imageInfo == null ? null : result);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we shouldn't create the RawImage for the cases where we don't use it

_imageStream.addListener(_handleImageChanged);
_imageStream.addListener(
_handleImageChanged,
chunkListener: widget.imageLoadingBuilder != null ? _handleImageChunkLoaded : null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as far as i can tell you don't handle widget.imageLoadingBuilder changing over time

void _handleImageChunkLoaded(ImageChunkEvent event) {
assert(widget.imageLoadingBuilder != null);
setState(() {
_chunkEvent = event;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is REALLY expensive. It forces a rebuild for every frame even if the builder doesn't actually return anything that is affected by this information.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But why provide a builder at all if you're not affected by this information? This information is the essence of why you provided a builder. And yes, it'll mean that you rebuild every time bytes come across the wire while the image is loading, which is likely every frame.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you may wish to show an indeterminate progress indicator while downloading. Or the word "LOADING". Or alternative text in an inline widget.

HttpClient client = _sharedHttpClient;
assert(() {
if (debugNetworkImageUseFreshHttpClient)
client = HttpClient();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you don't set autoUncompress to false on this one

if (response.statusCode != HttpStatus.ok)
throw Exception('HTTP request failed, statusCode: ${response?.statusCode}, $resolved');
/// Do not access this field directly; use [_httpClient] instead.
static final HttpClient _sharedHttpClient = HttpClient()..autoUncompress = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you turn off autoUncompress here, but you only support gzip on the other side. HTTP servers can use a variety of compression algorithms other than gzip.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(You should make sure to set the TE header to whatever it is you support. Also, consider Accept-Encoding and Content-Encoding, which are different than TE and Transfer-Encoding.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HttpClient documents this property as only respecting gzip -- https://api.dartlang.org/stable/2.3.0/dart-io/HttpClient/autoUncompress.html

@Hixie

Hixie commented May 15, 2019

Copy link
Copy Markdown
Contributor

Let's see if we can make FadeInImage a client of this.

@tvolkert

Copy link
Copy Markdown
Contributor Author

I split the consolidateHttpClientResponseBytes() changes off into a separate PR - #32853 since they're isolated.

@tvolkert

Copy link
Copy Markdown
Contributor Author

This is being broken off into multiple sub-PRs. Here are the ones that have been sent for review thus far:

@goderbauer

Copy link
Copy Markdown
Member

Should this PR be closed since it split off into multiple new PRs?

@tvolkert

Copy link
Copy Markdown
Contributor Author

Yep, done.

@tvolkert tvolkert closed this May 20, 2019
@tvolkert
tvolkert deleted the images branch May 21, 2019 00:57
@github-actions github-actions Bot locked as resolved and limited conversation to collaborators Aug 6, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ability to display loading progress of an image

6 participants