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

前言

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

而後這篇文章中,會分析一下Flutter中的佈局流程,以及點擊hitTest的調用流程markdown

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

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

本文從源碼的角度分析Flutter中layout的基礎流程,以及hitTest的調用流程。可是由於有些內容須要參考,能夠參考post

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

Flutter App的啓動流程優化

RenderObject

基礎

RenderObject能夠理解爲一個節點的信息,描述着節點的佈局Layout,圖層Layer和繪製Paint信息。ui

在文章說到,RenderObject是由Widget建立的。當構建Widget樹的時候,也會一併建立RenderObject樹。this

若是一個Widget是跟UI信息有關的,基本基類都是RenderObjectWidget,對應的Element的基類都是RenderObjectElement,並且會對應有一個RenderObject。spa

請求佈局更新

在Widget更新的時候,會調用RenderObjectElement的update方法

。update以下

@override
  void update(covariant RenderObjectWidget newWidget) {
    super.update(newWidget);
   ...
    widget.updateRenderObject(this, renderObject);
   ...
    _dirty = false;
  }

複製代碼

當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的佈局計算相對簡單。

相關文章
相關標籤/搜索