1. 程式人生 > >Android埋點技術分析

Android埋點技術分析

轉自:http://ju.outofmemory.cn/entry/338292

一、概念

埋點,是對網站、App或者後臺等應用程式進行資料採集的一種方法。通過埋點,可以收集使用者在應用中的產生行為,進而用於分析和優化產品後續的體驗,也可以為產品的運營提供資料支撐,其中常見的指標有PV、UV、頁面時長和按鈕的點選等,通常可以採集到下面這些資料。

  • 行為資料:時間、地點、人物、互動的內容等
  • 質量資料:App執行情況、瀏覽器載入情況、錯誤異常等
  • 環境資料:手機型號、作業系統版本、瀏覽器UA、地理、運營商、網路環境等
  • 運營資料:PV、UV、點選量、日活、留存、渠道來源等

採集行為資料時,通常需要在Web頁面/App裡面新增一些程式碼,當用戶的行為達到某種條件時,就會向伺服器上報使用者的行為。其實新增這些程式碼的過程就可以叫做“埋點”,在很久以前就已經出現了這種技術。隨著技術的發展和大家對資料採集要求的不斷提高,我認為埋點的技術方案走過了下面幾個階段:

  • 程式碼埋點:程式碼埋點是指在某個事件發生時呼叫資料傳送介面上報資料。 例如開發人員按照產品/運營的需求,在Web頁面/App的原始碼裡面新增行為上報的程式碼,當用戶的行為滿足某一個條件時,這些程式碼就會被執行,向伺服器上報行為資料。這種方案是最基礎的方案,每次增加或者修改資料上報的條件,都需要開發人員的參與,並且只能在下一個版本上線後才能看到效果。基本上所有的資料平臺都提供了這類資料上報的SDK,將行為上報的後臺伺服器介面封裝成了簡單的客戶端SDK介面。開發者可以通過嵌入這類SDK,在埋點的地方呼叫少量的程式碼就可以上報行為資料。

  • 全埋點:全埋點指的是將Web頁面/App內產生的所有的、滿足某個條件的行為,全部上報到後臺伺服器。 例如把一個App中所有的按鈕點選都進行上報,然後由產品/運營去後臺篩選所需要的行為資料。這種方案的優點非常明顯,就是可以不用在新增/修改行為上報條件時,再找開發人員去修改埋點的程式碼。然而它的缺點也和優點一樣明顯,那就是上報的資料量比程式碼埋點大很多,裡面可能很多是沒有價值的資料。此外,這種方案更傾向於獨立去看待使用者的行為,而沒有關注行為的上下文,給資料分析帶來了一些難度。很多公司也提供了這類功能的SDK,通過靜態或者動態的方式,“Hook”了原有的App程式碼 ,從而實現了行為的監測,在資料上報時通常是採用累積多條再上報的方案來合併請求。

  • 視覺化埋點:視覺化埋點是指通過視覺化工具配置採集節點,在App/Web解析配置查詢節點,監聽節點產生的事件並上報。 例如產品在Web頁面/App的介面上進行圈選,配置需要監測介面上哪一個元素,然後儲存這個配置,當App啟動時會從後臺伺服器獲得產品/運營預先圈選好的配置,然後根據這份配置查詢並監測App介面上的元素,當某一個元素滿足條件時,就會上報行為資料到後臺伺服器。有了暴力的全埋點技術方案,很容易聯想到按需埋點,視覺化埋點就是一種按需配置埋點的方案。現在也有一些公司提供了這類SDK,圈選監測元素時,有的是提供一個Web管理介面,手機在安裝並初始化了SDK之後,可以和管理介面了連線,讓使用者在Web管理介面上配置需要監測的元素,有的是直接讓使用者在手機上圈選元素進行埋點。

hook直譯是鉤子的意思,以前學資訊保安的時候在windows上聽到過,大體意思是通過某種手段去改變系統API的一個行為,繞過系統的某個方法,或者改變系統的工作流程。在這裡其實是指把本來要執行某個方法的物件替換成另一個,一般用的是反射或者代理,需要找到hook的程式碼位置,甚至還可以在編譯階段實現替換。全埋點和視覺化埋點都需要Hook掉App原本的程式碼實現。

業界有多家SDK都支援上面介紹的3種埋點方案中的一種或者全部,例如Mixpanel、Sensorsdata、TalkingData、GrowingIO、諸葛IO、Heap Analytics、MTA、Umeng Analytics、百度,只是大家對後兩種埋點的稱呼不完全相同,有的叫無埋點或者codeless埋點。由於 Mixpanel (支援程式碼埋點、視覺化埋點)和 Sensorsdata (全部支援)都開源了自己的全部SDK,技術方案也比較類似,下面以它們的Android SDK為例,簡單分析一下3種埋點方案的技術實現。關於JS的SDK技術實現,可以看下我的另一篇部落格- JS埋點SDK技術分析 

二、程式碼埋點

包含Mixpanel SDK在內的大部分SDK,都會把這種埋點方案封裝成一個比較簡單的介面,在這裡是 track(String eventName, JSONObject properties) ,開發者在呼叫這個介面時,可以把一個事件名稱和事件的屬性傳入,然後就可以上報到後臺了。一般程式碼埋點長這樣:

button.setOnClickListener(new View.OnClickListener() {
  @Override
  public void onClick(View v) {
    // 業務程式碼
    // ...
    // 埋點上報
    JSONObject properties = new JSONObject();
    properties.put("price", 6800);
    properties.put("name", "Pixel2 XL");
    Tracker.track("PURCHASE", properties);
    }
  });

Mixpanel SDK內部採用一條HandlerThread執行緒來處理事件,當開發者呼叫 track(String eventName, JSONObject properties) 方法時, 主執行緒切換到HandlerThread 當中,並先將事件存入資料庫。然後看SDK中是否累計到了40個事件,如果累計到40個事件的話,就合併它們上報到後臺。

當開發者設定為debug模式,或者手動呼叫 flush 介面時,可以立即上報累計的所有事件,不過由於只有一條執行緒,所以如果在flush的時候,前面的事件還沒有處理完成,SDK會間隔1分鐘再次去處理後面的這些事件。

開發者可以設定累計上報的事件數量閾值、事件阻塞時再次嘗試上報的時間間隔等。這種方案比較基礎,相信大部分開發者都接觸過,不需要過多分析。

三、全埋點

3.1 基本原理

全埋點要對方法進行Hook,按照 是否在執行時 這個條件來區分,Android全埋點可以有下面兩種方式:

  • 靜態Hook: AspectJ實現AOP,編譯期修改程式碼
  • 動態Hook: 執行時替換View.OnClickListener等事件回撥

這裡的Hook其實就是一種AOP實現。

那麼什麼是AOP?AOP為Aspect Oriented Programming的縮寫,意為:面向切面程式設計,通過預編譯方式和執行期動態代理實現程式功能的統一維護的一種技術。AOP是OOP的延續,是軟體開發中的一個熱點,也是Spring框架中的一個重要內容,是函數語言程式設計的一種衍生範型。利用AOP可以對業務邏輯的各個部分進行隔離,從而使得業務邏輯各部分之間的耦合度降低,提高程式的可重用性,同時提高了開發的效率。(from baidu baike)

簡而言之,AOP是可以通過預編譯方式和執行期動態代理實現在不修改原始碼的情況下給程式動態統一新增功能的一種技術。

Sensors Analytics AndroidSDK全埋點的實現就是通過在程式碼編譯階段,找到原始碼中需要上報事件的位置,插入SDK的事件上報程式碼。它用到的框架是 AspectJ 

3.2 使用AspectJ做靜態Hook

3.2.1 AspectJ基本概念

在很多地方我們可以看到AspectJ的身影,例如JakeWharton大神貢獻的一個註解日誌和效能調優框架 Hugo ,在Spring框架裡面也有應用到AspectJ的概念(不過Spring AOP的實現是用的動態代理)。我理解AspectJ裡面的主要幾個概念有:

  • JPoint: 程式碼切點(就是我們要插入程式碼的地方)
  • Aspect: 程式碼切點的描述
    • Pointcut: 描述切點具體是什麼樣的點,如函式被呼叫的地方( Call(MethodSignature) )、函式執行的內部( execution(MethodSignature) 
    • Advice: 描述在切點的什麼位置插入程式碼,如在Pointcut前面( @Before )還是後面( @After ),還是環繞整個Pointcut( @Around 

由此可見,在實現AOP功能時,需要做下面幾件事:

  • 定義一個Aspect,這個Aspect裡面必須有Pointcut和Advice兩個屬性
  • 編寫在匹配到符合Pointcut和Advice描述的程式碼時,需要注入的程式碼
  • 在程式碼編譯時,通過特殊的java編譯器(Aspect的ajc編譯器),找到符合我們定義的Aspect的程式碼,將需要注入的程式碼插入到Advice指定的位置。

如果你對AspectJ有了解的話,已經可以猜到SDK內部是怎麼實現全埋點的了;如果沒有接觸,我覺得也不用急於全面地去學習AspectJ,畢竟AspectJ的功能很強大(可遠不止前置、後置這麼簡單的增強),埋點這種業務只用到了AspectJ當中的一小部分功能而已,可以直接看下面的分析。

3.2.2 實現

神策SDK裡面是如何監測View點選事件呢?我把SDK程式碼簡化一下進行分析,有下面幾個步驟:

3.2.2.1 定義一個Aspect

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;

@Aspect
public class ViewOnClickListenerAspectj {

    /**
     * android.view.View.OnClickListener.onClick(android.view.View)
     *
     * @param joinPoint JoinPoint
     * @throws Throwable Exception
     */
    @After("execution(* android.view.View.OnClickListener.onClick(android.view.View))")
    public void onViewClickAOP(final JoinPoint joinPoint) throws Throwable {
        AopUtil.sendTrackEventToSDK(joinPoint, "onViewOnClick");
    }
}

這段Aspect的程式碼定義了: 在執行android.view.View.OnClickListener.onClick(android.view.View)方法原有的實現後面,需要插入 AopUtil.sendTrackEventToSDK(joinPoint, "onViewOnClick");這段程式碼。

AopUtil.sendTrackEventToSDK(joinPoint, "onViewOnClick"); 這段程式碼做的事情就是點選事件的上報。因為神策SDK將全埋點功能和主SDK包分離成了兩個jar包,所以通過AopUtil工具去呼叫真正的事件上報程式碼,這裡不細述其實現,下面直接看這段程式碼背後真正的點選上報實現。

SensorsDataAPI.sharedInstance().track(AopConstants.APP_CLICK_EVENT_NAME, properties);

可以看到AOP實現的點選監測,最後也走 track 方法進行上報了。

3.2.2.2 使用ajc編譯器向原始碼中“織入”Aspect程式碼

採用AspectJ框架編寫的程式碼,想要注入原來的工程的程式碼,需要在 /app/build.gradle 中引用ajc編譯器,指令碼如下:

...
import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath 'org.aspectj:aspectjtools:1.8.10'
    }
}

repositories {
    mavenCentral()
}

android {
    ...
}

dependencies {
    ...
    compile 'org.aspectj:aspectjrt:1.8.10'
}

final def log = project.logger
final def variants = project.android.applicationVariants

variants.all { variant ->
    if (!variant.buildType.isDebuggable()) {
        log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
        return;
    }

    JavaCompile javaCompile = variant.javaCompile
    javaCompile.doLast {
        String[] args = ["-showWeaveInfo",
                     "-1.5",
                     "-inpath", javaCompile.destinationDir.toString(),
                     "-aspectpath", javaCompile.classpath.asPath,
                     "-d", javaCompile.destinationDir.toString(),
                     "-classpath", javaCompile.classpath.asPath,
                     "-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]
        log.debug "ajc args: " + Arrays.toString(args)

        MessageHandler handler = new MessageHandler(true);
        new Main().run(args, handler);
        for (IMessage message : handler.getMessages(null, true)) {
           switch (message.getKind()) {
                case IMessage.ABORT:
                case IMessage.ERROR:
                case IMessage.FAIL:
                    log.error message.message, message.thrown
                    break;
                case IMessage.WARNING:
                    log.warn message.message, message.thrown
                    break;
                case IMessage.INFO:
                    log.info message.message, message.thrown
                    break;
                case IMessage.DEBUG:
                    log.debug message.message, message.thrown
                    break;
            }
        }
    }
}

在SensorsAndroidSDK中,把上面這段指令碼編寫成了一個 gradle外掛 ,開發者只需要在 app/build.gradle 引用這個外掛即可。

apply plugin: 'com.sensorsdata.analytics.android'

3.2.2.3 檢視織入後的class檔案

完成上面兩步,就可以實現在 android.view.View.OnClickListener.onClick(android.view.View) 方法中插入我們的資料上報程式碼了。我們在demo程式碼中加一個Button,並給它set一個OnClickListener,編譯一下程式碼,檢視 /build/intermediates/classes/debug/ 裡面class檔案,經過ajc編譯之後,原始程式碼中插入了Aspect的程式碼,並呼叫了 ViewOnClickListenerAspectj 裡面的onViewClickAOP 方法。

public class MainActivity extends Activity {
    public MainActivity() {
    }

    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        this.setContentView(2130968603);
        Button btnTst = (Button)this.findViewById(2131427422);
        btnTst.setOnClickListener(new OnClickListener() {
            public void onClick(View v) {
                JoinPoint var2 = Factory.makeJP(ajc$tjp_0, this, this, v);

                try {
                    Log.i("MainActivity", "button clicked");
                } catch (Throwable var5) {
                    ViewOnClickListenerAspectj.aspectOf().onViewClickAOP(var2);
                    throw var5;
                }

                ViewOnClickListenerAspectj.aspectOf().onViewClickAOP(var2);
            }

            static {
                ajc$preClinit();
            }
        });
    }
}

AspectJ的基本用法就是這樣,除了對 OnClickListener 進行替換,理論上可以對任何已知的方法進行替換,所以在埋點SDK中還可以採用對RatingBar、CheckBox、RadioButton等控制元件的點選進行監聽。

神策AndroidSDK藉助AspectJ插入Aspect程式碼,就是一種靜態Hook的方式。本質上是在程式沒有執行之前,通常是編譯或者連結的階段,對位元組碼進行修改,插入事件上報的程式碼。

修改位元組碼除了這種方案之外,還有Android Gradle外掛提供的trasform api(1.5.0版本以上)、ASM、Javassist。在網易樂得的埋點方案,Nuwa熱修復專案都可以見到這些技術的實踐。

3.3 使用代理模式實現動態Hook

3.3.1 代理模式

上面分析了以AspectJ為代表的 “靜態Hook” 實現方案,有沒有其他辦法可以不修改原始碼,只是 在App執行的時候去“動態Hook” 點選行為的處理呢?答案是肯定的,JAVA裡面有一個設計模式叫代理模式,從這個角度出發,看下怎麼 在執行時 實現點選事件的監測上報。

在 android.view.View.java 的原始碼( API>=14 )中,有這麼幾個關鍵的方法:

// getListenerInfo方法:返回所有的監聽器資訊mListenerInfo
ListenerInfo getListenerInfo() {
    if (mListenerInfo != null) {
        return mListenerInfo;
    }
    mListenerInfo = new ListenerInfo();
    return mListenerInfo;
}

// 監聽器資訊
static class ListenerInfo {
    ... // 此處省略各種xxxListener
    /**
     * Listener used to dispatch click events.
     * This field should be made private, so it is hidden from the SDK.
     * {@hide}
     */
    public OnClickListener mOnClickListener;

    /**
     * Listener used to dispatch long click events.
     * This field should be made private, so it is hidden from the SDK.
     * {@hide}
     */
    protected OnLongClickListener mOnLongClickListener;

    ...
}
ListenerInfo mListenerInfo;

// 我們非常熟悉的方法,內部其實是把mListenerInfo的mOnClickListener設成了我們建立的OnclickListner物件
public void setOnClickListener(@Nullable OnClickListener l) {
    if (!isClickable()) {
        setClickable(true);
    }
    getListenerInfo().mOnClickListener = l;
}

/**
 * 判斷這個View是否設定了點選監聽器
 * Return whether this view has an attached OnClickListener.  Returns
 * true if there is a listener, false if there is none.
 */
public boolean hasOnClickListeners() {
    ListenerInfo li = mListenerInfo;
    return (li != null && li.mOnClickListener != null);
}

通過上面幾個方法可以看到,點選監聽器其實被儲存在了 mListenerInfo.mOnClickListener 裡面。那麼實現 Hook點選監聽器 時,只要將這個 mOnClickListener 替換成我們包裝的 點選監聽器代理物件 就可以實現點選監聽的代理了。

3.3.2 實現

3.3.2.1 建立點選監聽器的代理類

// 點選監聽器的代理類,具有上報點選行為的功能
class OnClickListenerWrapper implements View.OnClickListener {
    // 原始的點選監聽器物件
    private View.OnClickListener onClickListener;

    public OnClickListenerWrapper(View.OnClickListener onClickListener) {
        this.onClickListener = onClickListener;
    }

    @Override
    public void onClick(View view) {
        // 讓原來的點選監聽器正常工作
        if(onClickListener != null){
            onClickListener.onClick(view);
        }
        // 點選事件上報,可以獲取被點選view的一些屬性
        track(APP_CLICK_EVENT_NAME, getSomeProperties(view));
    }
}

3.3.2.2 反射獲取一個View的mListenerInfo.mOnClickListener,替換成代理的點選監聽器

// 對一個View的點選監聽器進行hook
public void hookView(View view) {
    // 1. 反射呼叫View的getListenerInfo方法(API>=14),獲得mListenerInfo物件
    Class viewClazz = Class.forName("android.view.View");
    Method getListenerInfoMethod = viewClazz.getDeclaredMethod("getListenerInfo");
    if (!getListenerInfoMethod.isAccessible()) {
        getListenerInfoMethod.setAccessible(true);
    }
    Object mListenerInfo = listenerInfoMethod.invoke(view);
    
    // 2. 然後從mListenerInfo中反射獲取mOnClickListener物件
    Class listenerInfoClazz = Class.forName("android.view.View$ListenerInfo");
    Field onClickListenerField = listenerInfoClazz.getDeclaredField("mOnClickListener");
    if (!onClickListenerField.isAccessible()) {
        onClickListenerField.setAccessible(true);
    }
    View.OnClickListener mOnClickListener = (View.OnClickListener) onClickListenerField.get(mListenerInfo);
    
    // 3. 建立代理的點選監聽器物件
    View.OnClickListener mOnClickListenerWrapper = new OnClickListenerWrapper(mOnClickListener);
    
    // 4. 把mListenerInfo的mOnClickListener設成新的onClickListenerWrapper
    onClickListenerField.set(mListenerInfo, mOnClickListenerWrapper);
    // 用這個似乎也可以:view.setOnClickListener(mOnClickListenerWrapper);     
}

注意,如果是 API<14 的話,mOnClickListener直接是直接以一個Field儲存在View物件中的,沒有ListenerInfo,因此反射的次數要更少一些。

3.3.2.3 對App中所有的View進行動態Hook

我們在分析的是全埋點,那麼怎樣把App裡面所有的View點選都Hook到呢?有兩種方式:

  • 第一種:當Activity建立完成後,開始從Activity的DecorView開始自頂向下深度遍歷ViewTree,遍歷到一個View的時候,對它進行hookView操作。這種方式有點暴力,由於這裡面遍歷ViewTree的時候用到了大量反射,效能會有影響。

  • 第二種:比第一種方式稍微優秀一些,來源是一個Github上的開源庫 AndroidTracker (Kotlin實現)。他的處理方式是當Activity建立完成後,在DecorView中新增一個透明的View作為子View,在這個子View的onTouchEvent方法中,根據觸控座標找到螢幕中包含了這個座標的View,再對這些View嘗試進行hookView操作。 這種方式比較取巧,首先是拿到了手指按下的位置,根據這個位置來找需要被Hook的View,避免了在遍歷ViewTree的同時對View進行反射。具體實現是在遍歷ViewTree中的每個View時,判斷這個View的座標是否包含手指按下的座標,以及View是否Visible,如果滿足這兩個條件,就把這個View儲存到一個ArrayListhitViews。然後再遍歷這個ArrayList裡面的View,如果一個View#hasOnClickListeners返回true,那麼才對他進行hookView操作。

3.3.3 動態Hook小結

整體來看,動態Hook的思路這裡用到了反射,難免對程式效能產生影響,如果要採用這種方式實現全埋點方案,還需要好好評估。既然提到了代理,要說一下 這裡的“代理模式”其實還是JAVA的靜態代理,不是動態代理。因為 OnClickListener 和 OnClickListenerWrapper 是在編寫程式碼的時候就確定了,並不是在執行時動態生成了一個 OnClickListenerWrapper 。在JDK中動態代理是使用Native去生成了代理類的位元組碼(比如使用ASM等工具),並使用ClassLoader載入進來的。

3.4 全埋點參考資料

四、視覺化埋點

第三章介紹的是App全埋點,顯然這種方式產生的資料太多,無論是對使用者資源的節約,還是後續的資料分析都不太好。那麼能否 同樣藉助動態Hook技術,在執行時,只對我們感興趣的控制元件進行埋點呢?這就是視覺化埋點。

4.1 視覺化埋點原理

視覺化埋點,需要經過兩個步驟,可以由非技術人員操作完成。

  • 第一步:通過視覺化工具配置採集的View。 例如使用已經嵌入了SDK的App連線管理介面,當手機App與後臺同步時,後臺管理介面上會顯示和手機App一樣的介面,使用者可以在管理介面上用滑鼠選擇需要監測的元素,設定事件名稱,儲存這個配置。(也有一些SDK,比如GrowingIO的SDK圈選操作是在手機懸浮了一個原點,拖動圓點到需要監測的元素上來設定埋點位置的,不管是什麼方式本質上是一樣的,需要儲存一份配置到後臺)。
  • 第二步:App解析配置,找到View,Hook它的事件並上報資料。 例如嵌入了SDK的App啟動時,會從伺服器獲取到一份配置,再根據這份配置去檢測App中的介面及其元素,滿足配置的條件時向伺服器上報事件。

這裡面最重要的技術點就是如何把手機上需要埋點的元素記錄下來,然後根據配置資訊找到需要埋點的控制元件,再替換這個控制元件的互動事件處理方法(如點選、長按等)。下面以Mixpanel、SensorsdataSDK為例(這兩個SDK實現是一樣的),簡單分析一下技術方案的實現。

4.2 視覺化埋點實現

4.2.1 圈選需要監測的View,儲存配置

4.2.1.1 建立WebSocket連線後臺

採用WebSocket連線是因為要讓手機和後臺長時間保持連線,是一個 持續的、實時的雙向通訊 ,WebSocket正適合這種場景。

在Mixpanel和神策SDK裡面其實都用到了開源的 Java-WebSocket 實現。此外,還有一個非常著名的Android同屏工具 Vysor ,裡面也有一個基於WebSocket的網路框架 AndroidAsync 。如果對WebSocket感興趣,可以看看它們。這裡其實只要是用Java實現的WebSocket通訊就行。

4.2.1.2 把App介面截圖和裡面的子View資訊傳送到後臺

建立WebSocket連線後,SDK會在主執行緒中,對App中啟動的Activity進行掃描,找到介面的RootView(其實是DecorView)。在查詢RootView的同時,會採用反射呼叫View類 createSnapshot 方法對RootView進行截圖,從而實現了對螢幕的截圖。

截圖之後,SDK內部會判斷圖片的hash值,如果圖片發生了變化,會採用 先序 的方式遍歷Activity的ViewTree,遍歷同時讀取View的屬性(id、top、left、width、height、class名稱、layoutRules等等)。下面舉一個栗子:

一個簡單的Activity,ContentView裡面有一個LineaLayout,LinearLayout裡面放了一個Button。先序遍歷Activity的ViewTree後,SDK會把下面這些資料傳到WebSocket的伺服器(資料有點多,大概有13k,資料主要來自截圖):

{
    "type": "snapshot_response", 
    "payload": {
        "activities": [
            {
                "activity": "com.sensorsdata.analytics.android.demo.MainActivity", 
                "scale": 0.3809524, 
                "serialized_objects": {
                    "rootObject": 88528516, 
                    "objects": [
                        {
                            "hashCode": 88528516, 
                            "id": -1, 
                            "index": -1, 
                            "sa_id_name": null, 
                            "top": 0, 
                            "left": 0, 
                            "width": 1080, 
                            "height": 1920, 
                            "scrollX": 0, 
                            "scrollY": 0, 
                            "visibility": 0, 
                            "translationX": 0, 
                            "translationY": 0, 
                            "classes": [
                                "com.android.internal.policy.DecorView", 
                                "android.widget.FrameLayout", 
                                "android.view.ViewGroup", 
                                "android.view.View"
                            ], 
                            "subviews": [
5077, 
53242
                            ]
                        }, 
                        {
                            "hashCode": 57495077, 
                            "id": 16908822, 
                            "index": 0, 
                            "sa_id_name": null, 
                            "top": 0, 
                            "left": 0, 
                            "width": 1080, 
                            "height": 1920, 
                            "scrollX": 0, 
                            "scrollY": 0, 
                            "visibility": 0, 
                            "translationX": 0, 
                            "translationY": 0, 
                            "classes": [
                                "com.android.internal.widget.ActionBarOverlayLayout", 
                                "android.view.ViewGroup", 
                                "android.view.View"
                            ], 
                            "subviews": [
0808, 
3121
                            ]
                        }, 
                        {
                            "hashCode": 12620808, 
                            "id": 16908290, 
                            "index": 0, 
                            "sa_id_name": "android:content", 
                            "top": 210, 
                            "left": 0, 
                            "width": 1080, 
                            "height": 1710, 
                            "scrollX": 0, 
                            "scrollY": 0, 
                            "visibility": 0, 
                            "translationX": 0, 
                            "translationY": 0, 
                            "classes": [
                                "android.widget.FrameLayout", 
                                "android.view.ViewGroup", 
                                "android.view.View"
                            ], 
                            "subviews": [
14438
                            ]
                        }, 
                        {
                            "hashCode": 150314438, 
                            "id": -1, 
                            "index": 0, 
                            "sa_id_name": null, 
                            "top": 0, 
                            "left": 0, 
                            "width": 1080, 
                            "height": 1710, 
                            "scrollX": 0, 
                            "scrollY": 0, 
                            "visibility": 0, 
                            "translationX": 0, 
                            "translationY": 0, 
                            "classes": [
                                "android.widget.LinearLayout", 
                                "android.view.ViewGroup", 
                                "android.view.View"
                            ], 
                            "subviews": [
40701
                            ]
                        }, 
                        {
                            "hashCode": 104340701, 
                            "id": 2131427422, 
                            "index": 0, 
                            "sa_id_name": "buttonTest", 
                            "top": 0, 
                            "left": 0, 
                            "width": 1080, 
                            "height": 126, 
                            "scrollX": 0, 
                            "scrollY": 0, 
                            "visibility": 0, 
                            "translationX": 0, 
                            "translationY": 0, 
                            "classes": [
                                "android.widget.Button", 
                                "android.widget.TextView", 
                                "android.view.View"
                            ], 
                            "subviews": [ ]
                        }, 
                        {
                            "hashCode": 88713121, 
                            "id": 16908669, 
                            "index": 0, 
                            "sa_id_name": null, 
                            "top": 63, 
                            "left": 0, 
                            "width": 1080, 
                            "height": 147, 
                            "scrollX": 0, 
                            "scrollY": 0, 
                            "visibility": 0, 
                            "translationX": 0, 
                            "translationY": 0, 
                            "classes": [
                                "com.android.internal.widget.ActionBarContainer", 
                                "android.widget.FrameLayout", 
                                "android.view.ViewGroup", 
                                "android.view.View"
                            ], 
                            "subviews": [
55104, 
93113
                            ]
                        }, 
                        {
                            "hashCode": 164355104, 
                            "id": 16908668, 
                            "index": 0, 
                            "sa_id_name": null, 
                            "top": 0, 
                            "left": 0, 
                            "width": 1080, 
                            "height": 147, 
                            "scrollX": 0, 
                            "scrollY": 0, 
                            "visibility": 0, 
                            "translationX": 0, 
                            "translationY": 0, 
                            "classes": [
                                "android.widget.Toolbar", 
                                "android.view.ViewGroup", 
                                "android.view.View"
                            ], 
                            "subviews": [
58006, 
7783
                            ]
                        }, 
                        {
                            "hashCode": 222758006, 
                            "id": -1, 
                            "index": 0, 
                            "sa_id_name": null, 
                            "top": 38, 
                            "left": 42, 
                            "width": 553, 
                            "height": 71, 
                            "scrollX": 0, 
                            "scrollY": 0, 
                            "visibility": 0, 
                            "translationX": 0, 
                            "translationY": 0, 
                            "classes": [
                                "android.widget.TextView", 
                                "android.view.View"
                            ], 
                            "subviews": [ ]
                        }, 
                        {
                            "hashCode": 64817783, 
                            "id": -1, 
                            "index": 0, 
                            "sa_id_name": null, 
                            "top": 0, 
                            "left": 1080, 
                            "width": 0, 
                            "height": 147, 
                            "scrollX": 0, 
                            "scrollY": 0, 
                            "visibility": 0, 
                            "translationX": 0, 
                            "translationY": 0, 
                            "classes": [
                                "android.widget.ActionMenuView", 
                                "android.widget.LinearLayout", 
                                "android.view.ViewGroup", 
                                "android.view.View"
                            ], 
                            "subviews": [ ]
                        }, 
                        {
                            "hashCode": 161393113, 
                            "id": 16908673, 
                            "index": 0, 
                            "sa_id_name": null, 
                            "top": 0, 
                            "left": 0, 
                            "width": 0, 
                            "height": 0, 
                            "scrollX": 0, 
                            "scrollY": 0, 
                            "visibility": 8, 
                            "translationX": 0, 
                            "translationY": 0, 
                            "classes": [
                                "com.android.internal.widget.ActionBarContextView", 
                                "com.android.internal.widget.AbsActionBarView", 
                                "android.view.ViewGroup", 
                                "android.view.View"
                            ], 
                            "subviews": [ ]
                        }, 
                        {
                            "hashCode": 150453242, 
                            "id": 16908335, 
                            "index": 0, 
                            "sa_id_name": "android:statusBarBackground", 
                            "top": 0, 
                            "left": 0, 
                            "width": 1080, 
                            "height": 63, 
                            "scrollX": 0, 
                            "scrollY": 0, 
                            "visibility": 0, 
                            "translationX": 0, 
                            "translationY": 0, 
                            "classes": [
                                "android.view.View"
                            ], 
                            "subviews": [ ]
                        }
                    ]
                }, 
                "image_hash": "785C4DC3B01B4AFA56BA0E3A56CE8657", 
                "screenshot": "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fMttK5ojA0AORQNyKBqQQ9GAHIoG5FA0IIeiATkUDcihaEAORQNyKBrw7TZw4MZfc9nxuU6AbzVjNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuRQNCCHogE5FA3IoWhADkUDcigakEPRgByKBuT4f1ZVvXKwGu9EAAAAAElFTkSuQmCC"
            }
        ], 
        "snapshot_time_millis": 403
    }
}

最後面的 screenshot 就是手機的截圖,以base64編碼。

為了簡化分析,在上面的資料裡面沒有體現View的一些屬性,例如Button上顯示的text文字,實際上在遍歷ViewTree裡面每一個View的同時也會上報這個資訊,因為我們的Activity和裡面View大部分情況下都會是複用的,一個購物的Activity介面,裡面的按鈕可以顯示不同的文字,我們需要統計不同商品的點選次數,就必須要知道按鈕上顯示的文字是什麼。

對於View來講,關鍵資訊有這些:

  • activity:Activity類名
  • hashcode:view的hashcode
  • id:在Apk中的id
  • index:在父控制元件中的同類元素的順序,如果是根View,那麼為-1,如果父View沒有多個同類型的子View,那麼為0(例如LinearLayout中只有一個Button)
  • sa_id_name:在Apk中的控制元件的id的字串名稱,例如android:id=”@+id/button2”,結果就是 button2
  • top:距離螢幕上邊距
  • left:距離螢幕的左邊距
  • width:寬
  • height:高
  • classes:View自身以及所有的父類類名,是一個數組,這裡決定了一個View到底可以有哪些互動,比如點選、長按等
  • subviews:子View的hashcode,是一個數組

4.2.1.3 儲存待監測的元素的關鍵資訊

將上面收集到資料傳送到連線的WebSocket後臺,由後臺解析之後,可以把App介面的截圖展示在Web頁面。然後把可以監測的元素以方框的形式新增在介面上提示使用者(web頁面實現時,我推測只需要用到這個View的left、top、width、height屬性在html上加一個div標籤,然後設定一個有顏色的border屬性即可)。使用者可以在這個Web頁面點選需要監測的元素,設定這個元素的事件名稱(event_type和event_name),點選儲存。儲存一個需要監測的元素時,需要儲存這個元素在當前Activity的ViewTree的路徑 path ,以及這個View在父控制元件中的 index ,具體有下面幾個資訊:

  • target_activity:View所在的Activity類名
  • event_type:事件型別,例如點選事件
  • event_name:事件名稱
  • trigger_id:事件id
  • path:View在ViewTree中查詢路徑
    • prefix:表示是否需要監測這個View的兄弟元素,當為 shortest 時,表示只匹配到索引為index那一個元素,否則匹配所有的父控制元件下面所有的同類子元素
    • view_class:view的類名
    • index:View在父控制元件中同類元素的下標索引, 這個屬性一定程式上可以對抗ViewTree的更新導致的元素監測失效問題,因為父控制元件加入一個不同類的元素時,index的值不會發生改變
    • id:View在Apk中的id
    • sa_id_name:View在Apk中的id的字串名稱

4.2.2 獲取配置,查詢View,監測View的行為後上報事件

4.2.2.1 獲取配置,查詢View

SDK啟動時,會從伺服器拉取一份JSON格式的配置,儲存到sharedPreference裡,同時SDK會掃描 android.R 檔案裡面的資源id和資源的name並快取起來。

SDK得到配置之後,解析成JSON物件,讀取 event_bindings 欄位,再進一步讀取 events 欄位,這個欄位下面包含了一個數組,陣列的每個元素都描述了一類事件,幷包含了這類事件需要監測的元素所在的Activity和元素的路徑。這份配置基本上是這樣的一個結構:

event_bindings: {
    events:[
        {
            target_activity: ""
            event_name: "",
            event_type: "",
            ...
            path: [
                {
                    prefix:
                    view_class:
                    index:
                    id:
                    sa_id_name:
                }, 
                {
                    ...
                }
                ...
            ]
        }
    ]
}

收到了這份配置之後,SDK會把根據每個event資訊,生成一個 ViewVisitor 。 ViewVisitor 的作用就是把 path 數組裡面指向的所有View元素都找到,並且根據event_type, 給這個View設定相應的行為監測器 ,當這個View發生指定行為時,監測器就會監測到,並上報行為。

在生成ViewVisitor之後,SDK內部是以 Map<activity, ViewVisitor> 結構儲存它們的,這也比較容易理解,畢竟我們的介面是隨著一個一個的Activity被create,onResume之後才被使用者看見的嘛。在ViewVisitor物件中還有一個 PathFinder 物件,這個物件負責在ViewTree中根據path去查詢View(這裡其實是在一個tree裡面查詢node的問題)。

4.2.2.2 監測View的行為,上報事件

ViewVisitor 是怎麼給View設定監聽器,監測元素的產生的行為呢? 答案就是 View.AccessibilityDelegate 

在Android SDK裡面,AccessibilityService(無障礙服務)為我們提供了一系列的事件回撥,幫助我們指示一些使用者介面的狀態變化。我們可以派生輔助功能類,進而對不同的AccessibilityEvent進行處理,我們看下AccessibilityEvent裡面有哪些事件型別:

/**
 * Represents the event of clicking on a {@link android.view.View} like
 * {@link android.widget.Button}, {@link android.widget.CompoundButton}, etc.
 */
public static final int TYPE_VIEW_CLICKED = 0x00000001;

/**
 * Represents the event of long clicking on a {@link android.view.View} like
 * {@link android.widget.Button}, {@link android.widget.CompoundButton}, etc.
 */
public static final int TYPE_VIEW_LONG_CLICKED = 0x00000002;

/**
 * Represents the event of selecting an item usually in the context of an
 * {@link android.widget.AdapterView}.
 */
public static final int TYPE_VIEW_SELECTED = 0x00000004;

/**
 * Represents the event of setting input focus of a {@link android.view.View}.
 */
public static final int TYPE_VIEW_FOCUSED = 0x00000008;

/**
 * Represents the event of changing the text of an {@link android.widget.EditText}.
 */
public static final int TYPE_VIEW_TEXT_CHANGED = 0x00000010;
...

以點選事件 TYPE_VIEW_CLICKED 為例 ,當Activity介面的RootView開始繪製的時候(ViewTreeObserver.OnGlobalLayoutListener的onGlobalLayout回撥時),ViewVisitor也會開始尋找指定的View,並給這個View設定新的AccessibilityDelegate。簡單看一下這個新的View.AccessibilityDelegate是怎麼寫的:

private class TrackingAccessibilityDelegate extends View.AccessibilityDelegate {
...
            public TrackingAccessibilityDelegate(View.AccessibilityDelegate realDelegate) {
                mRealDelegate = realDelegate;
            }

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

            ...
            
            @Override
            public void sendAccessibilityEvent(View host, int eventType) {
                if (eventType == mEventType) {
                    fireEvent(host); // 事件上報
                }

                if (null != mRealDelegate) {
                    mRealDelegate.sendAccessibilityEvent(host, eventType);
                }
            }

            private View.AccessibilityDelegate mRealDelegate;
        }
        ...

可以看到在SDK的 TrackingAccessibilityDelegate#sendAccessibilityEvent 方法裡面,發出了事件上報。

這麼說View的點選處理方法中應該要呼叫 sendAccessibilityEvent 才行,那麼View在點選方法的內部實現裡有呼叫 sendAccessibilityEvent 方法嗎?看一下View處理點選事件 -View.performClick 的原始碼:

public boolean performClick() {
    final boolean result;
    final ListenerInfo li = mListenerInfo;
    if (li != null && li.mOnClickListener != null) {
        playSoundEffect(SoundEffectConstants.CLICK);
        li.mOnClickListener.onClick(this);
        result = true;
    } else {
        result = false;
    }
    sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
    return result;
}
...
public void sendAccessibilityEvent(int eventType) {
    if (mAccessibilityDelegate != null) {
        mAccessibilityDelegate.sendAccessibilityEvent(this, eventType);
    } else {
        sendAccessibilityEventInternal(eventType);
    }
}
...
public void setAccessibilityDelegate(@Nullable AccessibilityDelegate delegate) {
    mAccessibilityDelegate = delegate;
}

由此可見View的點選處理內部確實呼叫到了 sendAccessibilityEvent ,所以在RootView開始繪製的時候,給View註冊AccessibilityDelegate可以監測到它的點選事件。視覺化埋點這裡對View的事件監測也是一種 “動態Hook” 的實現,不過沒有采用第三章中介紹的反射獲取OnClickListener的方案,而是採用了獲取AccessibilityDelegate來實現,這種方式反射次數少一些,效率上會更好一些。

在網上看到有網友提出,setAccessibilityDelegate來監測View的點選對大多數廠商的機型和版本都是可以的,但是有部分機型是無法成功捕獲監控到點選事件。從View的標識生成,以及監測原理來講,這個方案的穩定性存在一些疑問。

4.3 視覺化埋點的難點和優化

上面簡單分析了Mixpanel和SensorsSDK視覺化埋點的基本實現,裡面最重要有一個技術點值得仔細琢磨,那就是 如何唯一標識App中的一個View?由於View是長在ViewTree上的一個節點,那麼用縱向的路徑,以及橫向的下標應該可以標識一個View。

  • 縱向的路徑:是指從根View到這個View的父控制元件的路徑上經過的每一個節點
  • 橫向的下標:是指這個View在父控制元件中的同類元素的下標索引(例如一個LinearLayout中有兩個Button,那麼第一個Button的下標就是0,第二個Button的下標就是1,這種方式可以抵抗父控制元件中加入一個非Button型別的元素時對ViewTree的改變,保證仍然可以找到Button,但是無法抵抗父控制元件中加入同類型的元素)

上面僅僅提到了標識一個View的基本方法,但是有很多實際場景,會對View的查詢造成毀滅性的影響,例如介面中Fragment的變化,ViewTree的變化,ListView中控制元件的複用等等,這裡有兩篇網易的部落格,裡面對一些場景的優化做了詳細地說明,可以仔細看看:

4.4 視覺化埋點參考資料

五、總結

最後簡單總結一下幾種方案的優缺點和使用場景,在實際應用中多種方式配合使用,平衡效率和可靠性,適合自己的業務才是最好的。

埋點方案 優點 缺點 適用場景
程式碼埋點 1.使用靈活,精確控制傳送時機 
2.方便設定自定義業務相關的屬性
1.埋點成本高,工作量大,必須是技術人員才能完成 
2.更新成本高,一旦上線很難修改。只能通過熱修復或者重新發版 
3.對業務程式碼的侵入大
對業務上下文理解要求較高的業務資料,如電商購物這類可能經過多次頁面跳轉,埋點時還需要帶上前面頁面中的一些資訊
全埋點 1.開發、維護成本低 
2.可以追溯歷史資料 
3.對業務程式碼侵入小 
4.可以收集到一些額外資訊,例如介面的熱力圖
1.高額流量和計算成本 
2.無法靈活收集屬性 
3.動態的Hook方式支援的控制元件有限、事件型別有限,大量事件監測時反射對App執行效能有影響 
4.靜態的Hook方式需要第三方編譯器參與,打包時間增長
上下文相對獨立的、通用的資料,如點選熱力圖,效能監控和日誌
視覺化埋點 1.開發、維護成本低 
2.可以按需埋點,靈活性好 
3.對業務程式碼侵入小
1.介面的結構發生變化時,圈選的待監測元素可能會失效 
2.支援的控制元件和事件型別有限 
3.無法靈活地收集到上下文屬性
上下文相對簡單,依靠控制元件可以獲得上下文資訊,介面結構比較簡單固定,如新聞閱讀、遊戲分享介面