-
Notifications
You must be signed in to change notification settings - Fork 29.7k
Image dispose #64582
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Image dispose #64582
Conversation
| /// If multiple [ImageStream]s may refer to the same image, this should be set | ||
| /// to true. Within the framework, this does not normally occur, but custom | ||
| /// [ImageProvider] or [ImageStream] implementations can behave differently. | ||
| final bool keepAlive; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
does re-using the framework's "keepAlive" nomenclature here have some potential for confusion? i.e. if users think that keep alive in the framework might be necessary for perf/state, might naming this similarly lead them to believe this should also be true?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In my mind it's a similar trade-off.
The only users who should be dealing with this are ones who are writing their own image providers, streams, or caches. If you think it'd help to expand the documentation here I'd be happy to, or if there's a clearer name for this that would be less confusing I'm not stuck on it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you could sort of invert the naming here - use it to describe what additional action to be taken. Instead of keeping it alive, you are eagerly disposing it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe autoDispose?
jonahwilliams
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we have any sort of test scenarios that show this using less memory/GC-ing correctly?
|
It won't use less memory until the related engine change lands. But the related engine change will need this work. I'm trying to push up the latest commits to my engine branch right now but GitHub is acting up. |
|
Draft here: flutter/engine#20754 Have to figure out why this is segfaulting flutter_test for a test that should be unrelated. |
|
|
||
| class TestImageInfo implements ImageInfo { | ||
| const TestImageInfo(this.value, { this.image, this.scale = 1.0, this.debugLabel, this.keepAlive = false }); | ||
| const TestImageInfo(this.value, { this.image, this.scale = 1.0, this.debugLabel, this.keepAlive = true }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
autoDispose?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missed these, should be fixed now.
|
I missed a case here where the |
| }); | ||
| } | ||
| _currentSizeBytes -= image.sizeBytes!; | ||
| image.completer.keepAlive = false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could somebody else have gained access to the completer and set keepAlive to true? We would be overwriting their request to keep the thing alive here. Does completer instead need to have a keepAlive ref count that gets decreased/increased and its only no longer kept alive when it reaches zero?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was hoping to avoid adding a new kind of refcount parameter, but maybe that would be better here rather than relying on the listener list to be the refcount. That might resolve your other concern below as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not saying that a ref count is the right idea here. Just worried that just having a boolean leads to other problems...
| } | ||
| _onLastListenerRemovedCallbacks.clear(); | ||
| if (!keepAlive && _currentImage?.autoDispose == true) { | ||
| _currentImage!.image.dispose(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if the last listener is removed and then another one is added?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It will only work if the imageCache still has a reference to the image.
I could add an assert about that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is that guaranteed at that in such a scenario the image would still be in the cache, though?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, someone could have evicted it or just disabled the cache
| /// A string used for debugging purpopses to identify the source of this image. | ||
| final String? debugLabel; | ||
|
|
||
| /// Whether or not the [image] should be disposed of when an associated |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In a multi frame scenario this also appears to control whether the previous frame gets disposed when the next frame is available regardless of the listeners on the ImageStream, no?
| } | ||
| _onLastListenerRemovedCallbacks.clear(); | ||
| if (!keepAlive && _currentImage?.autoDispose == true) { | ||
| _currentImage!.image.dispose(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, the ownership model of the image seems a little wonky. Is ImageStreamCompleter really authorized to call dispose on it? Not clear to me...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In fact, the Image widget appears to remove its listener when the surrounding TickerMode is disabled (which presumably would dispose the image here), but the widget continues to hold on to the image object and continues to render it on screen. Most likely that will crash badly since the image is disposed? (If we don't have it, we should add a test for an Image widget inside a disabled TickerMode)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm.
We could have the image widget tell the completer to keep the image alive even if listeners go to zero while the state is still around.
I agree this is not ideal.But we can't have the widget state own this, and we really need something to tell us when it's done with the image.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That sounds like a pretty cumbersome API and AFAIK it's not really a pattern we use elsewhere in the framework.
|
General comment: the ownership of the image object seems really wonky. It is not clear to me that ImageStreamCompleter can just dispose it when the listeners go away, see also the scenario I outlined in this comment: #64582 (comment) |
Co-authored-by: Jonah Williams <[email protected]> Co-authored-by: Michael Goderbauer <[email protected]>
|
Benchmark here: #64762 We should land the benchmark before landing this, assuming this can ever land :) |
|
I'm working on a revision of this that uses a disposable handle on the image. Will update in the next couple days. |
|
I've refactored this to create an I'm worried this might be breaking because |
| /// [maximumSize] and [maximumSizeBytes]. | ||
| void _touch(Object key, _CachedImage image, TimelineTask? timelineTask) { | ||
| assert(timelineTask != null); | ||
| assert(image is! _LiveImage); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we put a LiveImage in the _cache, it won't get disposed of properly.
Maybe they shouldn't be subclasses. But then we duplicate some logic. I'm flexible on this one.
|
|
||
| factory ImageHandle._create(ui.Image image) { | ||
| assert(image != null); | ||
| _handles[image] ??= ImageHandle._(image); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: avoid the !:
return _handles[image] ??= ImageHandle._(image);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
| /// be in a disposed state as well and will no longer be usable. | ||
| /// | ||
| /// The [debugLabel] may be used to identify the source of this image. | ||
| ImageInfo({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FYI: removing const is a breaking change.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Only if it breaks a contributed test or a test in google3.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah, that seems unlikely in this case - its not really a commonly used type
| } | ||
|
|
||
| /// Adds a listener callback that is called whenever a new concrete [ImageInfo] | ||
| /// object is available or an error is reported, if there are any listeners |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the second part of the first sentence is confusing
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rephrasing to hopefully help.
| /// | ||
| /// This always returns null if asserts are disabled, and is only meant for | ||
| /// use in debug mode or tests. | ||
| int? get debugRefCount { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
generally we only add debug only code that is actually used by the framework (in debug mode), and not just for testing. Seems like a lot to pollute the API just for a unit test
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We definitely have other cases where it's added only for tests (for example, debugAssertNoTransientCallbacks on SchedulerBinding). I can mark it @VisibleForTesting if you think that's better.
This could be helpful for application code, if someone is developing a new type of image provider and can't figure out why they still have references to it and who might be adding them. Alternatively, the tests that use it could instead just disable the image cache to avoid it adding more refs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think in this case it feels sort of me to me, because the API is otherwise so small. I admit though, this isn't really that important, but if you think you can test the ImageHandle without the getters, it would be better to YAGNI, right?
|
What I'm sort of missing at this point is and end-to-end of the caching procedures. I'm not sure where the best place for that to go would be, perhaps the imageCache itself? |
|
What do you have in mind for an end-to-end of caching procedures? |
|
Something like "life of an image" would be helpful IMO. The audience would be someone who is trying to write a new Image widget, for example |
|
There's a bit of documentation around that in the [ImageProvider] class, and probably tests in there. Is that what you're looking for? |
| /// | ||
| /// To get an ImageHandle, clients should call [ImageInfo.obtainImageHandle]. | ||
| /// | ||
| /// Clients of an [ImageStream] should call [dispose] on this object when they |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This API still seems ... akward? In order to access ImageInfo.image I need to call a different method (ImageInfo.obtainImageHandle) first. Could that method just return the thing I want so it's actually natural to call it first?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think to do that we'd have to make this a breaking change. This is an attempt to keep this "less-breaking" (it still requires that we make ImageInfo non-const).
I toyed with the idea of returning something that implements Image, which when you call dispose on it actually just decreases a refcount. The problem with that will be it isn't usable on the native side because it's not a real native backed object.
We could break this by @deprecated the image getter, and exposing the image off of ImageHandle, which you then dispose. Would that be better?
| /// Clients should _not_ call [ui.Image.dispose] directly, as it will prevent | ||
| /// other clients from using the same image reference should they need to. | ||
| class ImageHandle { | ||
| /// Do not use this, use [_create] instead to make sure that ImageHandles |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This surprised me a little. When first thinking about this, I expected the ImageHandle object to have on clear owner (whoever obtained it from the ImageInfo) and when they are done with it, it gets disposed and thrown away... It is a little surprising that multiple "owners" can (and must) call dispose on this object.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is basically just a ref-counter to a single ui.Image.
| return _handles[image] ??= ImageHandle._(image); | ||
| } | ||
|
|
||
| static final Expando<ImageHandle> _handles = Expando<ImageHandle>(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why an Expando? Instead of its magic, shouldn't we always know when something needs to get dropped out of it? (e.g. when the handle ref count gets to zero?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because we don't want to keep the ImageHandle or Images this stores alive.
The problem here is that if multiple ImageStreams are vending the same Image, e.g. because two different image providers are resolving to the same image. We want to make sure that a single Image always uses the same ImageHandle.
| /// [ImageHandle.dispose] when the client no longer will use the image. | ||
| ui.Image get image { | ||
| assert(!_imageHandle.debugDisposed, 'Object has been disposed.'); | ||
| assert(_imageHandle._refCount > 0, 'Call `obtainImageHandle` before accessing the image in an ImageInfo object.'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_refCount > 0 could mean that somebody else (not the current caller of image) has a valid handle, no? I guess, we can't really assert that the current caller has a valid handle...
(see my other comment above): That's why I wish we could have an API where you access the image from a valid handler...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes - it could be someone else keeping the refcount up, not the caller.
| } | ||
| } | ||
|
|
||
| /// Adds a listener callback that is called only called if there are listeners |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
grammar: ... is called only called ...
| } | ||
| } | ||
|
|
||
| /// Adds a listener callback that is called only called if there are listeners |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not sure I understand what this paragraph is saying. When should I add a regular listener? When a passive one?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The image cache wants to listen without actually requesting more frames. Maybe there's a better name for this.
|
I'm going to close this in favor of an alternative approach that will depend on another change in the engine. |
Allows for reference counting of images before disposal. This will allow multiple callers to hold a reference to an image and dispose of their reference without disposing the underlying image until all handles have been disposed. This will be used by the framework to help resolve some of the kludge I was trying to introduce in flutter/flutter#64582
Description
Some background:
In Flutter,
Imageobjects are underpinned bySkImages, which are immutable refcounted objects. An SkImage cannot release its underlying image data until its refcount drops to zero.We draw
SkImages intoSkPictures, so whatever picture we draw an image into also has to have its refcount go to zero to release the image memory.SkPictures end up being held byPictureobjects in Dart, which in turn are held byLayerobjects. They are also held internally by the engine byPictureLayerobjects, which do not have an exact Dart peer but are indirectly held by other Dart peer Layers (which hold on toEngineLayerobjects eventually). Layers have a somewhat ambiguous ownership model - they are owned potentially by other layers or byRenderObjects. RenderObjects have no disposal method.In an ideal world, we would have a proper lifecycle established for RenderObjects, Layers, EngineLayers, Pictures, and Images. Unfortunately, doing this will require a number of breaking changes. I've filed #64581 with most of the same text as this context to track.
In the mean time, we can improve the situation by calling
disposeon an image when its listener count has dropped to zero. We can allow users to opt-out of this if they have a specialImageStream,ImageProvider, orImageStreamCompleterimplementation that caches images and reuses them in unexpected ways (the framework's ownImageCachedoes not directly hold references toui.Imageobjects). We can also make sure we dispose previous frames for animated images.We can then update the engine side of
disposeonui.Imageso that it helps hint the Dart VM that a GC would be helpful the next time we notify idle. That PR is pending here:Related Issues
Part of #56482
A failed attempt to do this in a more generic way is here: flutter/engine#19842
It failed because it GC'd too often, which caused excessive CPU utilization.
Tests
I added the following tests:
Tests that the various
ImageStreamimplementers do the expected thing.Update to an unrelated test for nullsafety differences between test/framework code.
Update an existing test to opt-in to keeping the image alive, since it does weird things with the ui.Image objects.
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
Did any tests fail when you ran them? Please read Handling breaking changes.
(TBD)