Flutter檢視的Layout與Paint

升級之路發表於1970-01-01

通知:flutter最近 1.0 了!

本文目的

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

目錄結構

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

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

  • Flutter的繪圖原理
flutter-vsync

從圖中可以看到,當GPU發出vsync訊號時,會執行Dart程式碼繪製新UI,Dart-code會被執行為Layer Tree,然後經過Compositor合成後交由Skia引擎渲染處理為GPU資料,最後通過GL/Vulkan發給GPU。而我們要分析的地方就在Dart->
Layer Tree這裡。

  • UI的基本流程
render-pipeline

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

flutter-pipeline

通過上面的講解,我們大概已經瞭解了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

而以上這三個概念也對應著三種樹結構:模型樹、呈現樹、渲染樹。在解釋他們的概念和關係以後,我們已經認識到RenderObject的重要性,因為以下Layout、Paint包括relayout boundary和repaint boundary都是在這裡發生的。一般一個Widget被更新,那麼持有該Widget的節點的Element會被標記為dirtyElement,在下一次更新介面時,Element樹的這一部分子樹便會被觸發performRebuild,在Element樹更新完成後,便能獲得RenderObject樹,接下來會進入Layout和Paint的流程。

Layout

  • Layout的目的是要計算出每個節點所佔空間的真實大小。
layout-data-flow

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

  • Relayout boundary

它的目的是提高flutter的繪圖效能,它的作用是設定測量邊界,邊界內的Widget做任何改變都不會導致邊界外重新計算並繪製。

Relayout boundary

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

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

什麼是isTight呢?用BoxConstraints為例

BoxConstraints

它有四個屬性,分別是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
}) {
...省略1w+... if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {
relayoutBoundary = this;

} else {
final RenderObject parent = this.parent;
relayoutBoundary = parent._relayoutBoundary;

} ...省略1w+...
}複製程式碼

通過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’

print-relayout

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

Paint

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

paint-into-layers

佈局size計算是自下而上的,但是paint是自上而下的。在layout之後,所有的Widget的大小、位置都已經確定,這時不需要再做遍歷。

paint-target-layer-flow

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

這樣強制使圖層分開,以達到毫不相關的控制元件的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生效,從而提高效能。

[測試程式碼傳送門](http://link.zhihu.com/?target=https%3A//github.com/Dpuntu/RePaintBoundary-RelayoutBoundary)

參考

本文版權屬於再惠研發團隊,歡迎轉載,轉載請保留出處。@Dpuntu

來源:https://juejin.im/post/5c0fc3cb5188251da07e09b3#heading-8

相關文章