Flutter中的事件流和手势简析

事件流

在你点击按钮,滑动列表,缩放图片等等交互过程中,在背后却有成千上百的事件触发,如何处理这些事件?如何掌控事件的流动?无论在web, android或者ios,都是学习的一个难点,在Flutter同理也是一样,究竟Flutter的事件流有啥特别之处,接下来就慢慢展示给大家。

从根源开始

事件从哪里来?一般来说都不需要应用开发者去担心事件是如何从硬件收集起来的,但是事件的传递总需要有个源头。
在Flutter里面主要处理事件和手势相关的就在gestures文件夹下。
而Flutter框架事件的源头就在gestures/binding.dart里的GestureBinding类开始:

void initInstances() {
    super.initInstances();
    _instance = this;
    ui.window.onPointerDataPacket = _handlePointerDataPacket;
  }

可见事件是由ui.window.onPointerDataPacket产生,把事件传给GestureBinding._handlePointerDataPacket方法,而ui.window这个就是sky引擎的实现,以后有机会再去深入,现在只需关注上层。
纵观整个代码,会发现有很多binding,SchedulerBinding,GestureBinding,ServicesBinding,RendererBinding和WidgetsBinding等都跟引擎相关的,以后再慢慢逐个分析。
接着继续跟踪方法的调用过程:

clipboard.png

先看_handlePointerEvent方法:

void _handlePointerEvent(PointerEvent event) {
    assert(!locked);
    HitTestResult result;
    if (event is PointerDownEvent) {
      assert(!_hitTests.containsKey(event.pointer));
      result = new HitTestResult();
      hitTest(result, event.position);
      _hitTests[event.pointer] = result;
      assert(() {
        if (debugPrintHitTestResults)
          debugPrint('$event: $result');
        return true;
      }());
    } else if (event is PointerUpEvent || event is PointerCancelEvent) {
      result = _hitTests.remove(event.pointer);
    } else if (event.down) {
      result = _hitTests[event.pointer];
    } else {
      return;  // We currently ignore add, remove, and hover move events.
    }
    if (result != null)
      dispatchEvent(event, result);
  }

当是PointerDownEvent事件的时候,会新建一个HitTestResult对象,而这个HitTestResult对象里面有一个path的属性,可以推测这个属性就是用来记录事件传递所经过的的节点。
新建HitTestResult对象后,接下来重点就是调用GestureBinding.histTest方法。
在看看hitTest方法:

void hitTest(HitTestResult result, Offset position) {
    result.add(new HitTestEntry(this));
  }

这里把自身添加到HitTestResult上,意味着以后dispatchEvent时候会遍历path上的HitTestEntry,也会调起GestureBinding.handleEvent方法。
接着再看handleEvent方法:

 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);
    }
  }

这里就看到pointerRouter路由事件,以及手势相关的一些处理,手势等会再说。
但是看完整个方法调用都没看到事件是如何传递到节点树上,而pointerRouter仅仅是一个观察者模式的实现,找遍了代码也没找到对应的listener,事件是如何传递?我们的点击事件是如何响应?依然不清楚。

柳暗花明

既然GestureBinding上并没有事件如何传递节点树的实现,再看哪里用到这个类,总有地方需要依赖它的。
很快就注意到WidgetsFlutterBinding这个类了。

class WidgetsFlutterBinding extends BindingBase with SchedulerBinding, GestureBinding, ServicesBinding, RendererBinding, WidgetsBinding {
  static WidgetsBinding ensureInitialized() {
    if (WidgetsBinding.instance == null)
      new WidgetsFlutterBinding();
    return WidgetsBinding.instance;
  }
}

WidgetsFlutterBinding这个类mixin了好几个Binding,同时这个类也是框架的初始化入口,当我们跑起整个Flutter应用时:

void main() {
  runApp(new MyApp());
}

runApp其实就会执行WidgetsFlutterBinding.ensureInitialized方法初始化各个Binding:

void runApp(Widget app) {
  WidgetsFlutterBinding.ensureInitialized()
    ..attachRootWidget(app)
    ..scheduleWarmUpFrame();
}

然后attachRootWidget方法,就去设置根节点了:

void attachRootWidget(Widget rootWidget) {
    _renderViewElement = new RenderObjectToWidgetAdapter<RenderBox>(
      container: renderView,
      debugShortDescription: '[root]',
      child: rootWidget
    ).attachToRenderTree(buildOwner, renderViewElement);
  }

这里看到了真正的根节点renderView,事件怎样也应该从根节点开始传递吧。
沿着renderView就找到RndererBinding.hitTest方法:

void hitTest(HitTestResult result, Offset position) {
    assert(renderView != null);
    renderView.hitTest(result, position: position);
    // This super call is safe since it will be bound to a mixed-in declaration.
    super.hitTest(result, position); // ignore: abstract_super_member_reference
  }

到这里基本可以确定先调用RendererBinding.histTest方法接着调用GestureBinding.histTest方法。
再回头GestureBinding的实现也就是先让renderView.hitTest方法去确定事件传递路径,都添加到HitTestResult的path上,最后再添加GestureBinding自身作为最后的一个HitTestEntry。
而GestureBindg.dispatchEvent会遍历这些HitTestEntry调用他们的handleEvent方法:

void dispatchEvent(PointerEvent event, HitTestResult result) {
    assert(!locked);
    assert(result != null);
    for (HitTestEntry entry in result.path) {
      try {
        entry.target.handleEvent(event, entry);
      } catch (exception, stack) {
        FlutterError.reportError(new FlutterErrorDetailsForPointerEventDispatcher(
          exception: exception,
          stack: stack,
          library: 'gesture library',
          context: 'while dispatching a pointer event',
          event: event,
          hitTestEntry: entry,
          informationCollector: (StringBuffer information) {
            information.writeln('Event:');
            information.writeln('  $event');
            information.writeln('Target:');
            information.write('  ${entry.target}');
          }
        ));
      }
    }
  }

还有一个重点就是节点上hitTest方法实现,而节点一般都是继承自RenderBox的实现:

bool hitTest(HitTestResult result, { @required Offset position }) {
    if (_size.contains(position)) {
      if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
        result.add(new BoxHitTestEntry(this, position));
        return true;
      }
    }
    return false;
  }

当然首先判断点击是否在节点位置上,然后再交给children处理,接着自身处理,如果hitTestChildren或者hitTestSelf返回true,就把当前节点加入到HitTestResult上。
这个时候HitTestResult中的路径顺序一般就是:

目标节点-->父节点-->根节点-->GestureBinding

接着PointerDown,PointerMove,PointerUp,PointerCancel等事件分发,都根据这个顺序来遍历调用它们的handleEvent方法,就像浏览器事件的冒泡过程一样,既然像冒泡一样,搞过web开发的同学都知道,浏览器是可以用代码阻止冒泡的,那Flutter行不行尼?答案,暂时还没有发现有方法可以阻止这个冒泡过程。

手势

现在已经清楚框架的事件流,现在开始深入框架的手势系统。

GestureDector

The GestureDetector widget decides which gestures to attempt to recognize based on which of its callbacks are non-null.

根据文档所说GestureDetector控件可以检测手势,并且根据手势调起相应回调。

clipboard.png

GestureDector真的支持了相当多的手势,基本上常用都有了,框架实在太给力!

那GestureDector控件为什么有这么大本领,而手势是如何检测的尼?

先对这个控件层层剥皮,看它的build方法:

  Widget build(BuildContext context) {
    final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{};
    ...
    return new RawGestureDetector(
      gestures: gestures,
      behavior: behavior,
      excludeFromSemantics: excludeFromSemantics,
      child: child,
    );
  }
}

可以看到GestureDector其实就是根据注册的回调,添加对应的GestureRecognizer(手势识别器),并全传递到RawGestureDetector。
而RawGestureDetector的build方法:

Widget build(BuildContext context) {
    Widget result = new Listener(
      onPointerDown: _handlePointerDown,
      behavior: widget.behavior ?? _defaultBehavior,
      child: widget.child
    );
    if (!widget.excludeFromSemantics)
      result = new _GestureSemantics(owner: this, child: result);
    return result;
  }

关键在于_handlePointerDown方法:

void _handlePointerDown(PointerDownEvent event) {
    assert(_recognizers != null);
    for (GestureRecognizer recognizer in _recognizers.values)
      recognizer.addPointer(event);
  }

遍历_recognizers(手势识别器)调用addPointer方法,一般来说recognizer都是继承自PrimaryPointerGestureRecognizer的实现:

void addPointer(PointerDownEvent event) {
    startTrackingPointer(event.pointer);
    if (state == GestureRecognizerState.ready) {
      state = GestureRecognizerState.possible;
      primaryPointer = event.pointer;
      initialPosition = event.position;
      if (deadline != null)
        _timer = new Timer(deadline, didExceedDeadline);
    }
  }

到这里先理一下流程,当确定PointerDown事件落在GestureDector控件下的子组件时,在GestureDector上注册的GesutreRecognizer就会追踪这个pointer(就是我们的手指),注意了这里还是设置一个Timer后面再说有什么作用,先看startTrackingPointer方法:

 void startTrackingPointer(int pointer) {
    GestureBinding.instance.pointerRouter.addRoute(pointer, handleEvent);
    _trackedPointers.add(pointer);
    assert(!_entries.containsValue(pointer));
    _entries[pointer] = _addPointerToArena(pointer);
  }

啊哈,这里用到了GestureBinding.instance.pointerRouter,还记得上面提到的吗,事件传递的最后一站其实就是GestureBinding,然后调用它的handleEvent方法,到最后就是调用pointer.route方法路由事件,所以还要调用GestureRecognizer的handleEvent方法。
接着再看GestureRcognizer._addPointerToArea方法

GestureArenaEntry _addPointerToArena(int pointer) {
    if (_team != null)
      return _team.add(pointer, this);
    return GestureBinding.instance.gestureArena.add(pointer, this);
  }

这里又用到GestureBinding.instance.gestureArena,其实就是GestureArenaManager,再看add方法:

GestureArenaEntry add(int pointer, GestureArenaMember member) {
    final _GestureArena state = _arenas.putIfAbsent(pointer, () {
      assert(_debugLogDiagnostic(pointer, '★ Opening new gesture arena.'));
      return new _GestureArena();
    });
    state.add(member);
    assert(_debugLogDiagnostic(pointer, 'Adding: $member'));
    return new GestureArenaEntry._(this, pointer, member);
  }

这里就是新建了一个GestureArenaEntry对象,好吧,我们得整理一下他们的关系:

class GestureArenaManager {
    final Map<int, _GestureArena> _arenas = <int, _GestureArena>{};
}

class _GestureArena {
    final List<GestureArenaMember> members = <GestureArenaMember>[];
}

class OneSequenceGestureRecognizer extends GestureArenaMember {
    final Map<int, GestureArenaEntry> _entries = <int, GestureArenaEntry>{};
}

下面用个形象一点的方法描述它们的关系:
首先我们有一批竞技选手(各种Recognizer),我们也可能会有好几个竞技场地(_GestureArena),我们的场地管理员(GestureArenaManager)会根据Pointer的多少来构建场地,但是各个选手也要拿到每个竞技场的入场券(GestureArenaEntry)才能入场与其他选手一较高下。
当我们的选手拿着对应的入场券进场后,现在各个场地都聚集了一批选手,叮的一声(PointerDown事件),各个场地入口关闭,过了一会激烈的竞技,又叮的一声(PointerUp事件)竞技结束,我们就要打扫竞技场看一下哪一位选手胜利了。
这里PointerDown事件和PointerUp事件控制场地关闭和打扫,主要代码在GestureBinding.handleEvent方法上,上面就有提到,这里就不贴了。
那么怎么判断哪个手势是最后赢得胜利留下来的呢,不像现实竞技场那么残酷,这里是很斯文优雅的,对手自己会判断是否要退出竞争,判断条件当然是PointerDown,PointerMove,PointerUp事件传递的信息是否符合当前手势的定义,如果不符合就自动退出,如果符合就向竞技场(_GestureArena)申请我符合条件,请判我获取胜利,其他手势只能判断为失败了。
但是这里也会有一些情况需要特别处理:

  • 如果参与者只有一个,或者其他参与者退出后只剩一个,就会让唯一剩下的参与为胜利
  • 如果没有手势请求获取胜利,竞技场也没被其他手势hold住,怎么办,那么竞技场调用sweep方法会让默认第一个手势会判断为胜利,其他判断为失败
  • 如果手势之间有冲突,例如一个DoubleTap和一个Tap,DoubleTap手势可以请求竞技场Hold住(等一下不要那么快打扫,判断优胜者),但是请求竞技场hold住的手势,必须之后主动请求竞技场release(好了,你可以打扫了),等DoubleTap手势决定是否是优胜还是自动退出,就可以知道Tap手势是否最终生效,这样看Tap手势好像不会乱搞事情,就静静的等待所有对手退出,自己最终符合第一或者第二个条件,而判断为胜利。

所以整个竞技场的核心,只是仅仅让当前手势知道已经没有别的手势竞争,可以自己判断是否符合当前手势的定义而触发相应的事件,所以竞技场胜利的一方并不是百分百触发手势的,获得竞技场胜利只是触发手势的必要非充分条件。
当然整个机制还是有点出入的,下面还会继续分析。

举个栗子

例如TapGestureRecognizer,在不存在竞争的情况时,当GestureAreaManager.close调起时:

void close(int pointer) {
    final _GestureArena state = _arenas[pointer];
    if (state == null)
      return;  // This arena either never existed or has been resolved.
    state.isOpen = false;
    assert(_debugLogDiagnostic(pointer, 'Closing', state));
    _tryToResolveArena(pointer, state);
  }

就会接着调起_tryToResolveArena方法:

void _tryToResolveArena(int pointer, _GestureArena state) {
    assert(_arenas[pointer] == state);
    assert(!state.isOpen);
    if (state.members.length == 1) {
      //没有竞争的情况
      scheduleMicrotask(() => _resolveByDefault(pointer, state));
    } else if (state.members.isEmpty) {
      _arenas.remove(pointer);
      assert(_debugLogDiagnostic(pointer, 'Arena empty.'));
    } else if (state.eagerWinner != null) {
      assert(_debugLogDiagnostic(pointer, 'Eager winner: ${state.eagerWinner}'));
      _resolveInFavorOf(pointer, state, state.eagerWinner);
    }
  }

因为是没有竞争者,所以就会跳进_resolveByDefault方法:

void _resolveByDefault(int pointer, _GestureArena state) {
    if (!_arenas.containsKey(pointer))
      return;  // Already resolved earlier.
    assert(_arenas[pointer] == state);
    assert(!state.isOpen);
    final List<GestureArenaMember> members = state.members;
    assert(members.length == 1);
    _arenas.remove(pointer);
    assert(_debugLogDiagnostic(pointer, 'Default winner: ${state.members.first}'));
    state.members.first.acceptGesture(pointer);
  }

这里最后就调起TapGestureRecognizer.acceptGesture方法:

void acceptGesture(int pointer) {
    super.acceptGesture(pointer);
    if (pointer == primaryPointer) {
      _checkDown();
      _wonArenaForPrimaryPointer = true;
      _checkUp();
    }
 }

_checkDown会尝试调起onTapDown,这里可以说onTapDown会立即调用,_checkUp会尝试调起onTapUp,onTap的回调(至少等onPointerUp事件触发才会成功)。

接下来我们考虑如果父节点监听了Tap手势,也就是出现竞争情况,两个都是TapGestureRecognizer,情况会怎样的尼?

很明显GestureAreaManager.close方法中的_tryToResolveArena方法并没有起到啥作用,这个时候大家还记得deadline这个超时时间吗,TapGestureRecognizer设置的超时时间为100毫秒,当我们按下的时间超过100毫秒
TapGestureRecognizer.didExceedDeadline就会调用接着调起_checkDown方法(意味着onTapDown触发有可能延迟100毫秒,并不完全是你点下的一瞬间就触发),但是我们点击的时间很快(低于100毫秒)的时候又怎样尼?

别忘了在GestureAreaManager的方法处理之前,pointerRouter先会路由事件,直接调起 TapGestureRecognizer.handleEvent
-->TapGestureRecognizer.handlePrimaryPointer
-->TapGestureRecognizer._checkUp
-->TapGestureRecognizer.stopTrackingIfPointerNoLongerDown
-->TapGestureRecognizer.didStopTrackingLastPointer

既然我们在上面事件流的分析知道,事件流就类似浏览器事件冒泡的方式,所以注册在pointerRouter的监听器也是子组件优先调用接着是父组件。接着stopTrackingIfPointerNoLongerDown方法将注册的监听器从pointerRouter移除,didStopTrackingLastPointer方法把TapGestureRecognizer的状态设置成ready,准备好下次手势处理。

这里再简单介绍一下GestureRecognizer的几个状态:

  • ready 初始状态准备好识别手势
  • possible 开始追踪pointer,不停接收路由的事件,如果停止追踪,则吧状态转回ready
  • defunct 手势已经被决议(accepted或者rejected)

最后就是打扫竞技场了:

void sweep(int pointer) {
    final _GestureArena state = _arenas[pointer];
    if (state == null)
      return;  // This arena either never existed or has been resolved.
    assert(!state.isOpen);
    if (state.isHeld) {
      state.hasPendingSweep = true;
      assert(_debugLogDiagnostic(pointer, 'Delaying sweep', state));
      return;  // This arena is being held for a long-lived member.
    }
    assert(_debugLogDiagnostic(pointer, 'Sweeping', state));
    _arenas.remove(pointer);
    if (state.members.isNotEmpty) {
      // First member wins.
      assert(_debugLogDiagnostic(pointer, 'Winner: ${state.members.first}'));
      state.members.first.acceptGesture(pointer);
      // Give all the other members the bad news.
      for (int i = 1; i < state.members.length; i++)
        state.members[i].rejectGesture(pointer);
    }
  }

默认会让第一个手势胜出,其他都会调起rejectGesture方法。但是在我们刚才的举的例子已经不起作用了,因为手势都处理完毕,都回到ready状态了。

在看看如果是两个不同类型的手势竞争的情况下会怎样,例如:TapGestureRecognizer 和 LongPressGestureRecognizer。
假设在GestureDector上同时注册了onTap和onLongPress。

这个时候GestureRecognizer注册的顺序就很重要了,在GestureDector里面框架已经设置好各自顺序,这里TapGestureRecognizer先于LongPressGestureRecognizer处理事件,因为最后处理手势的时候默认是第一个胜出的。

LongPressGestureRecognizer设置的超时时间为500毫秒,如果点击时间低于500毫秒时,就好像没有竞争情况一样,onTap回调正常调起,但是点击时间超过500毫秒,又会怎样尼?

这时就会调起LongPressGestureRecognizer.didExceedDeadline方法:

void didExceedDeadline() {
    resolve(GestureDisposition.accepted);
    if (onLongPress != null)
      invokeCallback<Null>('onLongPress', onLongPress); 
  }

而接着调起的就是GestureArenaManager._resolve方法:

void _resolve(int pointer, GestureArenaMember member, GestureDisposition disposition) {
    final _GestureArena state = _arenas[pointer];
    if (state == null)
      return;  // This arena has already resolved.
    assert(_debugLogDiagnostic(pointer, '${ disposition == GestureDisposition.accepted ? "Accepting" : "Rejecting" }: $member'));
    assert(state.members.contains(member));
    if (disposition == GestureDisposition.rejected) {
      state.members.remove(member);
      member.rejectGesture(pointer);
      if (!state.isOpen)
        _tryToResolveArena(pointer, state);
    } else {
      assert(disposition == GestureDisposition.accepted);
      if (state.isOpen) {
        state.eagerWinner ??= member;
      } else {
        assert(_debugLogDiagnostic(pointer, 'Self-declared winner: $member'));
        _resolveInFavorOf(pointer, state, member);
      }
    }
  }

因为被决议为accepted,最后调起_resolveInFavorOf方法,至于eagerWinner的设置是在hitTest时候resolve才会起效。
再看_resolveInFavorOf方法:

void _resolveInFavorOf(int pointer, _GestureArena state, GestureArenaMember member) {
    assert(state == _arenas[pointer]);
    assert(state != null);
    assert(state.eagerWinner == null || state.eagerWinner == member);
    assert(!state.isOpen);
    _arenas.remove(pointer);
    for (GestureArenaMember rejectedMember in state.members) {
      if (rejectedMember != member)
        rejectedMember.rejectGesture(pointer);
    }
    member.acceptGesture(pointer);
  }

直接reject了TapGestureRecognizer,TapGestureRecognizer的状态被设置为defunt,LongPressGestureRecognizer成为最后的优胜者。

总结

我们可以在GestureRecognizer.handleEvent判断手势是否符合自己定义,例如滑动多少距离范围;
设置deadline超时时间规定手势需在多少时间内完成,或者超出多少时间才符合定义;
当检测到手势符合我们定义或者不符合时,可以调起resolve决议,让其他手势识别放弃监听手势并重置状态;
我们自定义手势识别器应在rejectGesture做一些清理或者状态重置的工作。


Flutter攻略
介绍Flutter框架基础,入门,进阶教程

Keep it simple.

560 声望
194 粉丝
0 条评论
推荐阅读
HTTP Security
HTTP SecurityHTTP CSP3目标减少内容注入攻击的风险,包括: 1) 嵌入文档的资源(worker/iframe) 2)内联脚本 3)动态脚本(eval) 4) 内联样式提供能力控制嵌入资源可以使用哪些源(origin)提供一个report机...

tain335阅读 365

正则表达式实例
收集在业务中经常使用的正则表达式实例,方便以后进行查找,减少工作量。常用正则表达式实例1. 校验基本日期格式 {代码...} {代码...} 2. 校验密码强度密码的强度必须是包含大小写字母和数字的组合,不能使用特殊...

寒青54阅读 7.8k评论 11

JavaScript有用的代码片段和trick
平时工作过程中可以用到的实用代码集棉。判断对象否为空 {代码...} 浮点数取整 {代码...} 注意:前三种方法只适用于32个位整数,对于负数的处理上和Math.floor是不同的。 {代码...} 生成6位数字验证码 {代码...} ...

jenemy46阅读 5.9k评论 12

从零搭建 Node.js 企业级 Web 服务器(十五):总结与展望
总结截止到本章 “从零搭建 Node.js 企业级 Web 服务器” 主题共计 16 章内容就更新完毕了,回顾第零章曾写道:搭建一个 Node.js 企业级 Web 服务器并非难事,只是必须做好几个关键事项这几件必须做好的关键事项就...

乌柏木66阅读 6.1k评论 16

再也不学AJAX了!(二)使用AJAX ① XMLHttpRequest
「再也不学 AJAX 了」是一个以 AJAX 为主题的系列文章,希望读者通过阅读本系列文章,能够对 AJAX 技术有更加深入的认识和理解,从此能够再也不用专门学习 AJAX。本篇文章为该系列的第二篇,最近更新于 2023 年 1...

libinfs39阅读 6.3k评论 12

封面图
从零搭建 Node.js 企业级 Web 服务器(一):接口与分层
分层规范从本章起,正式进入企业级 Web 服务器核心内容。通常,一块完整的业务逻辑是由视图层、控制层、服务层、模型层共同定义与实现的,如下图:从上至下,抽象层次逐渐加深。从下至上,业务细节逐渐清晰。视图...

乌柏木43阅读 7.3k评论 6

CSS 绘制一只思否猫
欢迎关注我的公众号:前端侦探练习 CSS 有一个比较有趣的方式,就是发挥想象,绘制各式各样的图案,比如来绘制一只思否猫?思否猫,SegmentFault 思否的吉祥物,是一只独一无二、特立独行、热爱自由的(&gt;^ω^&lt...

XboxYan43阅读 2.9k评论 14

封面图

Keep it simple.

560 声望
194 粉丝
宣传栏