Flutter事件

大前端最重要的两个事情,界面渲染和事件分发。下面我们通过源码和一些实例聊一聊Flutter的手势事件从接收到响应的整个过程。

以下源代码基于

  • Flutter 2.5.1
  • Dart 2.14.2

原始事件获取

在大前端中,大部分平台或系统原始指针处理方式都是类似的,按下、移动、抬起,其它高级事件如双击、滚动都是基于此来实现的。

在Flutter中,接收事件的入口是从_dispatchPointerDataPacket开始的,通过绑定回调后面会调到_handlePointerDataPacket开始处理事件

void _handlePointerDataPacket(ui.PointerDataPacket packet) {
  _pendingPointerEvents.addAll(PointerEventConverter.expand(packet.data, window.devicePixelRatio));
  if (!locked)
    _flushPointerEventQueue();
}
1
2
3
4
5

packet是从引擎层过来的手势数据,其中packet.data是一个数组,如果在电脑PC上只有鼠标操作时它的长度是1,在移动端上比如我们按下屏幕会同时触发hoverdown事件,它的长度是2,它的类型是PointerData,它有一些重要属性

  • change(PointerChange): 手势变化,如常见的hoverdownmoveup
  • kind(PointerDeviceKind): 触发手势的装置,如touch(移动设备屏幕)、mouse(鼠标)等
  • signalKind(PointerSignalKind): 除了手势外的响应方式,如鼠标滚轮滚动

PointerEventConverter.expand会将原始获取的手势数据转化为PointerEvent类,不同手势类型会继承它实现不同的类

  • PointerCancelEvent: 手势取消,一般用于重置正在运行中的手势,可Native触发,也可以调用cancelPointer触发,经常用于当跳转新页面后,将原来页面正在运行的手势重置(混合开发时,经常会碰到Flutter页跳转到Native后返回,Flutter页面卡顿,就是手势没有正确取消,可以看我这篇文章解决混合栈跳转导致Flutter页面事件卡死问题
  • PointerDownEvent: 当手指或鼠标左键按下触发
  • PointerMoveEvent: 当手指在屏幕上滑动或鼠标左键按下滑动触发
  • PointerUpEvent: 当手指移出屏幕或鼠标左键抬起时触发
  • PointerRemovedEvent: 当手势按下过程中移出到屏幕外触发
  • PointerScrollEvent: 一般由鼠标滚轮触发
  • PointerHoverEvent: 指针在屏幕上方时触发,常用于鼠标事件,触摸屏按下也会触发
  • PointerEnterEvent: 指针进入某个区域时触发,常用于鼠标事件,由PointerHoverEvent事件触发计算获得
  • PointerExitEvent: 鼠标移出某个区域时触发,常用于鼠标事件,由PointerHoverEvent事件触发计算获得

PointerEvent也有一些重要的属性

  • embedderId(int): 平台唯一标识符
  • pointer(int): 标识一次完整的手势,如手指按下、手指移动、手指抬起,当一次手势完成此标识会自增
  • kind(PointerDeviceKind): 标识手势监听的设备,如手指、鼠标
  • position(Offset): 指针相对于屏幕位置
  • delta(Offset): 指针偏移量,目前只有PointerHoverEventPointerMoveEvent有偏移值
  • buttons(int): 标识鼠标、手写笔等按键事件,比如按下鼠标右键时,触发PointerDownEvent同时buttons为kSecondaryButton,它是一个十六进制,Flutter定义了一些常量来标识,我以鼠标按键为例介绍一下
    • kPrimaryButton(0x01): 鼠标左键
    • kSecondaryButton(0x02): 鼠标右键
    • kTertiaryButton(0x04): 鼠标中键
    • kBackMouseButton(0x08): 鼠标返回键
    • kForwardMouseButton(0x08): 鼠标前进键
  • down(bool): 是否按下屏幕,如PointerDownEventPointerMoveEvent的down都为true
void _flushPointerEventQueue() {
  while (_pendingPointerEvents.isNotEmpty)
    handlePointerEvent(_pendingPointerEvents.removeFirst());
}
void handlePointerEvent(PointerEvent event) {
  if (resamplingEnabled) {
    _resampler.addOrDispatch(event);
    _resampler.sample(samplingOffset, _samplingClock);
    return;
  }
  _resampler.stop();
  _handlePointerEventImmediately(event);
}
1
2
3
4
5
6
7
8
9
10
11
12
13

上面_flushPointerEventQueue很简单,就是循环遍历已经存入的指针列表,然后执行handlePointerEventresamplingEnabled为true的话表示在一些特殊设备上可以给_handlePointerEventImmediately添加一些延时来让手势更加顺畅。

void _handlePointerEventImmediately(PointerEvent event) {
  HitTestResult? hitTestResult;
  if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent) {
    assert(!_hitTests.containsKey(event.pointer));
    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) {
    hitTestResult = _hitTests[event.pointer];
  }
  //...
  if (hitTestResult != null ||
      event is PointerAddedEvent ||
      event is PointerRemovedEvent) {
    assert(event.position != null);
    dispatchEvent(event, hitTestResult);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

_handlePointerEventImmediately过程有几个部分,当event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent时,会进行有hitTest过程,同时如果eventPointerDownEvent时,会将获得的hitTest结果缓存到_hitTests中。hitTest通常叫作命中测试,它是对事件发生范围内的组件进行收集,PointerDownEventPointerSignalEventPointerHoverEvent这三种类型的手势都是需要接触到屏幕且涉及到指针的变化.

event is PointerUpEvent || event is PointerCancelEvent时,代表一次手势的结束,所以它会将result_hitTests中移除并赋值给hitTestResult,值得注意的是这里就是通过pointer属性来确定其对应的PointerDownEvent拿到的命中测试结果的。

event.downtrue时,目前只有一种手势会进入到这里,就是PointerMoveEvent,为什么同样都是在屏幕上操作,它不应该也需要进行hitTest吗。在手势中,一般地『手指按下』、『手指移动』、『手指抬起』被认为是一次完整的手势过程,手指移动虽然位置会改变,但是它所作用的区域被认为还是在按下的区域,所以只要把按下区域的result拿来用就行了。

hitTest(命中测试)

在Flutter中,有个类似前端的事件冒泡机制叫命中测试(Hit Test),它的作用是根据事件的位置收集所有在该位置上的Widget,它先从顶部节点开始对所有RenderObject(如果对RenderObject树不了解可以先看看我的另一篇文章)进行深度遍历,然后从最底层的节点开始向上将每个节点收集到一个为HitTestResult类型的result变量中,最后根据该result进行事件分发。

例子

我以一个例子带大家看看hitTest过程Flutter底层到底做了些什么。

import 'package:flutter/material.dart';
void main() {
  runApp(MyHomePage(
    key: UniqueKey(),
  ));
}
class MyHomePage extends StatelessWidget {
  const MyHomePage({Key? key}) : super(key: key);
  
  Widget build(BuildContext context) {
    return ColoredBox(
      color: Colors.white,
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Listener(
            onPointerDown: (p) {
              print('down');
            },
            child: const ColoredBox(
              color: Colors.green,
              child: SizedBox(
                width: 100,
                height: 100,
              ),
            ),
          ),
          Listener(
            onPointerUp: (p) {
              print('up');
            },
            child: const ColoredBox(
              color: Colors.yellow,
              child: SizedBox(
                width: 100,
                height: 100,
              ),
            ),
          ),
        ],
      ),
    );
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

当我点击上面绿色的方块时,命中测试会有以下过程 hitTest

上面组件会生成如图中的RenderObject树,在RendBinding中会去调用RenderViewhitTestRenderView会先对子组件进行收集,子组件是_RenderColorBox,在看_RenderColorBoxhitTest之前,我们先看看RenderBoxhitTest

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;
}
1
2
3
4
5
6
7
8
9

它先简单判断点击位置(position)是否在当前节点范围中(需要注意的是传入的position已经是经过转化相对于当前节点的位置了),这是hitTest的关键,如果发生的事件不在组件范围,hitTest直接就失败了,就不用再遍历子组件了。

hitTestChildren

然后先调用hitTestChildren,它在这里很简单,作用是对子组件进行命中测试,如果子组件返回了true,说明子组件命中测试成功。

hitTestChildren默认返回false,因为可能当前组件没有子组件了。Flutter中比较典型的场景是当前组件存在一个子组件或多个子组件,当只有一个子组件时,大部分组件会使用RenderProxyBoxMixin这个mixin,比如SizedBoxOpacityColoredBox等,它的hitTestChildren

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

很简单,它直接调用子组件的hitTest。当有多个子组件时,比如ColumnRowStackWrapListBody等,它们都是自己重写了hitTestChildren,然后直接调用了RenderBoxContainerDefaultsMixin中的defaultHitTestChildren这个方法

bool defaultHitTestChildren(BoxHitTestResult result, { required Offset position }) {
  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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

简单解释一下,它是从最后一个子组件开始,向前遍历,result.addWithPaintOffset是计算当前子节点的偏移位置,因为传入的position是相对于父节点的偏移位置。如果一个子组件命中测试返回true,那么该hitTestChildren就直接返回true,不再遍历前面的组件了,这也是如果两个重叠的Stack子组件,上面的组件能接收到事件,而下面的组件接收不到事件的原因。

如果我想让下面的组件也能接收到事件怎么办呢,这就是behavior属性该发挥作用了。

hitTestSelf

我们假设所有组件判断自己是否可通过命中测试都是依赖子组件,而看上面我们知道如果没有子组件,hitTestChildren会返回false,那就没有组件能通过命中测试,那么整个组件树hitTest都是返回falsehitTestSelf就是解决这个问题。

hitTest过程中,如果hitTestSelf返回true就是告诉它,我是能接收事件命中的,让我来响应手势事件吧。在Flutter组件中有很多组件是直接将hitTestSelf返回true的,如MouseRegionTextureImageEditableText等组件的RenderObject对应的hitTestSelf。有些组件是需要简单的判断,如ListenerColoredBox组件使用的RenderProxyBoxWithHitTestBehavior这个mixin

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

它是通过behavior是否为HitTestBehavior.opaque来确定自己是否返回truebehavior这个东西又出现了,下面我们就来看看

behavior

hitTest过程如果通过其实就做了两个事情,我们将这两个事情取名为ABA是将自己添加到HitTestResult result中,这样我就能响应事件了,二是告诉父组件的hitTestChildren返回true。从上面RenderBox中的hitTest我们可以看到,这两个事情都是同时发生的,那么有没有可能A发生B不发生。答案就在RenderProxyBoxWithHitTestBehavior这个mixin中。

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;
}
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;
1
2
3
4
5
6
7
8
9
10

其中behavior这个属性发挥了重要的作用,它的类型HitTestBehavior是一个枚举,有三种情况

  • HitTestBehavior.deferToChild

deferToChild是默认行为,事件在组件范围内发生时,当前组件是否添加进命中测试由其子组件来决定。也就是子组件通过测试它才通过,子组件会影响当前组件的命中结果。

  • HitTestBehavior.opaque

opaque表示当事件在组件范围内发生时,不管子组件能否通过命中测试,当前组件都能通过。看上面hitTestSelf部分,只要behaviorHitTestBehavior.opaquehitTestSelf就返回true

  • HitTestBehavior.translucent

translucent就是比较神奇的存在了,当子组件没有通过命中测试返回了false,当前组件也能将自己添加到result中,但是却告诉了父组件当前组件没有通过命中测试,也就是它做了A,却没有做B

它们分别有什么作用和区别呢,下面我们通过几个示例来看看HitTestBehavior几个属性的作用。

void main() {
  runApp(MyHomePage());
}
class MyHomePage extends StatelessWidget {
  const MyHomePage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Center(
      child: Listener(
        onPointerDown: (_) {
          print("down");
        },
        child: IgnorePointer(
          child: ColoredBox(
            color: Colors.red,
            child: SizedBox(
              width: 100,
              height: 100,
            ),
          ),
        ),
      ),
    );
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

运行后会得到下面界面 示例

简单解释一下这里IgnorePointer的作用,为了有点击区域的概念,我使用了ColoredBoxSizedBox,让界面中间出现一个红色区域,但ColoredBox它本身就能接收hitTesthitTestSelf为true),为了不受它的影响,我套了一个IgnorePointerListener子组件不能通过命中测试。

上面Listener组件的behavior默认为HitTestBehavior.deferToChild,所以无论怎么点击红色区域,上面onPointerDown都不会有日志输出。

我们稍微改一下

Listener(
  behavior: HitTestBehavior.opaque,
  onPointerDown: (_) {
    print("down");
  },
  //...
)
1
2
3
4
5
6
7

现在再来点击红色区域,发现输出了down的日志。在我们日常开发中,经常会遇到一个情况,我们使用Container组件没有设置颜色时,在它外层添加的GestureDetector点击范围可能只有在文字上,当我们给它设置了个透明色后就正常了,还是因为添加了颜色后,Container组件会嵌套一个ColoredBoxColoredBox自己有通过命中测试的能力,所以就正常了,其实更好的做法是在GestureDetector上添加HitTestBehavior.opaque

我们再将上面例子改一下

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

  
  Widget build(BuildContext context) {
    return Center(
      child: Stack(
        textDirection: TextDirection.ltr,
        children: [
          Listener(
            behavior: HitTestBehavior.opaque,
            onPointerDown: (_) {
              print("down1");
            },
            child: IgnorePointer(
              child: ColoredBox(
                color: Colors.red,
                child: SizedBox(
                  width: 100,
                  height: 100,
                ),
              ),
            ),
          ),
          Positioned(
            top: 50,
            child: Listener(
              behavior: HitTestBehavior.opaque,
              onPointerDown: (_) {
                print("down2");
              },
              child: IgnorePointer(
                child: ColoredBox(
                  color: Colors.blue,
                  child: SizedBox(
                    width: 100,
                    height: 50,
                  ),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

下面蓝色方块是覆盖在红色方块上的,我们点击蓝色方块,会输出down2,但怎么都不会输出down1,如果我们将上面蓝色方块上的behavior设置成HitTestBehavior.translucent,将会输出

down2
down1
1
2

这样,不仅蓝色方块接收到了down事件,而且事件还传递到了下面的红色方块上,让红色方块也接收到了事件。至于为什么是先down2down1这个顺序,我们看看事件分发过程。

事件分发

dispatchEvent

// rendering/binding
 // from GestureBinding
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
  // 处理鼠标如`PointerExitEvent`、`PointerEnterEvent`等事件
  _mouseTracker!.updateWithEvent(
    event,
    () => (hitTestResult == null || event is PointerMoveEvent) ? renderView.hitTestMouseTrackers(event.position) : hitTestResult,
  );
  super.dispatchEvent(event, hitTestResult);
}
// gesturing/binding
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
  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) {
     //...
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

hitTestResult为null,会进行pointerRouter.routepointerRouter可以接收framework其它任何位置提供的回调,为引擎其它地方提供一个可以接收事件通知的能力。

如果hitTestResult不为空,将会循环遍历命中测试拿到的result,执行其handleEvent方法。由于这里循环是数组从左往右执行,所以命中测试和事件分发是先进先出原则,这也就解释了为什么上面示例会先输出down2再输出down1

handleEvent

handleEventHitTestTarget上的方法,RenderObject实现了HitTestTarget,所以所有的RenderObject类及子类上都可以通过实现handleEvent来接收到事件响应。

Flutter有很多组件自定义了handleEvent来处理比较复杂的事件,我们先先来看看ListenerhandleEvent


void handleEvent(PointerEvent event, HitTestEntry 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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

它的实现很简单,就是根据事件类型直接返回给Listener传入的回调,所以Listener也可以叫做原始事件监听器,像我们常用的GestureDetectorScrollable(ListViewPageView等都是基于它实现的)都是基于Listener实现的。

再看看GestureBindinghandleEvent的实现

 // from HitTestTarget
void handleEvent(PointerEvent event, HitTestEntry entry) {
  pointerRouter.route(event);
  if (event is PointerDownEvent) {
    gestureArena.close(event.pointer);
  } else if (event is PointerUpEvent) {
    gestureArena.sweep(event.pointer);
  } else if (event is PointerSignalEvent) {
    pointerSignalResolver.resolve(event);
  }
}
1
2
3
4
5
6
7
8
9
10
11

GestureBinding是每次命中测试最后都会被添加到HitTestResult result中的,所以上面的handleEvent方法每次事件分发过程的最后都会执行一次,pointerRouter.routehitTestResult为null时一样。gestureArena提供一个手势竞争场,这个后面将GestureDetector组件时再细讲。pointerSignalResolver目前只是给鼠标滚轮事件使用的,鼠标滚轮事件没有竞争场概念,所以它提供一个能让鼠标事件能够竞争的机会,比如当多个如ListView嵌套时,当发生PointerSignalEvent事件时,所有ListView都会都滚动,这显然是不对的,pointerSignalResolver就能解决这个问题。

总结

在Flutter中,当手指或鼠标接触屏幕到对应组件收到事件响应有两个过程,命中测试和事件分发,命中测试用于收集那些可以收到事件的组件,事件分发用于执行对应组件的接收事件函数,这样组件就可以在接收到事件后处理对应的逻辑。Listener是一个非常原始的事件监听组件,我们很多复杂的手势都可以基于它来实现。我突然想到一个问题,当我触摸移动一段距离再抬起时,PointerDownEventPointerMoveEventPointerUpEvent会依次触发,按照Listener组件,这时会同时触发点击事件和拖动事件,这显然不太合理,按照常理这时只会触发拖动事件,为了解决这个问题,Flutter引入了手势竞争的概念,我们后面再继续分析。

Last Updated: 7/13/2022, 12:12:03 AM