說說Flutter中的RepaintBoundary

原由

一個懶洋洋的下午,偶然間看到了這篇Flutter 踩坑記錄,做者的問題引發了個人好奇。做者的問題描述以下:html

一個聊天對話頁面,因爲對話框形狀須要自定義,所以採用了CustomPainter來自定義繪製對話框。測試過程當中發如今ipad mini上不停地上下滾動對話框列表居然出現了crash,進一步測試發現聊天過程當中也會頻繁出現crash。java

在對做者的遭遇表示同情時,也讓我聯想到了本身使用CustomPainter的地方。node

尋找問題

flutter_deer中有這麼一個頁面:git

效果圖

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

實現這個環形圖並不複雜。繼承CustomPainter,重寫paintshouldRepaint方法便可。paint方法負責繪製具體的圖形,shouldRepaint方法負責告訴Flutter刷新佈局時是否重繪。通常的策略是在shouldRepaint方法中,咱們經過對比先後數據是否相同來斷定是否須要重繪。canvas

當我滑動頁面時,發現自定義環形圖中的paint方法不斷在執行。???shouldRepaint方法失效了?其實註釋文檔寫的很清楚了,只怪本身沒有仔細閱讀。(本篇源碼基於Flutter SDK版本 v1.12.13+hotfix.3)api

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

複製代碼

註釋中提到兩點:markdown

  1. 即便shouldRepaint返回false,也有可能調用paint方法(例如:若是組件的大小改變了)。app

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

顯然我碰到的問題就是第一點。翻看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的實踐都放在其中。


本篇應該是今年的最後一篇博客了,由於沒有專門寫年度總結的習慣,就順便在這來個年度總結。總的來講,今年定的目標不只完成了,甚至還有點超額完成。明年的目標也已經明確了,那麼就努力去完成吧!(這總結就是留給本身看的,沒必要在乎。。。)

參考

相關文章
相關標籤/搜索