Skip to content

Allow RenderObjects with explicitly composited children to behave as repaint boundaries #101941

@jonahwilliams

Description

@jonahwilliams

Background

What is a repaint boundary? (conceptually, not the widget)

In Flutter it is common for multiple RenderObjects to all paint into the same Picture. When one of those RenderObjects is marked as needing to be repainted, the framework must ensure that all RenderObjects which painted into that picture repaint, and in the same order as before.

To ensure this happens, when markNeedsPaint is called, the framework walks the render object's parents, marking each dirty until it hits a repaint boundary. From that repaint boundary, the framework begins calling paint (we'll come back to this) in depth first order - only stopping when it his a repaint boundary with children that are not marked dirty, or the leaf. So the repaint boundary literally establishes the boundaries to where the framework must start/stop painting for the sake of correctness.

In order to ensure the boundaries are correct, the repaint boundary itself must be a render object that has an explicit Layer - today that layer must be an OffsetLayer. This enforces any parents or siblings would be drawn into a different picture. The various invariants of this are enforced by the RenderObject, PaintingContext and PipelineOwner classes.

Why are repaint boundaries good? (still conceptually, not the widget)

Well less work per frame on the ui side. On the engine side, avoid recreating pictures has numerous raster advantages. The framework does not currently deeply compare Picture objects, so repainting a picture with the exact same commands will produce a distinct picture. This defeats attempts at caching on the engine side, via the raster cache, or in the future potentially forcing engine tasks like tesselation to re-run. For example, see #101597 for the issue with animated opacity. TLDR: animating an opacity forces child to repaint too which defeats OpacityLayers built in raster caching.

The Repaint Boundary Widget is a trade-off

Consider an app with a loading spinner. Wrapping in an explicit repaint boundary can ensure that the animation does not force all parents/child tor repaint. For the bad cases read #101810 . TLDR: splitting up pictures can massively increase the numbers of layers in the tree, which is more expensive to traverse and composite. It can be hard for inexperienced users to identify a good place to locate a repaint boundary. Furthermore, as an application grows and develops the location of repaint boundaries may need to change. This implies not only a knowledge burden but an ongoing maintaince cost.

Overview

We can get more of the benefits of repaint boundaries and side-step some of the downsides by recognizing that many RenderObjects have the same characteristics as the much more limited RenderRepaintBoundary but cannot be treated as repaint boundaries due to implementation details of the PaintingContext/PipelineOwner. Consider the RenderOpacity widget, it has the following paint method:

void paint(PaintingContext context, Offset offset) {
if (child != null) {
if (_alpha == 0) {
// No need to keep the layer. We'll create a new one if necessary.
layer = null;
return;
}
assert(needsCompositing);
layer = context.pushOpacity(offset, _alpha, super.paint, oldLayer: layer as OpacityLayer?);
}
}

By pushing a new layer, this RenderObject could (in theory) repaint its child without affect any parent or sibling layers, and could also (in theory) update its opacity without repainting its children.

There are two general cases of repainting repaint boundaries

  1. We call markNeedsPaint and walk the tree until we find one. This ends up going through repaintCompositedChild. this conditionally creates an OffsetLayer for the render object, though it does not provide an offset since this offset could not have changed if the parent did not change.

/// Repaint the given render object.
///
/// The render object must be attached to a [PipelineOwner], must have a
/// composited layer, and must be in need of painting. The render object's
/// layer, if any, is re-used, along with any layers in the subtree that don't
/// need to be repainted.
///
/// See also:
///
/// * [RenderObject.isRepaintBoundary], which determines if a [RenderObject]
/// has a composited layer.
static void repaintCompositedChild(RenderObject child, { bool debugAlsoPaintedParent = false }) {
assert(child._needsPaint);
_repaintCompositedChild(
child,
debugAlsoPaintedParent: debugAlsoPaintedParent,
);
}

  1. While painting the tree, we hit a render object that is itself a repaint boundary (though not the one we started painting from). This updates the offset and conditionally stops painting based on whether or not that RO is dirty.

void _compositeChild(RenderObject child, Offset offset) {
assert(!_isRecording);
assert(child.isRepaintBoundary);
assert(_canvas == null || _canvas!.getSaveCount() == 1);
// Create a layer for our child, and paint the child into it.
if (child._needsPaint) {
repaintCompositedChild(child, debugAlsoPaintedParent: true);
} else {
assert(() {
// register the call for RepaintBoundary metrics
child.debugRegisterRepaintBoundaryPaint();
child._layerHandle.layer!.debugCreator = child.debugCreator ?? child;
return true;
}());
}
assert(child._layerHandle.layer is OffsetLayer);
final OffsetLayer childOffsetLayer = child._layerHandle.layer! as OffsetLayer;
childOffsetLayer.offset = offset;
appendLayer(childOffsetLayer);
}

Now its relatively easy to imagine changing these implementations so that the delegate to some RenderObject method which could provide a different Layer type. However, the part I have yet to fully realize is how to support a RenderObject that is conditionall a repaint boundary. For example, in the Opacity case we do not want to be a repaint boundary if we have no children or if our opacity is zero. In fact, we don't want a layer at all. Similarly, a RenderClipRect does not want to be a repaint boundary if it has the setting Clip.none which could change after the RO is created.

Detailed Design

In order to implement this functionality, we need a few more features on RenderObject.

First, in order to allow render objects to become and stop being repaint boundaries, we need to track their previous "isRepaintBoundary" state via _wasRepaintBoundary. For example, if a render object becomes a repaint boundary and then is marked for painting, if it wasn't a repaint boundary on the previous frame it may not have a layer yet. Therefore we need to treat it as if it isn't yet a repaint boundary and paint from the parent.

Next, we delegate the creation of the OffsetLayer used for the repaint boundary layer to the RenderObject itself via RenderObject.updateCompositedLayer. This allows for both creation and updating of the layer object, though currently it asserts when unnecessarily replacing the layer instance. In the future, this could be relaxed to support repaint boundaries that may need to change their layer type, for example a TransformLayer could be a repaint boundary that conditionally pushes either a TransformLayer or an ImageFilterLayer.

  /// Update the composited layer owned by this render object.
  ///
  /// This method is called by the framework for render object repaint
  /// boundaries to update the properties on their composited layer, potentially
  /// without repainting their children.
  ///
  /// If [oldLayer] is `null`, this method should return a new [OffsetLayer]
  /// (or subtype thereof). If [oldLayer] is not `null`, then this method should
  /// reuse the layer instance that is provided - it is an error to create a new
  /// layer in this instance.
  ///
  /// The [OffsetLayer.offset] property will be managed by the framework and
  /// does not need to be updated.
  OffsetLayer updateCompositedLayer(covariant OffsetLayer? oldLayer) {
    assert(isRepaintBoundary);
    return oldLayer ?? OffsetLayer();
  }

As an example, here is what the implementation of the RenderOpacity's method is:

  @override
  OffsetLayer updateCompositedLayer(covariant OpacityLayer? oldLayer) {
    final OpacityLayer updatedLayer = oldLayer ?? OpacityLayer();
    updatedLayer.alpha = _alpha;
    return updatedLayer;
  }

Notice in all of these we do not delegate management of the offset to render object. This is by design since we may need to update offset without modifying other properties of the layer or giving a chance for the render object to change properties. This also means that all render objects that want to be repaint boundaries must create a layer type that implements OffsetLayer - so that they can consume a changing parent offset. I'm tracking that in #101990

With the changes above (and a bit more plumbing) we have roughly enough to make the whole thing work, however there is a new opportunity for a performance improvement: we could update the properties of the layer without repainting either the parents or the children. This was not implemented before, since OffsetLayer has no interesting properties. OpacityLayer, on the other hand, could update its opacity without repainting its children.

To support this, I added a new state to render objects _needsLayerPropertyUpdate and a new method to mark this state dirty markNeedsLayerPropertyUpdate. When a render object with this state is processed, its children are only painted if it was also marked as needing to be painted. Otherwise, we directly update the layer properties by calling updateCompositedLayer

Other Questions

  • What about the paint method on render object? Painting in a render object that is a repaint boundary is roughly equivalent to calling context.pushLayer and performing some painting in the provided callback. Its not technically incorrect, and is up to the layer itself to mark as needing to be painted.

Metadata

Metadata

Assignees

Labels

P2Important issues not at the top of the work listc: performanceRelates to speed or footprint issues (see "perf:" labels)frameworkflutter/packages/flutter repository. See also f: labels.

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions