乐趣区

开年巨制!千人千面回放技术让你“看到”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 手势决议
从上面的介绍可以得出一次触摸事件可能触发多个手势识别器。框架通过让每个识别器加入一个“手势竞争场”来决议用户想要的手势。“手势竞争场”使用以下规则来决议哪个手势胜出,非常简单

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

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

本文作者:闲鱼技术 – 镜空阅读原文
本文为云栖社区原创内容,未经允许不得转载。

退出移动版