上篇通过一个需求熟悉了Flutter中常见的几种事件处理相关组件,这一篇再通过一个Bug具体了解下Flutter事件分发过程。
发现问题
对于视频帖子,点击视频区域会出现蒙层,可以进行拖动进度、全屏观看等操作。在一次体验中,突然发现全屏按钮点击无响应,于是开始排查。

定位原因
首先验证全屏按钮的点击事件是否正常,经测试发现事件本身没有问题,而是根本没有接收到点击事件。那事件被谁消费了呢?于是又扩大范围找相关的布局和组件。
视频和视频蒙层都收拢在MediaContentDetailWidget组件,因为在其他场景表现是正常的,所以问题出在上一层。视频区底部有个工具栏,通过Stack布局组合的,发现最近有一笔改动是加了个渐变背景,如图:

尝试去掉这个背景之后可以正常全屏了!那么前后区别到底是什么呢?toolbar是宽度自适应的(Row mainAxisSize: MainAxisSize.min),没加背景之前是不会挡住全屏按钮,设置了背景后会尽可能占用水平方向的空间,因此正好挡住了它。

此时又产生了一个疑问,虽然工具栏置于视频上方,但仅仅是工具icon有点击行为,整体并没设置点击事件,为什么会影响到事件继续传递到后面的组件?
有这种想法源于在原生的事件分发中,会先根据view定义顺序和z轴高度创建分发列表preorderedList,然后从列表第一个开始检查子view是否在点击范围内以及是否能接受触摸事件,满足条件的view基本都会通过onTouchEvent处理事件,也就是使用onClickListener或onLongClickListener消费事件,如果不能消费就继续循环寻找合适的子view。所以一般判断view能否消费事件就看有没有绑定单击和双击事件。

实际上Flutter事件分发的与Android原理一致,只是细节上有区别:仍然按照深度优先遍历渲染树,但在创建分发列表HitTestResult时就会判断好是否在点击范围内、能否消费事件等,而判断消费事件的依据不同,不是看有无绑定事件,而是看hitTest结果是否为true(具体是先调用hitTestChildren() 判断有无子节点通过命中测试,若没有再调用hitTestSelf)。这样分发过程就变简单了,遍历HitTestResult调用每一个节点的 handleEvent方法即可。
实例中的渐变背景使用了DecoratedBox(RenderDecoratedBox)组件,查看源码可知点击后其hitTestSelf会返回true,所以直接通过命中测试,导致 Stack (RenderStack)的 hitTestChildren 直接返回,就不再遍历其它子节点了。
/// 1-RenderDecoratedBox
class RenderDecoratedBox extends RenderProxyBox {
...
@override
bool hitTestSelf(Offset position) {
// 调用 Decoration # hitTest
return _decoration.hitTest(size, position, textDirection: configuration.textDirection);
}
...
}
/// 2-Decoration
abstract class Decoration with Diagnosticable {
...
/// 默认消费
bool hitTest(Size size, Offset position, { TextDirection? textDirection }) => true;
...
}
/// 3-RenderStack
class RenderStack extends RenderBox
with ContainerRenderObjectMixin<RenderBox, StackParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, StackParentData> {
...
@override
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
return defaultHitTestChildren(result, position: position);
}
bool defaultHitTestChildren(BoxHitTestResult result, { required Offset position }) {
ChildType? child = lastChild;
while (child != null) {
...
// 找到一个返回停止遍历
if (isHit)
return true;
child = childParentData.previousSibling;
}
return false;
}
...
}
实际上很多组件的hitTestSelf是true,如Text(RenderParagraph)、Image(RenderImage)、ColoredBox(会判断 behavior是否为HitTestBehavior.opaque,默认是)。因为Container是组合容器,如果有设置颜色color和背景decoration的话也会默认消费。

解决办法
想让组件阻止自己或子组件消费点击事件可以使用 IgnorePointer 和 AbsorbPointer ,原理就是hitTestChildren直接返回false,其后代将无法参与命中测试。两者区别是 AbsorbPointer 本身还会接收指针事件但子组件不会, IgnorePointer 本身和子组件都不会。
在这个实例中,因为组件优先级和能否点击关系是:视频组件(需要点击) < 渐变背景(不需要点击) < 工具栏(需要点击),所以不能把渐变背景和工具栏绑定在一起再使用 IgnorePointer,而是将背景单拎出来,这样就不会影响到两边的点击了。

总结
对于常用的线性布局(Row和Column)和流式布局(Wrap)因为不存在堆叠关系点击层级是比较直观是不容易出错的,需要关注的是层叠布局Stack上的组件,组件可重叠、部分场景需要上层组件背景很浅是甚至透明以凸显后面的组件,修改时多考虑下是否会影响到后面的组件。