当前位置: 首页 > news >正文

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

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

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

背景
现在的app基本都会提供用户反馈问题的入口,然而提供给用户反馈问题一般有两种方式:

直接用文字输入表达,或者截图
直接录制视频反馈
这两种反馈方式常常带来以下抱怨:

用户:输入文字好费时费力
开发1:看不懂用户反馈说的是什么意思?
开发2:大概看懂用户说的是什么意思了,但是我线下没办法复现哈
开发3:看了用户录制的视频,但是我线下没办法重现,也定位不到问题
所以:为了解决以上问题,我们用一套全新的思路来设计线上问题回放体系

Flutter 手势基础知识
如果要录制和回放flutter ui事件,那么我们首先必须了解flutter 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();

}

  1. Flutter UI碰撞测试

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

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)来处理该事件。

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

}

  1. Flutter UI手势决议

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

在任何时候,任何识别器都可以自己宣布失败并主动离开“手势竞争场”。如果在当前“竞争场”中只剩下一个识别器,那么剩下来的就是赢家,赢家意味着独自接收此触摸事件并做出响应动作
在任何时候,任何识别器都可以自己宣布胜利,并且最终就是它胜利,所有剩下的其他识别器都会失败

  1. Flutter UI手势例子

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

图片描述

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

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

图片描述

按G->K->C->A->WidgetsFlutterBinding顺序分别调用handleEvent()方法,最后通过WidgetsFlutterBinding调用自己的handleEvent()接口来统一决议最终哪个手势识别器胜出。
胜出的那个手势识别器通过回调方法回调到上层业务代码,流程如下
图片描述

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

}
录制流程图如下:

图片描述

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

图片描述

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

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和数据。
图片描述

总结
本文大概介绍了flutter ui手势问题回放,核心部分由四部分组成,一是flutter手势原理,二是flutter ui录制,三是flutter ui回放,四是整个框架图,由于篇幅有限,这四分部都介绍比较笼统,不够详细,请谅解!flutter录制回放代码其实很多,我这里只是附上比较重要,而且易于理解的代码。其他不重要或不易读懂的代码都省掉了。
如果对里面的技术点感兴趣,你可以关注我们的公众号。我们后续会单独对里面的技术点详细深入的分析发文。
如果觉得上面有错误的地方,请指出。谢谢
后续的深入
到目前为止,我们现在的flutter ui录制回放已经开发完成,但我们后续还需要继续优化和深入。我们后续从两个点来深入优化:1.如何在回放时模拟的触摸事件更逼真,比如滚动加速度,一次的滚动其实是一个曲线变化的过程 2.解决手势录制和回放不一致性。举个例子,在键盘里输入123,我们录制时截获到了手势123,但是由于业务上层的bug导致了当时输入3没有响应,输入框里只显示12,我们回放时模拟手势123,最终回放完后输入框显示123,所以这样导致录制和回放不一致性,这个问题怎么解决?这是个麻烦的问题,我们后续会解决。而且已经有这解决方案。

相关文章:

  • 资源 | 上千份简历模板统统给你!都拿去!
  • idou老师教你学Istio :5分钟简析Istio异常检测
  • CSS 专业技巧
  • 交互设计原则
  • Btrace使用入门
  • GraphQL学习过程应该是这样的
  • SpiderData 2019年2月23日 DApp数据排行榜
  • 剑指offer——面试题25:合并两个 排序的链表
  • 近期前端发展计划
  • 【Leetcode】101. 对称二叉树
  • 每秒解析千兆字节的JSON解析器开源,秒杀一大波解析器!
  • 解析DELL R710服务器迁移操作内容
  • WinRAR存在严重的安全漏洞影响5亿用户
  • Git知识
  • cd命令
  • 时间复杂度分析经典问题——最大子序列和
  • [译]CSS 居中(Center)方法大合集
  • 【Under-the-hood-ReactJS-Part0】React源码解读
  • 【翻译】Mashape是如何管理15000个API和微服务的(三)
  • 0基础学习移动端适配
  • Android开源项目规范总结
  • egg(89)--egg之redis的发布和订阅
  • el-input获取焦点 input输入框为空时高亮 el-input值非法时
  • HomeBrew常规使用教程
  • Java 最常见的 200+ 面试题:面试必备
  • miaov-React 最佳入门
  • Puppeteer:浏览器控制器
  • TiDB 源码阅读系列文章(十)Chunk 和执行框架简介
  • 动态规划入门(以爬楼梯为例)
  • 翻译 | 老司机带你秒懂内存管理 - 第一部(共三部)
  • 好的网址,关于.net 4.0 ,vs 2010
  • 前端知识点整理(待续)
  • 浅谈Kotlin实战篇之自定义View图片圆角简单应用(一)
  • 如何借助 NoSQL 提高 JPA 应用性能
  • 文本多行溢出显示...之最后一行不到行尾的解决
  • 在Mac OS X上安装 Ruby运行环境
  • MPAndroidChart 教程:Y轴 YAxis
  • 宾利慕尚创始人典藏版国内首秀,2025年前实现全系车型电动化 | 2019上海车展 ...
  • ​人工智能之父图灵诞辰纪念日,一起来看最受读者欢迎的AI技术好书
  • $Django python中使用redis, django中使用(封装了),redis开启事务(管道)
  • (1)虚拟机的安装与使用,linux系统安装
  • (4.10~4.16)
  • (超详细)语音信号处理之特征提取
  • (附程序)AD采集中的10种经典软件滤波程序优缺点分析
  • (每日持续更新)jdk api之FileReader基础、应用、实战
  • (免费领源码)python+django+mysql线上兼职平台系统83320-计算机毕业设计项目选题推荐
  • (三)docker:Dockerfile构建容器运行jar包
  • (实战篇)如何缓存数据
  • (一)WLAN定义和基本架构转
  • (已解决)什么是vue导航守卫
  • (原)本想说脏话,奈何已放下
  • (原創) 未来三学期想要修的课 (日記)
  • (转)Android中使用ormlite实现持久化(一)--HelloOrmLite
  • (转)EOS中账户、钱包和密钥的关系
  • (转)JAVA中的堆栈