Repository navigation
Conversation
|
|
||
| @override | ||
| void dispose() { | ||
| assert(_imageStream != null); |
There was a problem hiding this comment.
We might not have ever gotten an _imageStream before being disposed of if we were scrolling fast.
There was a problem hiding this comment.
How can this happen? Isn't provider.resolve in line 1036 always synchronously returning a stream? I also don't see code that changed when we call resolve on the provider...
There was a problem hiding this comment.
I'll put this back. This was from a different experiment
| } | ||
|
|
||
| /// Returns whether this [key] has been previously added by [putIfAbsent]. | ||
| bool containsKey(Object key) { |
There was a problem hiding this comment.
This is breaking (we have internal customers that implement ImageCache). There are ugly ways to make it not be breaking (e.g. make putIfAbsent tolerate having a loader that returns null, which is documented as being bad but not tested against, and we have internal implementers that tolerate it).
I think it's worth the break but am willing to hear other ideas.
There was a problem hiding this comment.
Don't hesitate to make breaking changes if they are valuable. Just make sure to go through the process (which involves getting feedback about whether it's valuable).
| /// method. | ||
| /// method. If they need to manage the actual resolution of the image, they | ||
| /// should override [resolveForStream] instead. | ||
| @nonVirtual |
There was a problem hiding this comment.
This is a bigger break that I need to make the way I'm doing DeferringImageProvider work. I don't to call resolve from there and end up decoding work before I'm ready.
There was a problem hiding this comment.
Interestingly, this would not break any internal customers or existing customer tests - it only breaks our own test implementations.
| int get hashCode => hashValues(imageProvider, getNextAction); | ||
|
|
||
| @override | ||
| String toString() => '$runtimeType(imageProvider: $imageProvider)'; |
There was a problem hiding this comment.
please don't use runtimeType.toString()
There was a problem hiding this comment.
it is incredibly slow
There was a problem hiding this comment.
This is our standard toString template right?
There was a problem hiding this comment.
(we do this everywhere)
There was a problem hiding this comment.
That doesn't make it fast
There was a problem hiding this comment.
Fair - how about we handle this in a separate PR and upate the style guide?
There was a problem hiding this comment.
(or file a bug against Dart SDK to make it be fast?)
There was a problem hiding this comment.
it's fine to use runtimeType.toString, just don't use it in release builds.
Co-Authored-By: Jonah Williams <[email protected]>
|
Related: #32156 |
| return stream; | ||
| } | ||
|
|
||
| /// Subclasses that need to manage resolution more finely should override |
There was a problem hiding this comment.
The documentation should probably emphasize what this method actually does. Then as a note add information about overriding. It's hard to overrride if you don't know the contract of the method.
| return; | ||
| } | ||
| switch (getNextAction()) { | ||
| case DeferringImageProviderAction.defer: |
There was a problem hiding this comment.
Do we really need defer? Seems like the cancelling work you did could be sufficient for your problem.
There was a problem hiding this comment.
Yes. Without this our only options are to either immediately cancel or immediately resolve. We want to be able to defer until a condition is met (scrolling has slowed)
There was a problem hiding this comment.
We also need an image provider here so we can see if we even should bother waiting or not - if the image is already in the cache we shouldn't wait.
| // to see if we've slowed down or disappeared. | ||
| // It's safe to use these calls as point-in-time - do not subscribe to | ||
| // updates of MediaQuery or Scrollable. | ||
| final double maxPhysicalDimension = WidgetsBinding.instance.window.physicalSize.longestSide; |
There was a problem hiding this comment.
Nit: being explicit on units here can help verify the math.
There was a problem hiding this comment.
Are you asking for a different variable name or something else?
There was a problem hiding this comment.
Yep, putting the units in the variable name is the easiest change.
There was a problem hiding this comment.
Ahh sure. That makes sense.
There was a problem hiding this comment.
Just to be a bit more obvious. I've seen velocity calculations get messed up when people mix logical pixels and hardware pixels.
There was a problem hiding this comment.
I am intentionally doing that but I'll add some explanation.
Basically, we want something a factor bigger than logical pixels, and I'm assuming this is safe because higher dpi should mean more memory available on device.
There was a problem hiding this comment.
Pushed up some changes to the docs and this property name.
| ScrollActivity _activity; | ||
|
|
||
| /// The current [ScrollActivity.velocity] of the current activity, if any. | ||
| double get currentScrollVelocity => activity?.velocity; |
There was a problem hiding this comment.
Document units in docstring and/or property name.
There was a problem hiding this comment.
Hm. I thought I did this one, I think I did the similar one instead. Will update.
There was a problem hiding this comment.
GitHub is messed up here. The text acutally reads:
///
/// This value is in logical pixels per second. It may be positive, negative,
/// 0, or null.
In addition to what's showing right now. You can see it at: f732fbe
There was a problem hiding this comment.
Cool thanks, what's a velocity of null mean?
There was a problem hiding this comment.
Hm. I might be wrong about this. I was thinking there was some code path somewhere that could set it to null or set the activity to null. I'm having trouble finding it. @goderbauer may know if this is accurate.
There was a problem hiding this comment.
Oh, derp.
This can return null if you're not in a scrollable context :)
There was a problem hiding this comment.
It's not clear to me why this should return null.
This can return null if you're not in a scrollable context :)
How would you have a ScrollPosition if you're not in a scrollable context?
Also: What does it mean from an API point of view when your velocity is null? Shouldn't this always return negative, 0 or positive numbers?
There was a problem hiding this comment.
You are right, I think I copied this over from scrollable.dart, which is where it can be null. Removed the bit about null here.
| // It's safe to use these calls as point-in-time - do not subscribe to | ||
| // updates of MediaQuery or Scrollable. | ||
| final double maxPhysicalPixels = WidgetsBinding.instance.window.physicalSize.longestSide; | ||
| if ((Scrollable.scrollingVelocityOfContext(context) ?? 0).abs() > maxPhysicalPixels) { |
There was a problem hiding this comment.
You are mixing units here, velocity versus physical pixels. I'd compare velocity to velocity.
There was a problem hiding this comment.
It's intentional though. The idea is the DPI is a good heuristic to use for this case.
There was a problem hiding this comment.
That's fine, i'd document it with another variable like.
double fullScreenPerSecond = maxPhysicalPixels;
There was a problem hiding this comment.
Is there some scenario where we could end up with enough velocity to meet this condition indefinitely? For example, some sort of absurdly pixel scaling combined with a looping animation?
There was a problem hiding this comment.
In such a situation we'd have to be scrolling at high velocity indefinitely. Or, we'd have to be in some kind of crazy scroll activity that's lying to us about how fast it's scrolling indefinitely.
I suppose the most pathological case I can think of is an animation that loops at high velocity such that it scrolls up and down very very fast. If you wanted the image to ever show up in that case, you'd have to precache it.
There was a problem hiding this comment.
And it couldn't be just any animation, it would have to be an animation that drove a scroll controller such that the activity used was a ballistic activity scrolling at high velocity.
gaaclarke
left a comment
There was a problem hiding this comment.
+1 . I'd like to get another lgtm. The documentation and testing is good and I don't see any obvious bugs. I still don't 100% grok the necessity of the defer and I'm not too keen on the usage of the DPI as a proxy to fudge the heuristics.
| scheduleMicrotask(() => deferredResolve(key)); | ||
| }); | ||
| return; | ||
| case DeferringImageProviderAction.cancel: |
There was a problem hiding this comment.
cancel seems like it could stop an image from being loaded, but not once it starts loading? It should be possible to cancel NetworkRequests too, though I'm not sure if HttpClient exposes an API for it
There was a problem hiding this comment.
We can look at cancelling once a load starts, but this PR isn't trying to solve that one.
Part of the problem there is that it will be very hard once we actually start the machinery on the IO thread in the engine to cancel it before allocations have happened.
Network request handling could use some love but I think that's orthogonal to this issue. This is trying to stop us from even making the network request to begin with if we won't need it.
There was a problem hiding this comment.
I think it would be unfortunate if we had to come up with an entirely separate solution for network requests. I might propose a slightly more general API:
The deferring image provider accepts a controller/token instead of a callback. This controller maintains a state flag similar to what the callback is providing now.
The controller state can be modified via setters/methods like cancel() or resume().
The cancellation could be propagated up through the network image provider, by exposing some functionality on ImageStream perhaps? Ideally this could eventually be wired up the HttpClient and others.
There was a problem hiding this comment.
My sense is that places we've introduced controllers (e.g. TextEditing) have really only caused more confusion and API challenges than they resolve. I think it will also become very confusing if cancel behaves differently in different contexts - did I cancel before making the request? Did I cancel the active request? Did I cancel it early enough to have effect? Did I call cancel after the whole thing was already done?
I think for NetworkRequests made by the framework in general, we should create another InheritedWidget that would handle things like this (caching requests, cancelling requests, etc). WDYT?
There was a problem hiding this comment.
I don't really agree but I'm not involved enough in the problem, so I'll defer to the rest of the framework team
| /// Subclasses should implement [obtainKey] and [load], which are used by this | ||
| /// method. | ||
| /// method. If they need to manage the actual resolution of the image, they | ||
| /// should override [resolveForStream] instead. |
There was a problem hiding this comment.
Added a couple words (of this method)
| /// never fire and the cached image is reused. | ||
| @immutable | ||
| class DeferringImageProvider<T> extends ImageProvider<T> { | ||
|
|
There was a problem hiding this comment.
nit: remove extra blank line
| /// This callback is never called if the [ImageCache] already has the image | ||
| /// present, such as from [precacheImage] or another provider successfully | ||
| /// loading it. | ||
| final DeferringImageProviderAction Function() getNextAction; |
| String toString() => '${objectRuntimeType(this, 'ExactAssetImage')}(name: "$keyName", scale: $scale, bundle: $bundle)'; | ||
| } | ||
|
|
||
| /// The action a [DeferringImageProvider] should take currently. |
There was a problem hiding this comment.
Maybe expand on this a little and mention that the getNextAction callback on DeferringImageProvider is supposed to return these.
| /// its [DeferringImageProvider.getNextAction] callback anymore. | ||
| cancel, | ||
|
|
||
| /// Indicates that the caller would like to defer the image and be called |
There was a problem hiding this comment.
Maybe clearer:
| /// Indicates that the caller would like to defer the image and be called | |
| /// Indicates that the caller would like to defer resolving the image and be called |
|
|
||
| @override | ||
| void dispose() { | ||
| assert(_imageStream != null); |
There was a problem hiding this comment.
How can this happen? Isn't provider.resolve in line 1036 always synchronously returning a stream? I also don't see code that changed when we call resolve on the provider...
|
|
||
| void _listenToStream() { | ||
| if (_isListeningToStream) | ||
| if (_isListeningToStream || _imageStream == null) |
There was a problem hiding this comment.
Same here (and below): how can this be null?
There was a problem hiding this comment.
Removed - this was from a previous experiment that I forgot to clean up.
| // It's safe to use these calls as point-in-time - do not subscribe to | ||
| // updates of MediaQuery or Scrollable. | ||
| final double maxPhysicalPixels = WidgetsBinding.instance.window.physicalSize.longestSide; | ||
| if ((Scrollable.scrollingVelocityOfContext(context) ?? 0).abs() > maxPhysicalPixels) { |
There was a problem hiding this comment.
What if I very quickly and continuously scroll up and down: Does this block loading images forever?
There was a problem hiding this comment.
You would have to precache the image in such a case.
There was a problem hiding this comment.
And for most cases, you'd eventually lose velocity long enough at some point between frames (during a direction change) that it should still end up working out.
| ScrollActivity _activity; | ||
|
|
||
| /// The current [ScrollActivity.velocity] of the current activity, if any. | ||
| double get currentScrollVelocity => activity?.velocity; |
There was a problem hiding this comment.
It's not clear to me why this should return null.
This can return null if you're not in a scrollable context :)
How would you have a ScrollPosition if you're not in a scrollable context?
Also: What does it mean from an API point of view when your velocity is null? Shouldn't this always return negative, 0 or positive numbers?
| /// is only safe to use as a single point in time reference. | ||
| /// | ||
| /// This value is in logical pixels per second. It may be positive, negative, | ||
| /// 0, or null. |
There was a problem hiding this comment.
Document what null means?
(here it's correct what you said above: You're not in a scrollable context)
| // It's safe to use these calls as point-in-time - do not subscribe to | ||
| // updates of MediaQuery or Scrollable. | ||
| final double maxPhysicalPixels = WidgetsBinding.instance.window.physicalSize.longestSide; | ||
| if ((Scrollable.scrollingVelocityOfContext(context) ?? 0).abs() > maxPhysicalPixels) { |
There was a problem hiding this comment.
It is a little surprising to tie our Image implementation to our implementation of Scrollable. If somebody were to write a new Scrolling lib (because they don't like our sliver-based on or whatever) they would not be able to make uses of this feature for images shown in their scrolling container... This my be more hypothetical, though.
There was a problem hiding this comment.
Yes, I agree this is a problem. I'm not sure what we can do about it at the moment. I think we should punt on it for now and deal with it if it comes up - we could rework this so that you could find other ways to notify widgets about how fast you are scrolling.
| return stream; | ||
| } | ||
|
|
||
| /// This method does the acutal work of [resolve], both decoding the image |
| /// | ||
| /// While it returns [DeferringImageProviderAction.defer], it schedules a new | ||
| /// call during the [SchedulerPhase.midFrameMicrotasks] of the next frame. | ||
| final DeferringImageProviderActionCallback handleDeferringImageProviderAction; |
There was a problem hiding this comment.
per our style guide this should be called onSomething.
| @override | ||
| void resolveForStream(ImageConfiguration configuration, ImageStream stream) { | ||
| void deferredResolve(Object key) { | ||
| if (PaintingBinding.instance.imageCache.containsKey(key)) { |
There was a problem hiding this comment.
we should document that this image provider only works with other image providers that use the cache correctly
| // Did this element get disposed of (scrolled too far out of view before | ||
| // scrolling slowed down enough to catch up)? | ||
| if (!mounted) { | ||
| return DeferringImageProviderAction.cancel; |
There was a problem hiding this comment.
it really would be better if we could do that in dispose...
| return; | ||
| } | ||
| } | ||
| obtainKey(configuration).then(deferredResolve); |
| } | ||
|
|
||
| /// This method does the acutal work of [resolve], both decoding the image | ||
| /// and making sure it ends up in the [ImageCache]. |
There was a problem hiding this comment.
"Decode the image and add it to the [ImageCache] if necessary".
|
@goderbauer @dnfield and I discussed this a bit. Some thoughts:
|
|
Talked offline a bit more - It seems like this logic would be more comfortable living more in the widgets layer. Going to experiment with that to see if I can clean up some of the rough edges here. |
|
Further discussion with @dnfield suggested that maybe what we should do is move the new image provider into the widgets layer, so it can know about BuildContext and Scrollable, and have it create a new ImageStream or ImageStreamCompleter that figures out whether to cancel based on when it runs out of listeners. |
|
I'm going to close this - the approach I'm going to take has some similarities to this but should be an improved API. At this point, there's lots of out of date comments on this PR and the approach is different enough that I'd like to not lose all the context here. Will follow up with the next PR after #49319 lands (second PR will depend on that one) |
|
Continued in #49389 |
Description
A List or GridView of images can end up consuming vast amounts of memory, particularly when the user "flings" the list causing the scrollable to go ballistic.
The basic problem is that we push a ton of decoding work onto the IO thread of the engine, and then end up not even needing those images because the children aren't in the tree anymore. This causes thrash on the image cache, wasted CPU load for decoding those images, and in some cases OOM death.
I've resolved this by adding a new ImageProvider implementation that allows the caller to defer or cancel loading the image. The ImageState now uses this to wrap its incoming image provider, and defers loading until scrolling has slowed down enough that we would be seen on screen (defined as scrolling velocity < the physical number of pixels on the longest side of the screen). If we're no longer in the tree at that point, we cancel the load. This provider is cache-aware, and will just return the image in the cache if at any point during its cycle the image appears in the cache.
/cc @hugosantos @ignatz @alml @cvarvara
/cc @liyuqian @chinmaygarde
Related Issues
#32143
#44510
b/144232910
#48305
Fixes #48775
Tests
I added the following tests:
Checklist
Before you create this PR confirm that it meets all requirements listed below by checking the relevant checkboxes (
[x]). This will ensure a smooth and quick review process.///).flutter analyze --flutter-repo) does not report any problems on my PR.Breaking Change
Right now this is - I'm open to feedback suggesting ways to make it not be.
Will add link to design doc shortly - will add link to migration guide if this remains an API break before landing.
Did any tests fail when you ran them? Please read Handling breaking changes.