您好,登錄后才能下訂單哦!
這篇“Flutter用戶側問題怎么解決”文章的知識點大部分人都不太理解,所以小編給大家總結了以下內容,內容詳細,步驟清晰,具有一定的借鑒價值,希望大家閱讀完這篇文章能有所收獲,下面我們一起來看看這篇“Flutter用戶側問題怎么解決”文章吧。
現在的app基本都會提供用戶反饋問題的入口,然而提供給用戶反饋問題一般有兩種方式:
直接用文字輸入表達,或者截圖
直接錄制視頻反饋
這兩種反饋方式常常帶來以下抱怨:
用戶:輸入文字好費時費力
開發1:看不懂用戶反饋說的是什么意思?
開發2:大概看懂用戶說的是什么意思了,但是我線下沒辦法復現哈
開發3:看了用戶錄制的視頻,但是我線下沒辦法重現,也定位不到問題
所以:為了解決以上問題,我們用一套全新的思路來設計線上問題回放體系
如果要錄制和回放flutter ui事件,那么我們首先必須了解flutter ui手勢基本原理。
我們可以把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(); }
當屏幕接收到觸摸時,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); } }
從上面的介紹可以得出一次觸摸事件可能觸發多個手勢識別器。框架通過讓每個識別器加入一個“手勢競爭場”來決議用戶想要的手勢。“手勢競爭場”使用以下規則來決議哪個手勢勝出,非常簡單
在任何時候,任何識別器都可以自己宣布失敗并主動離開“手勢競爭場”。如果在當前“競爭場”中只剩下一個識別器,那么剩下來的就是贏家,贏家意味著獨自接收此觸摸事件并做出響應動作
在任何時候,任何識別器都可以自己宣布勝利,并且最終就是它勝利,所有剩下的其他識別器都會失敗
下面示例表示屏幕window由ABCDEFKG視圖組成,其中A視圖是根視圖,即是最底下的視圖。紅圈表示觸摸點位置,觸摸落在G視圖的中間位置。
根據碰撞測試,遍歷出響應此觸摸事件的視圖路徑:
WidgetsFlutterBinding <— A <— C <— K <— G (其中GKCA是renderObject)
遍歷路徑列表后,開始調用各自的視圖(GKCA)entry.target.handleEvent來把自己識別器放到競技場里參加決議,當然有些視圖由于根據自己的邏輯判斷主動放棄識別該觸摸事件。這個處理過程如下圖
從上面的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(); }
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用戶側問題怎么解決”這篇文章的內容,相信大家都有了一定的了解,希望小編分享的內容對大家有幫助,若想了解更多相關的知識內容,請關注億速云行業資訊頻道。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。