开年巨制!千人千面回放技术让你“看到”Flutter用户侧问题

导语

发布app后,开发者最头疼的问题就是如何解决交付后的用户侧问题的还原和定位,是业界缺少一整套系统的解决方案的空白领域,闲鱼技术团队结合本身业务痛点在flutter上提出一套全新的技术思路解决这个问题。微信

咱们透过系统底层来捕获ui事件流和业务数据的流动,并利用捕获到的这些数据经过事件回放机制来复现线上的问题。本文先介绍flutter触摸手势事件原理,接着介绍里面怎样录制flutter ui手势事件,而后介绍怎样还原回放flutter ui手势事件,最后附上包括native录制回放的总体框架图。为了便于理解本文,读者能够先阅读我以前写的关于native录制和回放文章《千人千面线上问题回放技术》app

背景

如今的app基本都会提供用户反馈问题的入口,然而提供给用户反馈问题通常有两种方式:框架

  • 直接用文字输入表达,或者截图
  • 直接录制视频反馈

这两种反馈方式经常带来如下抱怨:测试

  • 用户:输入文字好费时费力
  • 开发1:看不懂用户反馈说的是什么意思?
  • 开发2:大概看懂用户说的是什么意思了,可是我线下没办法复现哈
  • 开发3:看了用户录制的视频,可是我线下没办法重现,也定位不到问题

因此:为了解决以上问题,咱们用一套全新的思路来设计线上问题回放体系优化

Flutter 手势基础知识

若是要录制和回放flutter ui事件,那么咱们首先必须了解flutter ui手势基本原理。ui

1. Flutter UI触摸原始数据Pointer

咱们能够把Flutter中的手势系统分两层概念来理解。第一层概念为原始触摸数据(pointer),它描述了屏幕上指针(例如,触摸,鼠标和触控笔)的时间,类型,位置和移动。 第二层概念为手势,描述由一个或多个原始移动数据组成的语义动做。通常状况下单独的原始触摸数据没有任何意义。
原始触摸数据是由系统传给native,native再经过flutter view channel传给flutter。
flutter接收native传来的原始数据接口以下:阿里云

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, ui.window.devicePixelRatio));
    if (!locked)
      _flushPointerEventQueue();
  }

2. Flutter UI碰撞测试

当屏幕接收到触摸时,dart Framework会对您的应用程序执行碰撞测试,以肯定触摸与屏幕相接的位置存在哪些视图(renderobject)。 触摸事件而后被分发到最内部的renderobject上。 从最内部renderobject开始,这些事件在renderobject树中向上冒泡传递,经过冒泡传递最后把全部的renderobject遍历出来,从这个传递机制可想而知,遍历出来renderobject列表里的最后一个是WidgetsFlutterBinding(严格来说WidgetsFlutterBinding不是renderobject),后面会介绍到WidgetsFlutterBinding。spa

void _handlePointerEvent(PointerEvent event) {
    assert(!locked);
    HitTestResult result;
    if (event is PointerDownEvent) {
      assert(!_hitTests.containsKey(event.pointer));
      result = 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);
  }

上面代码以 histTest()检测当前触摸 pointer event 涉及到哪些视图。
最后经过dispatchEvent(event, result)来处理该事件。debug

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

上面的代码就是用来分别调用每一个视图(RenderObject)的手势识别器独自处理当前触摸事件(决定是否接收此事件)。
entry.target是每一个widget对应的RenderObject,全部的RenderObject都须要实现(implements)HitTestTarget类的接口,HitTestTarget里面有就有handleEvent这个接口,因此每一个RenderObject都须要实现handleEvent这个接口, 这个接口就是用来处理手势识别。设计

abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin implements HitTestTarget

除了最后一个WidgetsFlutterBinding外,其余视图RenderObject调用本身的handleEvent来识别手势,其做用就是判断当前手势是否要放弃,若是不放弃则丢到一个路由器里(这个路由器就是手势竞技场)最后由WidgetsFlutterBinding 调用handleEvent统一决议这些手势识别器最终谁胜出,因此这里WidgetsFlutterBinding.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);
    }
  }

3. Flutter UI手势决议

从上面的介绍能够得出一次触摸事件可能触发多个手势识别器。框架经过让每一个识别器加入一个“手势竞争场”来决议用户想要的手势。“手势竞争场”使用如下规则来决议哪一个手势胜出,很是简单

  1. 在任什么时候候,任何识别器均可以本身宣布失败并主动离开“手势竞争场”。若是在当前“竞争场”中只剩下一个识别器,那么剩下来的就是赢家,赢家意味着独自接收此触摸事件并作出响应动做
  2. 在任什么时候候,任何识别器均可以本身宣布胜利,而且最终就是它胜利,全部剩下的其余识别器都会失败

4. Flutter UI手势例子

下面示例表示屏幕window由ABCDEFKG视图组成,其中A视图是根视图,便是最底下的视图。红圈表示触摸点位置,触摸落在G视图的中间位置。

问题回放2.png

根据碰撞测试,遍历出响应此触摸事件的视图路径:
WidgetsFlutterBinding <— A <— C <— K <— G (其中GKCA是renderObject)

遍历路径列表后,开始调用各自的视图(GKCA)entry.target.handleEvent来把本身识别器放到竞技场里参加决议,固然有些视图因为根据本身的逻辑判断主动放弃识别该触摸事件。这个处理过程以下图

问题回放3.png

按G->K->C->A->WidgetsFlutterBinding顺序分别调用handleEvent()方法,最后经过WidgetsFlutterBinding调用本身的handleEvent()接口来统一决议最终哪一个手势识别器胜出。
胜出的那个手势识别器经过回调方法回调到上层业务代码,流程以下
问题回放4.png

Flutter UI录制

从上面的flutter手势处理可知,咱们只须要在手势识别器回调上包装回调方法,便可拦截到手势回调方法,这样咱们就能够在拦截过程读到WidgetsFlutterBinding <— A <— C <— K <— G链路的这棵视图树。咱们只须要把这个棵树,树上的节点相关属性和手势类型记录下来,那回放时,经过这些信息去匹配到当前界面上的对应视图便可回放。下面是tap事件的录制代码,其余类型手势的录制代码原理同样,这里略过。

static GestureTapCallback onTapWithRecord(GestureTapCallback orgOnTap,       BuildContext context)
  {
    if (null != orgOnTap && null != context)
    {
      final GestureTapCallback onTapWithRecord = () {
        if(bStartRecord)
        {
          saveTapInfo(context, TouchEventUIType.OnTap,null);
        }
        if (null != orgOnTap)
        {
          orgOnTap();
        }
      };
      return onTapWithRecord;
    }
    return orgOnTap;
  }
  
static void saveTapInfo(BuildContext context, TouchEventUIType type, Offset point)
  {
    if(null == point && null != pointerPacketList && pointerPacketList.isNotEmpty)
    {
      final ui.PointerDataPacket last = pointerPacketList.last;
      if(null != last && null != last.data && last.data.isNotEmpty)
      {
        final ui.Rect rect = QueReplayTool.getWindowRect(context);

        point = new Offset(last.data.last.physicalX / ui.window.devicePixelRatio - rect.left,
          last.data.last.physicalY /ui.window.devicePixelRatio - rect.top);
      }
    }
    final RecordInfo record = createTapRecordInfo(context, type, point);
    if(null != record)
    {
      FlutterQuestionReplayPlugin.saveRecordDataToNative(record);
    }
    clearPointerPacketList();
  }

录制流程图以下:

问题回放1.png

Flutter UI回放

ui回放分两部分,第一部分经过录制的相关信息match到当前界面相应视图,第二部分是在此视图上进行模拟相关手势动做,这部分是个难点,也是重点,其中涉及到怎样生成原始的触摸数据信息,里面有时间,类型,坐标,方向,若是这些信息设置不合理或者错误会致使crash,还有滚动距离不符须要补偿,怎么补偿等等。
下面是滚动事件回放流程图,其余类型手势的回放原理同样。

问题回放1.png

上面的预处理,识别消耗指的是在滚动开始时,手势识别器要判断是否符合滚动手势所须要滚动的距离。
因此咱们为了让其控件滚动首先要生成一些触摸点数据,让手势识别器识别为滚动事件。这样才能进行后续的滚动动做。
下面是滚动处理逻辑代码,以下:

void verticalScroll(double dstPoint, double moveDis) {
    preReplayPacket = null;
    if (0.0 != moveDis) {
      //此处计算滚动方向,和滚动单元像素偏移,因为代码太长略过
      int count =
          ((ui.window.devicePixelRatio * moveDis) / (unit.abs())).round() * 2;
      if (count < minCount) {
        count = minCount; //保证最少偏移50/2=25 小于这个数 可能没反应,由于被其余控件检测滚动消耗掉了
        //还有就是若是count过小,count被scroll view消耗完前并无滚动,这是就触摸结束了(ui.PointerChange.up),那可能引发cell
        //点击事件跳转事件
      }
      final double physicalX =
          rect.center.dx * ui.window.devicePixelRatio; //376.0;
      double physicalY;
      final double needOffset = (count * unit).abs();
      final double targetHeight = rect.size.height * ui.window.devicePixelRatio;
      final int scrollPadding = rect.height ~/ 4;
      if (needOffset <= targetHeight / 2) {
        physicalY = rect.center.dy * ui.window.devicePixelRatio;
      } else if (needOffset > targetHeight / 2 && needOffset < targetHeight) {
        physicalY = (orgMoveDis > 0)
            ? (rect.bottom - scrollPadding) * ui.window.devicePixelRatio
            : (rect.top + scrollPadding) * ui.window.devicePixelRatio;
      } else {
        physicalY = (orgMoveDis > 0)
            ? (rect.bottom - scrollPadding) * ui.window.devicePixelRatio
            : (rect.top + scrollPadding) * ui.window.devicePixelRatio;
        count = ((rect.height - 2 * scrollPadding) *
                ui.window.devicePixelRatio /
                unit.abs())
            .round();
      }
      final List<ui.PointerDataPacket> packetList =createTouchDataList(count, unit, physicalY, physicalX);
      exeScroolTouch(packetList,dstPoint);
    } else {
      new Timer(const Duration(microseconds: fpsInterval), () {
        replayScrollEvent();
      });
    }
  }

上面代码大概处理逻辑:1.计算滚动方向,每一个生成的触摸数据偏移单元 2.计算滚动的开始位置 3.生成滚动原始触摸数据列表 4.循环发射原始触摸数据,并计算是否滚动到指定的位置,若是还达不到指定的位置,则继续补给

生成滚动原始触摸数据列表代码以下:
第一数据是down触摸数据,其余都是move触摸数据。up数据在这里不须要生成,当滚动距离到目标位置后才另外生成up触摸数据。为何这样设计?此处留给你们思考!

List<ui.PointerDataPacket>  createTouchDataList(int count,double unit,double physicalY,double physicalX)
  {
      final List<ui.PointerDataPacket> packetList =  <ui.PointerDataPacket>[];
      int uptime = 0;
      for (int i = 0; i < count; i++) {
      ui.PointerChange change;
      if (0 == i) {
      change = ui.PointerChange.down;
      } else {
      change = ui.PointerChange.move;
      physicalY += unit;
      if (i < 15) //前面几个点让在短期内偏移的距离长点 这样避开单击和长按事件
          {
      physicalY += unit;
      physicalY += unit;
      }
      }
      uptime += replayOnePointDuration;
      final ui.PointerData pointer = new ui.PointerData(
      timeStamp: new Duration(microseconds: uptime),
      change: change,
      kind: ui.PointerDeviceKind.touch,
      device: 1,
      physicalX: physicalX,
      physicalY: physicalY,
      buttons: 0,
      pressure: 0.0,
      pressureMin: 0.0,
      pressureMax: touchPressureMax,
      distance: 0.0,
      distanceMax: 0.0,
      radiusMajor: downRadiusMajor,
      radiusMinor: 0.0,
      radiusMin: downRadiusMin,
      radiusMax: downRadiusMax,
      orientation: orientation,
      tilt: 0.0);
      final List<ui.PointerData> pointerList = <ui.PointerData>[];
      pointerList.add(pointer);
      final ui.PointerDataPacket packet =
      new ui.PointerDataPacket(data: pointerList);
      packetList.add(packet);
      }
      return packetList;
  }

循环发射原始触摸数据,并判断是否继续补给代码以下:
咱们以定时器不断的往系统发送触摸数据,每次发送数据前都须要判断是否已经达到目标位置。

void exeScroolTouch(List<ui.PointerDataPacket> packetList,double dstPoint){
  Timer.periodic(const Duration(microseconds: fpsInterval), (Timer timer) {
  final ScrollableState state = element.state;
  final double curPoint = state.position.pixels;//ui.window.physicalSize.height*state.position.pixels/RecordInfo.recordedWindowH;
  final double offset = (dstPoint - curPoint).abs();
  final bool existOffset = offset > 1 ? true : false;
  if (packetList.isNotEmpty && existOffset) {
    sendTouchData(packetList, offset);
  } else if (packetList.isNotEmpty) {
  record.succ = true;
  timer.cancel();
  packetList.clear();
  if (null != preReplayPacket) {
  final ui.PointerDataPacket packet =
  createUpTouchPointPacket();
  if (null != packet) {
  ui.window.onPointerDataPacket(packet);
  }
  }
  new Timer(const Duration(microseconds: fpsInterval), () {
  replayScrollEvent();
  });
  } else if (existOffset) {
  record.succ = true;
  timer.cancel();
  packetList.clear();
  final ui.PointerDataPacket packet =
  createUpTouchPointPacket();
  if (null != packet) {
  ui.window.onPointerDataPacket(packet);
  }
  verticalScroll(dstPoint, dstPoint - curPoint);
  } else {
    finishReplay();
  }
  });
  }

问题回放总体框架图

下图包括native和flutter,包括ui和数据。
问题回放1.png

总结

  • 本文大概介绍了flutter ui手势问题回放,核心部分由四部分组成,一是flutter手势原理,二是flutter ui录制,三是flutter ui回放,四是整个框架图,因为篇幅有限,这四分部都介绍比较笼统,不够详细,请谅解!flutter录制回放代码其实不少,我这里只是附上比较重要,并且易于理解的代码。其余不重要或不易读懂的代码都省掉了。
  • 若是对里面的技术点感兴趣,你能够关注咱们的公众号。咱们后续会单独对里面的技术点详细深刻的分析发文。
  • 若是以为上面有错误的地方,请指出。谢谢

后续的深刻

到目前为止,咱们如今的flutter ui录制回放已经开发完成,但咱们后续还须要继续优化和深刻。咱们后续从两个点来深刻优化:1.如何在回放时模拟的触摸事件更逼真,好比滚动加速度,一次的滚动实际上是一个曲线变化的过程 2.解决手势录制和回放不一致性。举个例子,在键盘里输入123,咱们录制时截获到了手势123,可是因为业务上层的bug致使了当时输入3没有响应,输入框里只显示12,咱们回放时模拟手势123,最终回放完后输入框显示123,因此这样致使录制和回放不一致性,这个问题怎么解决?这是个麻烦的问题,咱们后续会解决。并且已经有这解决方案。

 

原文连接 更多技术干货 请关注阿里云云栖社区微信号 :yunqiinsight

相关文章
相关标签/搜索