本文目的
- 分析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中。
而以上這三個概念也對應著三種樹結構:模型樹、呈現樹、渲染樹。
在解釋他們的概念和關系以后,我們已經認識到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為例
它有四個屬性,分別是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 '
所以為了達到有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原理簡解