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

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

原文地址:https://blog.51cto.com/14031893/2353009

时间: 2024-11-05 10:10:06

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

千人千面、个性化推荐,解读数据赋能商家背后的AI技术

12月6-7日,由阿里巴巴集团.阿里巴巴技术发展部.阿里云云栖社区联合主办,以"2016 双 11 技术创新"为主题的阿里巴巴技术论坛,来自商家事业部的技术总监魏虎给大家分享了数据赋能商家背后的AI技术.首先对大数据和人工智能进行了简要介绍,接着着重分析了客户运营平台,包括实时分群算法.match和rank框架以及千人千面技术,最后讲解了千牛头条.服务市场和智能客服中AI技术的应用. 背景介绍 大数据 大数据主要有四个特征:Volume(大量).Value(价值).Velocity(速

ElasticSearch+Spark 构建高相关性搜索服务&amp;千人千面推荐系统 教程资源

本文配套资料获取链接:点击这里 基于大众点评搜索以及推荐业务,从企业实际项目落地实践的角度出发,在使用SpringBoot加mybatis完成用户登录.注册.商家入驻以及结合前端模板搭建运营后台门店服务管理功能后,借助ElasticSearch的最新版本ES7逐步迭代,完成高相关性进阶搜索服务,并基于spark mllib2.4.4构建个性化千人千面推荐系统. 课程所用到的技术: 在具体场景中的应用效果: 环境参数: 本文配套资料获取链接:点击这里 原文地址:https://www.cnblog

左手转型!右手创新! AppCan亮剑中国“互联网+”千人论坛,《移动平台》专著全国首发

备受瞩目的2015中国“互联网+”千人论坛,即将于5月23日在新世纪日航酒店盛大召开.届时,电子工业出版社刘九如.工业和信息化部原副部长杨学山.百度技术委员会理事长陈尚义.春秋航空CEO张秀智.互联网金融千人会秘书长易欢欢等众多重量级嘉宾将在现场与大家共同探讨“互联网+”背景下实体经济如何借助移动互联实现转型升级与颠覆式创新. 作为中国移动信息化的领导品牌AppCan,将以“移动互联网+重构传统企业移动化转型”为亮点议题盛装出席本次大会作精彩演讲,并与诸位移动互联行业专家深入交流.作为创办才4年

酷客多小程序受邀参加千人CEO峰会

近日由人民网上海.上海市青少年活动中心.央广视讯"千人CEO峰会"在于上海国际会议中心隆重召开,酷客多小程序创始人郝宪玮受邀参与本次活动,与现场1001位CEO共同探讨新媒体载体趋势,主要分享内容如下: 90后+现象级事件 说一下我的感受,打击蛮大的,前段时间去广东出差,跟我接触的人好像都是90后,还有可能是87后.我发现一些问题,原来接触的客户年龄都比我大,可能70几.60几的,但突然发现很多互联网企业,比如腾讯.比如阿里跟他们接触,比如说正在做一些新兴创新的很多可能都是88年之后的

左手转型!右手创新! AppCan亮剑中国“互联网+”千人论坛,《移动平台》专著全国首发

备受瞩目的2015中国"互联网+"千人论坛,即将于5月23日在新世纪日航酒店盛大召开.届时,电子工业出版社刘九如.工业和信息化部原副部长杨学山.百度技术委员会理事长陈尚义.互联网金融千人会秘书长易欢欢等众多重量级嘉宾将在现场与大家共同探讨"互联网+"背景下实体经济如何借助移动互联实现转型升级与颠覆式创新. 作为中国移动信息化的领导品牌AppCan,将以"移动互联网+重构传统企业移动化转型"为亮点议题盛装出席本次大会作精彩演讲,并与诸位移动互联行业

2015中国“互联网+”千人论坛 聚焦移动互联时代的实体经济变革

“互联网+”到底是什么?怎样影响我们的生活?通俗来说,“互联网+”就是“互联网+各个传统行业”,但这并不是简单的两者相加,而是利用信息通信技术以及互联网平台,让互联网与传统行业进行深度融合,创造新的发展生态.“互联网+”既是加法,更是乘法.它不仅是在现行的科技.经济和社会管理层面量的累积,更是一种科学地驾驭技术.利用技术基础上的提速.提质.提效,是对新事物的辩证趋利避害,是利用新技术的全方位创新,必将为转型升级赢得新的窗口和机遇. 政府工作报告这样解释“互联网+”:推动移动互联网.云计算.大数据

2014屌丝逆袭千人大会感悟:成功篇

12月9日,我和于成龙.张力.马道长,包括VIP圈子里的一些朋友组织了2014年最后一次千人YY语音逆袭大会. 这次大会筹备一个月之久,邀请了A5图王.牟长青.乔帮主.原野等四位嘉宾,也算是比较成功,会场最高峰来了2746位听众.而且我也很自豪的说,没有用任何刷YY在线人数的软件. A5图王代表了草根站长到成功的典型,站长界泰斗级人物. 牟长青代表了草根站长成功转型微信营销的一个典型. 乔帮主代表了正在奋斗中的草根做淘宝成功的案例. 原野代表了草根站长成功逆袭的故事,从农村出身到创业成功,并有了

利用千人基因组数据库查看SNP在不同地区、国家、洲的频率及个数

首先,进入千人基因组数据库的网站:https://www.ncbi.nlm.nih.gov/variation/tools/1000genomes/ 如下图所示,在数据库的框框里输入我们感兴趣的SNP,比如rs608139 搜索后出现如下界面,黄色区域是我们感兴趣的SNP,红色框框是不同国家和地区在该SNP对应的频率和个数. 千人基因组数据库包括的国家和地区如下表所示. 国家 Continent Population (Abb) 中国,北京 Asian CHB 日本,东京 Asian JPT 中

“千人0元洁牙”惠动全城,以庆长治爱牙日4.21成立!!!

祝:"长治4.21爱牙日"正式成立长兴×××品牌战略升级全新引进TCS"五星级服务"体系服务上党人民特举办2018首届"千人0元洁牙"健康保健~惠动全城为什么要洗牙?口腔护理在欧美国家已广受重视,在中国却刚刚起步,尽早关注口腔护理,对于身体健康大有益处.洁牙作为口腔护理的重要一环,有三大好处不容忽视:1.保持口腔健康有规律地定期洁牙,既能去除附着在牙齿上的牙垢.菌斑等,保持口腔清洁.口气清新,又可使牙齿表面更加干净光滑.洁白靓丽,让每个人都可以从