Flutter原始碼閱讀(3)-Flutter的佈局與hitTest

samstring發表於2021-07-11

前言

在前面這兩篇文章中,說了Flutter啟動時是如何去構建Widget.Element,RenderObject節點樹。

然後這篇文章中,會分析一下Flutter中的佈局流程,以及點選hitTest的呼叫流程

基本的佈局流程程式碼是在RenderObjcet這個類裡處理,但是這是一個最基礎的流程,不包含具體的座標體系,大小等。移動開發中,通常是使用笛卡爾座標。

RenderBox是繼承了RenderObjcet,實現了基於笛卡爾座標的佈局。

本文從原始碼的角度分析Flutter中layout的基礎流程,以及hitTest的呼叫流程。但是因為有些內容需要參考,可以參考

Widget,Element,RenderObject樹的構建和更新流程

Flutter App的啟動流程

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方法主要做了以下這幾個事情

  1. 處理佈局邊界_relayoutBoundary
  2. 如果sizedByParent是true,則呼叫performResize方法決定大小
  3. 呼叫performLayout方法
佈局邊界_relayoutBoundary_

_首先第一步這裡是確定了佈局邊界_relayoutBoundary,這一點其實很重要,結合上面的markNeedsLayout方法來說,當呼叫markNeedsLayout方法的時候,就是根據 _relayoutBoundary去判斷是否需要一直往上呼叫markNeedsLayout方法。呼叫markNeedsLayout越多,影響的節點就會越多,更新的UI速度就會越慢。所以從介面優化的角度上來說,增加 _relayoutBoundary 可以優化介面的流暢度。

具體可以通過下方的這個條件去入手

!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject
複製程式碼

總的來說,就是減少Widget樹的層級,以及儘量使用

  1. 不影響父節點的Widget。

  2. 由父節點決定大小的Widget

  3. 可以由約束確定唯一的大小的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)方法,呼叫流程如下

image.png

_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);//分發事件
    }
  }
複製程式碼

可以看到,這裡最主要是兩步

  1. hitTest 命中測試
  2. 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流程圖

hitTest流程圖

總結

這裡主要分析了佈局流程,但是沒有詳細的具體例子(不然文章篇幅暴漲),但是讀者可以閱讀原始碼的時候可以結合具體的例子去看,這裡推薦看Stack的實現,因為這個Widget的佈局計算相對簡單。

相關文章