Unverified Commit d54cdf9e authored by Dan Field's avatar Dan Field Committed by GitHub

Add a mechanism to observe layer tree composition. (#103378)

parent c62a7d41
......@@ -135,6 +135,91 @@ 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>{};
static int _nextCallbackId = 0;
/// Whether the subtree rooted at this layer has any composition callback
/// observers.
///
/// This only evaluates to true if the subtree rooted at this node has
/// observers. For example, it may evaluate to true on a parent node but false
/// on a child if the parent has observers but the child does not.
///
/// See also:
///
/// * [Layer.addCompositionCallback].
bool get subtreeHasCompositionCallbacks => _compositionCallbackCount > 0;
int _compositionCallbackCount = 0;
void _updateSubtreeCompositionObserverCount(int delta) {
assert(delta != 0);
_compositionCallbackCount += delta;
assert(_compositionCallbackCount >= 0);
if (parent != null) {
parent!._updateSubtreeCompositionObserverCount(delta);
}
}
void _fireCompositionCallbacks({required bool includeChildren}) {
for (final VoidCallback callback in List<VoidCallback>.of(_callbacks.values)) {
callback();
}
}
bool _debugMutationsLocked = false;
/// Describes the clip that would be applied to contents of this layer,
/// if any.
Rect? describeClipBounds() => null;
/// Adds a callback for when the layer tree that this layer is part of gets
/// composited, or when it is detached and will not be rendered again.
///
/// This callback will fire even if an ancestor layer is added with retained
/// rendering, meaning that it will fire even if this layer gets added to the
/// scene via some call to [ui.SceneBuilder.addRetained] on one of its
/// ancestor 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
/// tree to find information about the current transform or clip. The layer
/// may not be [attached] anymore in this state, but even if it is detached it
/// may still have an also detached parent it can visit.
///
/// If new callbacks are added or removed within the [callback], the new
/// callbacks will fire (or stop firing) on the _next_ compositing event.
///
/// {@template flutter.rendering.Layer.compositionCallbacks}
/// Composition callbacks are useful in place of pushing a layer that would
/// otherwise try to observe the layer tree without actually affecting
/// compositing. For example, a composition callback may be used to observe
/// the total transform and clip of the current container layer to determine
/// whether a render object drawn into it is visible or not.
///
/// Calling the returned callback will remove [callback] from the composition
/// callbacks.
/// {@endtemplate}
VoidCallback addCompositionCallback(CompositionCallback callback) {
_updateSubtreeCompositionObserverCount(1);
final int callbackId = _nextCallbackId += 1;
_callbacks[callbackId] = () {
assert(() {
_debugMutationsLocked = true;
return true;
}());
callback(this);
assert(() {
_debugMutationsLocked = false;
return true;
}());
};
return () {
assert(_callbacks.containsKey(callbackId));
_callbacks.remove(callbackId);
_updateSubtreeCompositionObserverCount(-1);
};
}
/// If asserts are enabled, returns whether [dispose] has
/// been called since the last time any retained resources were created.
///
......@@ -164,6 +249,7 @@ abstract class Layer extends AbstractNode with DiagnosticableTreeMixin {
/// Called by [LayerHandle].
void _unref() {
assert(!_debugMutationsLocked);
assert(_refCount > 0);
_refCount -= 1;
if (_refCount == 0) {
......@@ -205,6 +291,7 @@ abstract class Layer extends AbstractNode with DiagnosticableTreeMixin {
@protected
@visibleForTesting
void dispose() {
assert(!_debugMutationsLocked);
assert(
!_debugDisposed,
'Layers must only be disposed once. This is typically handled by '
......@@ -261,6 +348,7 @@ abstract class Layer extends AbstractNode with DiagnosticableTreeMixin {
@protected
@visibleForTesting
void markNeedsAddToScene() {
assert(!_debugMutationsLocked);
assert(
!alwaysNeedsAddToScene,
'$runtimeType with alwaysNeedsAddToScene set called markNeedsAddToScene.\n'
......@@ -282,6 +370,7 @@ abstract class Layer extends AbstractNode with DiagnosticableTreeMixin {
/// this method has no effect.
@visibleForTesting
void debugMarkClean() {
assert(!_debugMutationsLocked);
assert(() {
_needsAddToScene = false;
return true;
......@@ -331,6 +420,7 @@ abstract class Layer extends AbstractNode with DiagnosticableTreeMixin {
@protected
@visibleForTesting
set engineLayer(ui.EngineLayer? value) {
assert(!_debugMutationsLocked);
assert(!_debugDisposed);
_engineLayer?.dispose();
......@@ -375,6 +465,7 @@ abstract class Layer extends AbstractNode with DiagnosticableTreeMixin {
@protected
@visibleForTesting
void updateSubtreeNeedsAddToScene() {
assert(!_debugMutationsLocked);
_needsAddToScene = _needsAddToScene || alwaysNeedsAddToScene;
}
......@@ -387,18 +478,26 @@ abstract class Layer extends AbstractNode with DiagnosticableTreeMixin {
Layer? _previousSibling;
@override
void dropChild(AbstractNode child) {
void dropChild(Layer child) {
assert(!_debugMutationsLocked);
if (!alwaysNeedsAddToScene) {
markNeedsAddToScene();
}
if (child._compositionCallbackCount != 0) {
_updateSubtreeCompositionObserverCount(-child._compositionCallbackCount);
}
super.dropChild(child);
}
@override
void adoptChild(AbstractNode child) {
void adoptChild(Layer child) {
assert(!_debugMutationsLocked);
if (!alwaysNeedsAddToScene) {
markNeedsAddToScene();
}
if (child._compositionCallbackCount != 0) {
_updateSubtreeCompositionObserverCount(child._compositionCallbackCount);
}
super.adoptChild(child);
}
......@@ -407,6 +506,7 @@ abstract class Layer extends AbstractNode with DiagnosticableTreeMixin {
/// This has no effect if the layer's parent is already null.
@mustCallSuper
void remove() {
assert(!_debugMutationsLocked);
parent?._removeChild(this);
}
......@@ -529,6 +629,7 @@ abstract class Layer extends AbstractNode with DiagnosticableTreeMixin {
void addToScene(ui.SceneBuilder builder);
void _addToSceneWithRetainedRendering(ui.SceneBuilder builder) {
assert(!_debugMutationsLocked);
// There can't be a loop by adding a retained layer subtree whose
// _needsAddToScene is false.
//
......@@ -909,12 +1010,28 @@ class PerformanceOverlayLayer extends Layer {
}
}
/// The signature of the callback added in [Layer.addCompositionCallback].
typedef CompositionCallback = void Function(Layer);
/// A composited layer that has a list of children.
///
/// A [ContainerLayer] instance merely takes a list of children and inserts them
/// into the composited rendering in order. There are subclasses of
/// [ContainerLayer] which apply more elaborate effects in the process.
class ContainerLayer extends Layer {
@override
void _fireCompositionCallbacks({required bool includeChildren}) {
super._fireCompositionCallbacks(includeChildren: includeChildren);
if (!includeChildren) {
return;
}
Layer? child = firstChild;
while (child != null) {
child._fireCompositionCallbacks(includeChildren: includeChildren);
child = child.nextSibling;
}
}
/// The first composited layer in this layer's child list.
Layer? get firstChild => _firstChild;
Layer? _firstChild;
......@@ -935,6 +1052,9 @@ class ContainerLayer extends Layer {
ui.Scene buildScene(ui.SceneBuilder builder) {
updateSubtreeNeedsAddToScene();
addToScene(builder);
if (subtreeHasCompositionCallbacks) {
_fireCompositionCallbacks(includeChildren: true);
}
// Clearing the flag _after_ calling `addToScene`, not _before_. This is
// because `addToScene` calls children's `addToScene` methods, which may
// mark this layer as dirty.
......@@ -966,6 +1086,7 @@ class ContainerLayer extends Layer {
@override
void dispose() {
removeAllChildren();
_callbacks.clear();
super.dispose();
}
......@@ -994,6 +1115,7 @@ class ContainerLayer extends Layer {
@override
void attach(Object owner) {
assert(!_debugMutationsLocked);
super.attach(owner);
Layer? child = firstChild;
while (child != null) {
......@@ -1004,16 +1126,25 @@ class ContainerLayer extends Layer {
@override
void detach() {
assert(!_debugMutationsLocked);
super.detach();
Layer? child = firstChild;
while (child != null) {
child.detach();
child = child.nextSibling;
}
// Detach indicates that we may never be composited again. Clients
// interested in observing composition need to get an update here because
// they might otherwise never get another one even though the layer is no
// longer visible.
//
// Children fired them already in child.detach().
_fireCompositionCallbacks(includeChildren: false);
}
/// Adds the given layer to the end of this layer's child list.
void append(Layer child) {
assert(!_debugMutationsLocked);
assert(child != this);
assert(child != firstChild);
assert(child != lastChild);
......@@ -1072,6 +1203,7 @@ class ContainerLayer extends Layer {
/// Removes all of this layer's children from its child list.
void removeAllChildren() {
assert(!_debugMutationsLocked);
Layer? child = firstChild;
while (child != null) {
final Layer? next = child.nextSibling;
......@@ -1221,7 +1353,7 @@ class OffsetLayer extends ContainerLayer {
void applyTransform(Layer? child, Matrix4 transform) {
assert(child != null);
assert(transform != null);
transform.multiply(Matrix4.translationValues(offset.dx, offset.dy, 0.0));
transform.translate(offset.dx, offset.dy);
}
@override
......@@ -1321,6 +1453,9 @@ class ClipRectLayer extends ContainerLayer {
}
}
@override
Rect? describeClipBounds() => clipRect;
/// {@template flutter.rendering.ClipRectLayer.clipBehavior}
/// Controls how to clip.
///
......@@ -1408,6 +1543,9 @@ class ClipRRectLayer extends ContainerLayer {
}
}
@override
Rect? describeClipBounds() => clipRRect?.outerRect;
/// {@macro flutter.rendering.ClipRectLayer.clipBehavior}
///
/// Defaults to [Clip.antiAlias].
......@@ -1491,6 +1629,9 @@ class ClipPathLayer extends ContainerLayer {
}
}
@override
Rect? describeClipBounds() => clipPath?.getBounds();
/// {@macro flutter.rendering.ClipRectLayer.clipBehavior}
///
/// Defaults to [Clip.antiAlias].
......
......@@ -12,7 +12,6 @@ import 'package:flutter/painting.dart';
import 'package:flutter/semantics.dart';
import 'package:vector_math/vector_math_64.dart';
import 'binding.dart';
import 'debug.dart';
import 'layer.dart';
......@@ -326,6 +325,22 @@ class PaintingContext extends ClipContext {
_containerLayer.append(_currentLayer!);
}
/// Adds a [CompositionCallback] for the current [ContainerLayer] used by this
/// context.
///
/// Composition callbacks are called whenever the layer tree containing the
/// current layer of this painting context gets composited, or when it gets
/// detached and will not be rendered again. This happens regardless of
/// whether the layer is added via retained rendering or not.
///
/// {@macro flutter.rendering.Layer.compositionCallbacks}
///
/// See also:
/// * [Layer.addCompositionCallback].
VoidCallback addCompositionCallback(CompositionCallback callback) {
return _containerLayer.addCompositionCallback(callback);
}
/// Stop recording to a canvas if recording has started.
///
/// Do not call this function directly: functions in this class will call
......
......@@ -716,6 +716,255 @@ void main() {
layer.addToScene(builder);
expect(layer.engineLayer, null);
});
test('Layers describe clip bounds', () {
ContainerLayer layer = ContainerLayer();
expect(layer.describeClipBounds(), null);
const Rect bounds = Rect.fromLTRB(10, 10, 20, 20);
final RRect rbounds = RRect.fromRectXY(bounds, 2, 2);
layer = ClipRectLayer(clipRect: bounds);
expect(layer.describeClipBounds(), bounds);
layer = ClipRRectLayer(clipRRect: rbounds);
expect(layer.describeClipBounds(), rbounds.outerRect);
layer = ClipPathLayer(clipPath: Path()..addRect(bounds));
expect(layer.describeClipBounds(), bounds);
});
test('Subtree has composition callbacks', () {
final ContainerLayer root = ContainerLayer();
expect(root.subtreeHasCompositionCallbacks, false);
final List<VoidCallback> cancellationCallbacks = <VoidCallback>[];
cancellationCallbacks.add(root.addCompositionCallback((_) {}));
expect(root.subtreeHasCompositionCallbacks, true);
final ContainerLayer a1 = ContainerLayer();
final ContainerLayer a2 = ContainerLayer();
final ContainerLayer b1 = ContainerLayer();
root.append(a1);
root.append(a2);
a1.append(b1);
expect(root.subtreeHasCompositionCallbacks, true);
expect(a1.subtreeHasCompositionCallbacks, false);
expect(a2.subtreeHasCompositionCallbacks, false);
expect(b1.subtreeHasCompositionCallbacks, false);
cancellationCallbacks.add(b1.addCompositionCallback((_) {}));
expect(root.subtreeHasCompositionCallbacks, true);
expect(a1.subtreeHasCompositionCallbacks, true);
expect(a2.subtreeHasCompositionCallbacks, false);
expect(b1.subtreeHasCompositionCallbacks, true);
cancellationCallbacks.removeAt(0)();
expect(root.subtreeHasCompositionCallbacks, true);
expect(a1.subtreeHasCompositionCallbacks, true);
expect(a2.subtreeHasCompositionCallbacks, false);
expect(b1.subtreeHasCompositionCallbacks, true);
cancellationCallbacks.removeAt(0)();
expect(root.subtreeHasCompositionCallbacks, false);
expect(a1.subtreeHasCompositionCallbacks, false);
expect(a2.subtreeHasCompositionCallbacks, false);
expect(b1.subtreeHasCompositionCallbacks, false);
});
test('Subtree has composition callbacks - removeChild', () {
final ContainerLayer root = ContainerLayer();
expect(root.subtreeHasCompositionCallbacks, false);
final ContainerLayer a1 = ContainerLayer();
final ContainerLayer a2 = ContainerLayer();
final ContainerLayer b1 = ContainerLayer();
root.append(a1);
root.append(a2);
a1.append(b1);
expect(b1.subtreeHasCompositionCallbacks, false);
expect(a1.subtreeHasCompositionCallbacks, false);
expect(root.subtreeHasCompositionCallbacks, false);
expect(a2.subtreeHasCompositionCallbacks, false);
b1.addCompositionCallback((_) { });
expect(b1.subtreeHasCompositionCallbacks, true);
expect(a1.subtreeHasCompositionCallbacks, true);
expect(root.subtreeHasCompositionCallbacks, true);
expect(a2.subtreeHasCompositionCallbacks, false);
b1.remove();
expect(b1.subtreeHasCompositionCallbacks, true);
expect(a1.subtreeHasCompositionCallbacks, false);
expect(root.subtreeHasCompositionCallbacks, false);
expect(a2.subtreeHasCompositionCallbacks, false);
});
test('No callback if removed', () {
final ContainerLayer root = ContainerLayer();
final ContainerLayer a1 = ContainerLayer();
final ContainerLayer a2 = ContainerLayer();
final ContainerLayer b1 = ContainerLayer();
root.append(a1);
root.append(a2);
a1.append(b1);
// Add and immediately remove the callback.
b1.addCompositionCallback((Layer layer) {
fail('Should not have called back');
})();
root.buildScene(SceneBuilder()).dispose();
});
test('Observe layer tree composition - not retained', () {
final ContainerLayer root = ContainerLayer();
final ContainerLayer a1 = ContainerLayer();
final ContainerLayer a2 = ContainerLayer();
final ContainerLayer b1 = ContainerLayer();
root.append(a1);
root.append(a2);
a1.append(b1);
bool compositedB1 = false;
b1.addCompositionCallback((Layer layer) {
expect(layer, b1);
compositedB1 = true;
});
expect(compositedB1, false);
root.buildScene(SceneBuilder()).dispose();
expect(compositedB1, true);
});
test('Observe layer tree composition - retained', () {
final ContainerLayer root = ContainerLayer();
final ContainerLayer a1 = ContainerLayer();
final ContainerLayer a2 = ContainerLayer();
final ContainerLayer b1 = ContainerLayer();
root.append(a1);
root.append(a2);
a1.append(b1);
// Actually build the retained layer so that the engine sees it as real and
// reusable.
SceneBuilder builder = SceneBuilder();
b1.engineLayer = builder.pushOffset(0, 0);
builder.build().dispose();
builder = SceneBuilder();
// Force the layer to appear clean and have an engine layer for retained
// rendering.
expect(b1.engineLayer, isNotNull);
b1.debugMarkClean();
expect(b1.debugSubtreeNeedsAddToScene, false);
bool compositedB1 = false;
b1.addCompositionCallback((Layer layer) {
expect(layer, b1);
compositedB1 = true;
});
expect(compositedB1, false);
root.buildScene(builder).dispose();
expect(compositedB1, true);
});
test('Observe layer tree composition - asserts on mutation', () {
final ContainerLayer root = ContainerLayer();
final ContainerLayer a1 = ContainerLayer();
final ContainerLayer a2 = ContainerLayer();
final ContainerLayer b1 = ContainerLayer();
root.append(a1);
root.append(a2);
a1.append(b1);
bool compositedB1 = false;
b1.addCompositionCallback((Layer layer) {
expect(layer, b1);
expect(() => layer.remove(), throwsAssertionError);
expect(() => layer.dispose(), throwsAssertionError);
expect(() => layer.markNeedsAddToScene(), throwsAssertionError);
expect(() => layer.debugMarkClean(), throwsAssertionError);
expect(() => layer.updateSubtreeNeedsAddToScene(), throwsAssertionError);
expect(() => layer.dropChild(ContainerLayer()), throwsAssertionError);
expect(() => layer.adoptChild(ContainerLayer()), throwsAssertionError);
expect(() => (layer as ContainerLayer).append(ContainerLayer()), throwsAssertionError);
expect(() => layer.engineLayer = null, throwsAssertionError);
compositedB1 = true;
});
expect(compositedB1, false);
root.buildScene(SceneBuilder()).dispose();
expect(compositedB1, true);
});
test('Observe layer tree composition - detach triggers callback', () {
final ContainerLayer root = ContainerLayer();
final ContainerLayer a1 = ContainerLayer();
final ContainerLayer a2 = ContainerLayer();
final ContainerLayer b1 = ContainerLayer();
root.append(a1);
root.append(a2);
a1.append(b1);
bool compositedB1 = false;
b1.addCompositionCallback((Layer layer) {
expect(layer, b1);
compositedB1 = true;
});
root.attach(Object());
expect(compositedB1, false);
root.detach();
expect(compositedB1, true);
});
test('Observe layer tree composition - observer count correctly maintained', () {
final ContainerLayer root = ContainerLayer();
final ContainerLayer a1 = ContainerLayer();
root.append(a1);
expect(root.subtreeHasCompositionCallbacks, false);
expect(a1.subtreeHasCompositionCallbacks, false);
final VoidCallback remover1 = a1.addCompositionCallback((_) { });
final VoidCallback remover2 = a1.addCompositionCallback((_) { });
expect(root.subtreeHasCompositionCallbacks, true);
expect(a1.subtreeHasCompositionCallbacks, true);
remover1();
expect(root.subtreeHasCompositionCallbacks, true);
expect(a1.subtreeHasCompositionCallbacks, true);
remover2();
expect(root.subtreeHasCompositionCallbacks, false);
expect(a1.subtreeHasCompositionCallbacks, false);
});
}
class FakeEngineLayer extends Fake implements EngineLayer {
......
......@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
......@@ -246,8 +248,39 @@ void main() {
renderObject.dispose();
expect(renderObject.debugLayer, null);
});
test('Add composition callback works', () {
final ContainerLayer root = ContainerLayer();
final PaintingContext context = PaintingContext(root, Rect.zero);
bool calledBack = false;
final TestObservingRenderObject object = TestObservingRenderObject((Layer layer) {
expect(layer, root);
calledBack = true;
});
expect(calledBack, false);
object.paint(context, Offset.zero);
expect(calledBack, false);
root.buildScene(ui.SceneBuilder()).dispose();
expect(calledBack, true);
});
}
class TestObservingRenderObject extends RenderBox {
TestObservingRenderObject(this.callback);
final CompositionCallback callback;
@override
bool get sizedByParent => true;
@override
void paint(PaintingContext context, Offset offset) {
context.addCompositionCallback(callback);
}
}
// Tests the create-update cycle by pumping two frames. The first frame has no
// prior layer and forces the painting context to create a new one. The second
// frame reuses the layer painted on the first frame.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment