Flutter視圖的Layout與Paint

本文目的

  • 分析flutter的Layout與Paint
  • relayout boundary和repaint boundary是什么
  • 開發者如何使用relayout boundary和repaint boundary

目錄結構

  • Flutter的繪圖原理和UI的基本流程
  • Widget在flutter繪圖時的作用
  • 分析Layout
  • 分析Paint
  • 總結

Flutter的繪圖原理和UI的基本流程

  • Flutter的繪圖原理


    flutter-vsync.png

    從圖中可以看到,當GPU發出Vsunc信號時,會執行Dart代碼繪制新UI,Dart-code會被執行為Layer Tree,然后經過Compositor合成后交由Skia引擎渲染處理為GPU數據,最后通過GL/Vulkan發給GPU。
    而我們要分析的地方就在Dart->Layer Tree這里。

  • UI的基本流程


    render-pipeline.png

    比如用戶一個輸入操作,可以理解發出為Vsunc信號,這時,fliutter會先做Animation相關工作,然后Build當前UI,之后視圖開始布局和繪制。生成視圖數據,但是只會生成Layer Tree,并不能直接使用,還是需要Composite合成為一個Layer進行Rasterize光柵化處理。層級合并的原因是因為一般flutter的層級很多,直接把每一層傳給GPU傳遞,效率很低,所以會先做Composite,提高效率。
    光柵化之后才會給Flutter-Engine處理,這里只是Framework層面的工作,所以看不到Engine,而我們分析的也只是Framework中的一小部分。


    flutter-pipeline.png

    通過上面的講解,我們大概已經了解了flutter的繪圖的基本流程,但是我們并不清楚layout和paint做了什么,而Widget是如何變成Layou Tree的。但是這里內容太多,一句話說不清,所以我們還是先看下我們平時寫的大量Widget在flutter繪圖時的到底是啥用吧。

Widget在Flutter繪圖時的作用

在這之前,我們要先了解幾個概念

  • Widget
  • Element
  • RenderObject
Widget
  • 這里的Widget就是我們平時寫的Widget,它是 Flutter中控件實現的基本單位。一個Widget里面一般存儲了視圖的配置信息,包括布局、屬性等等。所以它只是一份直接使用的數據結構。在構建為結構樹,甚至重新創建和銷毀結構樹時都不存在明顯的性能問題。
Element
  • Element是Widget的抽象,它承載了視圖構建的上下文數據。flutter系統通過遍歷 Element樹來構建 RenderObject數據,所以Element是真正被使用的集合,Widget只是數據結構。比如視圖更新時,只會標記dirty Element,而不會標記dirty Widget。
RenderObject
  • 我們要分析的Layout、Paint均發生在RenderObject中,并且LayerTree也是由RenderObject生成,可見其重要程度。所以 Flutter中大部分的繪圖性能優化發生在這里。RenderObject樹構建的數據會被加入到 Engine所需的 LayerTree中。
Widget-Element-RenderObject .jpg

而以上這三個概念也對應著三種樹結構:模型樹、呈現樹、渲染樹。
在解釋他們的概念和關系以后,我們已經認識到RenderObject的重要性,因為以下Layout、Paint包括relayout boundary和repaint boundary都是在這里發生的。

一般一個Widget被更新,那么持有該 Widget的節點的Element會被標記為dirtyElement,在下一次更新界面時,Element樹的這一部分子樹便會被觸發performRebuild,在Element樹更新完成后,便能獲得RenderObject樹,接下來會進入Layout和Paint的流程。

Layout

  • Layout的目的是要計算出每個節點所占空間的真實大小。


    layout-data-flow.png

    在構建視圖樹的時候,節點的Constraints是自上而下的,但是計算layout是深度優先遍歷,這是因為節點通過Constraints并不一定能夠明確自己的size,有時它會依賴子節點的size,所以獲取size大小是自下而上。
    每個節點會接受到父對象的Constraints,子節點根據其來決定自己的大小,父對象會根據自己的邏輯決定子對象的位置來完成布局。
    所以flutter的layout實際上就是這么簡單的操作。那么簡單肯定就有一些問題,比如某個節點的size變了,整個視圖樹就得重新計算?
    肯定不是這樣的,否則flutter就不存在圖形的高性能了。flutter是通過Relayout boundary來處理這樣的問題的。

  • Relayout boundary
    它的目的是提高flutter的繪圖性能,它的作用是設置測量邊界,邊界內的Widget做任何改變都不會導致邊界外重新計算并繪制。
  • Relayout boundary.jpeg

    當然它是有條件的,當滿足以下三個條件的任意一個就會觸發Relayout boundary

    • constraints.isTight
    • parentUsesSize == false
    • sizedByParent == true
constraints.isTight

什么是isTight呢?用BoxConstraints為例


BoxConstraints.jpeg

它有四個屬性,分別是minWidth,maxWidth,minHeight,maxHeight

  • tight
    如果最小約束(minWidth,minHeight)和最大約束(maxWidth,maxHeight)分別都是一樣的
  • loose
    如果最小約束都是0.0(不管最大約束),如果最小約束和最大約束都是0.0,就同時是tightly和loose
  • bounded
    如果最大約束都不是infinite
  • unbounded
    如果最大約束都是infinite
  • expanding
    如果最小約束和最大約束都是infinite

所以isTight就是強約束,Widget的size已經被確定,里面的子Widget做任何變化,size都不會變。那么從該Widget開始里面的任意子Wisget做任意變化,都不會對外有影響,就會被添加Relayout boundary(說添加不科學,因為實際上這種情況,它會把size指向自己,這樣就不會再向上遞歸而引起父Widget的Layout了)

parentUsesSize == false

實際上parentUsesSize與sizedByParent看起來很像,但含義有很大區別
parentUsesSize表示父Widget是否要依賴子Widget的size,如果是false,子Widget要重新布局的時候并不需要通知parent,布局的邊界就是自身了。

sizedByParent == true

sizedByParent表示當前的Widget雖然不是isTight,但是通過其他約束屬性,也可以明確的知道size,比如Expanded,并不一定需要明確的size。

通過查看RenderObject-1579行,當然可以看到Layout的實現

void layout(Constraints constraints, { bool parentUsesSize = false }) {
    ...
    if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {
      relayoutBoundary = this;
    } else {
      final RenderObject parent = this.parent;
      relayoutBoundary = parent._relayoutBoundary;
    }
    ...
}

通過Layout可以看到,flutter為了提高效率所做的努力,那作為開發者可以直接使用relayout boundary嗎?
一般情況是不可以的,但是如果當你決定要自定義一個Row的時候,肯定是要使用它的。但是你可以間接的利用上面的三個條件來使你的Widget樹某些地方擁有relayout boundary。比如以下用法

Row(children: <Widget>[
        Expanded(
           child: Container(
                      height: 50.0, // add for test relayoutBoundary
                      child: LayoutBoundary(),
                      )),
         Expanded(
            child: Text('You have pushed the button this many times:'))
]

如果你想測試上面的三個條件成立時是否真的不會再layout,你可以自定義LayoutBoundaryDelegate來測試,比如

class LayoutBoundaryDelegate extends MultiChildLayoutDelegate {
  LayoutBoundaryDelegate();

  static const String title = 'title';
  static const String summary = 'summary';
  static const String paintBoundary = 'paintBoundary';

  @override
  void performLayout(Size size) {
    print('TestLayoutDelegate  performLayout ');

    final BoxConstraints constraints = BoxConstraints(maxWidth: size.width);

    final Size titleSize = layoutChild(title, constraints);
    positionChild(title, Offset(0.0, 0.0));

    final double summaryY = titleSize.height;
    final Size descriptionSize = layoutChild(summary, constraints);
    positionChild(summary, Offset(0.0, summaryY));

    final double paintBoundaryY = summaryY + descriptionSize.height;
    final Size paintBoundarySize = layoutChild(paintBoundary, constraints);
    positionChild(
        paintBoundary, Offset(paintBoundarySize.width / 2, paintBoundaryY));
  }

  @override
  bool shouldRelayout(LayoutBoundaryDelegate oldDelegate) => false;
}

自定義的MultiChildLayoutDelegate需要使用CustomMultiChildLayout來配合使用

Container(
          child: CustomMultiChildLayout(
              delegate: LayoutBoundaryDelegate(),
              children: <Widget>[
                LayoutId(
                    id: LayoutBoundaryDelegate.title,
                    child: Row(children: <Widget>[
                      Expanded(child: LayoutBoundary()),
                      Expanded(child: Text( 'You have pushed the button this many times:'))
                 ])),
                LayoutId(
                    id: LayoutBoundaryDelegate.summary,
                    child: Container(
                      child: InkWell(
                        child: Text(
                          _buttonText,
                          style: Theme.of(context).textTheme.display1),
                        onTap: () {
                          setState(() {
                            _index++;
                            _buttonText = 'onTap$_index';
                          });
                        },
                      ))),
                LayoutId(
                    id: LayoutBoundaryDelegate.paintBoundary,
                    child: Container(
                      width: 50.0,
                      height: 50.0,
                      child: PaintBoundary())),
              ]),
        )

我們在performLayout方法里做了打印操作,如果CustomMultiChildLayout的children里的任意一個child的size變化,就會打印這條信息,所以這樣的代碼在每次點擊onTap的時候,都會打印'TestLayoutDelegate performLayout '

relayout.jpeg

所以為了達到有RelayoutBoundary的效果,可以將代碼中的Container添加寬高以達到constraints.isTight條件,這個實驗就留給讀者自己測試吧。讀者可以自己嘗試驗證

Paint

  • Paint的一個重要工作就是確定哪些Element放在同一Layer


    paint-into-layers.png
  • 布局size計算是自下而上的,但是paint是自上而下的。在layout之后,所有的Widget的大小、位置都已經確定,這時不需要再做遍歷。


    paint-target-layer-flow.png

    Paint也是按照深度優先的順序,而且總是先繪制自身,再是子節點,比如節點 2是一個背景色綠色的視圖,在繪制完自身后,繪制子節點3和4。當繪制完以后,Layer是按照深度優先的倒敘進行返回,類似Size的計算,而每個Layer就是一層,最后的結果是一個Layer Tree。
    也許你已注意到在2節點由于一些其他原因導致它的部分UI5與6處于了同一層,這樣的結果會導致當2需要重繪的時候,與其不想相關的6實際上也會被重繪,而存在性能損耗。Flutter的工程師當然不會作出這么愚蠢的設計。所以為了提高性能,與relayout boundary相應的存在repaint boundary。

  • repaint boundary
    如果發生上面情況,repaint boundary會強制的使2切換到新Layer


    repaint boundary.jpeg

    這樣強制使圖層分開,以達到毫不相關的控件的Paint的時候,不會被影響導致重繪。
    Repaint boundary一般不需要開發者設置。但開發者可以手動設置,Flutter提供RepaintBoundary組件,你可以在你認為需要的地方,設置Repaint boundary。
    如何驗證添加RepaintBoundary后,child就不會被同層的Widget的repaint影響呢,我們可以自定義一個Paint,比如

class PaintBoundary extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CustomPaint(painter: CirclePainter(color: Colors.orange));
  }
}

class CirclePainter extends CustomPainter {
  final Color color;

  const CirclePainter({this.color});

  @override
  void paint(Canvas canvas, Size size) {
    print('CirclePainter paint');
    var radius = size.width / 2;
    var paint = Paint()
      ..color = color
      ..style = PaintingStyle.fill;
    canvas.drawCircle(Offset(radius, size.height), radius, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

只是很簡單的繪制一個橙色的圓,在RelayoutBoundary驗證代碼中已貼出使用。我們只需看設置RepaintBoundary和不設置時候的區別。實驗驗證結果RelayoutBoundary確實可以避免CirclePainter發生重繪,即'CirclePainter paint'只會打印一次。讀者可以自己嘗試驗證

總結

relayout boundary和repaint boundary都是Flutter為了提高繪圖性能而做的努力。通常開發者可以使用RepaintBoundary組件來提高應用的性能,也可以根據relayout boundary的幾個規則來使relayout boundary生效,從而提高性能。

測試代碼傳送門

參考

Flutter's Rendering Pipeline
深入了解Flutter界面開發(強烈推薦)
Flutter 渲染流水線淺析
Flutter原理與實踐
Flutter中的布局繪制流程簡析
Flutter Dart Framework原理簡解

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 原文鏈接:鏈接 概要 本文不是flutter界面開發入門文章,而是一篇深入介紹Flutter framework關...
    蓋世英雄_ix4n04閱讀 8,782評論 0 9
  • 國慶后面兩天在家學習整理了一波flutter,基本把能擼過能看到的代碼都過了一遍,此文篇幅較長,建議保存(star...
    Nealyang閱讀 4,381評論 1 17
  • 本文參加#未完待續,就要表白#活動,本人承諾,文章內容為原創,且未在其他平臺發表過。 —藍丼 雷鳴...
    藍丼閱讀 350評論 5 14
  • 早就聽說過這本書了,也看到了幾句廣為流傳的句子。 因為雖然大家都看過很多次了但還是覺得很漂亮所以復制在底下了。 (...
    我看過的書閱讀 153評論 0 0
  • 爿旗獵獵思清月,芳蒲怏怏羨碧蓮。 山雨欲停遙驅霧,飄飄似我閣上仙。
    逍遙天涯人閱讀 220評論 1 1