前言
在前面這兩篇文章中,說了Flutter啟動時是如何去構建Widget.Element,RenderObject節點樹。
然後這篇文章中,會分析一下Flutter中的佈局流程,以及點選hitTest的呼叫流程
基本的佈局流程程式碼是在RenderObjcet這個類裡處理,但是這是一個最基礎的流程,不包含具體的座標體系,大小等。移動開發中,通常是使用笛卡爾座標。
RenderBox是繼承了RenderObjcet,實現了基於笛卡爾座標的佈局。
本文從原始碼的角度分析Flutter中layout的基礎流程,以及hitTest的呼叫流程。但是因為有些內容需要參考,可以參考
Widget,Element,RenderObject樹的構建和更新流程
RenderObject
基礎
RenderObject可以理解為一個節點的資訊,描述著節點的佈局Layout,圖層Layer和繪製Paint資訊。
在文章說到,RenderObject是由Widget建立的。當構建Widget樹的時候,也會一併建立RenderObject樹。
如果一個Widget是跟UI資訊有關的,基本基類都是RenderObjectWidget,對應的Element的基類都是RenderObjectElement,而且會對應有一個RenderObject。
請求佈局更新
在Widget更新的時候,會呼叫RenderObjectElement的update方法
。update如下
@override
void update(covariant RenderObjectWidget newWidget) {
super.update(newWidget);
...
widget.updateRenderObject(this, renderObject);
...
_dirty = false;
}
複製程式碼
可以看到,當一個RenderObjectWidget更新的時候,就會呼叫
當Wiget是一個RenderObjectWidget的時候,更新的時候會呼叫RenderObjectElement的update方法。update方法就會反過來呼叫RenderObjectWidget的updateRenderObject方法。
然後Widget在updateRenderObject處理RenderObject。如果需要更新佈局的話,就呼叫RenerObject的markNeedsLayout方法去請求佈局更新。markNeedsLayout的實現如下
void markNeedsLayout() {
...
if (_relayoutBoundary != this) {
//如果當前節點不是佈局邊界,也就是該節點的佈局會影響到父佈局
//markParentNeedsLayout會向上遞迴呼叫markNeedsLayout()方法,直到父節點是佈局邊界為止
markParentNeedsLayout();
} else {
_needsLayout = true;
...
//owner是PipelineOwner,用來統一管理佈局,圖層,繪製
owner!._nodesNeedingLayout.add(this);
owner!.requestVisualUpdate();
}
}
}
複製程式碼
當呼叫markNeedsLayout的時候,不是馬上就改動UI介面,而是把這個改動記錄下來。當下次介面更新的時候,把所有的改動一次性修改
佈局更新請求處理
像以前提到Widget的構建流程中BuildOwner一樣,同樣存在一個排程中心PipelineOwner。他是負責處理RenderObject樹的佈局,圖層更新,和繪製流程。
當節點有佈局Layout更新需求時,就會呼叫會markNeedsLayout()方法,把自身新增到PipelineOwner中的_nodesNeedingLayout中列表中,
跟著會去呼叫PipelineOwner的requestVisualUpdate方法,這個方法會去註冊一個回撥,當幀訊號發出的時候,就會呼叫這個回撥。回撥執行時候,會呼叫RenderBinding的drawFrame方法(關於這個RenderBinding以及呼叫流程,可以檢視Flutter App的啟動流程,
這個方法如下
void drawFrame() {
assert(renderView != null);
pipelineOwner.flushLayout();
pipelineOwner.flushCompositingBits();
pipelineOwner.flushPaint();
if (sendFramesToEngine) {
renderView.compositeFrame(); // this sends the bits to the GPU
pipelineOwner.flushSemantics(); // this also sends the semantics to the OS.
_firstFrameSent = true;
}
}
複製程式碼
可以看出,當GPU幀訊號發出的時候,會呼叫PipelineOwner的flushLayout()方法去更新介面上的佈局資訊等,然後提交給GPU做渲染。
PS:本文重點講述的是佈局,加上圖層和繪製的處理流程和佈局的流程大致相似,所以這裡重點講得是flushLayout的過程。實現如下
void flushLayout() {
...
while (_nodesNeedingLayout.isNotEmpty) {
final List<RenderObject> dirtyNodes = _nodesNeedingLayout;
_nodesNeedingLayout = <RenderObject>[];
for (final RenderObject node in dirtyNodes..sort((RenderObject a, RenderObject b) => a.depth - b.depth)) {
if (node._needsLayout && node.owner == this)
node._layoutWithoutResize();
}
}
...
}
複製程式碼
這裡會取出_nodesNeedingLayout,也就是所有需要更新佈局的節點,對每個節點呼叫_layoutWithoutResize()方法。從這一步開始,就開始了節點的佈局流程了。
佈局流程
_layoutWithoutResize()方法,方法如下
void _layoutWithoutResize() {
...
performLayout();
...
_needsLayout = false;
markNeedsPaint();
}
複製程式碼
可以看到,基本上就只是呼叫了 performLayout()和 markNeedsPaint()這兩個方法
這裡performLayout()就是負責去算出節點自身的位置和大小的。RenderObject中沒有定義performLayout()的實現,具體得讓子類去實現。
而且理所當然的是,當佈局變化了,就需要重繪,所以這裡有呼叫了一個 markNeedsPaint()標記節點需要重繪。
如果我們自定一個RenderObjct的子類,是需要實現performLayout()方法去實現的我們的佈局方法的。如果有多個子節點。那麼我們還需要呼叫子節點的 layout(Constraints constraints, { bool parentUsesSize = false }方法。我們會對子節點約束傳入layout方法中,呼叫完子節點的layout方法後,我們就可以知道子節點所佔用的大小。從而去設定該節點的佈局
layout方法
這個layout方法是定義在RenderObject方法中的。如下
void layout(Constraints constraints, { bool parentUsesSize = false }) {
...
RenderObject? relayoutBoundary;//是否是佈局邊界,也就是說子節點佈局改變會不會影響父佈局
if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {
//如果滿足以下的條件,則代表該節點是佈局邊界
//1由父節點決定子節點的大小
//2父節點不需要用到子節點的大小
//3給定的約束能確定唯一的大小
//4父節點不是一個RenderObject
relayoutBoundary = this;
} else {
//否則的話,relayoutBoundary就等於父節點的佈局邊界relayoutBoundary
relayoutBoundary = (parent! as RenderObject)._relayoutBoundary;
}
...
if (!_needsLayout && constraints == _constraints && relayoutBoundary == _relayoutBoundary) {
...
//如果佈局邊界沒有改變,約束沒有改變,也沒有標記為_needsLayout,則直接結束
return;
}
//更新節點約束
_constraints = constraints;
if (_relayoutBoundary != null && relayoutBoundary != _relayoutBoundary) {
//如果自身佈局邊界改變了,則清空所有的子節點的邊界佈局,並標記_needsLayout為true
//這樣當該節點layout發生變化的時候,子節點的layout也會發生變化
visitChildren(_cleanChildRelayoutBoundary);
}
//更新_relayoutBoundary
_relayoutBoundary = relayoutBoundary;
...
if (sizedByParent) {
...
//如果是父節點決定子節點的大小,則呼叫方法,
//performResize是處理節點的大小
//如果sizedByParent是true,則在performResize決定大小,不要在performLayout決定大小
//performResize根據約束_constraints去決定大小
performResize();
...
..
}
...
try {
//呼叫performLayout()方法
performLayout();
...
}
...
_needsLayout = false;
markNeedsPaint();
...
}
複製程式碼
layout方法主要做了以下這幾個事情
- 處理佈局邊界_relayoutBoundary
- 如果sizedByParent是true,則呼叫performResize方法決定大小
- 呼叫performLayout方法
佈局邊界_relayoutBoundary_
_首先第一步這裡是確定了佈局邊界_relayoutBoundary,這一點其實很重要,結合上面的markNeedsLayout方法來說,當呼叫markNeedsLayout方法的時候,就是根據 _relayoutBoundary去判斷是否需要一直往上呼叫markNeedsLayout方法。呼叫markNeedsLayout越多,影響的節點就會越多,更新的UI速度就會越慢。所以從介面優化的角度上來說,增加 _relayoutBoundary 可以優化介面的流暢度。
具體可以通過下方的這個條件去入手
!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject
複製程式碼
總的來說,就是減少Widget樹的層級,以及儘量使用
-
不影響父節點的Widget。
-
由父節點決定大小的Widget
-
可以由約束確定唯一的大小的Widget。
這些需要看具體的Widget實現。
performResize
到了第二步,根據sizedByParent欄位的值,判斷是否呼叫performResize方法。如果sizedByParent為true,則代表節點的大小隻有父節點呼叫layout時候提供的constraints有關係。那麼就呼叫performResize()這個方法去確定節點的大小。一般來說,我們都是通過performLayout()方法去決定節點的大小。但是如果呼叫了performResize(),就不應該再在performLayout()去改變節點的大小
performLayout
到了第三步,我們可以看到,呼叫了performLayout()方法。結合前面的流程可以看出方法的呼叫如下
父節點performLayout -> 子節點layout -> 子節點performLayout -> 子子節點layout -> 子子節點performLayout -> .......
複製程式碼
就是一個節點在佈局的時候,如果存在子節點,就會呼叫子節點的layout方法並傳入約束,子節點進行佈局。然後一直重複這個過程,直到葉子節點為止
在檢視Flutter的佈局流程的水後,會經常在網上看到一張圖。
由父節點提供約束給子節點,子節點根據約束進行佈局,然後返回給父節點去進行佈局,完成佈局流程。其實這就是第三步所說的這個過程。
至此,大概的佈局流程就是這樣,如下方圖片所示
佈局流程圖
上方的這些佈局流程都是在RenderObjct的基礎上去展開的,但這只是定義了一個從上往下構建佈局的基本流程。但是不涉及到具體的座標系和節點大小。也就是說一個Widget顯示在介面上的那個位置,佔多少位置,光靠這個基礎的佈局流程是確定不了的。
Flutter中提供了一個基於笛卡爾積的佈局方式RenderBox。RenderBox是繼承於RenderObjct。在RednderObjct的佈局流程上擴充了笛卡爾座標,節點的大小和命中測試等。Flutter中大部分的RenderObject都是繼承於RenderBox的。
如果你需要自定義座標體系的佈局,可以繼承RenderObject。否則,繼承RenderBox是一個最好的選擇。
主要的佈局RenderBox
大小和位置
box.dart中定義了BoxConstraints和BoxParentData。分別繼承於Constraints和ParentData。RenderBox中的_constraints和parentData就是這兩種型別。
BoxConstraints定義如下
class BoxConstraints extends Constraints {
...
final double minWidth;//最小寬度
final double maxWidth;//最大寬度
final double minHeight;.//最小高度
final double maxHeight;//最大高度
...
}
複製程式碼
BoxParentData定義如下
class BoxParentData extends ParentData {
...
Offset offset = Offset.zero;//基於笛卡爾積的起始點,
...
}
複製程式碼
BoxConstraints確定了節點的大小,BoxParentData確定了節點的起始點。
每一個節點都接受了父子節點傳遞BoxConstraints和BoxParentData,然後按照上方的佈局流程,那麼節點的起始點和大小都能確定下來。
計算大小
RenderBox中提供了幾個未實現的方法,子類需要提供實現
double computeMinIntrinsicWidth(double height) //算出最小寬度
double computeMaxIntrinsicWidth(double height) //算出最大寬度
double computeMinIntrinsicHeight(double width) //算出最小高度
double computeMaxIntrinsicHeight(double width) //算出最大高度
Size computeDryLayout(BoxConstraints constraints) //算出父節點給的約束下子節點的大小
複製程式碼
通過這些辦法,節點可以算出應該佔用的尺寸。Flutter中是不建議直接呼叫這些方法的,而是需要通過呼叫以下方法獲取
double getMinIntrinsicWidth(double height) //得到最小寬度
double getMaxIntrinsicWidth(double height) //得到最大寬度
double getMinIntrinsicHeight(double width) //得到最小高度
double getMaxIntrinsicHeight(double width) //得到最大高度
Size getDryLayout(BoxConstraints constraints) //得到父節點給的約束下子節點的大小
複製程式碼
在前面的layout過程中,performLayout階段會呼叫子節點的layout方法,然後就能確定子節點的大小。再通過子節點的getMinIntrinsicxxx或是getDryLayout方法去獲取寬高,獲取子節點的尺寸後就可以進行自身的佈局。
順帶一提的是,xxxDryLayout方法是Flutter2.0以後才有的,這個方法是用來替代performResize方法的。也就是說如果一個節點的大小隻有父節點的約束決定,那麼不應該在performLayout方法中算出節點的大小,而應該在computeDryLayout計算出節點的大小。
而另外xxxDryLayout方法可以在不改變RenderObjct的其他狀態的情況下,算出節點應該佔用的大小。這裡的DryLayout中的Dry就是相對普通layout方法而言的,從上面可知,layout方法是會改變邊界佈局,約束等。
hitTest
在佈局完成後,介面UI也顯示完整了,那麼這時候使用者點選了某個Widget,這個點選事件是怎麼傳遞呢?這裡以點選事件為例,說明事件傳遞的流程
上一篇文章提到,在App啟動的時候會初始化一系列Binding,其中有一個是GestureBinding。當點選事件出現時,會呼叫GestureBinding的_handlePointerDataPacket方法,經過事件採用的操作最終會呼叫_handlePointerEventImmediately(PointerEvent event)方法,呼叫流程如下
_handlePointerEventImmediately如下
void _handlePointerEventImmediately(PointerEvent event) {
HitTestResult? hitTestResult;
if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent) {
...
hitTestResult = HitTestResult();//儲存hitTest結果
hitTest(hitTestResult, event.position);//進行hitTest
if (event is PointerDownEvent) {
_hitTests[event.pointer] = hitTestResult;
}
...
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
hitTestResult = _hitTests.remove(event.pointer);
} else if (event.down) {
...
hitTestResult = _hitTests[event.pointer];
}
...
}());
if (hitTestResult != null ||
event is PointerAddedEvent ||
event is PointerRemovedEvent) {
assert(event.position != null);
dispatchEvent(event, hitTestResult);//分發事件
}
}
複製程式碼
可以看到,這裡最主要是兩步
- hitTest 命中測試
- dispatchEvent 事件分發
hitTest 命中測試
因為Binding的mixin的設計,這裡的hitTest方法會走到RenderBinding的hitTest方法中,如下
@override
void hitTest(HitTestResult result, Offset position) {
...
renderView.hitTest(result, position: position);
//這裡呼叫了super.hitTest,這個定義在GestureBing當中
//會把Bingding也放入到hitTestResult中
super.hitTest(result, position);
}
複製程式碼
這裡會呼叫renderView.hitTest(result, position: position)方法。這裡的renderView就是App啟動的時候RenderObjct樹的根節點。它是RenderView型別的,繼承於RenderObject,mixin了RenderObjectWithChildMixin。其hitTest方法如下
bool hitTest(HitTestResult result, { required Offset position }) {
if (child != null)
child!.hitTest(BoxHitTestResult.wrap(result), position: position);
result.add(HitTestEntry(this));
return true;
}
複製程式碼
因為mixIn了RenderObjectWithChildMixin,所以當呼叫了子節點的hitTest方法的時候,會走到RenderBox的hitTest方法。如下
bool hitTest(BoxHitTestResult result, { required Offset position }) {
...
if (_size!.contains(position)) {
if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
}
return false;
}
複製程式碼
這裡的hitTest呼叫hitTestChildren和hitTestSelf方法。這兩個方法預設返回false,應該交由具體的子類實現。
hitTestChildren方法用於處理判斷子節點是否命中測試,hitTestSelf判斷節點本身是否響應命中測試。如果命中,就往命中測試結果中新增該節點。
一般而言,hitTestChildren方法中一般都會呼叫子節點的hitTest方法,通過
hitTest -> hitTestChildren -> hitTest -> hitTestChildren -> ....
複製程式碼
這個流程,會把所有符合命中測試的結果都存到GestureBinding的_handlePointerEventImmediately方法中的hitTestResult中,也就是說,在
dispatchEvent 事件分發
得到hitTestResult以後,就執行dispatchEvent方法,如下
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
...
//便利result
for (final HitTestEntry entry in hitTestResult.path) {
...
//事情處理與分發
entry.target.handleEvent(event.transformed(entry.transform), entry);
...
}
}
複製程式碼
因為這裡涉及很多的事件分發的處理,邊幅較大,所以不在這裡討論。
hitTest流程圖
總結
這裡主要分析了佈局流程,但是沒有詳細的具體例子(不然文章篇幅暴漲),但是讀者可以閱讀原始碼的時候可以結合具體的例子去看,這裡推薦看Stack的實現,因為這個Widget的佈局計算相對簡單。