FlutterFlutter原理篇:事件机制传播与响应机制与Hi
今天又到了我们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手势的运行以及冲突的原理》,好了就到这里了,你的点赞加关注是我写作持续的动力,谢谢···