FlutterFlutter原理篇:事件機制傳播與響應機制與HitTestBehavior的介紹

今天又到了我們Flutter原理篇的內容了,今天給大家講的是Flutter的事件傳播與機制,順便再給大家介紹下傳播里面HitTestBehavior的作用(感覺很多文章對于這個的介紹不是很詳細),好了讓我們開始吧:

老實說網絡上已經有不少文章介紹了Flutter的事件機制了,為什么我還要出一篇來寫呢,主要是一方面我覺得網絡上有些高手寫的并沒有通俗易懂,他們的內容沒有問題,但是語言組織上面可能沒有連貫,導致我在看他們的文章的時候有時候總覺得”跟不上節拍“,覺得他們沒有按照順序娓娓道來,給讀者的閱讀體驗性可能沒有那么好,最重要的是就怕讀者看完了沒看懂里面的重要思想這就不太好了,所以今天由我給大家來一偏通俗易懂的介紹Flutter事件傳播機制的文章,讓你在閱讀的時候幾乎無障礙理解

其實我們從事移動端開發,對于事件傳遞并不陌生,無論是Android還是iOS,還是Web等等都有這一套機制在里面,而且機制都大同小異,對于Flutter事件機制我覺得比較像iOS,因為他連函數名字都叫一樣的,好了回到正題來:
首先我們先拋出個結論就是Flutter的事件傳播機制流程大致分為兩個步驟:

1 命中測試階段,我習慣叫他為hitTest階段

這個階段里面主要是調用hitTest方法去測試一些列可以被響應的RenderObject對象(注意只有RenderObject才會有hitTest方法),然后把這些對象添加進一個隊列里面保存起來,剛剛說到iOS,這里面hitTest方法的名字與iOS是一樣的但是作用卻不太一樣,iOS里面的hitTest方法是去尋找一個最合適響應的對象返回,而Flutter里面卻是所有可以命中測試的對象都保存起來,這個階段的細節我們在下面的代碼再來講解

2 事件傳播階段

當第一個階段完成以后你的隊列里面就有了N個可以命中的RenderObject對象,這個時候進行事件分發dispatch,其實非常簡單就是循環調用命中的RenderObject對象的handleEvent
方法,調用順序是先進先出(大家要記住這里)

好了,原理非常的簡單,讓我去結合代碼看看細節是怎么樣的,首先事件命中測試是在 PointerDownEvent 事件觸發時進行的,一個完成的事件流是 down > move > up (cancle) (這里無論是Android,iOS都是一樣的),首先觸發新事件時,flutter 會調用此方法_handlePointerEventImmediately,如下:

GestureBinding._handlePointerEventImmediately

// 觸發新事件時,flutter 會調用此方法
void _handlePointerEventImmediately(PointerEvent event) {
  HitTestResult? hitTestResult;
  if (event is PointerDownEvent ) {
    hitTestResult = HitTestResult();
    // 發起命中測試
    hitTest(hitTestResult, event.position);
    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) { // PointerMoveEvent
    //直接獲取命中測試的結果
    hitTestResult = _hitTests[event.pointer];
  }
  // 事件分發
  if (hitTestResult != null) {
    dispatchEvent(event, hitTestResult);
  }
}

我們主要關注:hitTest,dispatchEvent兩個函數即可,其中HitTestResult的作用是存儲可以被命中的對象的,在這個方法里面首先發起了命中測試,這個函數的是mixin GestureBinding實現的,由于RendererBinding混合了GestureBinding

/// The glue between the render tree and the Flutter engine.
mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureBinding, SemanticsBinding, HitTestable {
}

所以接下來我們看看他的hitTest方法是怎么樣的,如下:

RendererBinding.hitTest

  @override
  void hitTest(HitTestResult result, Offset position) {
    assert(renderView != null);
    assert(result != null);
    assert(position != null);
    renderView.hitTest(result, position: position);
    super.hitTest(result, position);
  }

這里會調用renderView.hitTest的方法 ,我們知道renderView是整個RenderObject的根,我們看看他的這個方法實現:

RenderView.hitTest

  bool hitTest(HitTestResult result, { required Offset position }) {
    if (child != null)
      child!.hitTest(BoxHitTestResult.wrap(result), position: position);
    result.add(HitTestEntry(this));
    return true;
  }

這里面很清楚了,就是要調用child的hitTest方法,并且把result傳遞了過去,這里我們以RenderProxyBoxWithHitTestBehavior舉例子(因為他很常見,我們平時看到的Container的renderObject:_RenderColoredBox的hitTest會對應到他)

RenderProxyBoxWithHitTestBehavior.hitTest

  @override
  bool hitTest(BoxHitTestResult result, { required Offset position }) {
    bool hitTarget = false;
    if (size.contains(position)) {
      hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
      if (hitTarget || behavior == HitTestBehavior.translucent)
        result.add(BoxHitTestEntry(this, position));
    }
    return hitTarget;
  }

分兩步驟:

  • 首先調用size.contains 來簡單點擊區域是否在該組件范圍內,為true則進行下一步

  • 再會調用hitTestChildren方法與hitTestSelf方法來作為hitTarget的值,根據這個值來決定是否把當前的RenderObject添加進入result里面(behavior我們晚點再說)

我們以Container為hitTest命中對象舉例的話,最后的hitTestChildren就會是如下的:

RenderBoxContainerDefaultsMixin.hitTestChildren

  bool defaultHitTestChildren(BoxHitTestResult result, { required Offset position }) {
    // The x, y parameters have the top left of the node's box as the origin.
    ChildType? child = lastChild;
    while (child != null) {
      final ParentDataType childParentData = child.parentData! as ParentDataType;
      final bool isHit = result.addWithPaintOffset(
        offset: childParentData.offset,
        position: position,
        hitTest: (BoxHitTestResult result, Offset? transformed) {
          assert(transformed == position - childParentData.offset);
          return child!.hitTest(result, position: transformed!);
        },
      );
      if (isHit)
        return true;
      child = childParentData.previousSibling;
    }
    return false;
  }

我們可以看到上面代碼的主要邏輯是遍歷調用子組件的 hitTest() 方法,同時會做一個判讀:即遍歷過程中只要有子節點的 hitTest() 返回了 true 時:會終止子節點遍歷,這意味著該子節點前面的兄弟節點將沒有機會通過命中測試,也就沒有機會加入到命中隊列result里面

里面需要注意一個就是這個深度遍歷的過程,首先會找到組件的子組件進行hitTest判斷(子節點沒有Child了,子節點的hitTestChildren一般默認返回為false大家可以這么簡單的理解一下),如果hitTest為false那個繼續尋找他的上一個兄弟結點(因為深度遍歷所以是倒序)

如果子節點 hitTest() 返回了 true 導父節點 hitTestChildren 也會返回 true,最終會導致 父節點的 hitTest 返回 true,父節點被添加到 HitTestResult 中。

當子節點的 hitTest() 返回了 false 時,繼續遍歷該子節點前面的兄弟節點,對它們進行命中測試,如果所有子節點都返回 false 時,則父節點會調用自身的 hitTestSelf 方法,如果該方法也返回 false,則父節點就會被認為沒有通過命中測試。

好了,其實還是很簡單的,說完了命中測試,我們再來說說事件傳播階段
也就是事件分發,說完了分發我們就會舉兩個例子來論證我們的結論

事件的分發非常的簡單,代碼如下:

GestureBinding.dispatchEvent

  @pragma('vm:notify-debugger-on-exception')
  void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
    assert(!locked);
    // No hit test information implies that this is a [PointerHoverEvent],
    // [PointerAddedEvent], or [PointerRemovedEvent]. These events are specially
    // routed here; other events will be routed through the `handleEvent` below.
    if (hitTestResult == null) {
      assert(event is PointerAddedEvent || event is PointerRemovedEvent);
      try {
        pointerRouter.route(event);
      } catch (exception, stack) {
        //省略部分代碼
      }
      return;
    }
    for (final HitTestEntry entry in hitTestResult.path) {
      try {
        entry.target.handleEvent(event.transformed(entry.transform), entry);
      } catch (exception, stack) {
        //省略部分代碼
      }
    }
  }

最主要的就是for循環hitTestResult里面的存儲的HitTestEntry,而這個hitTestResult就是我們在_handlePointerEventImmediately命中初始階段初始化的那個變量,也就是存儲我們命中對象的對象,再調用這些HitTestEntry的handleEvent分發即可,就是這么簡單

好了,我們以下面的一個例子給大家舉例說明一下整個流程的運行,并且說明我們上面的論證,為什么子組件返回true以后前兄弟結點沒有辦法命中測試

class StackEventTest extends StatelessWidget {
  const StackEventTest({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        wChild(1),
        wChild(2),
      ],
    );
  }

  Widget wChild(int index) {
    return Listener(
      onPointerDown: (e) => print(index),
      child: Container(
        width: 100,
        height: 100,
        color: Colors.grey,
      ),
    );
  }
}

代碼很簡單,主要是Container的使用而已,我們就來看看Container的命中流程是怎么樣的呢

首先這里的Container對應的renderObject是一個_RenderColoredBox(因為他只有一個Colors屬性最后轉換是他)最后面回到RenderProxyBoxWithHitTestBehavior這個里面的hitTest里面如下:

 @override
  bool hitTest(BoxHitTestResult result, { required Offset position }) {
    bool hitTarget = false;
    if (size.contains(position)) {
      hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
      if (hitTarget || behavior == HitTestBehavior.translucent)
        result.add(BoxHitTestEntry(this, position));
    }
    return hitTarget;
  }

首先調用size.contains來簡單點擊區域是否在該組件范圍內,然后調用hitTestChildren,因為我們的例子Container沒有child所以下面直接返回false

  @override
  bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
    return child?.hitTest(result, position: position) ?? false;
  }

所以我們直接看他的hitTestSelf方法,這個方法如下:

  @override
  bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;

其實就是判斷他的behavior屬性到底是不是HitTestBehavior.opaque而已了,我們再看看初始化的時候傳的值就明白了

class _RenderColoredBox extends RenderProxyBoxWithHitTestBehavior {
  _RenderColoredBox({ required Color color })
    : _color = color,
      super(behavior: HitTestBehavior.opaque);  //注意這一行
      //省略部分代碼
}

這里指定了behavior為HitTestBehavior.opaque,所以hitTestSelf方法返回為true,所以他會被添加進入result命中隊列,而由于上面我們分析的子組件返回為true以后,他的前兄弟組件則不會添加進入命中隊列,所以不會影響點擊的事件,所以打印就只會有最后一個打印也就是2:

2021-12-24 01:04:53.052 6580-6629/com.example.my_app I/flutter: 2

好了,命中的流程現象與我們上面分析的一模一樣,我們結合這個例子再來就看看分發事件執行的流程是什么樣子的,我們先看看Listener底層是怎么樣的,首先他對應的renderObject是RenderPointerListener

class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
  /// Creates a render object that forwards pointer events to callbacks.
  ///
  /// The [behavior] argument defaults to [HitTestBehavior.deferToChild].
  RenderPointerListener({
    this.onPointerDown,
    this.onPointerMove,
    this.onPointerUp,
    this.onPointerHover,
    this.onPointerCancel,
    this.onPointerSignal,
    HitTestBehavior behavior = HitTestBehavior.deferToChild,
    RenderBox? child,
  }) : super(behavior: behavior, child: child);

而我們的onPointerDown函數直接賦值給他里面的一個屬性,如果Listener可以被命中的話,那么對應的RenderPointerListener對象會被加入到result命中隊列,由于事件分發的流程可知,會調用到命中對象的handleEvent分發,我們再來看看他的handleEvent方法:

  @override
  void handleEvent(PointerEvent event, HitTestEntry entry) {
    assert(debugHandleEvent(event, entry));
    if (event is PointerDownEvent)
      return onPointerDown?.call(event);
    if (event is PointerMoveEvent)
      return onPointerMove?.call(event);
    if (event is PointerUpEvent)
      return onPointerUp?.call(event);
    if (event is PointerHoverEvent)
      return onPointerHover?.call(event);
    if (event is PointerCancelEvent)
      return onPointerCancel?.call(event);
    if (event is PointerSignalEvent)
      return onPointerSignal?.call(event);
  }

看看,就是這么簡單如果是點擊Down事件的話直接執行onPointerDown方法,也就是我們再Listener中定義的打印函數

好了,到這樣我們已經結合源碼把事件命中,傳遞流程解說了一遍了,下面還剩下一個話題就是HitTestBehavior的介紹,由于很多解釋得不是很清楚,這里我順帶講一下

其實上面的例子我們就見過了HitTestBehavior的使用了,

  @override
  bool hitTest(BoxHitTestResult result, { required Offset position }) {
    bool hitTarget = false;
    if (size.contains(position)) {
      hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
      if (hitTarget || behavior == HitTestBehavior.translucent)
        result.add(BoxHitTestEntry(this, position));
    }
    return hitTarget;
  }

看一下這個流程,behavior的判斷要在hitTestChildren為false,并且hitTestSelf為false的時候才起真正的作用,其實說白了就是起一個輔助作用在hitTestChildren與hitTestSelf均未命中的情況下,如果你還想這個對象可以被加入命中隊列的話,那么可以初始化的時候給behavior賦上合適的值即可,而hitTestSelf又如下:

@override
  bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;

我們再看看這個枚舉的幾個值:

/// How to behave during hit tests.
enum HitTestBehavior {
  /// Targets that defer to their children receive events within their bounds
  /// only if one of their children is hit by the hit test.
  deferToChild,

  /// Opaque targets can be hit by hit tests, causing them to both receive
  /// events within their bounds and prevent targets visually behind them from
  /// also receiving events.
  opaque,

  /// Translucent targets both receive events within their bounds and permit
  /// targets visually behind them to also receive events.
  translucent,
}

大意就是:

  • deferToChild: 命中測試決定于子組件是否通過命中測試
  • opaque:顧名思義不透明的,也就是說自己接收命中測試,但是會阻礙前兄弟節點進行命中測試
  • translucent:顧名思義半透明,也就是說自己可以命中測試,但是不會阻礙前兄弟節點進行命中測試

其實大家可以不用糾結這個語意記不住也沒有關系,根據代碼分析一下情況便可以知道了,這也是我搞不懂為什么網上很多人在糾結這個翻譯的意思,其實這個枚舉就是用來做判斷使用的,僅此而已

好了,讓我結合這個HitTestBehavior枚舉的解釋最好再來一個例子說明下:

class HitTestBehaviorTest extends StatelessWidget {
  const HitTestBehaviorTest({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        wChild(1),
        wChild(2),
      ],
    );
  }

  Widget wChild(int index) {
    return Listener(
      //behavior: HitTestBehavior.deferToChild, // 運行這一行不會輸出
      //behavior: HitTestBehavior.opaque, // 運行這一行點擊只會輸出 2
      behavior: HitTestBehavior.translucent, // 運行這一行點擊會同時輸出 2 和 1
      onPointerDown: (e) => print(index),
      child: SizedBox.expand(),
    );
  }
}

和上面的例子差不多,但是Listener的child是SizedBox,我們先看看他對應的renderObject是RenderConstrainedBox,而由于RenderConstrainedBox繼承于RenderProxyBox,我們直接看他:

class RenderProxyBox extends RenderBox with RenderObjectWithChildMixin<RenderBox>, RenderProxyBoxMixin<RenderBox> {
  /// Creates a proxy render box.
  ///
  /// Proxy render boxes are rarely created directly because they simply proxy
  /// the render box protocol to [child]. Instead, consider using one of the
  /// subclasses.
  RenderProxyBox([RenderBox? child]) {
    this.child = child;
  }
}
@optionalTypeArgs
mixin RenderProxyBoxMixin<T extends RenderBox> on RenderBox, RenderObjectWithChildMixin<T> {
}

由于他的繼承與混合的特性所以他的hitTestChildren如下:

RenderProxyBoxMixin.hitTestChildren

@override
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
  return child?.hitTest(result, position: position) ?? false;
}

由于SizedBox是沒有子類的,所以hitTestChildren返回為false,他的hitTestSelf如下直接返回false:

RenderBox.hitTestSelf

@protected
bool hitTestSelf(Offset position) => false;

我們再來看看Listener的hitTest函數:

RenderProxyBoxWithHitTestBehavior.hitTest

@override
bool hitTest(BoxHitTestResult result, { required Offset position }) {
  bool hitTarget = false;
  if (size.contains(position)) {
    hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
    if (hitTarget || behavior == HitTestBehavior.translucent)
      result.add(BoxHitTestEntry(this, position));
  }
  return hitTarget;
}

他的hitTestChildren對應的是SizedBox我們上面分析的兩步結果為false,他的hitTestSelf如下:

@override
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;

結合我們上面分析的,如果傳遞的behavior的值為opaque的時候他自己可以被命中,但是前兄弟節點不會被命中;如果傳遞的是translucent的話那么自己可以命中,而且錢兄弟節點也可以命中,如果傳遞的是deferToChild的話,那么不會有任何的命中,

  • 所以我們在Demo中Listener中的behavior傳遞HitTestBehavior.opaque輸出2;
  • 傳遞HitTestBehavior.translucent輸出2,1;
  • 傳遞HitTestBehavior.deferToChild就不會有任何輸出;

好了,我們已經結合例子又說明了HitTestBehavior的使用及原理,今天的文章要結束啦,如果你喜歡的話記得給我點贊加關注,下一篇我們會接著說一下《GestureDetector手勢的運行以及沖突的原理》,好了就到這里了,你的點贊加關注是我寫作持續的動力,謝謝···

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容