Android 全埋點方案盤點

全埋點是什麼

全埋點,也叫無埋點、無碼埋點、無痕+埋點、自動埋點。
全埋點是指無需Android應用開發工程師寫代碼或只寫少量的代碼,就能預先自動收集用戶的所有行爲數據,然後就可以根據實際的業務分析需求從中篩選出所需行爲數據並進行分析。

全埋點採集的事件目前主要包括以下四種

$AppStart事件

指應用程序啓動(冷啓動和熱啓動)場景。熱啓動也就是指應用程序從後臺恢復的情況。

$AppEnd事件

指應用程序退出 ,包括正常退出、按Home鍵進入後臺、應用程序被強殺、應用程序崩潰等場景。

$AppViewScreen事件

是指應用程序頁面瀏覽,對於Android應用程序來說,就是指切換Activity或Fragment。

$AppClick事件

是指應用程序控件點擊,也即View被點擊,比如點擊Button、ListView等。

$AppClick是採集難度最大的事件,全埋點的解決方案基本也是圍繞着如何採集$AppClick事件來進行的。

#AppClick事件的整體解決思路,就是要找到那個被點擊的控件處理邏輯,然後再利用一定的技術原理,對原處理邏輯進行"攔截",或者在原處理邏輯的執行前面或執行後面"插入"相應的埋點代碼邏輯,從而達到自動埋點的效果。

攔截的原理,參考Android時間處理機制來進行。
插入的原理,參考編譯器對Java代碼的整體處理流程來進行。 JavaCode -> .java -> .class -> .dex,選擇在不同的階段"插入"埋點代碼,所採用的技術或者原理也不盡相同。

$AppViewScreen全埋點方案

對於Activity,就是onResume方法,我們只要自動地在onResume裏觸發$AppScreen事件,即可解決$AppViewScreen事件的全埋點。

關鍵技術:Application.ActivityLifecycleCallbacks

可提供全局Activity的監控。

$ AppStart、$AppEnd全埋點方案

歸根結底就是判斷當前應用程序是處於前臺還是處於後臺,Android系統本身沒有給應用程序提供相關的接口來判斷這些狀態。

應用程序如果有多個進程該如何判斷是處於前臺還是後臺 ?

通過IPC機制實現數據共享

應用程序如果發生崩潰或者被強殺了該如何判斷該應用程序是處於前臺還是處於後臺 ?

引入Session的概念:對於一個應用程序,當它的一個頁面退出了,如果在30S之內沒有新的頁面打開,我們就任務這個應用程序處於後臺 (觸發$AppEnd事件)。
當它的一個頁面顯示出來了,如果與上一個頁面的退出時間的間隔超過了30s,我們就認爲這個應用程序重新處於前臺了 (觸發了 $AppStart 事件)。

30s之內沒有新的頁面進來 (按了Home鍵/返回鍵退出應用程序、應用程序發生崩潰、應用程序被強殺),則會觸發$AppEnd,或者在下次啓動的時候補發一個$AppEnd事件。

$ AppClick全埋點方案1:代理View.OnClickListener

android.R.id.content

android.R.id.content對應的視圖是一個FrameLayout佈局,它目前就只有一個子元素,就是setContent時候的View。

需要注意
在不同的SDK版本下,android.R.id.content所指的顯示區域有所不同。

  • SDK 14+(Native ActionBar):該顯示區域指的是ActionBar下面的那部分。
  • Support Library Revision lower than 19: 使用AppCompat,則顯示區域包含ActionBar
  • Support Library Revision 19(or greater):使用AppCompat,則顯示區域不包含ActionBar,即與第一種情況相同。

原理概述

通過ActivityLifecycleCallbacks的onResume方法,我們可以取到當前正在顯示的Activity實例,通過activity.findViewById(android.R.id.content)可以拿到id爲content的這個FrameLayout,然後,再逐層遍歷這個RootView,並判斷當前View是否設置了mOnClickListener對象,如果已設置mOnClickListener對象並且mOnClickListener又不是我們自定義的WrapperOnClickListener類型,則通過WrapperOnClickListener代理當前View設置的mOnClickLIstener。

引入DecorView

當前方案是無法採集MenuItem控件的點擊事件的,這是因爲我們通過android.R.id.content取到的RootView是不包含Activity標題欄的,也就是不包括MenuItem的父容器。
我們可以使用DecorView來解決

activity.getWindow().getDecorView()  

這樣,我們就可以遍歷到MenuItem了。

 @Override
 public void onActivityResumed(@NonNull Activity activity) {
     new Handler().postDelayed(new Runnable() {
         @Override
         public void run() {
             delegateViewsOnClickListener(activity,activity.getWindow().getDecorView());
         }
     }, 300);
 }

引入ViewTreeObserver.OnGlobalLayoutListener

當前方案還有一個問題,無法採集onResume()生命週期之後動態創建的View點擊事件。
可以通過ViewTreeObserver.OnGlobalLayoutListener來解決這個問題。

OnGlobalLayoutListener是ViewTreeObserver的一個內部接口。當一個視圖樹的佈局發生變化時,可以被ViewTreeObserver.OnGlobalLayoutListener監聽到。

所以,基於這個原理,我們可以給當前Activity的RootView也添加一個ViewTreeObserver.OnGlobalLayoutListener監聽器,當收到onGlobalLayout方法回調時(即視圖樹的佈局發生變化,比如新的View被創建),我們重新去遍歷一次RootView,然後找到那些沒有被代理過的mOnClickListener對象的View並進行代理,即可解決上面提到的問題。

另外,關於ViewTreeObserver.OnGlobalLayoutListener監聽器,建議在頁面退出的時候remove掉,即在onStop的時候調用removeOnGlobalLayoutListener方法。

由於該方案遍歷的是Activity的RootView,所以遊離於Activity之上的點擊是無法採集的,比如Dialog、PopupWindow等。

可以採用代碼埋點的方法輔助解決這個問題。

對於Dialog,可以通過dialog.getWindow().getDecorView()拿到它的RootView,然後手動觸發遍歷並代理即可。

public void trackDialog(final Activity activity,final Dialog dialog){
    if (dialog.getWindow() != null) {
        dialog.getWindow().getDecorView().getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                SensorsDataPrivate.delegateViewsOnClickListener(activity, dialog.getWindow().getDecorView());
            }
        });
    }
}

然後在Dialog創建之後show之前調用即可。

$ AppClick全埋點方案2:代理Window.Callback

Window.Callback是Window類的一個內部接口,該接口包含了一系列類似於dispatchXXX和onXXX的接口。
當Window接收到外部狀態改變的通知時,就會回調其中的相應方法。
比如,當用戶點擊某個控件時,就會回調Window.Callback中的dispatchTouchEvent(MotionEvent event)方法。

原理概述

我們可以在ActivityLifecycleCallbacks的onActivityCreated方法回調中,拿到當前正在顯示的Activity對象,通過activity.getWindow().getCallback()就可以拿到當前對應的Windo.Callback對象,最後通過自定義的WrapperWindowCallback代理這個Window.Callback對象。然後,在WraperWindowCallback的dispatchTouchEvent()方法中通過MotionEvent參數找到那個被點擊的View對象,並插入埋點代碼,最後再調用緣由Window.Callback的dispatchTouchEvent()方法,即可達到"插入"埋點代碼的效果。

缺點

每次點擊時,都要去遍歷一次RootView,所以效率相對來說較低,對應用程序的整體性能影響也比較大。 無法採集像Dialog、PopupWindow等遊離於Activity之外的控件的點擊事件。

$ AppClick全埋點方案3:代理View.AccessibilityDelegate

Accessibility

Accessibility,即輔助功能。

View.AccessibilityDelegate

View.performClick()源碼中,系統會先調用當前View已設置的mOnClickListener對象的onClick()方法,然後再調用sendAccessibilityEvent()方法,在sendAccessibilityEvent()方法的內部實現裏,其實是調用mAccessibilityDelegate對象的sendAccessibilityEvent方法,並傳入當前View對象和AccessibilityEvent.TYPE_VIEW_CLICKED參數。

所以,我們只需要代理View的mAccessibilityDelegate對象,當一個View被點擊時,在原有mOnClickListener對象的相應方法執行之後,我們就能收到這個點擊的回調。

原理概述

在ActivityLifecycleCallbacks的onActivityResumed方法中,我們可以通過activity.getWindow().getDecorView()方法拿到當前Activity的RootView,通過rootView.getViewTreeObserver()對象,然後再通過addOnGlobalLayoutListener()方法給RootView註冊ViewTreeObserverOnGlobalLayoutListener監聽器,這樣,可以在當前Activity的視圖狀態發生改變時去主動遍歷一次RootView。
並且,用我們自定義的WraperAccessibilityDelegate代理當前View的mAccessibilityDelegate對象。在我們自定義的WraperAccessibilityDelegate類中的sendAccessibilityEvent()方法實現裏,我們先調用原有的mAccessibilityDelegate對象的sendAccessibilityEvent方法,然後再插入埋點代碼,其中host就是被點擊的View對象,從而可以做到自動埋點的效果。

缺點

輔助功能需要用戶手動啓動,而且在部分ROM上輔助功能可能會失效。
無法採集Dialog、PopupWindow等遊離於Activity之外的控件的點擊事件。

$ AppClick全埋點方案4:透明層

該方案主要用到了Android系統事件處理機制方面的知識。

原理概述

onTouchEvent是在View中定義的一個方法,用來處理傳遞到View的手勢事件。
該方案就是基於View的onTouchEvent方法來實現的。

我們可以自定義一個透明的View,然後添加到每個Activity的最上層。這樣,每當用戶點擊任何控件時,直接點擊的其實就是我們的這個自定義的透明View。
重寫這個View的onTouchEvent方法,就可以根據MontionEvent裏的點擊座標信息(x,y),在當前Activity的RootView裏找到實際上被點擊的那個View對象。
找到被點擊的View之後,我們再通過自定義的WrapperOnClickListener代理當前View的mOnClickListener對象。
在WrapperOnClickListener的onClick方法裏,先調用View原有的mOnClickListener.onClick,然後再插入埋點代碼,就能達到自動埋點的目的了。

缺點

無法採集Dialog、PopupWindow的點擊事件
每次點擊都要遍歷一次RootView,效率比較低

$ AppClick全埋點方案5:AspectJ

AOP是Aspect Oriented Programming 的縮寫哦度會即面向切面編程。

AspectJ

AspectJ最核心的模塊就是它提供的ajc編譯器,它其實就是將AspectJ的代碼在編譯器插入到目標程序當中。

自定義Gradle Plugin

//TODO 這部分可以實踐下

原理概述

對於Android系統中的View,它的點擊處理邏輯,都是通過設置相應的listener對象重寫相應的回調方法實現的。
我們可以把AspectJ的處理腳本放到我們自定義的插件裏,然後編寫相應的切面類,再定義合適的PointCut用來匹配我們的織入方式 (listener對象的相應回調方法),比如android.view.View.OnClickListener的onClick(android.view.View)方法,就可以在編譯期間埋入埋點代碼,從而達到自動埋點的效果。

缺點

由於定義的切點依賴編程語言,目前該方案無法兼容Lambda語法。

$ AppClick全埋點方案6:ASM

實現一套Transform,去遍歷所有.class文件的所有方法,然後進行修改 (在特定listener的回調方法中插入埋點代碼),最後再對原文件進行替換,即可達到插入代碼的目的。

關鍵技術

Gradle Transform是Android官方提供給開發者在項目構建階段 (即由.class到.dex轉換期間)用來修改.class文件的一套標準API。目前比較經典的應用是字節碼插樁、代碼注入等。

ASM

ASM是一個功能比較齊全的Java字節碼操作與分析框架哦度會使用ASM,我們可以動態生成類或者增強既有類的功能。

原理概述

我們可以自定義一個Gradle Plugin,然後註冊一個Transform對象。在transform方法裏,可以分別編列目錄和jar包,然後我們就可以遍歷當前應用程序所有的.class文件,然後再利用ASM框架的相關API,去加載相應的.class文件、解析.class文件,就可以找到滿足特定條件的.class文件和相關方法,最後去修改相應的方法以動態插入埋點字節碼,從而達到自動埋點的效果。

缺點

目前來看,實現全埋點,使用ASM框架是一個相對完美的選擇,暫時沒有發現有什麼缺點。

$ AppClick全埋點方案7:Javassist

Java字節碼以二進制的形式存儲在.class文件中,每一個.class文件包含一個Java類或接口。Javaassist框架就是一個已經編譯好的類中添加新的方法,或者是修改已有的方法,並且不需要對字節碼方法有深入的瞭解。

Javassist可以繞過編譯,直接操作字節碼,從而實現代碼的注入。
所以,使用Javassist框架的最佳時機就是在構建工具Gradle將源文件編譯成.class文件之後,在將.class打包成.dex文件之前。

原理概述

在自定義的Plugin裏,我們可以註冊一個自定義的Transform,從而可以分別對當前應用程序的所有源碼目錄好jar包進行遍歷。在遍歷過程中,利用Javassist框架的API可以對滿足特定條件的方法進行修改,比如插入相關埋點代碼。
整個原理與使用ASM框架類似,此時只是把操作.class文件的框架由ASM換成Javassist了。

$ AppClick全埋點方法8:AST

APT

APT (Annotation Processing Tool),即註解處理器,是一種處理註解的工具。確切來說,它是javac的一個工具,用來在編譯時掃描和處理註解。註解處理器以Java代碼(或者編譯過的字節碼)作爲輸入,以生成.java文件作爲輸出。簡單來說,就是在編譯器通過註解生成.java文件。

AST

AST是Abstract Syntax Tree的縮寫,即抽象語法樹,是編譯器對代碼的第一步加工之後的結果,是一個樹形式表示的源代碼。源代碼的每個元素映射到一個字節或子樹。

Java的編譯過程可以分爲三個階段:
第一階段:所有的源文件會被解析成語法樹。
第二階段:調用註解處理器,即APT模塊。如果註解處理器產生了新的源文件,新的源文件也要參與編譯。
第三階段:語法樹會被分析並轉化爲類文件。

原理概述

編輯器對代碼處理的流程大概是

JavaTXT -> 詞語法分析 -> 生成AST -> 語義分析 -> 編譯字節碼   

通過AST,可以達到修改源代碼的功能。

在自定義註解處理器的process方法裏,通過roundEnvironment.getRootElements方法可以拿到所有的Element對象,通過trees.getTree(element)方法可以拿到對應的抽象語法書(AST),然後我們自定義一個TreeTranslator,在visitMethodDef裏即可對方法進行判斷。如果是目標處理方法,則通過AST框架的相關API即可插入埋點代碼,從而實現全埋點的效果。

缺點

  • com.sun.tools.javac.tree相關API語法晦澀,理解難度大,要求有一定的編譯原理基礎。
  • APT無法掃描其他module,倒是AST無法處理其他module
  • 不支持Lambda語法
  • 帶有返回值的方法,很難把埋點代碼插入到方法之後

其他

本文內容是閱讀《Android全埋點解決方案》後的記錄整理

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章