網易HubbleData之Android無埋點實踐[轉]
網易HubbleData的Android SDK在程式碼埋點整體架構的基礎上新增了無埋點功能,本文主要針對網易HubbleData在Android SDK中的無埋點實踐進行分享。重點死磕無埋點兩大核心技術:1. View的唯一ID;2. 無埋點實現(代理監聽方案和gradle外掛方案)。
1 背景
網易HubbleData是一個洞察使用者行為的資料分析系統,提供一套完整的資料解決方案。一個典型的資料平臺,對於資料的處理,是由如下的5個步驟組成的:
其中,第一個步驟,也即資料採集是最核心的問題。網易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採用手動程式碼埋點的方案,主要關注的是事件模型、埋點介面、上報策略等問題。整體架構如下圖所示:
程式碼埋點雖然使用起來靈活,但是開發成本較高,並且一旦上線就很難修改。參考業界先進方案並結合網易公司內部產品的埋點需求,網易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 |
簡單示例如下:
2.1.3 ViewID優化
考慮到在實際佈局中有可能存在一些動態插入、刪除的控制元件,或者說控制元件被複用,都可能引起View Path的變化,從而導致ViewID不唯一。為了保證ViewID的一致性,我們從以下幾個方面著手,對ViewID進行了一定程度地優化。
(1) Index
如上圖所示,當頁面佈局發生動態變化時,比如說刪除一個子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不受影響。
(2) 可複用View
先來看一個應用場景:
如圖所示,當ListView上滑時,螢幕下方即將顯示的<元素6>其實複用了螢幕上方即將滑出的<元素0>,也就是說<元素6>與<元素0>的index均為0,在這種情況下,我們無法通過前述index的定義來區分這兩個列表Item。
7-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);
示例如下:
b. V7-RecyclerView
RecyclerView的情形比較簡單,可通過呼叫getChildPosition
和getChildAdapterPosition
獲取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_uedc
也就是說,Fragment初始化順序影響ViewID。而前述Index優化方案並不能解決這一問題。
Fragment節點特殊處理
針對Fragment初始化順序影響ViewID的問題,我們採用的解決方案是:
如果能夠獲取到Fragment例項的類名,則使用Fragment例項的類名替換View Path中的Fragment,並設定[index]為特殊標記[-]。例如:使用控制元件篇Tab對應的Fragment例項ControlSetFragment以及特殊標記[-]替換原View Path中的Fragment[3]
如何獲取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中的。
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資料蒐集程式碼。
監控哪些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子類的
onResumed
,onPaused
,onHiddenChanged
,setFragmentUserVisibleHint
方法的位元組碼進行修改,新增資料採集程式碼。
目標效果:
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] ,歡迎一起研究。