Repository navigation
Conversation
| /// 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% |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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>[ |
There was a problem hiding this comment.
Don't add your own, just use one from image_data.dart
| // 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; |
There was a problem hiding this comment.
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.
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)
|
@gspencergoog @dnfield PTAL |
| /// 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 |
There was a problem hiding this comment.
I can change it, but pourquoi (I see 152 files in flutter/lib and flutter_test/lib with "you" in the comment)?
There was a problem hiding this comment.
Either way, done 😄
| /// | ||
| /// Once an image has fully loaded, this builder will no longer be consulted. | ||
| /// | ||
| /// {@tool sample} |
There was a problem hiding this comment.
You could make this a snippet pretty easily...
Co-Authored-By: Greg Spencer <[email protected]>
… 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.
| /// 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. |
There was a problem hiding this comment.
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]. |
There was a problem hiding this comment.
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. |
| expectedContentLength = -1; | ||
| } else { | ||
| // We need to un-compress the bytes as they come in. | ||
| sink = gzip.decoder.startChunkedConversion(output); |
There was a problem hiding this comment.
why do we want to override autoUncompress?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
How do you download a gzip with this API?
There was a problem hiding this comment.
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') { |
There was a problem hiding this comment.
what if it's "identity, gzip"?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Please at a minimum file a bug and add a TODO
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
should we document that exceptions from this callback will be uncaught and will cause the future never to complete?
There was a problem hiding this comment.
(actually i guess they will be caught by listen?)
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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. |
| /// 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 |
There was a problem hiding this comment.
'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?
There was a problem hiding this comment.
ah if it's just passing through an existing API, sounds good to me.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
(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.)
There was a problem hiding this comment.
HttpClient documents this property as only respecting gzip -- https://api.dartlang.org/stable/2.3.0/dart-io/HttpClient/autoUncompress.html
|
Let's see if we can make FadeInImage a client of this. |
|
I split the |
|
This is being broken off into multiple sub-PRs. Here are the ones that have been sent for review thus far: |
|
Should this PR be closed since it split off into multiple new PRs? |
|
Yep, done. |
Description
Add support for image loading progress.
This PR adds a
imageLoadingBuilderproperty to theImageclass. 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:
consolidateHttpClientResponseBytesnotifies onBytesReceived for every chunk of bytesNetworkImagenotifies listeners of chunk eventsImageStreamCompleterdelivers chunk events to listenersImageStreamCompleterdrops chunks events on the floor (does not buffer the events) if there are no listenersImageStreamCompleterreports errors in the chunk event streamImageinvokesimageLoadingBuilderas the image loadsImageignores chunk events ifimageLoadingBuilderis nullChecklist
///).flutter analyze --flutter-repo) does not report any problems on my PR.Breaking Change
Does your PR require Flutter developers to manually update their apps to accommodate your change?