得到Android團隊無埋點方案

小熊345發表於2017-11-19

客戶端埋點是資料收集的最基本手段,但由於業務迭代速度很快,手動埋點方案雖然靈活多變,但是極大的增加了客戶端開發人員的工作量。開發完成業務功能需要花費很大的精力處理埋點事宜,而且隨著迭代版本,埋點的數量會越來越多,這些老舊埋點的維護工作也需要付出不小的努力。並且,手動埋點的正確性同樣是個極度考驗開發人員的耐性和認真程度的問題,在所難免會出現這樣那樣的問題。所以,如果能夠研發出一款不需要或者很少需要開發人員介入就能實現根據不同業務場景埋點的功能sdk對於提高版本迭代速度和開發人員的幸福感絕對是一件非常有價值的事情。android技術進階QQ群 271165123

更大的價值還在於,不需要開發人員介入,運營或者用研的同學就可以隨時動態調整資料收集方案。

縱觀目前比較成熟的無埋點方案,存在著如下問題:

問題1:通過XPath定位控制元件,理論上可行,但實踐表明這個方案的複雜度非常高,尤其對於處理像GridView,ListView,RecyclerView的控制元件更是捉襟見肘。不僅如此,生成xpath的過程本身就是一個及其耗費效能的行為,它需要遍歷view tree,儲存非常多的路徑資訊到view上。

問題2:獲取控制元件對應的資料是通過 data path的方式解決,每次新增新埋點時,如果需要上報資料,那用研人員需要和開發人員逐一確認控制元件資料的path,這極大的限制了客戶端開發的自由度,即使簡單的重構也會使得之前配置的埋點資訊失效。

針對如上問題,我們經過深挖內在邏輯關係及對比優劣,總結出了一套更靈活,更合理的無埋點方案,下面分三個部分逐一介紹實現考量及內部機制。

一、定位與使用者產生互動行為的目標控制元件

關於定位互動控制元件,我們也考慮過xpath的方案,但是考慮到其實現的複雜度,不靈活和各種潛在的問題,我們拋棄了這種方案。通過反覆的閱讀View的touch事件處理相關的原始碼,我們終於發現瞭解決問題的更好的方式。

ViewGroup中有一個TouchTarget 型別的變數 mFirstTouchTarget,表示消費當前觸控事件的控制元件列表。例如,點選螢幕上一個按鈕,那麼按鈕所在ViewGroup的mFirstTouchTarget 變數就指向這個按鈕。當ViewGroup派發觸控事件時,他會首先判斷變數mFirstTouchTarget是否存在,如果變數存在,會迴圈遍歷TouchTarget連結串列元素,找到能處理該事件的View並將MotionEvent 派發給該View。如果不存在TouchTarget,ViewGroup 會迴圈遍歷所有child view,直到找到一個能處理該事件的View,並將該View作為first touch target 賦值給mFirstTouchTarget。

當使用者觸發Down事件時,會執行如下邏輯,尋找消費當前事件的TouchTarget。

if (actionMasked == MotionEvent.ACTION_DOWN){
    //如果是down事件,遍歷child,找到TouchTarget
    ..
    ..
    final View[] children = mChildren;
    for (int i = childrenCount - 1; i >= 0; i--) {
       final int childIndex = getAndVerifyPreorderedIndexchildrenCount, i, customOrder);
       final View child = getAndVerifyPreorderedView(preorderedList, children, childIndex);
       ..
       ..
       if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
          // child 消費了觸控事件
          ..
          ..
          // 根據消費了觸控事件的View建立TouchTarget
           newTouchTarget = addTouchTarget(child, idBitsToAssign);
          ..
          ..
          break;
      }
}複製程式碼

當觸發Down事件並且找到TouchTarget,或者觸發非Down事件時,執行如下處理邏輯。

if (mFirstTouchTarget == null) {
    // No touch targets so treat this as an ordinary view.
    handled = dispatchTransformedTouchEvent(ev, canceled, null,TouchTarget.ALL_POINTER_IDS);
} else {
    //Down事件發生時找到TouchTarget,或者非Down事件直接執行如下邏輯

    // 將事件派發給TouchTarget表示的View
    TouchTarget predecessor = null;
    TouchTarget target = mFirstTouchTarget;

    while (target != null) {
        final TouchTarget next = target.next;

        if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
            handled = true;
        } else {
            final boolean cancelChild = resetCancelNextUpFlag(target.child)|| intercepted;

            if (dispatchTransformedTouchEvent(ev, cancelChild,target.child,target.pointerIdBits)) {
               //指定TouchTarget對應的View正確消費了事件
                handled = true;
             }
             ..
             ..
         }
     ..
     ..
     }
}複製程式碼

提示:由於消費觸控事件的控制元件可能為多個(splitting touch events),所以需要遍歷TouchTarget連結串列。引用官方原文:
This behavior is enabled by default for applications that target an SDK version of 11 (Honeycomb) or newer. On earlier platform versions this feature was not supported and this method is a no-op.

MotionEvents may be split and dispatched to different child views depending on where each pointer initially went down. This allows for user interactions such as scrolling two panes of content independently, chording of buttons, and performing independent gestures on different pieces of content.

利用ViewGroup的這種事件處理機制,我們通過在Activity的window上呼叫window.setCallback() 接管視窗的事件派發,並在dispatchTouchEvent處理函式中新增analyzeMotionEvent()方法。如果接收到up事件,執行處理邏輯,通過ViewGroup TouchTarget連結串列,找到本次互動行為的目標控制元件。拿到控制元件後,通過 Activity的類名+控制元件所在的layout檔名+控制元件id對應的資源名,我們就可以確定目標控制元件的唯一標識。

dispatchTouchEvent原始碼如下:

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        if (!AutoPointer.isAutoPointEnable()) {
            return super.dispatchTouchEvent(ev);
        }

        int actionMasked = ev.getActionMasked();

        if (actionMasked != MotionEvent.ACTION_UP) {
            return super.dispatchTouchEvent(ev);
        }

        long t = System.currentTimeMillis();
        analyzeMotionEvent();

        //非線上版本,列印執行時間
        if (!AutoPointer.isOnlineEnv()) {
            long time = System.currentTimeMillis() - t;
            DDLogger.d(TAG, String.format(Locale.CHINA, "處理時間:%d 毫秒", time));
        }

        return super.dispatchTouchEvent(ev);
    }複製程式碼

analyzeMotionEvent原始碼如下:

    /**
     * 分析使用者的點選行為
     */
    private void analyzeMotionEvent() {
        if (mViewRef == null || mViewRef.get() == null) {
            DDLogger.e(TAG, "window is null");
            return;
        }

        ViewGroup decorView = (ViewGroup) mViewRef.get();
        int content_id = android.R.id.content;
        ViewGroup content = (ViewGroup) decorView.findViewById(content_id);
        if (content == null) {
            content = decorView; //對於非Activity DecorView 的情況處理
        }

        Pair<View, Object> targets = findActionTargets(content);
        if (targets == null) {
            DDLogger.e(TAG, "has no action targets!!!");
            return;
        }

        //傳送任務在單執行緒池中
        int hashcode = targets.first.hashCode();
        if (mIgnoreViews.contains(hashcode)) return;

        PointerExecutor.getHandler().post(PointPostAction.create(targets.first, targets.second));
    }複製程式碼

二、獲取與目標控制元件對應的業務資料

對於獲取控制元件資料,為了最大化獲取速度,我們在系統中配置了多個資料獲取策略。如果目標控制元件是AbsListView或者RecyclerView 的child view及child view 的chid,那我們可以通過child view在adapter中的位置獲取到我們想要的資料。這種方式能夠處理大多數頁面控制元件資料的獲取問題。系統配置策略的方式如下:

    private static Map<String, DataStrategy> mStrategies = new HashMap<>();

    static {
        //configure RecyclerView and subclass's search strategy
        DataStrategy recyclerViewStrategy = new RecyclerViewStrategy();
        mStrategies.put("RecyclerView", recyclerViewStrategy);
        mStrategies.put("DDCollectionView", recyclerViewStrategy);

        //ExpandableListView
        DataStrategy EListViewStrategy = new ExpandableListViewStrategy();
        mStrategies.put("ExpandableListView", EListViewStrategy);
        mStrategies.put("DDExpandableListView", EListViewStrategy);

        DataStrategy adapterViewStrategy = new AdapterViewStrategy();
        //ListView
        mStrategies.put("ListView", adapterViewStrategy);
        mStrategies.put("DDListView", adapterViewStrategy);
        mStrategies.put("ListViewCompat", adapterViewStrategy);

        //GridView
        mStrategies.put("GridView", adapterViewStrategy);
        mStrategies.put("DDGridView", adapterViewStrategy);

        //ViewPager
        DataStrategy viewPagerStrategy = new ViewPagerStrategy();
        mStrategies.put("ViewPager", viewPagerStrategy);

        //TabLayout
        DataStrategy tabLayoutStrategy = new TabLayoutStrategy();
        mStrategies.put("TabLayout", tabLayoutStrategy);
    }複製程式碼

對於那些完全自定義佈局繪製的頁面,例如個人中心等頁面,業務開發人員需要通過框架api建立一個控制元件樹到資料的對映關係,這樣框架在需要獲取資料時,通過這個關係就可以非常容易的獲取到想要的資料。

    /**
     * 配製自定義佈局的資料繫結關係,自定義佈局內的任何
     * 控制元件發生點選行為時,傳送的埋點都會攜帶改資料
     *
     * @param id
     * @param object
     * @return
     */
    @NonNull
    @Override
    public DataConfigureImp configLayoutData(@IdRes int id, @NonNull Object object) {
        Preconditions.checkNotNull(object);

        mDataLayout.put(id, object);
        return this;
    }複製程式碼

根據TouchTarget找到資料獲取策略或者資料對映關係,我們可以非常簡單的獲取到繫結的資料,獲取資料的演算法如下:

        if (strategyView != null) {
            Object data = strategy.fetchTargetData(strategyView);

            return Pair.create(touchTarget, data);
        }

        if (configDataView != null) {
            return Pair.create(touchTarget, mDataLayout.get(configId));
        }

        //解決自定義佈局的資料繫結問題
        if (dataAdapter != null) {
            return Pair.create(touchTarget, dataAdapter.getData());
        }複製程式碼

三、實現埋點的動態可配置

在測試環境下,用研人員會通過手動模擬點選的方式獲取sdk上報的控制元件唯一id和資料資訊,在確認id,和資料的正確性之後,需要手動配置id和埋點事件的對應關係,及上報的資料欄位,並儲存到配置倉庫。線上上環境,當使用者啟動app會拉取配置資訊並載入到記憶體。這樣,當使用者觸發點選行為時,會根據第一步獲取的id資訊查詢配置,如果在配置中查到對應的條目,會將對應的事件及資料上報到伺服器。

為了處理配置下拉失敗無法傳送埋點的情況,我們需要將同樣的配置放在主專案的assets目錄下,每次啟動app請求配置介面判斷配置資訊是否發生變化,如果配置沒有變化,直接使用assets中的配置檔案,否則,下拉最新配置,使用最新的埋點配置資訊。

四、無痕埋點方案對現有專案的約束

使用無埋點sdk需要遵循一定的開發規範,關於具體的開發規範請檢視工程README。為了確保專案編碼的規範性,我們開發了一系列lint檢查規則來幫助發現錯誤。
lint 工程程式碼 github.com/jessie345/C…
整合lint功能 github.com/jessie345/C…

五、繼續優化
目前,整合這個無埋點方案有一些使用約束並且需要在主專案中新增一些特定的配置函式。下一步需要做的就是解耦。通過javasist技術,儘量將所有約束遷移到用動態技術保證,而不是通過lint規範,將其侵入性降到最低。

至此,無埋點sdk的核心運作機制已經全部梳理清楚。

相關文章