說說Flutter中的RepaintBoundary

唯鹿發表於2019-12-09

起因

一個懶洋洋的下午,偶然間看到了這篇Flutter 踩坑記錄,作者的問題引起了我的好奇。作者的問題描述如下:

一個聊天對話頁面,由於對話方塊形狀需要自定義,因此採用了CustomPainter來自定義繪製對話方塊。測試過程中發現在ipad mini上不停地上下滾動對話方塊列表竟然出現了crash,進一步測試發現聊天過程中也會頻繁出現crash。

在對作者的遭遇表示同情時,也讓我聯想到了自己使用CustomPainter的地方。

尋找問題

flutter_deer中有這麼一個頁面:

效果圖

頁面最外層是個SingleChildScrollView,上方的環形圖是一個自定義CustomPainter,下方是個ListView列表。

實現這個環形圖並不複雜。繼承CustomPainter,重寫paintshouldRepaint方法即可。paint方法負責繪製具體的圖形,shouldRepaint方法負責告訴Flutter重新整理佈局時是否重繪。一般的策略是在shouldRepaint方法中,我們通過對比前後資料是否相同來判定是否需要重繪。

當我滑動頁面時,發現自定義環形圖中的paint方法不斷在執行。???shouldRepaint方法失效了?其實註釋文件寫的很清楚了,只怪自己沒有仔細閱讀。(本篇原始碼基於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);

複製程式碼

註釋中提到兩點:

  1. 即使shouldRepaint返回false,也有可能呼叫paint方法(例如:如果元件的大小改變了)。

  2. 如果你的自定義View比較複雜,應該儘可能的避免重繪。使用RepaintBoundary或者RenderObject.isRepaintBoundary為true可能會有對你有所幫助。

顯然我碰到的問題就是第一點。翻看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);
      }
    }
  }

複製程式碼

SingleChildScrollView的滑動中必然需要繪製它的child,也就是最終執行到paintChild方法。


  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);
    }
   
  }

複製程式碼

paintChild方法中,只要child.isRepaintBoundary為false,那麼就會執行paint方法,這裡就直接跳過了shouldRepaint

解決問題

isRepaintBoundary在上面的註釋中提到過,也就是說isRepaintBoundary為true時,我們可以直接合成檢視,避免重繪。Flutter為我們提供了RepaintBoundary,它是對這一操作的封裝,便於我們的使用。


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

}

複製程式碼

那麼解決問題的方法很簡單:在CustomPaint外層套一個RepaintBoundary。詳細的原始碼點選這裡

效能對比

其實之前沒有到發現這個問題,因為整個頁面滑動流暢。

為了對比清楚的對比前後的效能,我在這一頁面上重複新增十個這樣的環形圖來滑動測試。下圖是timeline的結果:

優化前

優化後

優化前的滑動會有明顯的不流暢感,實際每幀繪製需要近16ms,優化後只有1ms。在這個場景例子中,並沒有達到大量的繪製,GPU完全沒有壓力。如果只是之前的一個環形圖,這步優化其實可有可無,只是做到了更優,避免不必要的繪製。

在查詢相關資料時,我在stackoverflow上發現了一個有趣的例子

作者在螢幕上繪製了5000個彩色的圓來組成一個類似“萬花筒”效果的背景圖。


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

複製程式碼

同時螢幕上有個小黑點會跟隨著手指滑動。但是每次的滑動都會導致背景圖的重繪。優化的方法和上面的一樣,我測試了一下這個Demo,得到了下面的結果。

在這裡插入圖片描述
這個場景例子中,繪製5000個圓給GPU帶來了不小的壓力,隨著RepaintBoundary的使用,優化的效果很明顯。

一探究竟

那麼RepaintBoundary到底是什麼?RepaintBoundary就是重繪邊界,用於重繪時獨立於父佈局的。

在Flutter SDK中有部分Widget做了這個處理,比如TextFieldSingleChildScrollViewAndroidViewUiKitView等。最常用的ListView在item上預設也使用了RepaintBoundary

在這裡插入圖片描述
大家可以思考一下為什麼這些元件使用了RepaintBoundary

接著上面的原始碼中child.isRepaintBoundary為true的地方,我們看到會呼叫_compositeChild方法;


  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);
    /// 建立完成,進行繪製
    child._paintWithContext(childContext, Offset.zero);
    childContext.stopRecordingIfNeeded();
  }

複製程式碼

child._needsPaint為true時會最終通過_repaintCompositedChild方法在當前child建立一個圖層(layer)。

這裡說到的圖層還是很抽象的,如何直觀的觀察到它呢?我們可以在程式的main方法中將debugRepaintRainbowEnabled變數置為true。它可以幫助我們視覺化應用程式中渲染樹的重繪。原理其實就是在執行上面的stopRecordingIfNeeded方法時,額外繪製了一個彩色矩形:

  @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;
    }());
  }

複製程式碼

效果如下:

在這裡插入圖片描述
不同的顏色代表不同的圖層。當發生重繪時,對應的矩形框也會發生顏色變化。

在重繪前,需要markNeedsPaint方法標記重繪的節點。


  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(); // 更新繪製
      }
    } else if (parent is RenderObject) {
      final RenderObject parent = this.parent;
      parent.markNeedsPaint();
      assert(parent == this.parent);
    } else {
      if (owner != null)
        owner.requestVisualUpdate();
    }
  }

複製程式碼

markNeedsPaint方法中如果isRepaintBoundary為false,就會呼叫父節點的markNeedsPaint方法,直到isRepaintBoundary為 true時,才將當前RenderObject新增至_nodesNeedingPaint中。

在繪製每幀時,呼叫flushPaint方法更新檢視。


  void flushPaint() {

    try {
      final List<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); <--- 這裡重繪,深度優先
          } else {
            node._skippedPaintingOnLayer();
          }
        }
      }
      
    } finally {
     
      if (!kReleaseMode) {
        Timeline.finishSync();
      }
    }
  }


複製程式碼

這樣就實現了區域性的重繪,將子節點與父節點的重繪分隔開。

tips:這裡需要注意一點,通常我們點選按鈕的水波紋效果會導致距離它上級最近的圖層發生重繪。我們需要根據頁面的具體情況去做處理。這一點在官方的專案flutter_gallery中就有做類似處理。

總結

其實總結起來就是一句話,根據場景合理使用RepaintBoundary,它可以幫你帶來效能的提升。 其實優化方向不止RepaintBoundary,還有RelayoutBoundary。那這裡就不介紹了,感興趣的可以檢視文末的連結。

如果本篇對你有所啟發和幫助,多多點贊支援!最後也希望大家支援我的Flutter開源專案flutter_deer,我會將我關於Flutter的實踐都放在其中。


本篇應該是今年的最後一篇部落格了,因為沒有專門寫年度總結的習慣,就順便在這來個年度總結。總的來說,今年定的目標不僅完成了,甚至還有點超額完成。明年的目標也已經明確了,那麼就努力去完成吧!(這總結就是留給自己看的,不必在意。。。)

參考

相關文章