說說Flutter中的RepaintBoundary

起因

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

一個聊天對話頁面肾砂,由于對話框形狀需要自定義列赎,因此采用了CustomPainter來自定義繪制對話框。測試過程中發(fā)現(xiàn)在ipad mini上不停地上下滾動對話框列表竟然出現(xiàn)了crash,進一步測試發(fā)現(xiàn)聊天過程中也會頻繁出現(xiàn)crash包吝。

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

尋找問題

flutter_deer中有這么一個頁面:

效果圖

頁面最外層是個SingleChildScrollView诗越,上方的環(huán)形圖是一個自定義CustomPainter砖瞧,下方是個ListView列表。

實現(xiàn)這個環(huán)形圖并不復雜嚷狞。繼承CustomPainter块促,重寫paintshouldRepaint方法即可。paint方法負責繪制具體的圖形床未,shouldRepaint方法負責告訴Flutter刷新布局時是否重繪竭翠。一般的策略是在shouldRepaint方法中,我們通過對比前后數(shù)據(jù)是否相同來判定是否需要重繪薇搁。

當我滑動頁面時斋扰,發(fā)現(xiàn)自定義環(huán)形圖中的paint方法不斷在執(zhí)行。啃洋?传货??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绝编,也有可能調(diào)用paint方法(例如:如果組件的大小改變了)僻澎。

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

顯然我碰到的問題就是第一點祖乳。翻看SingleChildScrollView源碼我們發(fā)現(xiàn)了問題:


  @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逗堵,也就是最終執(zhí)行到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蜒秤,那么就會執(zhí)行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蚤假。詳細的源碼點擊這里

性能對比

其實之前沒有到發(fā)現(xiàn)這個問題吧兔,因為整個頁面滑動流暢磷仰。

為了對比清楚的對比前后的性能,我在這一頁面上重復添加十個這樣的環(huán)形圖來滑動測試境蔼。下圖是timeline的結(jié)果:

優(yōu)化前
優(yōu)化后

優(yōu)化前的滑動會有明顯的不流暢感灶平,實際每幀繪制需要近16ms,優(yōu)化后只有1ms欧穴。在這個場景例子中民逼,并沒有達到大量的繪制,GPU完全沒有壓力涮帘。如果只是之前的一個環(huán)形圖拼苍,這步優(yōu)化其實可有可無,只是做到了更優(yōu)调缨,避免不必要的繪制疮鲫。

在查找相關資料時,我在stackoverflow上發(fā)現(xiàn)了一個有趣的例子弦叶。

作者在屏幕上繪制了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;
}

同時屏幕上有個小黑點會跟隨著手指滑動。但是每次的滑動都會導致背景圖的重繪伤哺。優(yōu)化的方法和上面的一樣燕侠,我測試了一下這個Demo,得到了下面的結(jié)果立莉。

在這里插入圖片描述

這個場景例子中绢彤,繪制5000個圓給GPU帶來了不小的壓力,隨著RepaintBoundary的使用蜓耻,優(yōu)化的效果很明顯茫舶。

一探究竟

那么RepaintBoundary到底是什么?RepaintBoundary就是重繪邊界刹淌,用于重繪時獨立于父布局的饶氏。

在Flutter SDK中有部分Widget做了這個處理,比如TextField有勾、SingleChildScrollView疹启、AndroidViewUiKitView等蔼卡。最常用的ListView在item上默認也使用了RepaintBoundary

在這里插入圖片描述

大家可以思考一下為什么這些組件使用了RepaintBoundary喊崖。

接著上面的源碼中child.isRepaintBoundary為true的地方,我們看到會調(diào)用_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);
    /// 創(chuàng)建完成贷祈,進行繪制
    child._paintWithContext(childContext, Offset.zero);
    childContext.stopRecordingIfNeeded();
  }

child._needsPaint為true時會最終通過_repaintCompositedChild方法在當前child創(chuàng)建一個圖層(layer)趋急。

這里說到的圖層還是很抽象的,如何直觀的觀察到它呢势誊?我們可以在程序的main方法中將debugRepaintRainbowEnabled變量置為true呜达。它可以幫助我們可視化應用程序中渲染樹的重繪。原理其實就是在執(zhí)行上面的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;
    }());
  }

效果如下:

在這里插入圖片描述

不同的顏色代表不同的圖層霜威。當發(fā)生重繪時,對應的矩形框也會發(fā)生顏色變化册烈。

在重繪前戈泼,需要markNeedsPaint方法標記重繪的節(jié)點。


  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赏僧,就會調(diào)用父節(jié)點的markNeedsPaint方法大猛,直到isRepaintBoundary為 true時,才將當前RenderObject添加至_nodesNeedingPaint中淀零。

在繪制每幀時挽绩,調(diào)用flushPaint方法更新視圖。


  void flushPaint() {

    try {
      final List<RenderObject> dirtyNodes = _nodesNeedingPaint; <-- 獲取需要繪制的臟節(jié)點
      _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); <--- 這里重繪驾中,深度優(yōu)先
          } else {
            node._skippedPaintingOnLayer();
          }
        }
      }
      
    } finally {
     
      if (!kReleaseMode) {
        Timeline.finishSync();
      }
    }
  }


這樣就實現(xiàn)了局部的重繪唉堪,將子節(jié)點與父節(jié)點的重繪分隔開。

tips:這里需要注意一點肩民,通常我們點擊按鈕的水波紋效果會導致距離它上級最近的圖層發(fā)生重繪唠亚。我們需要根據(jù)頁面的具體情況去做處理。這一點在官方的項目flutter_gallery中就有做類似處理此改。

總結(jié)

其實總結(jié)起來就是一句話趾撵,根據(jù)場景合理使用RepaintBoundary侄柔,它可以幫你帶來性能的提升共啃。 其實優(yōu)化方向不止RepaintBoundary,還有RelayoutBoundary暂题。那這里就不介紹了薪者,感興趣的可以查看文末的鏈接攻人。

如果本篇對你有所啟發(fā)和幫助瞬浓,多多點贊支持猿棉!最后也希望大家支持我的Flutter開源項目flutter_deer,我會將我關于Flutter的實踐都放在其中杖爽。


本篇應該是今年的最后一篇博客了掂林,因為沒有專門寫年度總結(jié)的習慣,就順便在這來個年度總結(jié)锣杂。總的來說踱蠢,今年定的目標不僅完成了,甚至還有點超額完成企锌。明年的目標也已經(jīng)明確了陡鹃,那么就努力去完成吧F季ā(這總結(jié)就是留給自己看的,不必在意蹬叭。。坦喘。)

參考

?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末贷揽,一起剝皮案震驚了整個濱河市蓖救,隨后出現(xiàn)的幾起案子循捺,更是在濱河造成了極大的恐慌,老刑警劉巖恰力,帶你破解...
    沈念sama閱讀 206,968評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件正罢,死亡現(xiàn)場離奇詭異履怯,居然都是意外死亡柠硕,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評論 2 382
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來胁编,“玉大人,你說我怎么就攤上這事市框⊥梦郑” “怎么了额衙?”我有些...
    開封第一講書人閱讀 153,220評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我斧账,道長嗓袱,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,416評論 1 279
  • 正文 為了忘掉前任败去,我火速辦了婚禮三椿,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己狈涮,他們只是感情好,可當我...
    茶點故事閱讀 64,425評論 5 374
  • 文/花漫 我一把揭開白布溅话。 她就那樣靜靜地躺著,像睡著了一般屑墨。 火紅的嫁衣襯著肌膚如雪关炼。 梳的紋絲不亂的頭發(fā)上匣吊,一...
    開封第一講書人閱讀 49,144評論 1 285
  • 那天,我揣著相機與錄音寸潦,去河邊找鬼色鸳。 笑死,一個胖子當著我的面吹牛见转,可吹牛的內(nèi)容都是我干的命雀。 我是一名探鬼主播,決...
    沈念sama閱讀 38,432評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼牡直!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起厌蔽,我...
    開封第一講書人閱讀 37,088評論 0 261
  • 序言:老撾萬榮一對情侶失蹤担孔,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后盛龄,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,586評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡宪赶,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,028評論 2 325
  • 正文 我和宋清朗相戀三年赚哗,在試婚紗的時候發(fā)現(xiàn)自己被綠了竖哩。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,137評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出质帅,到底是詐尸還是另有隱情票彪,我是刑警寧澤登渣,帶...
    沈念sama閱讀 33,783評論 4 324
  • 正文 年R本政府宣布,位于F島的核電站四康,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜鼻听,卻給世界環(huán)境...
    茶點故事閱讀 39,343評論 3 307
  • 文/蒙蒙 一撑碴、第九天 我趴在偏房一處隱蔽的房頂上張望醉拓。 院中可真熱鬧鼻忠,春花似錦泪电、人聲如沸钻哩。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,333評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽伦乔。三九已至评矩,卻和暖如春阱飘,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背忘渔。 一陣腳步聲響...
    開封第一講書人閱讀 31,559評論 1 262
  • 我被黑心中介騙來泰國打工畦粮, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留宣赔,地道東北人儒将。 一個月前我還...
    沈念sama閱讀 45,595評論 2 355
  • 正文 我出身青樓钩蚊,卻偏偏與公主長得像砰逻,于是被迫代替她去往敵國和親蝠咆。 傳聞我的和親對象是個殘疾皇子勺美,可洞房花燭夜當晚...
    茶點故事閱讀 42,901評論 2 345

推薦閱讀更多精彩內(nèi)容