Android 開發必看 | Flutter之全埋點思考與實現

1. 背景

用戶數據分析與埋點,在互聯網產品的設計與迭代中是不可缺少的一部分,利用用戶的行爲規律、用戶畫像,能在很大程度上幫助團隊制定合適的運營策略與產品方向。

隨着產品的迭代與業務的發展,對業務團隊的敏捷性與創新性提出了更高的要求,而通過大數據的手段在一定程度上可以幫助我們實現這個願景,同時,良好的數據分析可以幫助我們進行更好更優的決策。

一般我們會採集的數據會包括用戶的點擊行爲操作、頁面瀏覽量(PV)、頁面停留時長、訪客(UV)等等。而對於產生的數據本身,其整個流程主要可以總結爲以下幾點:

  • 數據採集
  • 數據上報
  • 數據存儲
  • 數據分析
  • 數據展示

我們常說的『埋點』就是數據採集領域的術語,通過採集到用戶產生的原始數據,進行一定層次的過濾,達到產品運營的需求。數據採集的方式也可以說是埋點的幾種方式。

現狀、痛點

目前公司App產品在未引入Flutter之前,一直採用純原生的埋點功能,原生的實現方案相對來說是比較完善的,採用混合開發後,隨着迭代後Flutter模塊的增多,僅僅在Flutter側以代碼埋點通過channel調用原生模塊埋點接口的方式以逐漸不能滿足我們的需求,即使這種方式能精準採集到需要的信息,但是對於App產品來說,耗費的成本逐漸增大,運營、開發、測試,都需要參與,溝通成本也逐漸增大。另外一方面,現在市面已有的第三方統計平臺,要麼不支持Flutter,要麼也只是提供一個簡單的插件提供接口手動調用,我們迫切的需要一個類似原生的自動化埋點方案解決問題。

原生的實現方式

1. 無痕埋點

俗稱"全埋點"、"無埋點",通過在端上自動採集並上報儘可能多的數據,根據一定的規則過濾篩選出自己需要的可用數據。

優點:

  • 能很大程度減少開發、測試的重複工作,不需要對業務標識進行唯一的區分,ID的規則由設計的SDK和產品溝通約定好即可,減少業務人員後續的溝通成本和使用步驟
  • 數據可以回溯並相對全面

缺點:

  • 需要設計出一套全埋點的技術成品,能獲取到準確的指標數據,前期的技術投入大
  • 數據量大。需要後端落地後進行大量處理,採用智能系統分析平臺或者是數據庫查詢數據聚合。同時需要產品進行自我還原業務場景。

2. 可視化埋點

可視化埋點是通過運營人員在可視化的工具選擇需要收集的埋點數據,端側獲取配置後,再基於預先設置的規則,通過組件或控件精準採集,根據配置條件自動埋點上報的方式。

優點:很大程度減少開發、測試的重複工作,數據量可靠,可以在線上可視化工具動態的進行埋點配置,無需每次等到發版才能生效。

缺點:採集信息不夠靈活,並且無法解決數據回溯的問題

2. 具體實現

無痕埋點:以無痕埋點爲切入點,結合現在已有的原生方案,遷移到Flutter平臺。

無痕埋點需要自動採集數據,因此針對頁面、控件等元素需要生成其 ID,該 ID 需儘量具備『唯一性』和『穩定性』。『唯一性』非常好理解,因爲對於任意元素而言,其 ID 應該是與其他所有元素都不同的,這樣我們才能根據 ID 唯一標識出那個我們想要的元素,採集上來的數據纔是準確的,不重複的。而『穩定性』則是說,元素的 ID 應儘量不受版本的變動而改變,這樣後期關聯業務含義的操作纔會更加便捷。

1. Flutter頁面ID的規則

根據"唯一性"與"穩定性",將頁面所在類的類型作爲ID,它是相對唯一的,除了頁面複用,基本不存在其他類名相同的頁面(不同的package例外),其次它是相對穩定的,除了修改類名情況下才會改變,除了一些頁面重大的改版之外不會輕易修改類名。在Flutter中,頁面也是Widget,因此,ID定義規則如下:

ID = Widget Type+"額外參數"(widget爲當前前臺顯示的頁面)

2. Flutter頁面的PV、UV

一旦有了頁面的唯一ID的生成規則,我們就可以在頁面曝光的時候,去生成這個ID,然後上傳即可實現頁面的PV、UV指標。至於頁面的曝光時機,在Flutter存在接口RouteObserver

//繼承這個類,在MaterialApp中可配置,可以配置多個Observer
class RouteObserver<R extends Route<dynamic>> extends NavigatorObserver {

   void didPop(Route<dynamic> route, Route<dynamic> previousRoute) {
         ...
   }
    void didPush(...){...}
    ...

}
複製代碼

能監控頁面的曝光時機還不夠,有時我們不僅僅需要的是知道進入了哪個頁面,還需要知道在某個頁面停留了多長的時間,並且應用在前後臺的切換也要計算進去。同樣的,在Flutter中存在監聽頁面的生命週期的接口WidgetsBindingObserver:

abstract class WidgetsBindingObserver {
    //省略部分代碼
  /// Called when the system puts the app in the background or returns
  /// the app to the foreground.
  ///
  /// An example of implementing this method is provided in the class-level
  /// documentation for the [WidgetsBindingObserver] class.
  ///
  /// This method exposes notifications from [SystemChannels.lifecycle].
  void didChangeAppLifecycleState(AppLifecycleState state) { }
}
複製代碼

其中AppLifecycleState是個枚舉類,包含四種狀態:

enum AppLifecycleState {
    resumed,
    inactive,
    paused,
    detached,
}
複製代碼

該接口通過以上四種狀態,我們可以知道在某個頁面停留的時長是多久。

以上是採集頁面pv、uv、頁面路徑的基本思路,具體的代碼不多做介紹,邏輯參考原生的實現即可。後面我着重介紹用戶行爲操作,點擊行爲埋點數據的採集實現。

3. Flutter組件ID的規則

對於組件的ID來說,它的規則要比頁面的定義更加複雜。首先,Flutter的組件本身並沒有一個id的概念,雖然Flutter的每個Widget都可以通過一個唯一key去標識,但是在創建Widget的時候除非有特殊的需求(比如複用等),我們一般不會去傳入一個key,所以需要換個思路:根據視圖樹。

[圖片上傳中...(image-c1c490-1605191361082-11)]

每個頁面的組件都是根據其父子、兄弟關係構建出視圖樹繪製在頁面上。從我們觀測的組件的本身開始,在這個視圖樹上逐級向上遍歷搜索,直到根節點,找到這個組件在這個樹上的位置信息等特徵信息,這樣就能得到一個組件在視圖樹上的 一個組件路徑,也就是說,我們可以根據這個路徑,在視圖樹中定位到這個組件(圖片引用自極客時間-Flutter專欄):

widget、Element、RenerObject關係

[圖片上傳中...(image-dfdb0a-1605191361082-10)]

三棵樹 Flutter中,存在這麼三棵樹(爲了便於理解我們抽象RenderObject也爲一個樹),當我們點擊了某個Widget的時候,我們期望的結果是可以通過這個Widget獲取它在視圖樹上的位置,可惜的是Flutter中的Widget並沒有一個類似"parent"和"child"屬性可以供我們去獲取,也沒有提供接口讓我們去獲取,其實這也比較好理解,因爲Widget本身就只是一個配置信息,這點在Widget源碼中註釋也有體現:"Describes the configuration for an [Element]."

再從Element樹入手,通過對Element源碼的閱讀,Element實現了BuildContext,而BuildContext它定義了一系列的接口去獲取父子element與指定的RenderObject、指定類型的Widget、指定的State等等:

abstract class BuildContext {
    ...
    ///搜索Element父節點
   void visitAncestorElements(bool visitor(Element element));
   ///搜索Element子節點 
   void visitChildElements(bool visitor(Element element));

    T findAncestorWidgetOfExactType<T extends Widget>();
    T findAncestorStateOfType<T extends State>();
    T findAncestorRenderObjectOfType<T extends RenderObject>();
    ...還有其他的省略...
}

複製代碼

Element實現了具體的搜索方法:

void visitAncestorElements(bool visitor(Element element)) {
  assert(_debugCheckStateIsActiveForAncestorLookup());
  Element ancestor = _parent;
  while (ancestor != null && visitor(ancestor))
    ancestor = ancestor._parent;
}
複製代碼

而根據Element,是可以通過element.widget獲取與之對應的Widget的,根據Widget也就得到了具體的路徑。

而如果選擇從RenderObejct入手,它內部定義了獲取父親節點與子節點的方法:

abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin implements HitTestTarget {
    ///獲取樹上的父節點
    AbstractNode? get parent => _parent;

    ...
    //遍歷搜索子節點
    void visitChildren(RenderObjectVisitor visitor) { }
    ...
}
複製代碼

RenderObject在源碼中看似沒有定義接口去直接獲取對應的Element的,更加無法直接去獲取對應的Widget,但是注意到它有一個debugCreator屬性:

  /// The object responsible for creating this render object.
  /// Used in debug messages.
  Object? debugCreator;///表示這個render obejct表示負責創建此render object的對象,也就這個render object被誰持有
複製代碼

雖然是個Object類型的,但是源碼中對應的就是DebugCreator類:

/// A wrapper class for the [Element] that is the creator of a [RenderObject].
///
/// Attaching a [DebugCreator] attach the [RenderObject] will lead to better error
/// message.
class DebugCreator {
  /// Create a [DebugCreator] instance with input [Element].
  DebugCreator(this.element);

  /// The creator of the [RenderObject].
  final Element element;

  @override
  String toString() => element.debugGetCreatorChain(12);
}
複製代碼

Element的子類RenderObjectElementmountupdate方法中對這個屬性進行了創建:

@override
void mount(Element parent, dynamic newSlot) {
  super.mount(parent, newSlot);
 //省略部分代碼...
  _renderObject = widget.createRenderObject(this);
//省略部分代碼...
  assert(() {
      //複製debugCreator屬性方法(assert部分會在Release的時候刪除)
    _debugUpdateRenderObjectOwner();
    return true;
  }());
//省略部分代碼...
}

  @override
  void update(covariant RenderObjectWidget newWidget) {
    super.update(newWidget);
   ...
    assert(() {
            //複製debugCreator屬性方法(assert部分會在Release的時候刪除)
      _debugUpdateRenderObjectOwner();
      return true;
    }());
   ...
  }

  void _debugUpdateRenderObjectOwner() {
    assert(() {
        //將當前Element傳入到DebugCreator中保存。RenderObjectElement繼承Element
      _renderObject.debugCreator = DebugCreator(this);
      return true;
    }());
  }

複製代碼

可以看到通過這種方式,如果是可以通過在RenderObject中的debugCreator屬性被賦值,那麼是可以通過這個屬性獲取到對應的Element的,也就可以獲取到Widget。但是通過代碼也看到這個屬性賦值定義在assert中,Release下不會走這部分,所以這一塊要做修改。

所以,如果能在點擊的時候能直接或間接獲取到Element,根據上面路徑的規則生成,對於上圖中的GestureDetector,它的路徑爲:

Contain[0]/Column[0]/Contain[1]/GestureDetector[0]

同時,爲了防止不同頁面中可能存在的路徑相同情況,給這個路徑加上當前頁面的標識,所以path最後的規則爲:

[ 頁面ID:組件路徑 ]。

4. Flutter中事件與手勢分析

爲了更好的理解Flutter中的手勢事件,下面簡要的做一個分析:

Flutter中指針事件表示用戶交互的原始觸摸數據,例如PointerDownEventPointerUpEventPointerCancelEvent等等,當手指觸摸屏幕的時候,發生觸摸事件,Flutter會確定觸發的位置上有哪些組件,並將觸摸事件交給最內層的組件去響應,事件會從最內層的組件開始,沿着組件樹向根節點向上一級級冒泡分發。

通過對一個簡單的GestureDetector組件的點擊回調的debug觀測,得到如下圖的一個調用結構:

[圖片上傳中...(image-c56bb-1605191361080-9)]

上圖中,_rootRunUnary以下爲引擎自己實現的調用,會將收集到的事件傳遞到GestureBinding._handlePointerDataPacket中:

mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, HitTestTarget {
  @override
  void initInstances() {
    super.initInstances();
    _instance = this;
      ///binding初始化的時候設置了回調方法,接受引擎傳來的事件數據
    window.onPointerDataPacket = _handlePointerDataPacket;///onPointerDataPacket就是一個function
  }
    ....
}
複製代碼

GestureBinding._flushPointerEventQueue方法就是對隊列中的事件依次取出並進行處理:

final Queue<PointerEvent> _pendingPointerEvents = Queue<PointerEvent>();
void _flushPointerEventQueue() {
    assert(!locked);

    if (resamplingEnabled) {
      _resampler.addOrDispatchAll(_pendingPointerEvents);
      _resampler.sample(samplingOffset);
      return;
    }

    // Stop resampler if resampling is not enabled. This is a no-op if
    // resampling was never enabled.
    _resampler.stop();

    while (_pendingPointerEvents.isNotEmpty)
      _handlePointerEvent(_pendingPointerEvents.removeFirst());
  }
複製代碼

所以,真正開始處理PointerEvent應該是從GestureBinding_handlePointerEvent方法開始:

  void _handlePointerEvent(PointerEvent event) {
    assert(!locked);
    HitTestResult? hitTestResult;
    if (event is PointerDownEvent || event is PointerSignalEvent) {
      assert(!_hitTests.containsKey(event.pointer));
      hitTestResult = HitTestResult();///1.創建一個HitTestResult對象
      hitTest(hitTestResult, event.position);///2.命中測試,實際先調用到RendererBinding的hitTest方法
      if (event is PointerDownEvent) {
        _hitTests[event.pointer] = hitTestResult;///如果是PointerDownEvent,創建事件標識id與hitTestResult的映射
      }
    ...
    } else if (event is PointerUpEvent || event is PointerCancelEvent) {
      hitTestResult = _hitTests.remove(event.pointer);///事件序列結束後移除
    } else if (event.down) {
        ///其他是事件重用Down事件避免每次都要去命中測試(比如:PointerMoveEvents)
      hitTestResult = _hitTests[event.pointer];
    }
     ... 
    if (hitTestResult != null ||
        event is PointerHoverEvent ||
        event is PointerAddedEvent ||
        event is PointerRemovedEvent) {
      assert(event.position != null);
      dispatchEvent(event, hitTestResult);///分發事件
    }
  }
複製代碼

對代碼中的幾點註釋說明:

  1. 如果是PointerDownEvent或者是PointerSignalEvent,直接創建一個HitTestResult對象,該對象內部有一個_path字段(集合);

  2. 調用hitTest方法進行命中測試,而該方法就是將自身作爲參數創建HitTestEntry,然後將HitTestEntry對象添加到HitTestResult_path中。HitTestEntry中只有一個HitTestTarget字段。實際也就是將這個創建的HitTestEntry添加到HitTestResult_path字段中,當做事件分發冒泡排序中的一個路徑節點。

     ///先RendererBinding的hitTest方法,方法定義如下: 
    void hitTest(HitTestResult result, Offset position) {
        assert(renderView != null);
        assert(result != null);
        assert(position != null);
        renderView.hitTest(result, position: position);
        super.hitTest(result, position);
      }
    複製代碼
    

    內部調用主要就是兩步:

    • 調用RenderViewhitTest方法(從根節點RenderView開始命中測試):

        bool hitTest(HitTestResult result, { required Offset position }) {
          if (child != null)
              ///內部會先對child進行命中測試
            child!.hitTest(BoxHitTestResult.wrap(result), position: position);
          result.add(HitTestEntry(this));///將自己添加到_path字段,作爲一個事件分發的路徑節點
          return true;
          }
             ///child是RenderBox類型對象,`hitTest`方法在RenderBox中實現:
           bool hitTest(HitTestResult result, { @required Offset position }) {
               ///...去掉assert部分
               ///這裏就是判斷點擊的區域置是否在size範圍,是否在當前這個RenderObject節點上
             if (_size.contains(position)) {
               ///在當前節點,如果child與自己的hitTest命中測試有一個是返回true,就加入到HitTestResult中
               if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
                 result.add(BoxHitTestEntry(this, position));
                 return true;
               }
             }
             return false;
      複製代碼
      
    • 調用父類的hitTest方法,也就是GestureBindinghitTest方法:

        @override // from HitTestable
        void hitTest(HitTestResult result, Offset position) {
          result.add(HitTestEntry(this));
        }
      複製代碼
      

經過一系列的hitTest後,通過一下判斷:

if (hitTestResult != null ||
    event is PointerHoverEvent ||
    event is PointerAddedEvent ||
    event is PointerRemovedEvent) {
  assert(event.position != null);
  dispatchEvent(event, hitTestResult);
}
複製代碼

調用到GestureBindingdispatchEvent方法:

void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
   ...
  for (final HitTestEntry entry in hitTestResult.path) {
    try {
      entry.target.handleEvent(event.transformed(entry.transform), entry);
    } catch (exception, stack) {
     ....
      ));
    }
  }
}
複製代碼

該方法就是遍歷_path中的每個HitTestEntry,取出target進行事件的分發,而HitTestTarget除了幾個Binding,其具體都是由RenderObject實現的,所以也就是對每個RenderObject節點進行事件分發,也就是我們說的“事件冒泡”,冒泡的第一個節點是最小child節點(最內部的組件),最後一個是GestureBinding

值得注意的是,Flutter中並沒有機制去取消或者去停止事件進一步的分發,我們只能在hitTestBehavior中去調整組件在命中測試期內應該如何表現,而且只有通過命中測試的組件才能觸發事件。

所以,_handlePointerEvent方法主要就是不斷通過hitTest方法計算出所需的HitTestResult,然後再通過dispatchEvent對事件進行分發。

以上是簡單的對Flutter的事件分發進行一個分析,具體到我們組件層面的使用,Flutter內部還做了較多的處理,在Flutter中,具備手勢點擊事件的組件的實現,可直接使用的組件層面主要分爲以下(也可以其它緯度分類):

  1. 直接使用Listener組件監聽事件
  2. 其他基於對手勢識別器GestureRecoginzer的實現:
    • 使用GestureDetector組件
    • 使用FloatButtonInkWell...等結構爲:xx--xx->GestureDecector->Listener這種依託於GestureDecector->Listener的組件
    • 類似Switch,內部也是基於GestureRecoginzer實現的組件

針對第二點,在遇到多個手勢衝突的時候,爲了確定最終響應的手勢,還得經過一個"手勢競技場"的過程,也就是在上圖中recognizer手勢識別器以上部分的調用結構,在"手勢競技場"中勝利的才能最終將事件響應組件層面。

以上爲手勢事件的一個大概的流程分析,瞭解了其原理與基本流程,能更好的幫助我們去完成自動埋點功能的實現。如果對Flutter手勢事件原理還有不清楚的可以去查閱其它資料或者留言交流。

5.AOP

通過上面的描述,首先我們肯定是可以在響應的單擊、雙擊、長按回調函數通過直接調用SDK埋點代碼來獲得我們的數據,那麼如何才能實現這一步的自動化呢?

AOP:在指定的切點插入指定的代碼,將所有的代碼插樁邏輯幾種在一個SDK內處理,可以最大程度的不侵入我們的業務。

目前阿里閒魚開源的一款面向Flutter設計的AOP框架:Aspectd,具體的使用不多做介紹,看github地址即可。

通過上述手勢事件的分析,選擇以下兩個切入點(當然也有其它的切入方式):

  • HitTestTargethandleEvent(PointerEvent event,HitTestEntry entry)方法;
  • GestureRecognizerinvokeCallback<T>(String name,RecognizerCallback<T> callback,{String debugReport})方法;

其代碼大致如下所示:

 @Call("package:flutter/src/gestures/hit_test.dart", "HitTestTarget",
      "-handleEvent")
  @pragma("vm:entry-point")
  dynamic hookHitTestTargetHandleEvent(PointCut pointCut) {
    dynamic target = pointCut.target;
    PointerEvent pointerEvent = pointCut.positionalParams[0];
    HitTestEntry entry = pointCut.positionalParams[1];
    curPointerCode = pointerEvent.pointer;
    if (target is RenderObject) {
        if (curPointerCode > prePointerCode) {
          clearClickRenderMapData();
        }
        if (!clickRenderMap.containsKey(curPointerCode)) {
            clickRenderMap[curPointerCode] = target;
          }
    }
    prePointerCode = curPointerCode;
    target.handleEvent(pointerEvent, entry);
  }

  @Call("package:flutter/src/gestures/recognizer.dart", "GestureRecognizer",
      "-invokeCallback")
  @pragma("vm:entry-point")
  dynamic hookinvokeCallback(PointCut pointcut) {
    var result = pointcut.proceed();
    if (curPointerCode > preHitPointer) {
      String argumentName = pointcut.positionalParams[0];

      if (argumentName == 'onTap' ||
          argumentName == 'onTapDown' ||
          argumentName == 'onDoubleTap') {
        RenderObject clickRender = clickRenderMap[curPointerCode];
        if (clickRender != null) {
          DebugCreator creator = clickRender.debugCreator;
          Element element = creator.element;
          //通過element獲取路徑
          String elementPath = getElementPath(element);
          ///豐富採集時間
           richJsonInfo(element, argumentName, elementPath);
        }
        preHitPointer = curPointerCode;
      }
    }

    return result;
  }
複製代碼

大體的實現思路如下:

  1. 通過Map記錄事件唯一的pointer標識符與響應的RenderObject的映射關係,只記錄_path中的第一個,也就是命中測試的最小child,且記錄下當前事件序列的pointer(pointer在一個事件序列中是唯一的值,每發生一次手勢事件,它會自增1);
  2. GestureRecognizerinvokeCallback<T>(String name,RecognizerCallback<T> callback,{String debugReport})方法中,通過上面記錄的的pointer,在Map中取出RenderObject,取debugCreator屬性得到Element,再得到對應的widget;

在上述第2步中,其實存在一個問題,就是RenderObjectdebugCreator字段,這個字段表示負責創建此render object的對象,源碼中創建過程寫在aessert中,所以其實只能在debug模式下獲取到,它在源碼中實際創建位置在RenderObjectElementmount,在update執行更新的時候同樣也會更新:

@override
void mount(Element parent, dynamic newSlot) {
  super.mount(parent, newSlot);
 //省略部分代碼...
  _renderObject = widget.createRenderObject(this);
//省略部分代碼...
  assert(() {
      //assert部分會在Release的時候刪除
    _debugUpdateRenderObjectOwner();
    return true;
  }());
//省略部分代碼...
}

void _debugUpdateRenderObjectOwner() {
    assert(() {
        //將當前Element傳入到DebugCreator中保存。RenderObjectElement繼承Element
      _renderObject.debugCreator = DebugCreator(this);
      return true;
    }());
  }
複製代碼

爲了讓我們在AOP的時候,在Release模式下也能獲取到這個數據,所以我們要特殊處理。既然在源碼中它只能在debug下創建,我們就創造條件讓它在Release下也創建。

@Execute('package:flutter/src/widgets/framework.dart','Element','-mount')
@pragma('vm:entry-point')
static dynamic hookElementMount(PointCut pointCut){
    dynamic obj = pointCut.proceed;
    Element element = pointCut.target;
    if(kReleaseMode||kProfileMode){
        //release和profile模式創建這個屬性
        element.renderObject.debugCreator = DebugCreator(element);
    }
}

@Execute('package:flutter/src/widgets/framework.dart','Element','-update')
@pragma('vm:entry-point')
static dynamic hookElementUpdate(PointCut pointCut){
    dynamic obj = pointCut.proceed;
    Element element = pointCut.target;
    if(kReleaseMode||kProfileMode){
        //release和profile模式創建這個屬性
        element.renderObject.debugCreator = DebugCreator(element);
    }
}
複製代碼

debugCreator字段處理完成後,我們就可以根據RenderObject獲取對應的Element,獲取到Element也就可以去計算組件的path id了。

通過以上操作,在實際中,我們對一個GestureDetector進行點擊測試後,得到如下結果:

GestureDetector[0]/Column[0]/Contain[0]/BodyBuilder[0]/MediaQuery[0]/LayoutId[0]/CustomMultiChildLayout[0]/AnimatedBuilder[0]/DefaultTextStyle[0]/AnimatedDefaultTextStyle[0]/_InkFeatures[0]/NotificationListener<LayoutChangedNotification>[0]/PhysicalModel[0]/AnimatedPhysicalModel[0]/Material[0]/PrimaryScrollController[0]/_ScaffoldScope[0]/Scaffold[0]/MyHomePage[0].../MyApp[0]
複製代碼

經過對比發現,這似乎確實是我們代碼中創建的組件的路徑沒錯,但是好像中間多了很多奇怪的組件路徑,這似乎不是我們自己創建的,這裏還是存在一些問題要優化。

6.關於組件ID的優化

  1. 組件路徑ID過長:

    組件的路徑ID很長。因爲Flutter佈局嵌套包裝的特點,如果一直向上搜索父親節點,會一直搜索到MyApp這裏,中間還會包含很多系統內部創建的組件。

  2. 不同平臺特性:(==去掉這點,無需優化,因爲平臺特性只會出現在系統內部節點,自己編寫的除非有特別的判斷,否則不會出現差異性==)

    在不同的平臺,爲了保持某些平臺的特性風格,可能會出現路徑中某個節點不一致的情況(比如在IOS平臺的路徑可能會出現一個側滑的節點,其他平臺沒有)。例如以"Cupertino"、"Material"開頭的這種組件,要選擇屏蔽掉差異。

  3. 動態插入Widget不穩定

    根據上面定義的規則,在頁面元素不發生變動的情況下,基本上是能保證"穩定性"與"唯一性",但是如果頁面元素髮生動態變化,或者在不同的版本之間UI進行了改版,此時我們定義的規則就會變的不夠穩定,也可能不再唯一,比如下圖所示:

[圖片上傳中...(image-85fa0d-1605191361072-8)]

在插入一個Widget後,我們的GestureDetector的路徑變成了Contain[0]/Column[0]/Contain[2]/GestureDetector[0],與之前相比發生了變化,這點優化比較簡單:將同級兄弟節點的位置,變成相同類型的組件的位置。優化後的組件路徑爲:Contain[0]/Column[0]/Contain[1]/GestureDetector[0]。這樣在插入一個非同類型的Widget後,其路徑依舊不變,但如果插入的是同類型的還是會發生改變,所以這個是屬於相對的穩定。

那麼剩下的問題如何優化呢?

7.Dart元編程解決遺留問題

問題1:我們實際獲取到的路徑並不是我們在代碼中創建的組件路徑,比如:

//我們自己代碼創建一個Contain
@override
Widget build(BuildContext context){
    return Contain(
       child:Text('text'),
    );
}
//實際上Contain的內部build函數,會做層層的包裝,其他組件也是類似情況
@override
  Widget build(BuildContext context) {
    Widget current = child;
    if (child == null && (constraints == null || !constraints.isTight)) {
      current = LimitedBox(
        maxWidth: 0.0,
        maxHeight: 0.0,
        child: ConstrainedBox(constraints: const BoxConstraints.expand()),
      );
    }
        ...省略部分代碼
    if (alignment != null)
      current = Align(alignment: alignment, child: current);
         ...省略部分代碼
    return current;
  }
複製代碼

因爲這個情況,會導致出現三個情況:

  • 我們在用上述方式獲取組件路徑的時候,中間會夾雜很多我們並不那麼關心的組件路徑,即使這些確實是在路徑上的的組件,我們實際上只想要關注我們創建的那部分,關鍵是如何去除"多餘組件路徑"。
  • 系統組件有時內部爲了在一些情況下支持各個平臺特性,還會出現使用各自不同的組件,這種差異需要屏蔽。
  • 因爲Flutter獨特的嵌套方式,每個組件在搜索父節點時最終會搜索到main中,實際其實我們只需要以當前頁面爲劃分即可。

如何解決呢?注意到當我們使用Flutter自帶的工具Flutter Inspector觀測我們創建的頁面時,出現的是我們想要的組件展示情況:

[圖片上傳中...(image-54a276-1605191361071-7)]

[圖片上傳中...(image-4ea4a5-1605191361071-6)]

通過圖中可以看到,widgets的展示形式完整的表示了我們自己頁面代碼中創建widget的結構,那麼這個是如何實現的呢?

實際上,這個是通過一個WidgetInspectorService的服務來實現的,一個被GUI工具用來與WidgetInspector交互的服務。在Foundation/Binding.dart中通過initServiceExtensions註冊,而且只有在debug環境下才會註冊這個拓展服務。

通過對官方開源的dev-tools源碼的分析,其應用層面的關鍵方法如下:

// Returns if an object is user created.
//返回該對象是否自己創建的(這裏我們針對的是widget)
bool _isLocalCreationLocation(Object object) {
  final _Location location = _getCreationLocation(object);
  if (location == null)
    return false;
  return WidgetInspectorService.instance._isLocalCreationLocation(location);
}

/// Creation locations are only available for debug mode builds when
/// the `--track-widget-creation` flag is passed to `flutter_tool`. Dart 2.0 is
/// required as injecting creation locations requires a
/// [Dart Kernel Transformer](https://github.com/dart-lang/sdk/wiki/Kernel-Documentation).
///
/// Currently creation locations are only available for [Widget] and [Element].
_Location _getCreationLocation(Object object) {
  final Object candidate =  object is Element ? object.widget : object;
  return candidate is _HasCreationLocation ? candidate._location : null;
}

  bool _isLocalCreationLocation(_Location location) {
    if (location == null || location.file == null) {
      return false;
    }
    final String file = Uri.parse(location.file).path;

    // By default check whether the creation location was within package:flutter.
    if (_pubRootDirectories == null) {
      // TODO(chunhtai): Make it more robust once
      // https://github.com/flutter/flutter/issues/32660 is fixed.
      return !file.contains('packages/flutter/');
    }
    for (final String directory in _pubRootDirectories) {
      if (file.startsWith(directory)) {
        return true;
      }
    }
    return false;
  }
複製代碼

方法中出現的兩個關鍵類_Location_HasCreationLocation,是在編譯期通過Dart Kernel Transformer實現的,與Android中的ASM實現Transform類似,Dart在編譯期間也是有一個個的Transform來實現一些特定的操作的,這部分可以在Dart的源碼中找到。

widget_inspctor的這個功能,就是在debug模式的編譯期間,通過一個特定的Transform,讓所有的Widget 實現了抽象類_HasCreationLocation,同時改造了Widget的構造器函數,添加一個命名參數(_Location類型),通過AST,給_Location屬性賦值,實現transform的轉換。

但是,這個功能是隻能在debug模式下開啓的,我們要達到這個效果,只能自己實現一個Transform,支持在非debug模式下也能使用。而且,我們可以直接利用aspectd的已有功能,稍微改造一下,添加一個自己的Transform,而且不需要添加widget創建的行列等複雜的信息,只需要能夠區分widget是開發者自己項目創建的即可,也就是隻需要一個標識即可。

同樣的在實現的過程中也有幾點要注意:

  1. 對於創建widget的時候,如果加了const修飾,比如下面示例,是需要單獨作爲一個Transform來處理的。

    Text widget = const Text('文字');
    Contain(
     child:const Text('文字'),
    );
    複製代碼
    
  2. 在debug下可以用TreeNodeLocation字段做區分,但是在release下這個字段是null,不能按照這個區分出自己項目創建的widget。

  3. 如果使用Aspectd的話,自己添加的改造Transform要添加在Aspectd內部實現的幾個Transform之前。因爲Aspectd提供的比如call api,在用在構造函數的時候,會將方法調用處替換掉,我們如果在這個後面注入會無效。所以轉換的順序應該是修改普通構造在最前面,其次是處理常量聲明表達式,最後是Aspectd自己的轉換。

參考源碼的track_widget_constructor_locations.dart的實現,Transform實現的關鍵代碼如下:

  • 自己定義的一個類,讓widget實現這個類,注意該類定義的時候需要我們在main方法中直接或者間接的使用到,對應的_resolveFlutterClasse方法也要修改。

    void _resloveFlutterClasses(Iterable<Library> libraries){
        for(Library library in libraries){
            final Uri importUri = library.importUri;
            if(importUri != null && importUri.scheme == 'package'){
                //自己定義類的完整路徑,比如是:example/local_widget_track_class.dart
                if(importUri.path = 'example/local_widget_track_class.dart'){
                    for(Class cls in library.classes){
                        //定義的類名,比如是:LocalWidgetLocation
                        if(cls.name = 'LocalWidgetLocation'){
                            _localWidgetLocation = cls;
                        }
                    }
                }else if(importUri.path == 'flutter/src/widgets/framework.dart'|| ....){
                    ...
                }
            }
        }
    }
    複製代碼
    
  • 繼承Transformer主要需要實現visitStaticInvocationvisitConstructorInvocation方法:

      @override
      StaticInvocation visitStaticInvocation(StaticInvocation node) {
        node.transformChildren(this);
        final Procedure target = node.target;
        if (!target.isFactory) {
          return node;
        }
        final Class constructedClass = target.enclosingClass;
        if (!_isSubclassOfWidget(constructedClass)) {
          return node;
        }
    
        _addLocationArgument(node, target.function, constructedClass);
        return node;
      }
    
      @override
      ConstructorInvocation visitConstructorInvocation(ConstructorInvocation node) {
        node.transformChildren(this);
        final Constructor constructor = node.target;
        final Class constructedClass = constructor.enclosingClass;
        if(_isSubclassOfWidget(constructedClass)){
          _addLocationArgument(node, constructor.function, constructedClass);
        return node;
      }
    
       void _addLocationArgument(InvocationExpression node, FunctionNode function,
          Class constructedClass) {
        _maybeAddCreationLocationArgument(
          node.arguments,
          function,
          ConstantExpression(BoolConstant(true)),
        );
      }
    
     void _maybeAddCreationLocationArgument(
        Arguments arguments,
        FunctionNode function,
        Expression creationLocation,
        ) {
      if (_hasNamedArgument(arguments, _creationLocationParameterName)) {
        return;
      }
      if (!_hasNamedParameter(function, _creationLocationParameterName)) {
        if (function.requiredParameterCount !=
            function.positionalParameters.length) {
          return;
        }
      }
      final NamedExpression namedArgument = NamedExpression(_creationLocationParameterName, creationLocation);
      namedArgument.parent = arguments;
      arguments.named.add(namedArgument);
    }
    複製代碼
    
  • 對於加了const修飾的Widget,單獨作爲一個Transform來處理注入屬性,該Transform需要重寫visitConstantExpression方法,通過給InstanceConstantfiledValue字段添加一個值達到我們需要的效果。

    Text widget = const Text('文字');
    Contain(
     child:const Text('文字'),
    );
    
    //Transform示例代碼如下:
      @override
      TreeNode visitConstantExpression(ConstantExpression node) {
        node.transformChildren(this);
          if (node.constant is InstanceConstant) {
            InstanceConstant instanceConstant = node.constant;
            Class clsNode = instanceConstant.classReference.node;
            if (clsNode is Class && _isSubclassOf(clsNode, _widgetClass)) {
              final Name fieldName = Name(
                _locationFieldName,
                _localCreatedClass.enclosingLibrary,
              );
              Reference useReference = _localFieldReference;
              final Field locationField =
                  Field(fieldName, isFinal: true, reference: useReference,isConst: true);
              useReference.node = locationField;
              Constant constant = BoolConstant(true);
              instanceConstant.fieldValues
                  .putIfAbsent(useReference, () => constant);
            }
          }
    
        return super.visitConstantExpression(node);
      }
    複製代碼
    

以上代碼的實現思路其實並不難,可以對Dart源碼中的類似實現多參考參考。通過上述的Transform轉換,我們可以完美的解決『多餘組件路徑』的問題,現在我們得到的路徑是實打實的我們自己代碼創建的widget路徑:

GestureDetector[0]/ Column[0]/Center[0]/Scaffold[0]/MyHomePage[0]/MaterialApp[0]/MyApp[0]
複製代碼

同時,因爲直接使用Listener組件的時候,調用不會經過GestureRecognizerinvokeCallback方法的,所以要過濾掉這個情況單獨處理。是直接自己代碼創建Listener則以該Listener爲節點計算path id,否則交由後續的invokeCallback處理計算path。修改後的代碼如下:

  @Call("package:flutter/src/gestures/hit_test.dart", "HitTestTarget",
      "-handleEvent")
  @pragma("vm:entry-point")
  dynamic hookHitTestTargetHandleEvent(PointCut pointCut) {
    dynamic target = pointCut.target;
    PointerEvent pointerEvent = pointCut.positionalParams[0];
    HitTestEntry entry = pointCut.positionalParams[1];
    curPointerCode = pointerEvent.pointer;
    if (target is RenderObject) {
      bool localListenerWidget = false;
      if (target is RenderPointerListener) {
        ///處理單獨使用Listener
        RenderPointerListener pointerListener = target;
        if (pointerListener.onPointerDown != null &&
            pointerEvent is PointerDownEvent) {
          DebugCreator debugCreator = pointerListener.debugCreator;
          dynamic widget;
          debugCreator.element.visitAncestorElements((element) {
            if (element.widget is Listener) {
              widget = element.widget;
              if (widget.isLocal != null && widget.isLocal) {
                localListenerWidget = true;
                String elementPath = getElementPath(element);
                //豐富當前事件的信息
                richJsonInfo(element, element, 'onTap', elementPath);
              }
              //else if(...) //可以過濾側滑返回可能影響到的情況。因爲它本身設置的HitTestBehavior.translucent,點擊到側滑欄區域它會成爲我們認爲的最小widget
            }
            return false;
          });
        }
      }
      if (!localListenerWidget) {
        if (curPointerCode > prePointerCode) {
          clearClickRenderMapData();
        }
        if (!clickRenderMap.containsKey(curPointerCode)) {
            clickRenderMap[curPointerCode] = target;
          }
      }
    }
    prePointerCode = curPointerCode;
    target.handleEvent(pointerEvent, entry);
  }
複製代碼

對於路徑,還需要繼續優化:對於點擊的組件,我們得確定當前顯示的頁面是哪個頁面或者路由,以此拆分出頁面。對此,我們監聽ModalRoutebuildPage方法,該方法是個抽象方法,不同類型的路由不同的具體實現,我們對每個頁面做拆分,拆分爲以當前頁面節點爲搜索的終止節點,得出實際的path id路徑,代碼大致如下所示:

class CurPageInfo {
  Type curScreenPage;
  Type curDialogPage;
  ModalRoute curRoute;
  BuildContext curPageContext;
  CurPageInfo(this.curScreenPage, this.curPageContext);
}  

@Call('package:flutter/src/widgets/routes.dart', 'ModalRoute', '-buildPage')
  @pragma('vm:entry-point')
  dynamic hookRouteBuildPage(PointCut pointcut) {
    ModalRoute target = pointcut.target;
    List<dynamic> positionalParams = pointcut.positionalParams;
      WidgetsBinding.instance.addPostFrameCallback((callback) {
        BuildContext buildContext = positionalParams[0];
        bool isLocal = false;
        while (buildContext != null && !isLocal) {
          buildContext.visitChildElements((ele) {
            dynamic widget = ele.widget;
            if (widget.isLocal != null && widget.isLocal) {
              isLocal = widget.isLocal;
              print('當前頁面的Page = ${widget.runtimeType} isLocal = $isLocal');
              if(target.opaque){   ///opaque是不透明的意思。true就是表示不透明
                curPageInfo = CurPageInfo(widget.runtimeType,positionalParams[0]);
              }else{
                curPageInfo.curPageContext = positionalParams[0];///第一個參數還是上個Page頁面
                curPageInfo.curDialogPage = widget.runtimeType;
              }
              return;
            }
            buildContext = ele;
          });
        }
        curPageInfo.curRoute = target;
      });
    return target.buildPage(positionalParams[0], positionalParams[1], positionalParams[2]);
  }
複製代碼

值得注意的是,Flutter中的彈窗Dialog的顯示也是一個route,一般來說這個不能當做頁面的,所以在計算當前Page的時候要特殊處理。

優化過後,現在得到的路徑就是:

GestureDetector[0]/ Column[0]/Center[0]/Scaffold[0]/MyHomePage[0] 
複製代碼

可以看到現在確實可以以Page頁面對路徑做拆分。

通過以上的方式,將數據採集完成後,剩下只需要將原始數據轉化爲我們需要的數據格式即可(比如轉化封裝成標準Json),在採集的時候我們可以添加更多的屬性字段(比如手機版本型號、App的版本、時間戳...)來豐富這個採集的事件,然後以隊列的形式存儲到我們的數據庫中,上報服務器後可以刪除數據庫的已上報數據。

8.實現過程中的其他問題點

在落地到實際的項目中去實現的時候,當然也會遇到一些其他的問題,比如:

  1. 類似Cupertino風格的側滑返回導致點擊時統計的這種錯誤問題;
  2. 如何豐富當前點擊組件的信息,如果獲取當前組件中需要的例如文字、圖片等信息;
  3. 對特殊組件的兼容處理;
  4. Aspectd框架本身的一些問題;
  5. 實現可視化埋點功能工具;
  6. ...

一些落地中的問題多花點時間都是可以解決的,可能大家發現後會有更好的解決方案,這裏因爲篇幅問題不多做介紹。而關於Ascpetd的一些實現與使用上的問題,我給該框架提了PR,部分已經被合併了,有興趣的可以去看看,如果瞭解到該框架的實現原理,其實會發現可以實現的功能,遠不止框架本身這麼簡單,比如上面對組件的transform也是有相同的思想。

3.結果展示

完成了上述的操作後,整合出了一個Demo,展示如下:

1. 常規click組件(主要是GestureDecector、Listener、GestureDecector衍生類): [圖片上傳中...(image-560d6f-1605191361067-5)]

2.幾個特殊的Click Widget: [圖片上傳中...(image-dca363-1605191361067-4)]

3.在Dialog上的採集: [圖片上傳中...(image-75539c-1605191361067-3)]

4.單一列表數據: [圖片上傳中...(image-5af67b-1605191361067-2)]

5.帶複雜組件的列表: [圖片上傳中...(image-3516ac-1605191361067-1)]

6.Tab實現: [圖片上傳中...(image-ec0358-1605191361067-0)]

無埋點功能數據採集不是萬能的,不是銀彈,比如這種組件唯一性方案,是會隨着版本的迭代可能發生改變的(可以通過上傳的App版本號來區分不同版本的數據的,區分後,很多數據都是可以歸納在一起的),對於可視化埋點來說,不同的版本的版本的數據不能完全通用。知曉實現的原理,才能瞭解在哪些情況下數據是不準確的,以技術人員的角度去看待這些問題,解釋這些問題,才能避免產品運營戰略上的方向錯誤。

4.結語

該方案在公司產品的實施,從開始到現在,大概有一年的時間,中間也一直在優化,爲了針對自己公司產品,某些地方也做了定製化處理,不過基本原理依舊沒怎麼改變。因個人水平有限,如果文章中有錯誤或者可以更加優化的地方,歡迎交流一起進步。另文中一些地方由於篇幅受限,沒有一一展開處理,後續有時間再分享出來。

本文未經允許,拒絕任何形式的轉載!

本文中關於埋點與數據分析的部分概念的解釋來源於網上,目的是爲了更好的讓大家瞭解。

原文地址:https://juejin.im/post/6892371163859976199
來源:掘金 Miracle_

最後

本文在開源項目:https://github.com/xieyuliang/Note-Android 中已收錄,裏面包含不同方向的自學編程路線、面試題集合/面經、及系列技術文章等,資源持續更新中...

這次就分享到這裏吧,下篇見

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章