大數據

Android端埋點自動採集技術原理剖析

前言:
-更多關於數智化轉型、數據中臺內容請加入阿里雲數據中臺交流群—數智俱樂部 (文末掃描二維碼或點此加入

-阿里雲數據中臺官網 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機制明顯帶有侷限性,即其對應的事件監聽僅僅只是點擊行為,但在事件採集中,除了點擊行為之外,另一核心功能點則是曝光。
曝光是當指對前控件可見狀態持續性的監聽記錄,其對於某個具體的控件並未有任何交互動作。
因此為了實現同時監聽點擊和曝光,我們可在上述監聽原理的基礎上進行擴展,從而利用一套體系,完成對點擊和開關事件的同時監聽,技術實現原理如下圖所示:
image.png

其中,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對象。

整體點擊實現邏輯流程圖如下:
image.png

如何實現曝光
曝光的實現邏輯核心在於如何持續性監聽某個控件的可見狀態。依託於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端的實現有異曲同工之處,敬請期待下期分享。

數據中臺是企業數智化的新基建,阿里巴巴認為數據中臺是集方法論、工具、組織於一體的,“快”、“準”、“全”、“統”、“通”的智能大數據體系。目前正通過阿里雲數據中臺解決方案對外輸出,包括零售金融互聯網政務等領域,其中核心產品有:

官方站點:
數據中臺官網 https://dp.alibaba.com
數據中臺釘釘群二維碼2.jpg

Leave a Reply

Your email address will not be published. Required fields are marked *