Skip to content

Add a mechanism to observe layer tree composition. #103378

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

Merged
merged 18 commits into from
May 17, 2022

Conversation

dnfield
Copy link
Contributor

@dnfield dnfield commented May 10, 2022

This is WIP to enable a refactor of visibility_detector that does not affect compositing.

Currently the way visibility_detector works is use a render object that always requires composition so that it can push a layer that always re-composites its part of the tree so that it can observe composition.

This has several major drawbacks:

  • It defeats raster caching for parts of the tree that would otherwise be cacheable
  • It marks parts of the tree as dirty more often
  • It increases the total size of the layer tree
  • It breaks up portions of the tree surrounded by these layers into individual pictures, which has negative performance impacts in most cases

This patch does not have tests yet, and in particular it's not clear to me whether we'd need some kind of removeCallback mechanism - if we do, the API will have to look different.

Looking for some early feedback while I continue to work on the visibility_detector change.

@flutter-dashboard flutter-dashboard bot added the framework flutter/packages/flutter repository. See also f: labels. label May 10, 2022
@dnfield
Copy link
Contributor Author

dnfield commented May 10, 2022

removeCallback case: call it when RO gets disposed.

Argument against: RO can track when it's disposed and just short circuit the callback. RO also shoul dnever really get disposed when its tree is compositing anyway...?


void _fireCompositionCallbacks(bool includeChildren) {
for (final VoidCallback callback in List<VoidCallback>.of(_callbacks)) {
for (final VoidCallback callback in List<VoidCallback>.of(_callbacks.values)) {
Copy link
Member

Choose a reason for hiding this comment

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

I think .toList is the fast one since the exact type of the receiver is known in the implementation

Copy link
Contributor Author

Choose a reason for hiding this comment

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

toList calls of

Copy link
Member

Choose a reason for hiding this comment

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

🤦‍♂️

Copy link
Member

Choose a reason for hiding this comment

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

https://github.com/dart-lang/sdk/blob/main/sdk/lib/_internal/vm/lib/array.dart#L169

Looks like if you give List.of an iterable that isn't backed by a list it first converts it to a list. so calling toList should be universally better

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

RIP

/// Adds a [CompositionCallback] for the current [ContainerLayer] used by this
/// context.
///
/// Composition callbacks are called whenever the layer tree a container layer
Copy link
Member

Choose a reason for hiding this comment

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

nit: this sentence is missing an is, I think.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Reworded - I changed my mind on how this was going to sound and forgot to finish rewriting it ahah

///
/// Calling the returned callback will remove [callback] from the composition
/// callbacks.
/// See also:
Copy link
Member

Choose a reason for hiding this comment

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

nit: add blank line before this

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

child._fireCompositionCallbacks(includeChildren);
child = child.nextSibling;
}

Copy link
Member

Choose a reason for hiding this comment

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

nit: remove blank line

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

@@ -136,6 +136,16 @@ class AnnotationResult<T> {
/// * [RenderView.compositeFrame], which implements this recomposition protocol
/// for painting [RenderObject] trees on the display.
abstract class Layer extends AbstractNode with DiagnosticableTreeMixin {
final Map<int, VoidCallback> _callbacks = <int, VoidCallback>{};

void _fireCompositionCallbacks(bool includeChildren) {
Copy link
Member

Choose a reason for hiding this comment

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

nit: make the argument a required optional parameter to make callsites easier to read.

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


}

/// Adds a callback for when the layer tree that this layer is part of gets
Copy link
Member

Choose a reason for hiding this comment

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

Conceptually, why is this a feature of ContainerLayer over the Layer base class?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Mainly because RenderObjects are the normal clients for this via the PaintingContext, and the PaintingContext only knows its container layer - picture layers might not ever be created for it.

Copy link
Member

Choose a reason for hiding this comment

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

Since the backing storage is in the Layer class, it feels like this method should also be defined there? PaintingContext could still use it on the ContainerLayer, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That will then require casts to get to parents, but I guess that's ok.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

oh, it won't. I see now.

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 but it's probably ok.

super.detach();
Layer? child = firstChild;
while (child != null) {
child.detach();
child = child.nextSibling;
}
// Children fired them already in child.detach().
_fireCompositionCallbacks(false);
Copy link
Member

Choose a reason for hiding this comment

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

Why do we need to fire on detach?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Detach indicates we're not going to get drawn again. If we don't fire here, we risk telling clients that we rendered something and never updating when we got dropped.

Attach shouldn't be needed because if you get composed after attach buildScene will be called.

I'll update the comment.

Copy link
Member

Choose a reason for hiding this comment

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

Maybe also update the doc comment explaining when the callback will be called. From the current description my understanding was it would only happen when the layer is still part of the scene, not when it is going away...

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

@@ -936,6 +1013,7 @@ class ContainerLayer extends Layer {
ui.Scene buildScene(ui.SceneBuilder builder) {
updateSubtreeNeedsAddToScene();
addToScene(builder);
_fireCompositionCallbacks(true);
Copy link
Member

Choose a reason for hiding this comment

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

This adds a walk of the entire layer tree every time we build a new scene (i.e. every single frame) - irregardless of whether any callbacks are registered or not and people not using this feature at all would also pay the cost?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So one thought I had is that we could mark the root of a tree about whether any callbacks are actually present or not. I started working on that but abandoned it because of added complexity and I just wanted to have something working. I can take a look at it again.

Copy link
Member

Choose a reason for hiding this comment

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

Ideally if there are no callbacks this has zero cost. maybe we do that with a single pristine check for now and optimize later?

Copy link
Member

@goderbauer goderbauer left a comment

Choose a reason for hiding this comment

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

Overall, I think this looks reasonable.

if (!alwaysNeedsAddToScene) {
markNeedsAddToScene();
}

_incrementSubtreeCompositionObserverCount(-(child as Layer)._childrenWithCompositionCallbacks);
Copy link
Member

Choose a reason for hiding this comment

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

We can probably type the "child" argument as Layer in the method signature, right?

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 that will cause breakage in implementations that override these methods today, but it's probably worth it...

if (!alwaysNeedsAddToScene) {
markNeedsAddToScene();
}
_incrementSubtreeCompositionObserverCount((child as Layer)._childrenWithCompositionCallbacks);
Copy link
Member

Choose a reason for hiding this comment

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

same

}
}

/// Adds a callback for when the layer tree that this layer is part of gets
Copy link
Member

Choose a reason for hiding this comment

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

it also gets called when the layer is no longer part of the tree, right?


}

/// Adds a callback for when the layer tree that this layer is part of gets
Copy link
Member

Choose a reason for hiding this comment

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

Since the backing storage is in the Layer class, it feels like this method should also be defined there? PaintingContext could still use it on the ContainerLayer, right?

return () {
_callbacks.remove(_nextCallbackId);
if (_callbacks.isEmpty) {
_incrementSubtreeCompositionObserverCount(-1);
Copy link
Member

Choose a reason for hiding this comment

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

nit: maybe this should be called update instead of increment?

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

Comment on lines 1121 to 1122
// interested in observing composition will need to get updated to show
// Children fired them already in child.detach().
Copy link
Member

Choose a reason for hiding this comment

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

something is odd here. Is the sentence on the previous line incomplete?

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, fixed that.

///
/// The callback receives a reference to this layer. The recipient must not
/// mutate the layer during the scope of the callback, but may traverse the
/// tree to find information about the current transform or clip.
Copy link
Member

Choose a reason for hiding this comment

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

We should probably document what happens if new callbacks are added/removed within the callback.

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

/// layers.
///
/// The callback receives a reference to this layer. The recipient must not
/// mutate the layer during the scope of the callback, but may traverse the
Copy link
Member

Choose a reason for hiding this comment

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

nit: if the callback is received do to a detach there will be nothing to traverse. Somewhere this doc should talk about that case.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Detached != not in a tree, it just means there's no owner.

You can be in a state where !attached && parent != null.

@dnfield dnfield marked this pull request as ready for review May 10, 2022 23:40
@flutter-dashboard
Copy link

It looks like this pull request may not have tests. Please make sure to add tests before merging. If you need an exemption to this rule, contact Hixie on the #hackers channel in Chat (don't just cc him here, he won't see it! He's on Discord!).

If you are not sure if you need tests, consider this rule of thumb: the purpose of a test is to make sure someone doesn't accidentally revert the fix. Ask yourself, is there anything in your PR that you feel it is important we not accidentally revert back to how it was before your fix?

Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing.

@dnfield
Copy link
Contributor Author

dnfield commented May 10, 2022

Added some tests.

if (!alwaysNeedsAddToScene) {
markNeedsAddToScene();
}
if (child._childrenWithCompositionCallbacks != 0) {
_updateSubtreeCompositionObserverCount(child._childrenWithCompositionCallbacks);
Copy link
Member

Choose a reason for hiding this comment

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

isn't this missing a -? The count needs to be reduced by child._childrenWithCompositionCallbacks right?

If there is no failing test for this case, let's add 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.

You are right. I thought I had a test covering this but did not. Added one.

_debugMutationsLocked = true;
return true;
}());
callback(this is ContainerLayer ? this as ContainerLayer : parent!);
Copy link
Member

Choose a reason for hiding this comment

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

Why is this not just callback(this)?

From the docs, that's what I would have expected here and the type checking is kinda odd here. Also, what's the guarantee that a non-Container Layer will always have a non-null parent?

Copy link
Member

Choose a reason for hiding this comment

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

The callback should probably just always receive a reference to "this" - if the callback wants, it can then check whether it was given a non-ContainerLayer and access the parent.

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 callback was written to receive a ContainerLayer, which has all the interesting methods for traversal on it.

In the layer tree, leaf Layer objects always have a parent at compositing time - in fact, this method is called from buildScene, which only exists on ContainerLayer. If there was no container layer parent, then this method would never get called.

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 the callback was typed to receive a Layer, for example, and a caller wanted to start reviewing the clip bounds applied, it woud have to do something like ContainerLayer container = layer is ContainerLayer ? layer : layer.parent!.

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'll ggo ahead and change this now - I suppose if the implementation ever shifted or some new more interesting method for this is added on Layer it'd be better to not have to refactor this API later.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also moved describeClipBounds to layer.

@@ -910,12 +1006,28 @@ class PerformanceOverlayLayer extends Layer {
}
}

/// The signature of the callback added in [Layer.addCompositionCallback].
typedef CompositionCallback = void Function(ContainerLayer);
Copy link
Member

Choose a reason for hiding this comment

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

Related to the above, why is the argument not typed as ContainerLayer and not just Layer?

/// Adds a [CompositionCallback] for the current [ContainerLayer] used by this
/// context.
///
/// Composition callbacks are called whenever the layer tree containing the
Copy link
Member

Choose a reason for hiding this comment

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

nit: some of these docs seem duplicated from Layer.addCompositionCallback. Consider using a macro.

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

};
return () {
_callbacks.remove(callbackId);
if (_callbacks.isEmpty) {
Copy link
Member

Choose a reason for hiding this comment

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

This seems incorrect? We are increasing the count unconditionally in line 203 whenever a new callback is added, but we are only decreasing the count by one when the last callback is removed. So if I add 2 and remove both of them the _childrenWithCompositionCallbacks would remain at 1 + 1 - 1 = 1?

Copy link
Member

Choose a reason for hiding this comment

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

Not sure what the desired behavior for the count is. _updateSubtreeCompositionObserverCount makes it sound like we want to count observers, so increasing for each callback sounds right. But _childrenWithCompositionCallbacks makes it sound like we are counting the children that have observers meaning we should only increase the count for the first observer added to a child. We should probably unify the naming...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ahh yes, I mixed up two different approaches I was trying. I'll write some more tests for this and fix.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added a test, unconditionally call -1 here, and renamed var.

Copy link
Member

@goderbauer goderbauer left a comment

Choose a reason for hiding this comment

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

LGTM

}());
};
return () {
_callbacks.remove(callbackId);
Copy link
Member

Choose a reason for hiding this comment

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

Maybe assert that the callback is actually still in _callbacks before attempting to remove it to avoid that somebody accidentally calls the remover twice?

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.

Calling it twice is wasted work but it shouldn't cause any program incorrectness at least. But assert sgtm.

@fluttergithubbot fluttergithubbot merged commit d54cdf9 into flutter:master May 17, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request May 17, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/plugins that referenced this pull request May 18, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/plugins that referenced this pull request May 18, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Aug 30, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/plugins that referenced this pull request Aug 30, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
framework flutter/packages/flutter repository. See also f: labels.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants