Skip to content

Conversation

@dnfield
Copy link
Contributor

@dnfield dnfield commented Aug 25, 2020

Description

Some background:

In Flutter, Image objects are underpinned by SkImages, which are immutable refcounted objects. An SkImage cannot release its underlying image data until its refcount drops to zero.

We draw SkImages into SkPictures, 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 by Picture objects in Dart, which in turn are held by Layer objects. They are also held internally by the engine by PictureLayer objects, which do not have an exact Dart peer but are indirectly held by other Dart peer Layers (which hold on to EngineLayer objects eventually). Layers have a somewhat ambiguous ownership model - they are owned potentially by other layers or by RenderObjects. 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 dispose on an image when its listener count has dropped to zero. We can allow users to opt-out of this if they have a special ImageStream, ImageProvider, or ImageStreamCompleter implementation that caches images and reuses them in unexpected ways (the framework's own ImageCache does not directly hold references to ui.Image objects). We can also make sure we dispose previous frames for animated images.

We can then update the engine side of dispose on ui.Image so 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 ImageStream implementers 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.

  • 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 read the Tree Hygiene wiki page, which explains my responsibilities.
  • 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

Did any tests fail when you ran them? Please read Handling breaking changes.

(TBD)

@flutter-dashboard flutter-dashboard bot added the framework flutter/packages/flutter repository. See also f: labels. label Aug 25, 2020
/// 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;
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Maybe autoDispose?

Copy link
Contributor

@jonahwilliams jonahwilliams left a 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?

@dnfield
Copy link
Contributor Author

dnfield commented Aug 25, 2020

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.

@dnfield
Copy link
Contributor Author

dnfield commented Aug 25, 2020

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 });
Copy link
Contributor

Choose a reason for hiding this comment

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

autoDispose?

Copy link
Contributor Author

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.

@dnfield
Copy link
Contributor Author

dnfield commented Aug 26, 2020

I missed a case here where the ImageCache might be holding a reference to the completer, but all listeners are gone, and the ImageCache needs to keep the completer alive. Added a mutable property to the stream so the cache can tell it to stay alive in that case.

});
}
_currentSizeBytes -= image.sizeBytes!;
image.completer.keepAlive = false;
Copy link
Member

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?

Copy link
Contributor Author

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.

Copy link
Member

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();
Copy link
Member

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?

Copy link
Contributor Author

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.

Copy link
Member

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?

Copy link
Contributor Author

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
Copy link
Member

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();
Copy link
Member

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...

Copy link
Member

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)

Copy link
Contributor Author

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.

Copy link
Member

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.

@goderbauer
Copy link
Member

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]>
@dnfield
Copy link
Contributor Author

dnfield commented Aug 27, 2020

Benchmark here: #64762

We should land the benchmark before landing this, assuming this can ever land :)

@dnfield
Copy link
Contributor Author

dnfield commented Aug 29, 2020

I'm working on a revision of this that uses a disposable handle on the image. Will update in the next couple days.

@dnfield
Copy link
Contributor Author

dnfield commented Sep 1, 2020

I've refactored this to create an obtainImageHandle API that callers have to dispose when they're done with the image.

I'm worried this might be breaking because ImageInfo isn't const anymore. CodeSearch tells me that we were the only one using it as const in a test so maybe it's ok.

/// [maximumSize] and [maximumSizeBytes].
void _touch(Object key, _CachedImage image, TimelineTask? timelineTask) {
assert(timelineTask != null);
assert(image is! _LiveImage);
Copy link
Contributor Author

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);
Copy link
Contributor

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);

Copy link
Contributor Author

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({
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor

@jonahwilliams jonahwilliams Sep 1, 2020

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
Copy link
Contributor

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

Copy link
Contributor Author

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 {
Copy link
Contributor

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

Copy link
Contributor Author

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.

Copy link
Contributor

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?

@jonahwilliams
Copy link
Contributor

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?

@dnfield
Copy link
Contributor Author

dnfield commented Sep 1, 2020

What do you have in mind for an end-to-end of caching procedures?

@jonahwilliams
Copy link
Contributor

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

@dnfield
Copy link
Contributor Author

dnfield commented Sep 1, 2020

There's a bit of documentation around that in the [ImageProvider] class, and probably tests in there. Is that what you're looking for?

@Piinks Piinks added the a: images Loading, displaying, rendering images label Sep 2, 2020
///
/// To get an ImageHandle, clients should call [ImageInfo.obtainImageHandle].
///
/// Clients of an [ImageStream] should call [dispose] on this object when they
Copy link
Member

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?

Copy link
Contributor Author

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
Copy link
Member

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.

Copy link
Contributor Author

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>();
Copy link
Member

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?)

Copy link
Contributor Author

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.');
Copy link
Member

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...

Copy link
Contributor Author

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
Copy link
Member

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
Copy link
Member

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?

Copy link
Contributor Author

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.

@dnfield
Copy link
Contributor Author

dnfield commented Sep 9, 2020

I'm going to close this in favor of an alternative approach that will depend on another change in the engine.

@dnfield dnfield closed this Sep 9, 2020
dnfield added a commit to flutter/engine that referenced this pull request Sep 23, 2020
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
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Sep 9, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

a: images Loading, displaying, rendering images framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants