Flutter用户侧问题怎么解决
本文主要介绍"Flutter用户侧问题如何解决",希望能够解决您遇到有关问题,下面我们一起来看这篇 "Flutter用户侧问题如何解决" 文章。
背景
现在的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来把自己识别器放到竞技场里参加决议,当然有些视图由于根据自己的逻辑判断主动放弃识别该触摸事件。这个处理过程如下图
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(); } }); }
关于 "Flutter用户侧问题如何解决" 就介绍到这。希望大家多多支持编程宝库。
Data Lake Analytics的Geospatial分析函数怎么理解:本文主要介绍"Data Lake Analytics的Geospatial分析函数如何理解",希望能够解决您遇到有关问题,下面我们一起来看这篇 "Data Lak ...