開年鉅製!千人千面回放技術讓你「看到」Flutter用戶側問題

導語

發佈app後,開發者最頭疼的問題就是如何解決交付後的用戶側問題的還原和定位,是業界缺少一整套系統的解決方案的空白領域,閒魚技術團隊結合本身業務痛點在flutter上提出一套全新的技術思路解決這個問題。微信

咱們透過系統底層來捕獲ui事件流和業務數據的流動,並利用捕獲到的這些數據經過事件回放機制來複現線上的問題。本文先介紹flutter觸摸手勢事件原理,接着介紹裏面怎樣錄製flutter ui手勢事件,而後介紹怎樣還原回放flutter ui手勢事件,最後附上包括native錄製回放的總體框架圖。爲了便於理解本文,讀者能夠先閱讀我以前寫的關於native錄製和回放文章《千人千面線上問題回放技術》app

背景

如今的app基本都會提供用戶反饋問題的入口,然而提供給用戶反饋問題通常有兩種方式:框架

  • 直接用文字輸入表達,或者截圖
  • 直接錄製視頻反饋

這兩種反饋方式經常帶來如下抱怨:測試

  • 用戶:輸入文字好費時費力
  • 開發1:看不懂用戶反饋說的是什麼意思?
  • 開發2:大概看懂用戶說的是什麼意思了,可是我線下沒辦法復現哈
  • 開發3:看了用戶錄製的視頻,可是我線下沒辦法重現,也定位不到問題

因此:爲了解決以上問題,咱們用一套全新的思路來設計線上問題回放體系優化

Flutter 手勢基礎知識

若是要錄製和回放flutter ui事件,那麼咱們首先必須瞭解flutter ui手勢基本原理。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。spa

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)來處理該事件。debug

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視圖的中間位置。

問題回放2.png

根據碰撞測試,遍歷出響應此觸摸事件的視圖路徑:
WidgetsFlutterBinding <— A <— C <— K <— G (其中GKCA是renderObject)

遍歷路徑列表後,開始調用各自的視圖(GKCA)entry.target.handleEvent來把本身識別器放到競技場裏參加決議,固然有些視圖因爲根據本身的邏輯判斷主動放棄識別該觸摸事件。這個處理過程以下圖

問題回放3.png

按G->K->C->A->WidgetsFlutterBinding順序分別調用handleEvent()方法,最後經過WidgetsFlutterBinding調用本身的handleEvent()接口來統一決議最終哪一個手勢識別器勝出。
勝出的那個手勢識別器經過回調方法回調到上層業務代碼,流程以下
問題回放4.png

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();
  }

錄製流程圖以下:

問題回放1.png

Flutter UI回放

ui回放分兩部分,第一部分經過錄制的相關信息match到當前界面相應視圖,第二部分是在此視圖上進行模擬相關手勢動做,這部分是個難點,也是重點,其中涉及到怎樣生成原始的觸摸數據信息,裏面有時間,類型,座標,方向,若是這些信息設置不合理或者錯誤會致使crash,還有滾動距離不符須要補償,怎麼補償等等。
下面是滾動事件回放流程圖,其餘類型手勢的回放原理同樣。

問題回放1.png

上面的預處理,識別消耗指的是在滾動開始時,手勢識別器要判斷是否符合滾動手勢所須要滾動的距離。
因此咱們爲了讓其控件滾動首先要生成一些觸摸點數據,讓手勢識別器識別爲滾動事件。這樣才能進行後續的滾動動做。
下面是滾動處理邏輯代碼,以下:

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和數據。
問題回放1.png

總結

  • 本文大概介紹了flutter ui手勢問題回放,核心部分由四部分組成,一是flutter手勢原理,二是flutter ui錄製,三是flutter ui回放,四是整個框架圖,因爲篇幅有限,這四分部都介紹比較籠統,不夠詳細,請諒解!flutter錄製回放代碼其實不少,我這裏只是附上比較重要,並且易於理解的代碼。其餘不重要或不易讀懂的代碼都省掉了。
  • 若是對裏面的技術點感興趣,你能夠關注咱們的公衆號。咱們後續會單獨對裏面的技術點詳細深刻的分析發文。
  • 若是以爲上面有錯誤的地方,請指出。謝謝

後續的深刻

到目前爲止,咱們如今的flutter ui錄製回放已經開發完成,但咱們後續還須要繼續優化和深刻。咱們後續從兩個點來深刻優化:1.如何在回放時模擬的觸摸事件更逼真,好比滾動加速度,一次的滾動實際上是一個曲線變化的過程 2.解決手勢錄製和回放不一致性。舉個例子,在鍵盤裏輸入123,咱們錄製時截獲到了手勢123,可是因爲業務上層的bug致使了當時輸入3沒有響應,輸入框裏只顯示12,咱們回放時模擬手勢123,最終回放完後輸入框顯示123,因此這樣致使錄製和回放不一致性,這個問題怎麼解決?這是個麻煩的問題,咱們後續會解決。並且已經有這解決方案。

 

原文連接 更多技術乾貨 請關注阿里云云棲社區微信號 :yunqiinsight

相關文章
相關標籤/搜索