1. 程式人生 > >網易HubbleData之Android無埋點實踐[轉]

網易HubbleData之Android無埋點實踐[轉]

轉自 https://neyoufan.github.io/2017/07/11/android/%E7%BD%91%E6%98%93HubbleData%E4%B9%8BAndroid%E6%97%A0%E5%9F%8B%E7%82%B9%E5%AE%9E%E8%B7%B5/

網易HubbleData的Android SDK在程式碼埋點整體架構的基礎上新增了無埋點功能,本文主要針對網易HubbleData在Android SDK中的無埋點實踐進行分享。重點死磕無埋點兩大核心技術:1. View的唯一ID;2. 無埋點實現(代理監聽方案和gradle外掛方案)。

1 背景

網易HubbleData是一個洞察使用者行為的資料分析系統,提供一套完整的資料解決方案。一個典型的資料平臺,對於資料的處理,是由如下的5個步驟組成的:

1-data_process1-data_process

其中,第一個步驟,也即資料採集是最核心的問題。網易HubbleData支援全端資料採集,包括iOS、Android、JS、JAVA等多個平臺。本文主要討論Android平臺的資料採集方案。業內各家公司從不同角度,提出了多種技術方案,這些方案大體上可以歸為三類:

(1) 程式碼埋點:在某個事件發生時呼叫SDK裡面相應的介面傳送埋點資料,百度統計、友盟、TalkingData、Sensors Analytics等第三方資料統計服務商大都採用這種方案。

  • 優點:使用者控制精準,自由地選擇什麼時候傳送資料。
  • 缺點:開發及測試代價大;需要等待APP更新。

(2) 視覺化埋點:通過視覺化工具配置採集節點,在Android端自動解析配置並上報埋點資料,從而實現所謂的自動埋點,代表方案是已經開源的Mixpanel。

  • 優點:解放開發人員,解決了程式碼埋點代價大的問題;通過服務端配置埋點,解決等待APP更新的問題。
  • 缺點:覆蓋功能有限,只能配置一些公共屬性;埋點只能從當前時刻開始,無法“回溯”。

(3) 無埋點:它並不是真正的不需要埋點,而是Android端自動採集全部事件並上報埋點資料,在後端資料計算時過濾出有用資料,代表方案是國內的GrowingIO。

  • 優點:解放開發人員,解決了程式碼埋點代價大的問題;解決了等待APP更新和資料“回溯”的問題;可以自動獲取很多啟發性的資訊。
  • 缺點:覆蓋的功能有限,不能靈活地自定義屬性;給網路傳輸和耗電等效能帶來更大的負載。

網易HubbleData的Android SDK早已有之,公司內部諸如考拉、易信、LOFTER、美學、漫畫等多款產品都已接入使用。原有Android SDK採用手動程式碼埋點的方案,主要關注的是事件模型、埋點介面、上報策略等問題。整體架構如下圖所示:

2-code_structrue2-code_structrue

程式碼埋點雖然使用起來靈活,但是開發成本較高,並且一旦上線就很難修改。參考業界先進方案並結合網易公司內部產品的埋點需求,網易HubbleData的Android SDK在程式碼埋點整體架構的基礎上新增了無埋點功能,本文主要針對網易HubbleData在Android SDK中無埋點實踐進行簡單分享。

2 無埋點關鍵技術

2.1 View的唯一ID

2.1.1 如何唯一地標識一個View?

SDK內部在自動收集控制元件資料時,需要將介面上的任何一個View與其他View區分開來。這就需要為介面上的每一個控制元件分配一個唯一的ViewID。此ViewID除了具有區分性,還需要具有一致性,即同一個View無論介面佈局如何動態變化,或者說多次進入同一頁面,此ViewID理論上保持不變。

View中可以找到的特徵資訊:

  • Id: 靜態整數。在編譯期,aapt會生成R類,其中包含所有資源ID。

  • Resource Id:開發者操作控制元件的唯一標識。一般由開發者在佈局檔案中指定android:id,通過findViewById找到View。

  • Class Name:View所屬的Class,例如TextView、LinearLayout、ListView、ViewPager等。

這些特徵資訊中的Id如果能夠使用,是可以直接用作ViewID的,但是,從aapt生成id的原則來看,不同版本相同的resource Id對應的整數Id 是有可能不一樣的,所以沒有辦法使用Id來唯一標識。

Resource Id是開發者定義的View標識,對於有Resource Id 的View可以說具備了唯一標識,那麼沒有Resource Id的View,我們考慮通過一個index屬性來區分,index屬性可以取每個控制元件所屬父元件的index(也即每個控制元件是其父控制元件的第幾個孩子),並逐級向上遍歷找到根節點,最後形成一個View Path即可用來唯一地標識這個View。

2.1.2 ViewID構造

通過上述分析,我們得到一條View Path:獲取每個控制元件自身的ID、類名、Resource Id以及位於所屬父元件的Index等特徵資訊,並逐級向上遍歷找到根節點。

並結合該View所在的頁面資訊,我們得到ViewID的構造形式如下:

sha-256(page : path)
  • page: ActivityName
  • path: view在控制元件樹中的全路徑,按照如下形式進行拼接,其中index為當前view所屬父元件的index,id為編寫佈局檔案時的android:id屬性值,有則拼接,且index固定為0,無則不拼接。
 

1

 

parent1[index]#id/parent2[index]#id/.../view[index]#id

簡單示例如下:

3-view_path_example3-view_path_example

2.1.3 ViewID優化

考慮到在實際佈局中有可能存在一些動態插入、刪除的控制元件,或者說控制元件被複用,都可能引起View Path的變化,從而導致ViewID不唯一。為了保證ViewID的一致性,我們從以下幾個方面著手,對ViewID進行了一定程度地優化。

(1) Index

3-viewPath_example_bad.png3-viewPath_example_bad.png

如上圖所示,當頁面佈局發生動態變化時,比如說刪除一個子view,其他子view所屬父元件的index也可能會改變,為此,我們對view所屬父元件的index進行改造,通過如下演算法對index賦值:

  • 每個ViewGroup下的所有View作為一個數組,從0開始;

  • 每個ViewGroup下的所有View先按照Class分類,然後再把每個型別中的資料按照陣列的方式,從0開始;

  • 每個ViewGroup下的所有View先按照Class分類,再確認是否有Resource Id,如果存在,則index為0,否則index為所屬Class型別陣列下的序號。

該優化處理對所有View適用。優化後效果如下:即動態改變一些控制元件後,只會影響同類型的控制元件,其他型別控制元件的index不受影響,也即ViewID不受影響。

4-index_remove_example4-index_remove_example

(2) 可複用View

先來看一個應用場景:

5-recycleView5-recycleView

如圖所示,當ListView上滑時,螢幕下方即將顯示的<元素6>其實複用了螢幕上方即將滑出的<元素0>,也就是說<元素6>與<元素0>的index均為0,在這種情況下,我們無法通過前述index的定義來區分這兩個列表Item。

7-example_position_versus_index7-example_position_versus_index

所幸,針對這種情況,我們可以用position的取值進行區分,也就是令index = position。

通過實踐發現,發生上述複用情形的View主要有以下幾類:AdapterView、RecyclerView和ViewPager,其api都提供了獲取position的介面。

a. AdapterView

AdapterView的派生類均可通過getPositionForView獲取position。

 

1

 

index = position = ((AdapterView) group).getPositionForView(child);

作為AdapterView的派生類之一,ExpandableListView因為涉及到groupPosition和childPosition,因此需要特殊處理。在構造ViewID時,將能夠採集到的position資訊都新增到View Path中,具體策略如下:

  • 先將ExpandableListView作為普通AdapterView計算position

  • 列表Item為header元素,View Path中新增[header:position]

  • 列表Item為footer元素,footer的index需要額外計算,計算公式如下,View Path中新增[footer:footerIndex]

 

1

2

3

4

 

// Calculates the footer index among footers;

// For instance, there are five footers, so the footer index ranges from zero to four.

// The first footer index is zero.

footerIndex = position - (expandableListView.getCount() - expandableListView.getFooterViewsCount());

  • 列表Item為組元素,View Path中新增[group:groupPosition]
  • 列表Item為組內元素,View Path中新增[group:groupPosition,child:childPosition]

涉及到的api介面如下:

((AdapterView) expandableListView).getPositionForView();
public long getExpandableListPosition(int flatListPosition);
public static int getPackedPositionType(long packedPosition);
public static int getPackedPositionGroup(long packedPosition);
public static int getPackedPositionChild(long packedPosition);

示例如下:

6-ExpandListView6-ExpandListView

b. V7-RecyclerView

RecyclerView的情形比較簡單,可通過呼叫getChildPositiongetChildAdapterPosition獲取position。

@Deprecated
public int getChildPosition(View child);

public int getChildAdapterPosition(View child);

c. V4 - ViewPager

V4 - ViewPager可通過呼叫getCurrentItem獲取position。

public int getCurrentItem();

(3) Fragment節點

主流App的主頁均是採用如圖所示的Tab切換Fragment的設計。在這種情形下,如果主頁內嵌的Fragment採用“懶載入”方案,則底部Tab的點選順序決定了該Tab對應Fragment的初始化順序,從而導致Fragment所屬父元件的index動態變化。

8-mainpage_frags_init_orders_uedc8-mainpage_frags_init_orders_uedc

也就是說,Fragment初始化順序影響ViewID。而前述Index優化方案並不能解決這一問題。

Fragment節點特殊處理

針對Fragment初始化順序影響ViewID的問題,我們採用的解決方案是:

如果能夠獲取到Fragment例項的類名,則使用Fragment例項的類名替換View Path中的Fragment,並設定[index]為特殊標記[-]。例如:使用控制元件篇Tab對應的Fragment例項ControlSetFragment以及特殊標記[-]替換原View Path中的Fragment[3]

10-fragment_classname_replace10-fragment_classname_replace

如何獲取Fragment例項?

採用程式碼埋點或後續即將講到的外掛埋點,在Fragment各例項類中過載下面的幾個方法,並在各方法中插入SDK提供的方法呼叫,從而實現Fragment生命週期監聽:

@Override
public void onResume() {
    super.onResume();
    DATracker.getInstance().onFragmentResume(this);
}

@Override
public void onPause() {
    super.onPause();
    DATracker.getInstance().onFragmentPause(this);
}

@Override
public void setUserVisibleHint(boolean isVisibleToUser) {
    super.setUserVisibleHint(isVisibleToUser);
    DATracker.getInstance().setFragmentUserVisibleHint(this, isVisibleToUser);
}

@Override
public void onHiddenChanged(boolean hidden) {
    super.onHiddenChanged(hidden);
    DATracker.getInstance().onFragmentHiddenChanged(this, hidden);
}

通過上述呼叫,當Fragment生命週期變化時,SDK能夠記錄當前活躍的所有Fragment。當某個活躍的Fragment上的控制元件被點選了,SDK構造該控制元件的ViewID時,會自動將該Fragment例項的類名寫入View Path。

V4 - ViewPager內嵌Fragment

這裡要說明的是,ViewPager內嵌的View不僅是可複用的,同時,由於其“懶載入”、“預載入”機制,其內嵌View的載入順序也是動態的。特別地,當ViewPager內嵌Fragment時,按照前述對Fragment節點的處理,我們會使用Fragment例項的類名替換View Path中的Fragment,並設定[index]為特殊標記[-]。之所以將[index]設定為特殊標記[-],是因為Fragment動態載入導致index不可靠,而ViewPager中內嵌的Fragment卻可以呼叫ViewPager的getCurrentItem拿到position作為index,這種情況下,是可以將index的值新增到View Path中的。

11-viewpager_positon_0_7_uedc11-viewpager_positon_0_7_uedc

2.2 無埋點實現

通過前述方案,我們可以使用ViewID唯一地標識螢幕上的控制元件。那麼,比如一個Button,當這個Button被點選了,SDK又是如何捕捉到這一點選事件,並且拿到Button例項的呢,也就是如何實現自動埋點的呢?這裡,我們提供了兩種方案。

2.2.1 代理監聽

原理

在應用程式中,輔助功能事件是使用者與可視介面元件互動的訊息。這些訊息是由輔助功能服務處理。輔助功能服務使用在這些事件中的資訊產生附加的反饋和提示。Android 4.0(API14)及更高版本上,輔助功能方法屬於View類的一部分,也是View.AccessibilityDelegate的一部分。其中可用於實現無埋點的方法如下:

sendAccessibilityEvent()

當用戶在一個檢視上操作時呼叫此方法。事件按照使用者操作型別分類,涵蓋以下事件型別:

  • TYPE_VIEW_CLICKED
  • TYPE_VIEW_LONG_CLICKED
  • TYPE_VIEW_FOCUSED
  • TYPE_VIEW_SELECTED
  • TYPE_VIEW_HOVER_ENTER
  • TYPE_VIEW_SCROLLED
  • TYPE_VIEW_TEXT_CHANGED

採用輔助功能事件實現無埋點,簡單來講,就是給View設定AccessibilityDelegate,當View產生了click,long_click等事件時,會在響應原有的Listener方法後,傳送訊息給AccessibilityDelegate,然後在sendAccessibilityEvent方法下蒐集自動埋點事件。

private class TrackingAccessibilityDelegate extends View.AccessibilityDelegate {

    public TrackingAccessibilityDelegate(ViewNode viewNode, View.AccessibilityDelegate realDelegate) {
        mViewNode = viewNode;
        mRealDelegate = realDelegate;
    }

    public View.AccessibilityDelegate getRealDelegate() {
        return mRealDelegate;
    }

    @Override
    public void sendAccessibilityEvent(View host, int eventType) {
        if (eventType == mEventType && host == mViewNode.getView()) {
                ...
                // 自動埋點
            fireEvent(mViewNode, type);// sends tracking data
        }

            // 響應原AccessibilityDelegate
        if (null != mRealDelegate) {
            mRealDelegate.sendAccessibilityEvent(host, eventType);
        }
    }

    private View.AccessibilityDelegate mRealDelegate;
    private ViewNode mViewNode;
}

設定代理的時機

實現Application.ActivityLifecycleCallbacks,用來監聽Activity生命週期,當監聽到某個Activity進入onResumed狀態時,通過以下方式獲取RootView:

mViewRoot = this.mActivity.getWindow().getDecorView().getRootView()

從RootView出發深度優先遍歷控制元件樹,為滿足特定條件的View設定代理監聽。

介面動態變化怎麼辦?

實現ViewTreeObserver.OnGlobalLayoutListener,用來監聽介面變化。當監聽到介面變化時,重新遍歷控制元件樹,為滿足特定條件的View設定代理監聽,已經設定過代理的View不再重複設定。

介面的監測操作需要放在介面主執行緒中,起初我們擔心這樣會對應用本身的介面互動產生影響,所幸,經過實際測試,這樣實現是可行的,介面互動感知不到任何影響。

監控哪些View?

  • AutoCompleteTextView(搜尋框)

    新增 TextWatcher 監聽文字變化,2s 後延時傳送文字輸入結果

  • AbsListView(列表)

    OnItemClickListener 存在 - 對原有OnItemClickListener作一層包裝,在響應原有的Listener方法後,蒐集自動埋點事件。

  • 一般View

    hasOnClickListeners 或 isClickable 返回 true - 設定AccessibilityDelegate

2.2.2 gradle外掛

原理

試想一下我們程式碼埋點的過程:首先定位到事件響應函式,例如Button的onClick函式,然後在該事件響應函式中呼叫SDK資料蒐集介面。下面,我們介紹使用gradle外掛自動在目標響應函式中插入SDK資料蒐集程式碼,達到自動埋點的目的。

我們的gradle外掛採用 Android gradle 外掛提供的最新的Transform API,在Apk編譯環節中、class打包成dex之前,插入了中間環節,呼叫 ASM API對class檔案的位元組碼進行掃描,當掃描到目標事件響應函式時,在函式頭部或尾部插入SDK資料蒐集程式碼。

12-gradle_plugin_theory12-gradle_plugin_theory

監控哪些View?

我們在目標View的事件響應函式中插入SDK資料蒐集程式碼,即可實現對該型別View的監控。例如,在Button的點選事件響應函式onClick中插入SDK資料蒐集程式碼後,當Button被點選,便會執行到onClick中的SDK資料蒐集程式碼,從而實現Button點選事件的自動蒐集。

目標事件響應函式(方法):

  • onClick(Landroid/view/View;)V
  • onClick(Landroid/content/DialogInterface;I)V
  • onItemClick(Landroid/widget/AdapterView;Landroid/view/View;IJ)V
  • onItemSelected(Landroid/widget/AdapterView;Landroid/view/View;IJ)V
  • onGroupClick(Landroid/widget/ExpandableListView;Landroid/view/View;IJ)Z
  • onChildClick(Landroid/widget/ExpandableListView;Landroid/view/View;IIJ)Z
  • onRatingChanged(Landroid/widget/RatingBar;FZ)V
  • onStopTrackingTouch(Landroid/widget/SeekBar;)V
  • onCheckedChanged(Landroid/widget/CompoundButton;Z)V
  • onCheckedChanged(Landroid/widget/RadioGroup;I)V

具體實現:

  • 對app中指定包進行掃描,篩選出實現了目標介面的類,在目標方法中新增資料採集程式碼。

例如,篩選出實現了android/view/View$OnClickListener介面的類,然後在onClick(Landroid/view/View;)V方法中注入採集資料的程式碼。

目標效果:

public class MainActivity extends AppCompatActivity implements OnClickListener, 
    android.content.DialogInterface.OnClickListener, 
    OnItemClickListener, 
    OnItemSelectedListener, 
    OnRatingBarChangeListener, 
    OnSeekBarChangeListener, 
    OnCheckedChangeListener, 
    android.widget.RadioGroup.OnCheckedChangeListener, 
    OnGroupClickListener, OnChildClickListener {

    public void onClick(View var1) {
        PluginAgent.onClick(var1);
    }

    public void onClick(DialogInterface var1, int var2) {
        PluginAgent.onClick(this, var1, var2);
    }

    public void onItemClick(AdapterView<?> var1, View var2, int var3, long var4) {
        PluginAgent.onItemClick(this, var1, var2, var3, var4);
  }
    ...
}

Fragment生命週期追蹤

在ViewID優化中,我們講到Fragment節點的優化時,提到可通過重寫Fragment的幾個與生命週期相關的函式監聽Fragment生命週期。這個過程除了使用程式碼埋點,也可藉助外掛自動完成:掃描class檔案,定位Fragment的幾個與生命週期相關的函式,自動插入程式碼。

目標函式(方法):

  • onResume()V
  • onPause()V
  • setUserVisibleHint(Z)V
  • onHiddenChanged(Z)V

具體實現:

  • 對app中指定包進行掃描,篩選出所有父類為下列其中之一的子類。以下是Fragment及系統內建的幾個常見的Fragment派生類。
android/app/Fragment
android/app/DialogFragment
android/app/ListFragment
android/support/v4/app/Fragment
android/support/v4/app/DialogFragment
android/support/v4/app/ListFragment
  • 對這些Fragment子類的onResumedonPausedonHiddenChangedsetFragmentUserVisibleHint方法的位元組碼進行修改,新增資料採集程式碼。

目標效果:

public class BaseFragment extends Fragment {
    public BaseFragment() {
    }

    public void onResume() {
        super.onResume();
        PluginAgent.onFragmentResume(this);
    }
    public void onHiddenChanged(boolean var1) {
        super.onHiddenChanged(var1);
        PluginAgent.onFragmentHiddenChanged(this);
   }

    public void onPause() {
        super.onPause();
       PluginAgent.onFragmentPause(this);
    }

    public void setUserVisibleHint(boolean var1) {
        super.setUserVisibleHint(var1);
        PluginAgent.setFragmentUserVisibleHint(this, var1);
    }
}

2.2.3 代理監聽 vs gradle外掛

外掛埋點方案,發生在編譯期,當目標事件響應函式被執行時,才會觸發我們插入的程式碼主動蒐集事件。除了消耗一點編譯速度,應用執行期間基本不受影響。

代理監聽方案,由於事先並不清楚使用者會觸發哪些互動事件,所以需要為所有可互動的View設定代理,涉及到控制元件樹遍歷,因此效能略遜於gradle外掛方案。但好在控制元件樹遍歷消耗的時間是毫秒級的,不會影響介面互動。

下面總結一下這兩種方案的優缺點。

(1) 代理監聽方案

缺點:

  • 遍歷,被動等待被觸發
  • 攔截彈窗比較困難
  • Fragment生命週期需手動攔截

優點:

  • 對於可點選但又未設定點選監聽器的View,可設定監聽器

(2) gradle外掛方案

優點:

  • 無需遍歷,主動觸發事件
  • 主動攔截彈窗(待擴充套件)

缺點:

  • 目前只支援Gradle1.5+構建工具

3 總結與展望

以上就是網易HubbleData在Android端的無埋點實踐中總結的重點難點。還有一些邊邊角角的點就不一一細述了。

當然,我們的無埋點方案也並不完美,還有一些未解決的問題。例如,ViewID的構造及優化方案並不能適用於所有情況;通過無埋點蒐集的資料也僅限控制元件的一些固有屬性,並沒有蒐集到更有價值的業務資料…

網易HubbleData也將持續跟進業界先進埋點技術,及時升級埋點方案。後續針對比較有意思的技術點,也會繼續整理出來分享給大家。

如果對該專案感興趣,可以聯絡 [email protected] ,歡迎一起研究。