Flutter 作为一个 UI 框架,本身也有自己的事件处理方式,本文主要阐述触摸事件从 native 传递到 Flutter 后是如何被 widget 识别以及分发的。至于 native 系统是如何监听触摸事件以及传递事件到 Flutter,感兴趣的可以自己去了解下不同的宿主系统处理的方式也是不同的。
事件处理流程
Flutter 中对触摸事件的处理大致可以分为以下几个阶段:
监听事件的到来
对 widget 是否能响应事件进行命中测试
将事件分发给通过命中测试的 widget
后续将触摸事件直接称为 event
监听事件
event 是由 native 系统通过消息通道传递到 Flutter 中的,因此 Flutter 必然会有对应的监听方法或者回调,从 Flutter 启动流程的源码中可以在 mixin GestureBinding 查看到下面代码:
@override void initInstances() { super.initInstances(); _instance = this; window.onPointerDataPacket = _handlePointerDataPacket; }
复制代码
其中 window.onPointerDataPacket 正是监听 event 的回调,window是 Flutter 连接宿主操作系统的接口,其中包含了当前设备和系统的一些信息以及 Flutter Engine 的一些回调,下面展示了其部分属性。其他属性可以自行查看官方文档,注意这里的window不是 dart:html 标准库里 window 类。
class Window { // 当前设备的DPI,即一个逻辑像素显示多少物理像素,数字越大,显示效果就越精细保真。 // DPI是设备屏幕的固件属性,如Nexus 6的屏幕DPI为3.5 double get devicePixelRatio => _devicePixelRatio; // Flutter UI绘制区域的大小 Size get physicalSize => _physicalSize; // 当前系统默认的语言Locale Locale get locale; // 当前系统字体缩放比例。 double get textScaleFactor => _textScaleFactor; // 当绘制区域大小改变回调 VoidCallback get onMetricsChanged => _onMetricsChanged; // Locale发生变化回调 VoidCallback get onLocaleChanged => _onLocaleChanged; // 系统字体缩放变化回调 VoidCallback get onTextScaleFactorChanged => _onTextScaleFactorChanged; // 绘制前回调,一般会受显示器的垂直同步信号VSync驱动,当屏幕刷新时就会被调用 FrameCallback get onBeginFrame => _onBeginFrame; // 绘制回调 VoidCallback get onDrawFrame => _onDrawFrame; // 点击或指针事件回调 PointerDataPacketCallback get onPointerDataPacket => _onPointerDataPacket; // 调度Frame,该方法执行后,onBeginFrame和onDrawFrame将紧接着会在合适时机被调用, // 此方法会直接调用Flutter engine的Window_scheduleFrame方法 void scheduleFrame() native 'Window_scheduleFrame'; // 更新应用在GPU上的渲染,此方法会直接调用Flutter engine的Window_render方法 void render(Scene scene) native 'Window_render'; // 发送平台消息 void sendPlatformMessage(String name, ByteData data, PlatformMessageResponseCallback callback) ; // 平台通道消息处理回调 PlatformMessageCallback get onPlatformMessage => _onPlatformMessage; ... //其它属性及回调 }
复制代码
现在我们有了 event 在 Flutter 端的入口函数_handlePointerDataPacket,通过这个函数我们可以查看 Flutter 接收到 event 后是如何操作的,比较简单我们直接看下代码。
_handlePointerDataPacket
将 event 做一次转换,然后添加到一个队列中
///_pendingPointerEvents: Queue<PointerEvent>类型的队列 ///locked: 通过标记位来实现的一个锁 void _handlePointerDataPacket(ui.PointerDataPacket packet) { // We convert pointer data to logical pixels so that e.g. the touch slop can be // defined in a device-independent manner. _pendingPointerEvents.addAll(PointerEventConverter.expand(packet.data, window.devicePixelRatio)); if (!locked) _flushPointerEventQueue(); }
复制代码
_flushPointerEventQueue
遍历上面的队列,locked 可以理解为一个简单的信号量(锁),调用对应的 handlePointerEvent,handlePointerEvent 内直接调用_handlePointerEventImmediately 方法。
void _flushPointerEventQueue() { assert(!locked); while (_pendingPointerEvents.isNotEmpty) handlePointerEvent(_pendingPointerEvents.removeFirst()); }
///handlePointerEvent :默认啥也没干就是调用了_handlePointerEventImmediately方法 ///简化后的代码 void handlePointerEvent(PointerEvent event) { _handlePointerEventImmediately(event); }
复制代码
_handlePointerEventImmediately
核心方法:根据不同事件类型开启不同的流程,这里我们只关心 PointerDownEvent 事件。
可以看到当 flutter 监听到 PointerDownEvent 时,会对指定位置开启命中测试流程。
Flutter 中包含多种事件类型:可以在 lib->src->gesture->event.dart 中查看具体信息
// PointerDownEvent: 手指在屏幕按下是产生的事件void _handlePointerEventImmediately(PointerEvent event) { HitTestResult? hitTestResult; if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent) {//down assert(!_hitTests.containsKey(event.pointer)); ///存储通过命中测试的widget hitTestResult = HitTestResult(); ///开始命中测试 hitTest(hitTestResult, event.position); ///测试完成后会将通过命中测试的结果存放到一个全局map对象里 if (event is PointerDownEvent) { _hitTests[event.pointer] = hitTestResult; } } else if (event is PointerUpEvent || event is PointerCancelEvent) {//cancel hitTestResult = _hitTests.remove(event.pointer); } else if (event.down) {//move hitTestResult = _hitTests[event.pointer]; } if (hitTestResult != null || event is PointerAddedEvent || event is PointerRemovedEvent) { assert(event.position != null); ///分发事件 dispatchEvent(event, hitTestResult); } }
复制代码
本阶段主要内容:
注册了监听事件的回调:_handlePointerDataPacket
接收事件后,将转换后的事件放到一个 queue 中:_flushPointerEventQueue
遍历 queue 开始命中测试流程:_handlePointerEventImmediately-> hitTest(hitTestResult, event.position)
命中测试
目的是确定在给定的 event 的位置上有哪些渲染对象(renderObject),并且在这个过程中会将通过命中测试的对象存放在上文中的 HitTestResult 对象中。 通过源码调用流程看下 flutter 内部是如何进行命中测试的,在这些流程中那些我们是可以控制的。
准备
开始命中测试源码分析之前先看下下面的代码,这是 Flutter 入口函数 main 方法中调用 runApp 初始化的核心方法,这里 WidgetsFlutterBinding 实现了多个 mixin,而这些 mixin 中有多个都实现了 hitTest 方法,这种情况下离 with 关键字远的优先执行,所以在_handlePointerEventImmediately 中调用的 hitTest 方法是在 RendererBinding 中而不是 GestureBinding。具体细节可以去了解下 dart 中 with 多个 mixin 且每个 mixin 中都包含同一个方法时的调用关系,简单说就是会先调用最后 with 的 mixin。
class WidgetsFlutterBinding extends BindingBase with GestureBinding, SchedulerBinding, ServicesBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding { static WidgetsBinding ensureInitialized() { if (WidgetsBinding.instance == null) WidgetsFlutterBinding(); return WidgetsBinding.instance!; } }
复制代码
RendererBinding. hitTest: 命中测试的开始方法
主要作用是调用渲染树根节点的 hitTest 方法
@override void hitTest(HitTestResult result, Offset position) { assert(renderView != null); assert(result != null); assert(position != null); /// renderView:渲染树根节点,继承自RenderObject renderView.hitTest(result, position: position); super.hitTest(result, position); }
复制代码
RendererBinding.renderView:
渲染树的根节点
/// The render tree that's attached to the output surface. RenderView get renderView => _pipelineOwner.rootNode! as RenderView; /// Sets the given [RenderView] object (which must not be null), and its tree, to /// be the new render tree to display. The previous tree, if any, is detached. set renderView(RenderView value) { assert(value != null); _pipelineOwner.rootNode = value; }
复制代码
RenderView.hitTest
根节点的 hitTest 方法实现中有两个注意点:
根节点必然会被添加到 HitTestResult 中,默认通过命中测试
从这里开始下面的调用流程就是和 child 类型相关了
child 重写了 hitTest 调用重写后的方法
child 没有重写则调用父类 RenderBox 的默认实现
bool hitTest(HitTestResult result, { required Offset position }) { ///child是一个 RenderObject 对象 if (child != null) child!.hitTest(BoxHitTestResult.wrap(result), position: position); result.add(HitTestEntry(this)); return true; }
复制代码
RenderBox.hitTest
默认实现的方法,如果 child 没有重写则会调用到此方法,内部主要包含下面两个方法的调用:
/// 移除了断言后的代码 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; } /// RenderBox中默认实现都是返回的false @protected bool hitTestSelf(Offset position) => false; @protected bool hitTestChildren(BoxHitTestResult result, { required Offset position }) => false;
复制代码
重写 hitTest:
在这个例子里,我们自定义一个 widget,重写其 hitTest 方法,看下调用流程。
void main() { runApp( MyAPP()); } class MyAPP extends StatelessWidget { const MyAPP({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Container( child: DuTestListener(), ); } } class DuTestListener extends SingleChildRenderObjectWidget { DuTestListener({Key? key, this.onPointerDown, Widget? child}) : super(key: key, child: child); final PointerDownEventListener? onPointerDown; @override RenderObject createRenderObject(BuildContext context) => DuTestRenderObject()..onPointerDown = onPointerDown; @override void updateRenderObject( BuildContext context, DuTestRenderObject renderObject) { renderObject.onPointerDown = onPointerDown; } } class DuTestRenderObject extends RenderProxyBox { PointerDownEventListener? onPointerDown; @override bool hitTestSelf(Offset position) => true; //始终通过命中测试 @override void handleEvent(PointerEvent event, covariant HitTestEntry entry) { //事件分发时处理事件 if (event is PointerDownEvent) onPointerDown?.call(event); } @override bool hitTest(BoxHitTestResult result, {required Offset position}) { // TODO: implement hitTest print('ss'); result.add(BoxHitTestEntry(this, position)); return true; } }
复制代码
点击屏幕(黑色的)展示下面的调用栈:
子类重写 HitTest 后,在 RenderView 后,直接调用了我们重载的 hitTest 方法,完全印证了我们上面分析的逻辑
常用 widget 分析
本节来分析下 Flutter 中的 Center、Column,看下 Flutter 是如何处理 child 和 children 两种类型的 hitTest.
Center
继承:Center->Align->SingleChildRenderObjectWidget
在 Align 中重写 createRenderObject 返回 RenderPositionedBox 类。RenderPositionedBox 本身没有重写 hitTest 方法,但在其父类的父类 RenderShiftedBox 中重写了 hitTestChildren 方法
hitTestChildren
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) { if (child != null) { ///父组件在传递约束到子widget时,会计算一些子widget在父widget中的偏移,这些数据通常存在BoxParentData中 ///这里就使用子widget在父widget中的偏移 final BoxParentData childParentData = child!.parentData! as BoxParentData; return result.addWithPaintOffset( offset: childParentData.offset, position: position, hitTest: (BoxHitTestResult result, Offset? transformed) { assert(transformed == position - childParentData.offset); ///递归调用child的hitTest方法 ///transformed转换后的位置 return child!.hitTest(result, position: transformed!); }, ); } return false; } addWithPaintOffset bool addWithPaintOffset({ required Offset? offset, required Offset position, required BoxHitTest hitTest, }) { ///做一些坐标转换 final Offset transformedPosition = offset == null ? position : position - offset; if (offset != null) { pushOffset(-offset); } ///回调callBack final bool isHit = hitTest(this, transformedPosition); if (offset != null) { popTransform(); } return isHit; }
复制代码
将上面示例中 MyApp 中的 build 换成下面代码,在来看下调用栈
@override Widget build(BuildContext context) { return Container( child: Center(child: DuTestListener()), ); }
复制代码
调用栈:
很清晰,因为 Center 相关父类没有重写 hitTest 方法,所以 renderView 中直接调用基类 RenderBox 中的 hitTest,这个 hitTest 中又调用了被重写的 hitTestChildren,在 hitTestChildren 中通过递归的方式对 widget 进行命中测试。
Column
继承:Column->Flex->MultiChildRenderObjectWidget
RenderFlex 在 Flex 中重写 createRenderObject 返回 RenderFlex,RenderFlex 本身没有重写 hitTest 方法,而是重写了 hitTestChildren 方法
hitTestChildren
内部直接调用了 RenderBoxContainerDefaultsMixin.defaultHitTestChildren 方法
@override bool hitTestChildren(BoxHitTestResult result, { required Offset position }) { return defaultHitTestChildren(result, position: position); } RenderBoxContainerDefaultsMixin.defaultHitTestChildren 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; }
复制代码
Center 和 Colunm 一个是包含单个 widget,一个包含多个 widget,而且都是重写了 hitTestChildren 方法来控制命中测试,两者主要区别就在于 Colunm 的 hitTestChildren 使用了 while 循环来遍历自己的子 widget 进行命中测试。而且 Colunm 遍历顺序是先遍历 lastchild,如果 lastchild 没有通过命中测试,则会继续遍历它的兄弟节点,如果 lastchild 通过命中测试,这直接 return true,其兄弟节点没有机会进行命中测试,这种遍历方式也可以叫做深度优先遍历。
如果需要兄弟节点也可以通过命中测试,可以参考<Flutter 实战> 8.3 节的描述,这里不在展开
将上面事例中 MyApp 中的 build 换成下面代码,在来看下调用栈
@override Widget build(BuildContext context) { return Container( child: Column( children: [ DuTestListener(), DuTestListener() ], ) ); }
复制代码
调用栈
虽然我们包含了两个 DuTestListener,但是最终只会调用一次 DuTestListener 的 hitTest 方法,就是因为 lastChid 已经通过命中测试,它的兄弟节点没有机会进行命中测试了。
流程图:
命中测试小结:
从 Render Tree 的节点开始向下遍历子树
遍历的方式:深度优先遍历
可以通过重写 hitTest、hitTestChildren、hitTestSelf 来自定义命中测试相关的操作
存在兄弟节点时,从最后一个开始遍历,任何一个通过命中测试,则终止遍历,未遍历的兄弟节点没有机会在参与。
深度优先遍历的过程会先对子 widget 进行命中测试,因此子 widget 会先于父 widget 添加到 BoxHitTestResult 中。
所有通过命中测试的 widget 会被添加到 BoxHitTestResult 内一个数组中,用于事件分发。
注意:hitTest 方法的返回值不会影响是否通过命中测试,只有被添加到 BoxHitTestResult 中的 widget 才是通过命中测试的。
事件分发
完成所有节点的命中测试后,代码返回到 GestureBinding._handlePointerEventImmediately,将通过命中测试的 hitTestResult 存储在一个全局的 Map 对象_hitTests 里,key 为 event.pointer,而后调用 dispatchEvent 方法进行事件分发。
GestrueBinding.dispatchEvent
///精简后的代码 void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) { assert(!locked); if (hitTestResult == null) { assert(event is PointerAddedEvent || event is PointerRemovedEvent); pointerRouter.route(event); return; } for (final HitTestEntry entry in hitTestResult.path) { entry.target.handleEvent(event.transformed(entry.transform), entry); } }
复制代码
通过源码可以看到 dispatchEvent 函数的的作用就是遍历通过命中测试的节点,然后调用对应的 handleEvent 方法,子类可以重写 handleEvent 方法来监听事件的分发。
仍然以上面的代码为例看下调用栈:
和我们想的一致从 dispatchEvent 方法开始,调用我们自定义的 widget 中的 handleEvent。
小结:
总结
本文主要通过源码的调用流程结合一些简单的事例来分析 flutter 中事件的响应原理,这里讨论的只是最基础的事件处理流程,Flutter 在这些基础流程上封装了事件监听、手势处理以及层叠组件这些更加语义化的 widget,感兴趣的同学可以自己取看下对应的源码。
文/阿宝
关注得物技术,做最潮技术人!
评论