前言:
-更多關於數智化轉型、數據中臺內容請加入阿里雲數據中臺交流群—數智俱樂部 (文末掃描二維碼或點此加入)
-阿里雲數據中臺官網 https://dp.alibaba.com/index
(作者:qingliang_hu)
定義
APP埋點自動採集是指用戶在APP內的操作行為自動採集並上報日誌,其表現在APP上的元素(按鈕、圖片等)的行為主要分為點擊和曝光行為。
其中曝光意為該元素在可視區域停留時長達到一定閾值,即標記為一次曝光行為。
本文主要定位為對Andorid端內部自動採集技術的原理剖析。
核心原理
主流的Android端的事件監聽機制主要有Listener代理,Hook,AccessibilityDelegate,dispatchTouchEvent四種監聽方式,下面將簡要總結四種方式的具體實現。
(此處不介紹採用AspectJ框架編譯期注入代碼方式實現監聽,主要原因在於此方式相對而言太暴力,業務侵入性太強,很難在業務方APP上進行推廣和實現,感興趣的可自行Google/Baidu。)
Listener代理
在Android中,對於事件的監聽及邏輯處理主要通過對View.onClickListener中的onClick方法進行覆寫,如
View saveView = findViewById(R.id.btnSave);
saveView.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
//TO DO
}
});
因此,可以通過自定義監聽代理類ProxyListener,實現View.OnClickListener中的onClick方法,將控件的onClickListener統一替換成ProxyListener,從而完成點擊監聽和日誌上報。代碼如下:
ProxyListener監聽代理類:
public abstract class ProxyListener implements View.OnClickListener{
@Override public void onClick(View view) { // doOnClick為業務方控件點擊事件的邏輯實現 doOnClick(view); sendLog(view); } protected void sendLog(View view) { //TODO:detail of sendLog(), based on Thread Runnable runnable = new Runnable() { @Overrid public void run() { //TODO:do send log } }; Thread thread = new Thread(runnable); thread.start(); } protected abstract void doOnClick(View view);
}
對於所有控件,統一替換調用監聽代理類:
View saveView = findViewById(R.id.btnSave);
saveView.setOnClickListener(new ProxyListener() {
@Override
public void doOnClick(View v) {
//TO DO
}
});
hook機制
Hook機制基於java反射原理,從rootview開始,遞歸遍歷所有的控件View對象,並hook其對應的OnClickListenr對象,將其替換成用於上報日誌的監聽代理類ProxyListener,從而實現動態hook。實現代碼如下:
Step 1: 創建監聽代理管理類,用於統一管理OnClickListenr對象的調用即實現:
public class ProxyManager {
public static void sendLog(View view){} public static class ProxyListener implements View.OnClickListener{ View.OnClickListener mOriginalListener; public ProxyListener(View.OnClickListener l) { mOriginalListener = l; } @Override public void onClick(View v) { //TODO: send log sendLog(v); if(mOriginalListener != null) { mOriginalListener.onClick(v); } } }
}
Step 2: 創建反射管理類,用於保存hook到的OnClickListener對象:
public class HookView {
public Method mHookMethod; public Field mHookField; public HookView(View view) { try { Class viewClass = Class.forName("android.view.View"); if(viewClass != null) { mHookMethod = viewClass.getDeclaredMethod("getListenerInfo"); if(mHookMethod != null) { mHookMethod.setAccessible(true); } } Class listenerInfoClass = Class.forName("android.view.View$ListenerInfo"); if(listenerInfoClass != null) { mHookField = listenerInfoClass.getDeclaredField("mOnClickListener"); } if(mHookField != null) { mHookField.setAccessible(true); } } catch (Exception e) {} }
}
Step 3: 遞歸深度遍歷所有的控件,為其替換OnClickListenr對象
public void hookViews(View view) {
try { if(view.getVisibility() == View.VISIBLE) { if(view instanceof ViewGroup) { ViewGroup group = (ViewGroup) view; int count = group.getChildCount(); for(int i=0; i<count; i++) { View child = group.getChildAt(i); hookViews(view); } } else { if(view.isClickable()) { HookView hookView = new HookView(view); Object listenerInfo = hookView.mHookMethod.invoke(view); Object originalLinstner = hookView.mHookField.get(listenerInfo); hookView.mHookField.set(listenerInfo, new ProxyManager.ProxyListener((View.OnClickListener)originalLinstner)); } } } } catch (Exception e) {}
}
AccessibilityDelegate機制
AccessibilityService輔助功能設計的初衷是為殘障人士提供對於APP操作的輔助功能,如語音或者觸摸的提示,後來也被廣泛應用於搶紅包等後臺服務中。
AccessibilityDelegate同樣也是輔助功能,其輔助主體主要是APP上的具體控件View,可檢測控件點擊,選中,滑動,文本變化等,當該view的相關屬性出現變化時,將回調AccessibilityDelegate中的sendAccessibilityEvent,具體事件類型通過AccessibilityEvent來區分。
因此,藉助AccessibilityDelegate,一旦控件觸發點擊行為時,可用該輔助功能實現日誌上報邏輯,代碼如下:
創建自定義的AccessibilityDelegate,作用於每個View對象上:
public class ClickDelegate extends View.AccessibilityDelegate {
@Override public void sendAccessibilityEvent(View host, int eventType) { super.sendAccessibilityEvent(host, eventType); if(AccessibilityEvent.TYPE_VIEW_CLICKED == eventType) { sendLog(); } } public void sendLog(){} public ClickDelegate(final View rootView) { rootView.getViewTreeObserver().addOnGlobalLayoutListener(new OnGlobalLayoutListener() { @Override public void onGlobalLayout() { setDelegate(rootView); } }); } public void setDelegate(View view) { if(view.getVisibility() == View.VISIBLE) { if(view instanceof ViewGroup) { ViewGroup group = (ViewGroup) view; int count = group.getChildCount(); for(int i=0; i<count; i++) { View child = group.getChildAt(i); setDelegate(view); } } else { if(view.isClickable()) { view.setAccessibilityDelegate(this); } } } } public ClickDelegate(){}
}
其中,在ClickDelegate的構造函數中為根結點rootView添加布局監聽器OnGlobalLayoutListener,實現每當界面的視圖樹發生變化時,通過遞歸遍歷,對新增的控件添加自定義的AccessibilityDelegate,從而實現全局監聽。
dispatchTouchEvent機制
dispatchTouchEvent方法為點擊事件響應鏈上的具體事件分發函數,通過繼承FrameLayout,即可覆寫該函數,實現對於所有點擊事件的監聽。當然,自定義的ProxyLayout必須植入於app的控件樹的根節點,從而在進行事件分發的時候,能夠優先處理響應事件。
public class ProxyLayout extends FrameLayout{
public ProxyLayout(Context context, AttributeSet attrs) { super(context); } @Override public boolean dispatchTouchEvent(MotionEvent ev) { if (ev.getAction() == MotionEvent.ACTION_UP) { View rootView = this.getRootView(); ClickDelegate clickDelegate = new ClickDelegate(); clickDelegate.setDelegate(rootView); } return super.dispatchTouchEvent(ev); }
}
同時實現自動監聽和自動曝光
上述4種監聽機制均提供了全局監聽思想,但Listener機制與Hook機制明顯帶有侷限性,即其對應的事件監聽僅僅只是點擊行為,但在事件採集中,除了點擊行為之外,另一核心功能點則是曝光。
曝光是當指對前控件可見狀態持續性的監聽記錄,其對於某個具體的控件並未有任何交互動作。
因此為了實現同時監聽點擊和曝光,我們可在上述監聽原理的基礎上進行擴展,從而利用一套體系,完成對點擊和開關事件的同時監聽,技術實現原理如下圖所示:
其中,ActivityLifecycleCallbacks監聽當前界面的出入狀態,一旦頁面進入時,便開啟對當前頁面控件的監聽。同時,頁面控件監聽則通過對當前視圖的根結點rootview添加onGlobalLayoutListener,監聽視圖樹的變化。一旦視圖樹發生變化,啟用控件遍歷,尋找目標埋點控件,為其添加AccessibilityDelegate用於點擊監聽,構建自定義ExposureView對象,用於曝光狀態記錄。與此同時,為了解決視圖樹不發生變化但仍需對控件監聽情況,在onGlobalLayoutListener的實現中,添加Runnable對象EventBinding,實現控件遍歷操作,並設置每隔500ms的定時機制,從而完成對於點擊和曝光的全局監控。
其核心代碼如下:
public void run() {
if (!mAlive) { //如果進程關閉,移除runnable mHandler.removeCallbacks(this); return; } // 判斷當前頁是否關閉,是否需要清除runnable final View viewRoot = mViewRoot.get(); if (null == viewRoot || mDying) { cleanUp(); return; } //尋找目標控件 pathfinder.findTargetView(viewRoot, viewVisitorMap, dataTrackSet); //移除已有的runnable mHandler.removeCallbacks(this); //新建定時器 mHandler.postDelayed(this, 500);
}
實踐
下面將具體介紹如何藉助AccessibilityDelegate實現自動點擊和自動曝光。部分代碼參考業界實現方案。
如何實現點擊
針對點擊行為監聽採用AccessibilityDelegate機制,但在View.java中,每個View上的AccessibilityDelegate只有一個,並不是一個數組,這意味著如果有業務方也使用該功能,在可視化埋點中如果直接調用View.setAccessibilityDelegate,將產生邏輯處理覆蓋,其源碼如下:
public void setAccessibilityDelegate(AccessibilityDelegate delegate) {
//mAccessibilityDelegate為單一變量 mAccessibilityDelegate = delegate;
}
因此,為了解決該衝突問題,可將原有的AccessibilityDelegate對象進行保存,並在觸發自己的輔助對象的回調方法時,顯示調用已有對象的sendAccessibilityEvent方法。具體而言,我們需要新建TrackingAccessibilityDelegate對象並繼承View.AccessibilityDelegate,實現sendAccessibilityEvent方法,其中mRealDeleage對象為該控件原有對象,在函數的最後,手動調用該delegate對象的sendAccessibilityEvent方法。代碼示意:
public void sendAccessibilityEvent(View host, int eventType) {
try { if (eventType == mEventType) { fireEvent(host); } } catch (Exception e) { log.error(e); } finally { if (null != mRealDelegate) { mRealDelegate.sendAccessibilityEvent(host, eventType); } }
}
由於每次控件遍歷操作均需要對目標控件進行設置AccessibilityDelegate操作,因此,為了避免相同類型的delegate重複設置問題(重複設置並未影響使用),可在開始進行delegate設置時,對View上已有的delegate對象進行類型判斷,如果是我們的delegate,則無需重複判斷,代碼示例如下:
public boolean willFireEvent(final String eventName) {
if (getEventName() == eventName) { return true; } else if (mRealDelegate instanceof MyAccessibilityDelegate) { return ((MyAccessibilityDelegate)mRealDelegate).willFireEvent(eventName); } else { return false; }
}
willFireEvent函數返回當前AccessibilityDelegate是否為可視化埋點的delegate對象,此處,eventName為自建的MyAccessibilityDelegate對象的一個基本屬性,直接對應於ut日誌中的arg1,可用於標識一個MyAccessibilityDelegate對象。
整體點擊實現邏輯流程圖如下:
如何實現曝光
曝光的實現邏輯核心在於如何持續性監聽某個控件的可見狀態。依託於GlobalLayoutListener以及GlobalLayoutListener中添加的每隔500ms控件掃描定時器runnable,可實現對於控件的持續狀態的持續性監控。
對於每個可見的控件而言,需要記錄其曝光的整個生命週期,包括從開始曝光->持續曝光->結束曝光。其中,整個生命週期需要建立在基礎的曝光規則之上,即達到可見面積≥50%,可見時長≥500ms才為合規的曝光。
因此,一旦控件從不可見狀態轉變為可見狀態時,我們將記錄其當前可見狀態的面積和可見時間點,噹噹前控件樹發生變化或者觸發控件掃描定時器運作時,需要對已有的曝光控件的狀態進行更新,具體更新規則可見如下源碼:
private void checkViewState(ExposureView exposureView, boolean status) {
boolean needExposureProcess = isSatisfySize(exposureView.view); if (needExposureProcess) { switch (exposureView.lastState) { case ExposureView.INITIAL: //初始態需要處理,view的狀態初始化 exposureView.lastState = ExposureView.SEEN; exposureView.beginTime = System.currentTimeMillis(); break; case ExposureView.SEEN: //當前控件依然可見,僅更新可見態控件當前的結束時間 exposureView.endTime = System.currentTimeMillis(); break; case ExposureView.UNSEEN: //不可見態,符合曝光條件,則初始化處理 exposureView.lastState = ExposureView.SEEN; exposureView.beginTime = System.currentTimeMillis(); break; default: break; } } else { switch (exposureView.lastState) { case ExposureView.INITIAL: break; case ExposureView.SEEN: //可見態,不符合界面曝光規則計算,則證明由可見態變為不可見,需要提交曝光數據 exposureView.lastState = ExposureView.UNSEEN; exposureView.endTime = System.currentTimeMillis(); break; case ExposureView.UNSEEN: //不可見態 break; default: break; } } if (exposureView.isSatisfyTimeRequired()) { if(status) { //頁面切換,提交滿足曝光條件的控件 addToCommit(exposureView); currentViews.remove(exposureView.tag); return; } if(exposureView.lastState == ExposureView.SEEN) { return; } else if(exposureView.lastState == ExposureView.UNSEEN) { addToCommit(exposureView); currentViews.remove(exposureView.tag); } } else if (exposureView.lastState == ExposureView.UNSEEN) { currentViews.remove(exposureView.tag); }
}
一旦曝光控件達到曝光時長及曝光面積限制,並且當前控件已從可見態轉為不可見狀態時,將提交緩存的曝光控件信息,調用採集SDK接口上報曝光日誌。其核心邏輯實現流程圖如下:
總結
自動採集和自動曝光技術實現手段較多,但每種實現類型差異也較大,需要根據具體使用場景和自有的業務特性做合適,正確的選擇。本文僅介紹Android端的技術原理,IOS端的實現有異曲同工之處,敬請期待下期分享。
數據中臺是企業數智化的新基建,阿里巴巴認為數據中臺是集方法論、工具、組織於一體的,“快”、“準”、“全”、“統”、“通”的智能大數據體系。目前正通過阿里雲數據中臺解決方案對外輸出,包括零售、金融、互聯網、政務等領域,其中核心產品有:
- Dataphin,一站式、智能化的數據構建及管理平臺;
- Quick BI,隨時隨地 智能決策;
- Quick Audience,全方位洞察、全域營銷、智能增長;
- Quick A+, 跨多端全域應用體驗分析及洞察的一站式數據化運營平臺;
官方站點:
數據中臺官網 https://dp.alibaba.com