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手势的运行以及冲突的原理》,好了就到这里了,你的点赞加关注是我写作持续的动力,谢谢···

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,026评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,655评论 1 296
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,726评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,204评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,558评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,731评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,944评论 2 314
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,698评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,438评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,633评论 2 247
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,125评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,444评论 3 255
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,137评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,103评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,888评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,772评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,669评论 2 271

推荐阅读更多精彩内容