Talk about RepaintBoundary in Flutter

Keywords: Android SDK

cause

I came across this by chance on a lazy afternoon Flutter Trench Record The author's question aroused my curiosity.The author's question is described below:

A chat dialog page uses CustomPainter to customize the drawing dialog because the shape of the dialog needs to be customized.During the test process, we found that crash appeared in the list of dialogs scrolling up and down on the ipad mini, and further tests found that crash occurred frequently in the chat process.

In expressing sympathy for the author's experience, it also reminds me of where I used CustomPainter.

Find a problem

stay flutter_deer There is a page like this:

The outermost layer of the page is a SingleChildScrollView, the top ring is a custom CustomPainter, and the bottom is a ListView list.

It's not complicated to implement this ring chart.Inherit CustomPainter and override the paint and shouldRepaint methods.The paint method is responsible for drawing specific graphics, and the shouldRepaint method is responsible for telling Flutter whether to redraw when it refreshes the layout.The general strategy is in the shouldRepaint method, where we compare the data before and after to see if it needs to be redrawn.

When I slide the page, I find that the paint method in the custom ring graph is constantly executing.???ShouRepaint method failed?In fact, the comment document is clearly written, just because I didn't read it carefully.(This source code is based on Flutter SDK version v1.12.13+hotfix.3)


  /// If the method returns false, then the [paint] call might be optimized
  /// away.
  ///
  /// It's possible that the [paint] method will get called even if
  /// [shouldRepaint] returns false (e.g. if an ancestor or descendant needed to
  /// be repainted). It's also possible that the [paint] method will get called
  /// without [shouldRepaint] being called at all (e.g. if the box changes
  /// size).
  ///
  /// If a custom delegate has a particularly expensive paint function such that
  /// repaints should be avoided as much as possible, a [RepaintBoundary] or
  /// [RenderRepaintBoundary] (or other render object with
  /// [RenderObject.isRepaintBoundary] set to true) might be helpful.
  ///
  /// The `oldDelegate` argument will never be null.
  bool shouldRepaint(covariant CustomPainter oldDelegate);

Two points are mentioned in the note:

  1. Even if shouldRepaint returns false, it is possible to call the paint method (for example, if the size of the component changes).
  2. If your custom View is complex, you should avoid redrawing as much as possible.Using RepaintBoundary or RenderObject.isRepaintBoundary as true may help you.

Obviously the first point is the problem I'm having.Looking at the SingleChildScrollView source, we found a problem:


  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null) {
      final Offset paintOffset = _paintOffset;

      void paintContents(PaintingContext context, Offset offset) {
        context.paintChild(child, offset + paintOffset); <----
      }

      if (_shouldClipAtPaintOffset(paintOffset)) {
        context.pushClipRect(needsCompositing, offset, Offset.zero & size, paintContents);
      } else {
        paintContents(context, offset);
      }
    }
  }

In the sliding of the SingleChildScrollView, it is necessary to draw its child, that is, to finally execute the paintChild method.


  void paintChild(RenderObject child, Offset offset) {
    
    if (child.isRepaintBoundary) {
      stopRecordingIfNeeded();
      _compositeChild(child, offset);
    } else {
      child._paintWithContext(this, offset);
    }

  }

  void _paintWithContext(PaintingContext context, Offset offset) {
      ...
    _needsPaint = false;
    try {
      paint(context, offset); //<-----
    } catch (e, stack) {
      _debugReportException('paint', e, stack);
    }
   
  }

In the paintChild method, as long as the child.isRepaintBoundary is false, the paint method is executed, skipping shouldRepaint directly.

Solve the problem

IsRepaintBoundary is mentioned in the note above, that is, when isRepaintBoundary is true, we can synthesize views directly to avoid redrawing.Flutter provides us with RepaintBoundary It is an encapsulation of this operation for our convenience.


class RepaintBoundary extends SingleChildRenderObjectWidget {
  
  const RepaintBoundary({ Key key, Widget child }) : super(key: key, child: child);

  @override
  RenderRepaintBoundary createRenderObject(BuildContext context) => RenderRepaintBoundary();
}


class RenderRepaintBoundary extends RenderProxyBox {
  
  RenderRepaintBoundary({ RenderBox child }) : super(child);

  @override
  bool get isRepaintBoundary => true; /// <-----

}

The solution is simple: put a RepaintBoundary on the CustomPaint outer layer.Detailed source code click here.

Performance comparison

Actually, I didn't find this problem before, because the whole page slides smoothly.

In order to compare the performance before and after a clear comparison, I add ten such rings repeatedly on this page to slide the test.The following figure is the result of timeline:

Sliding before optimization will have a noticeable smoothness. It takes nearly 16 ms to draw each frame, but only 1 ms after optimization.In this scenario example, not much drawing has been achieved, and the GPU is completely stress-free.If it's just a previous ring graph, this optimization may or may not be possible, but it's better to avoid unnecessary drawing.

While looking for information, I found it on stackoverflow An interesting example.

The author draws 5,000 colored circles on the screen to form a background image similar to a kaleidoscope effect.


class ExpensivePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    print("Doing expensive paint job");
    Random rand = new Random(12345);
    List<Color> colors = [
      Colors.red,
      Colors.blue,
      Colors.yellow,
      Colors.green,
      Colors.white,
    ];
    for (int i = 0; i < 5000; i++) {
      canvas.drawCircle(
          new Offset(
              rand.nextDouble() * size.width, rand.nextDouble() * size.height),
          10 + rand.nextDouble() * 20,
          new Paint()
            ..color = colors[rand.nextInt(colors.length)].withOpacity(0.2));
    }
  }

  @override
  bool shouldRepaint(ExpensivePainter other) => false;
}

At the same time, a small black dot on the screen will follow your finger.However, each slide will result in a redraw of the background image.Optimized as above, I tested this Demo and got the following results.

In this scenario example, drawing 5,000 circles puts a lot of pressure on the GPU, and as RepaintBoundary is used, the optimization effect is obvious.

On inquiry

So what exactly is RepaintBoundary?RepaintBoundary is a redrawing of a boundary that is independent of the parent layout when redrawing.

Some widgets in the Flutter SDK do this, such as TextField, SingleChildScrollView, AndroidView, UiKitView, and so on.The most commonly used ListView also uses RepaintBoundary by default on item s:

You can think about why these components use RepaintBoundary.

Next, where child.isRepaintBoundary is true in the source code above, we see that the _compositeChild method is called;


  void _compositeChild(RenderObject child, Offset offset) {
    ...
    // Create a layer for our child, and paint the child into it.
    if (child._needsPaint) {
      repaintCompositedChild(child, debugAlsoPaintedParent: true); // <---- 1
    } 

    final OffsetLayer childOffsetLayer = child._layer;
    childOffsetLayer.offset = offset;
    appendLayer(child._layer);
  }

  static void repaintCompositedChild(RenderObject child, { bool debugAlsoPaintedParent = false }) {
    _repaintCompositedChild( // <---- 2
      child,
      debugAlsoPaintedParent: debugAlsoPaintedParent,
    );
  }

  static void _repaintCompositedChild(
    RenderObject child, {
    bool debugAlsoPaintedParent = false,
    PaintingContext childContext,
  }) {
    ...
    OffsetLayer childLayer = child._layer;
    if (childLayer == null) {
      child._layer = childLayer = OffsetLayer(); // <---- 3
    } else {
      childLayer.removeAllChildren();
    }
   
    childContext ??= PaintingContext(child._layer, child.paintBounds);
    ///Create complete, draw
    child._paintWithContext(childContext, Offset.zero);
    childContext.stopRecordingIfNeeded();
  }

When child._needsPaint is true, a layer is eventually created in the current child using the _repaintCompositedChild method.

The layer mentioned here is still abstract. How can I visually see it?We can set the debugRepaintRainbowEnabled variable to true in the main method of the program.It can help us visualize tree redrawing in our application.The principle is that when the stopRecordingIfNeeded method above is executed, an extra colored rectangle is drawn:

  @protected
  @mustCallSuper
  void stopRecordingIfNeeded() {
    if (!_isRecording)
      return;
    assert(() {
      if (debugRepaintRainbowEnabled) { // <-----
        final Paint paint = Paint()
          ..style = PaintingStyle.stroke
          ..strokeWidth = 6.0
          ..color = debugCurrentRepaintColor.toColor();
        canvas.drawRect(estimatedBounds.deflate(3.0), paint);
      }
      return true;
    }());
  }

The results are as follows:


Different colors represent different layers.When redrawing occurs, the corresponding rectangle box also changes color.

Before redrawing, you need the markNeedsPaint method to mark the redrawn nodes.


  void markNeedsPaint() {
    if (_needsPaint)
      return;
    _needsPaint = true;
    if (isRepaintBoundary) {
      // If we always have our own layer, then we can just repaint
      // ourselves without involving any other nodes.
      assert(_layer is OffsetLayer);
      if (owner != null) {
        owner._nodesNeedingPaint.add(this);
        owner.requestVisualUpdate(); // Update Drawing
      }
    } else if (parent is RenderObject) {
      final RenderObject parent = this.parent;
      parent.markNeedsPaint();
      assert(parent == this.parent);
    } else {
      if (owner != null)
        owner.requestVisualUpdate();
    }
  }

In the markNeedsPaint method, if isRepaintBoundary is false, the parent node's markNeedsPaint method is called until isRepaintBoundary is true, and the current RenderObject is added to _nodesNeedingPaint.

When each frame is drawn, the flushPaint method is called to update the view.


  void flushPaint() {

    try {
      final List<RenderObject> dirtyNodes = _nodesNeedingPaint; <-- Get dirty nodes to draw
      _nodesNeedingPaint = <RenderObject>[];
      // Sort the dirty nodes in reverse order (deepest first). 
      for (RenderObject node in dirtyNodes..sort((RenderObject a, RenderObject b) => b.depth - a.depth)) {
        assert(node._layer != null);
        if (node._needsPaint && node.owner == this) {
          if (node._layer.attached) {
            PaintingContext.repaintCompositedChild(node); <--- Redraw here, depth first
          } else {
            node._skippedPaintingOnLayer();
          }
        }
      }
      
    } finally {
     
      if (!kReleaseMode) {
        Timeline.finishSync();
      }
    }
  }

This enables partial redrawing, separating the redrawing of child nodes from the redrawing of parent nodes.

tips: One thing to note here is that the ripple effect that we usually click on a button causes the layer closest to its superior to be redrawn.We need to deal with it according to the specifics of the page.This is in the official project flutter_gallery Some do similar things.

summary

In summary, a reasonable use of RepaintBoundary for scenarios can help you improve performance.In fact, the direction of optimization is not only RepaintBoundary, but also RelayoutBoundary.That's not covered here. If you're interested, you can check the links at the end of the article.

If this article has inspired and helped you, give more support!Finally, I want you to support my Flutter open source project flutter_deer I'll put all my Flutter practices into it.

This should be the last blog post of the year, because there is no habit of writing an annual summary, so I'll summary this year by the way.Overall, the target set this year has not only been achieved, but has even been somewhat overfulfilled.Next year's goal is also clear, so work hard to complete it!(This summary is for your own viewing, don't care.)

Reference resources

Posted by rockinaway on Mon, 09 Dec 2019 02:09:13 -0800