开年巨制!千人千面回放技术让你“看到”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();}

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

3. Flutter UI手势决议

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

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

4. 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,所以这样导致录制和回放不一致性,这个问题怎么解决?这是个麻烦的问题,我们后续会解决。而且已经有这解决方案。


原文链接
本文为云栖社区原创内容,未经允许不得转载。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/519757.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Java中数组的打印

数组不能直接打印&#xff0c;打印出来是一个地址&#xff0c;所以编写以下方法用于打印一个数组 public static void printArr(int[] arr) { for (int i : arr) { System.out.print(i" "); } } println 打印一个对象&#xff0c;默认调用这个对象的toString方法

苹果或推出Windows版Safari浏览器;微软正“取下”Chromium版Edge浏览器的Beta标签;亚马逊申请新专利……...

关注并标星星CSDN云计算 速递、最新、绝对有料。这里有企业新动、这里有业界要闻&#xff0c;打起十二分精神&#xff0c;紧跟fashion你可以的&#xff01;每周两次&#xff0c;打卡即read更快、更全了解泛云圈精彩newsgo go goSK海力士将于CES推出新款SSD&#xff1a;128层4D …

Tensorflow源码解析2 -- 前后端连接的桥梁 - Session

1 Session概述 Session是TensorFlow前后端连接的桥梁。用户利用session使得client能够与master的执行引擎建立连接&#xff0c;并通过session.run()来触发一次计算。它建立了一套上下文环境&#xff0c;封装了operation计算以及tensor求值的环境。 session创建时&#xff0c;…

为减少用户电话排队,阿里研发了智能客服调度系统

提到调度&#xff0c;大家脑海中可能想起的是调度阿里云的海量机器资源&#xff0c;而对于阿里集团客户体验事业群&#xff08;CCO&#xff09;而言&#xff0c;我们要调度的不是机器&#xff0c;而是客服资源。今天&#xff0c;我们邀请阿里高级技术专家力君&#xff0c;为大家…

@value获取不到值

private static String fastDFS;Value("${fastDFS.webUrl}") public void setfastDFS(String fastDFS) {FastdfsClientUtil.fastDFS fastDFS; }

亚信科技高念书:“一巩固三发展”五年打造百亿企业

12月23日下午&#xff0c;“AI你—2019亚信科技媒体沟通会”在北京举办&#xff0c;亚信科技&#xff08;股票代码&#xff1a;01675.HK&#xff09;执行董事兼CEO高念书&#xff0c;高级副总裁兼公共与政府事务中心总经理陈武&#xff0c;副总裁兼CTO欧阳晔博士&#xff0c;副…

基于快速GeoHash,如何实现海量商品与商圈的高效匹配?

小叽导读&#xff1a;闲鱼是一款闲置物品的交易平台APP。通过这个平台&#xff0c;全国各地“无处安放”的物品能够轻松实现流动。这种分享经济业务形态被越来越多的人所接受&#xff0c;也进一步实现了低碳生活的目标。 今天&#xff0c;闲鱼团队就商品与商圈的匹配算法为我们…

质量审查一体化智能平台 搭建总览图

一、服务器部署总览 服务器ip应用服务端口192.168.43.106Gitlab8080192.168.43.117Jenkins8080192.168.43.117soanr9000192.168.43.25testEnv_ ExShell8080192.168.43.25testEnv_HotDepoly8081192.168.43.37soanr_mysqlEnv_mysql3306 服务器ip应用服务端口用户秘钥192.168.43…

独家揭秘!阿里大规模数据中心的性能分析

阿里妹导读&#xff1a;数据中心已成为支撑大规模互联网服务的标准基础设施。随着数据中心的规模越来越大&#xff0c;数据中心里每一次软件&#xff08;如 JVM&#xff09;或硬件&#xff08;如 CPU&#xff09;的升级改造都会带来高昂的成本。合理的性能分析有助于数据中心的…

@NotBlank注解使用不生效的解决办法

NotBlank注解使用时必须在入参处(controller)加Valid 如果不加Valid&#xff0c;NotBlank不会生效。NotEmpty等也是一样。 参考链接&#xff1a;https://www.cnblogs.com/azhqiang/p/8086007.html

云+X案例展 | 金融类:七牛云Pandora 助阵某银行实现日志智能管理

本案例由七牛云投递并参与评选&#xff0c;CSDN云计算独家全网首发&#xff1b;更多关于【云X 案例征集】的相关信息&#xff0c;点击了解详情丨挖掘展现更多优秀案例&#xff0c;为不同行业领域带来启迪&#xff0c;进而推动整个“云行业”的健康发展。银行作为国民经济的重要…

函数运行环境系统动态链接库版本太低?函数计算 fun 神助力分忧解难

背景 最近在处理线上工单的时候&#xff0c;遇到一个用户使用 nodejs runtime 时因为函数计算运行环境的 gcc 版本过低导致无法运行的问题&#xff0c;觉得非常有意思&#xff0c;所以深入的帮用户寻找了解决方案。觉得这个场景应该具有一定的通用性&#xff0c;所以在这篇文章…

liunx JMeter 进行压力测试

启动脚本 jmeter.sh -n -t test.jmx -l result.jtl参数说明 - h 帮助 -> 打印出有用的信息并退出 - n 非GUI模式 -> 在GUI模式下进行JMeter测试 - t 测试文件 要运行的JMeter测试脚本文件 - l 日志文件 记录结果的文件 - r 远程执行 启动远程服务 - H 代理主机 设置JMe…

Nginx相关 解决nginx反向代理后页面上的js/css文件无法加载

解决nginx反向代理后页面上的js/css文件无法加载 location ~ \.php$ {proxy_pass http://127.0.0.1:8000;include naproxy.conf;}location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {expires 30d;proxy_pass http://127.0.0.1:8000;include naproxy.conf;}location ~ .*\.(js|c…

蚂蚁金服核心技术:百亿特征实时推荐算法揭秘

文章提出一整套创新算法与架构&#xff0c;通过对TensorFlow底层的弹性改造&#xff0c;解决了在线学习的弹性特征伸缩和稳定性问题&#xff0c;并以GroupLasso和特征在线频次过滤等自研算法优化了模型稀疏性。在支付宝核心推荐业务获得了uvctr的显著提升&#xff0c;并较大地提…

老码农肺腑直言:为什么我不建议你学Python?

关于Python&#xff0c;我们听到最多的一句话就是&#xff1a;代码简洁。目前来看&#xff0c;代码量上C:Java:Python100:10:1。Python简洁度完胜&#xff0c;但这却也是他的“死亡缺点”&#xff01;今天想跟大家分享&#xff0c;学Python的一系列惊天大坑……Python钱多“话少…

如何评估深度学习模型效果?阿里工程师这么做

复杂的深度模型中&#xff0c;如果效果不好&#xff0c;是因为网络设计的欠缺&#xff1f;还是数据天然缺陷&#xff1f;是训练代码的bug&#xff1f;还是Tensorflow自身的问题&#xff1f;基于此&#xff0c;阿里工程师推出了DeepInsight深度学习质量平台&#xff0c;致力于解…

Java计算百分比

计算百分比方法&#xff0c;取几位数&#xff0c;括号里面的参数留几个0就行了 String result new DecimalFormat("0.00").format((float) size / total * 100);