The cause of

One lazy afternoon, I came across this article about the trampling of Flutter. The author’s question aroused my curiosity. The author’s problem is described as follows:

A chat dialog page. Because the dialog shape needs to be customized, CustomPainter is used to customize the drawing dialog box. During the test, it was found that the scrolling up and down dialog box list on iPad Mini unexpectedly appeared crash. Further test found that the chat also frequently appeared crash.

While expressing sympathy for the author’s suffering, I was reminded of my own use of CustomPainter.

Looking for problems

In Flutter_deer there is this page:

The outer layer of the page is a SingleChildScrollView, with a custom CustomPainter at the top and a ListView at the bottom.

Implementing this ring diagram is not complicated. Inherit from CustomPainter and rewrite paint and shouldRepaint. The paint method is responsible for drawing concrete shapes, and the shouldRepaint method is responsible for telling a Flutter whether to redraw when it refreshes the layout. The general strategy is that in the shouldRepaint method, we decide if we need to redraw by comparing the data before and after to see if it is the same.

As I scroll through the page, I notice that the paint method in the custom ring diagram keeps executing. ????? ShouldRepaint doesn’t work, right? In fact, the notes of the document is very clear, I only blame myself for not reading carefully. (This source is based on Flutter SDK 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);

Copy the code

There are two points in the notes:

  1. It is possible to call the paint method even if shouldRepaint returns false (for example, if the size of the component changes).

  2. If your custom View is complex, avoid redrawing as much as possible. Use RepaintBoundary or RenderObject. IsRepaintBoundary there may be some help to you is true.

Obviously the problem I had was the first one. SingleChildScrollView source: SingleChildScrollView


  @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); }}}Copy the code

In the sliding of the SingleChildScrollView it is necessary to draw its child, which is ultimately executed into 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); }}Copy the code

In the paintChild method, as long as child.isrepaintBoundary is false, the paint method is executed, so should paint is skipped.

To solve the problem

IsRepaintBoundary is mentioned in the comments above, which means that when isRepaintBoundary is true, we can directly compose the view and avoid redrawing. The Flutter provides us with the RepaintBoundary, which encapsulates this operation and is easy for us to use.


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

}

Copy the code

The solution to the problem is simple: apply a RepaintBoundary over the CustomPaint layer. Click here for the full source code.

The performance comparison

I didn’t notice this problem before, because the whole page slides smoothly.

To get a clear picture of the performance before and after, I repeatedly added ten of these circles to the page to slide the test. Here is the result of timeline:

Before optimization, there will be an obvious sense of unsmoothness in sliding. In fact, each frame needs nearly 16ms to draw, but only 1ms after optimization. In this scenario example, the amount of drawing is not achieved and the GPU is completely stress-free. If it is just a ring graph before, this optimization is actually unnecessary, but it is better to avoid unnecessary drawing.

While looking for information, I found an interesting example on StackOverflow.

The author drew 5,000 colored circles on the screen to create a “kaleidoscope” effect of the background.


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.newPaint() .. color = colors[rand.nextInt(colors.length)].withOpacity(0.2)); }}@override
  bool shouldRepaint(ExpensivePainter other) = >false;
}

Copy the code

At the same time, a little black dot on the screen will follow your finger. But each slide causes the background image to be redrawn. The optimization method is the same as above. I tested the Demo and got the following results.

RepaintBoundary

To find out

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

There are some widgets in the Flutter SDK that do this, such as TextField, SingleChildScrollView, AndroidView, UiKitView, etc. The most common ListView also uses RepaintBoundary by default on the item:

RepaintBoundary

Then in the source code above where child.isrepaintBoundary is true, 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 and draw
    child._paintWithContext(childContext, Offset.zero);
    childContext.stopRecordingIfNeeded();
  }

Copy the code

Child._needspaint is true when a layer is finally created on the current child using the _repaintCompositedChild method.

This layer is very abstract. How do you visualize it? We can be the main method of the program will be debugRepaintRainbowEnabled variable is set to true. It helps us visualize the redraw of render trees in our applications. When the stopRecordingIfNeeded method is executed, an additional colored rectangle is drawn:

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

The effect is as follows:

Before redrawing, the markNeedsPaint method is required to mark the redrawn node.


  void markNeedsPaint(a) {
    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 the drawing}}else if (parent is RenderObject) {
      final RenderObject parent = this.parent;
      parent.markNeedsPaint();
      assert(parent == this.parent);
    } else {
      if(owner ! =null) owner.requestVisualUpdate(); }}Copy the code

If isRepaintaint is false, the parent’s markNeedsPaint method will be called until isRepaintaint is true, Before the current RenderObject is added to _nodesNeedingPaint.

The flushPaint method is called to update the view as each frame is drawn.


  void flushPaint(a) {

    try {
      finalList<RenderObject> dirtyNodes = _nodesNeedingPaint; < 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(); }}}Copy the code

This enables local redrawing, separating the redrawing of the child node from that of the parent node.

Tips: One thing to note here is that usually the water ripple effect we click on a button will cause the layer closest to it to be redrawn. We need to deal with it according to the specific situation of the page. This is done in the official project Flutter_gallery.

conclusion

In fact, it can be summed up in one sentence, reasonable use of RepaintBoundary according to the scene, it can help you bring performance improvement. In fact, the optimization direction is not only RepaintBoundary, but also RelayoutBoundary. That is not introduced here, interested can see the link at the end of the article.

If this post inspires or helps you, give it a thumbs up! Finally, I hope you will support my open source project of Flutter, Flutter_DEER, in which I will put my practice of Flutter.


This should be the last blog post of the year, since I am not in the habit of writing an annual summary, I will write an annual summary here. On the whole, the targets set for this year have not only been fulfilled, but even exceeded. Next year’s goals are clear, so go for it! (This summary is for yourself, don’t worry about…)

reference

  • Layout and Paint for the Flutter view

  • Create a custom RenderBox guide for Flutter