Android埋點(diǎn)技術(shù)分析
一、埋點(diǎn),是對(duì)網(wǎng)站、App或者后臺(tái)等應(yīng)用程序進(jìn)行數(shù)據(jù)采集的一種方法。通過(guò)埋點(diǎn),可以收集用戶在應(yīng)用中的產(chǎn)生行為,進(jìn)而用于分析和優(yōu)化產(chǎn)品后續(xù)的體驗(yàn),也可以為產(chǎn)品的運(yùn)營(yíng)提供數(shù)據(jù)支撐,其中常見(jiàn)的指標(biāo)有PV、UV、頁(yè)面時(shí)長(zhǎng)和按鈕的點(diǎn)擊等。
采集行為數(shù)據(jù)時(shí),通常需要在Web頁(yè)面/App里面添加一些代碼,當(dāng)用戶的行為達(dá)到某種條件時(shí),就會(huì)向服務(wù)器上報(bào)用戶的行為。其實(shí)添加這些代碼的過(guò)程就可以叫做“埋點(diǎn)”,在很久以前就已經(jīng)出現(xiàn)了這種技術(shù)。隨著技術(shù)的發(fā)展和大家對(duì)數(shù)據(jù)采集要求的不斷提高,我認(rèn)為埋點(diǎn)的技術(shù)方案走過(guò)了下面幾個(gè)階段:
代碼埋點(diǎn):代碼埋點(diǎn)是指開(kāi)發(fā)人員按照產(chǎn)品/運(yùn)營(yíng)的需求,在Web頁(yè)面/App的源碼里面添加行為上報(bào)的代碼,當(dāng)用戶的行為滿足某一個(gè)條件時(shí),這些代碼就會(huì)被執(zhí)行,向服務(wù)器上報(bào)行為數(shù)據(jù)。這種方案是最基礎(chǔ)的方案,每次增加或者修改數(shù)據(jù)上報(bào)的條件,都需要開(kāi)發(fā)人員的參與,并且只能在下一個(gè)版本上線后才能看到效果。很多公司都提供了這類數(shù)據(jù)上報(bào)的SDK,將行為上報(bào)的后臺(tái)服務(wù)器接口封裝成了簡(jiǎn)單的客戶端SDK接口。開(kāi)發(fā)者可以通過(guò)嵌入這類SDK,在埋點(diǎn)的地方調(diào)用少量的代碼就可以上報(bào)行為數(shù)據(jù)。
全埋點(diǎn):全埋點(diǎn)指的是將Web頁(yè)面/App內(nèi)產(chǎn)生的所有的、滿足某個(gè)條件的行為,全部上報(bào)到后臺(tái)服務(wù)器。例如把App中所有的按鈕點(diǎn)擊都進(jìn)行上報(bào),然后由產(chǎn)品/運(yùn)營(yíng)去后臺(tái)篩選所需要的行為數(shù)據(jù)。這種方案的優(yōu)點(diǎn)非常明顯,就是可以不用在新增/修改行為上報(bào)條件時(shí),再找開(kāi)發(fā)人員去修改埋點(diǎn)的代碼。然而它的缺點(diǎn)也和優(yōu)點(diǎn)一樣明顯,那就是上報(bào)的數(shù)據(jù)量比代碼埋點(diǎn)大很多,里面可能很多是沒(méi)有價(jià)值的數(shù)據(jù)。此外,這種方案更傾向于獨(dú)立去看待用戶的行為,而沒(méi)有關(guān)注行為的上下文,給數(shù)據(jù)分析帶來(lái)了一些難度。很多公司也提供了這類功能的SDK,通過(guò)靜態(tài)或者動(dòng)態(tài)的方式,“Hook”了原有的App代碼,從而實(shí)現(xiàn)了行為的監(jiān)測(cè),在數(shù)據(jù)上報(bào)時(shí)通常是采用累積多條再上報(bào)的方案來(lái)合并請(qǐng)求。
hook直譯是鉤子的意思,以前學(xué)信息安全的時(shí)候在windows上聽(tīng)到過(guò),大體意思是通過(guò)某種手段去改變系統(tǒng)API的一個(gè)行為,繞過(guò)系統(tǒng)的某個(gè)方法,或者改變系統(tǒng)的工作流程。在這里其實(shí)是指把本來(lái)要執(zhí)行某個(gè)方法的對(duì)象替換成另一個(gè),一般用的是反射或者代理,需要找到hook的代碼位置,甚至還可以在編譯階段實(shí)現(xiàn)替換。
可視化埋點(diǎn): 可視化埋點(diǎn)是指產(chǎn)品/運(yùn)營(yíng)在Web頁(yè)面/App的界面上進(jìn)行圈選,配置需要監(jiān)測(cè)界面上哪一個(gè)元素,然后保存這個(gè)配置,當(dāng)App啟動(dòng)時(shí)會(huì)從后臺(tái)服務(wù)器獲得產(chǎn)品/運(yùn)營(yíng)預(yù)先圈選好的配置,然后根據(jù)這份配置監(jiān)測(cè)App界面上的元素,當(dāng)某一個(gè)元素滿足條件時(shí),就會(huì)上報(bào)行為數(shù)據(jù)到后臺(tái)服務(wù)器。有了全埋點(diǎn)技術(shù)方案,從體驗(yàn)優(yōu)化的角度很容易想到按需埋點(diǎn),可視化埋點(diǎn)就是一種按需配置埋點(diǎn)的方案?,F(xiàn)在也有一些公司提供了這類SDK,圈選監(jiān)測(cè)元素時(shí),一般都是提供一個(gè)Web管理界面,手機(jī)在安裝并初始化了SDK之后,可以和管理界面了連接,讓用戶在Web管理界面上配置需要監(jiān)測(cè)的元素。
業(yè)界有多家SDK都支持上面介紹的3種埋點(diǎn)方案中的一種或者全部,例如Mixpanel、Sensorsdata、TalkingData、GrowingIO、諸葛IO、Heap Analytics、MTA、Umeng Analytics、百度,只是大家對(duì)后兩種埋點(diǎn)的稱呼不完全相同,有的叫無(wú)埋點(diǎn)或者codeless埋點(diǎn)。由于 Mixpanel (支持代碼埋點(diǎn)、可視化埋點(diǎn))和 Sensorsdata (全部支持)都開(kāi)源了自己的全部SDK,技術(shù)方案也比較類似,下面以它們的Android SDK為例,簡(jiǎn)單分析一下3種埋點(diǎn)方案的技術(shù)實(shí)現(xiàn)。關(guān)于JS的SDK技術(shù)實(shí)現(xiàn),可以看下我的另一篇博客-JS埋點(diǎn)SDK技術(shù)分析。
二、代碼埋點(diǎn)
包含Mixpanel SDK在內(nèi)的大部分SDK,都會(huì)把這種埋點(diǎn)方案封裝成一個(gè)比較簡(jiǎn)單的接口,在這里是 track(String eventName, JSONObject properties) ,開(kāi)發(fā)者在調(diào)用這個(gè)接口時(shí),可以把一個(gè)事件名稱和事件的屬性傳入,然后就可以上報(bào)到后臺(tái)了。
在實(shí)現(xiàn)上,Mixpanel SDK默認(rèn)采用一條HandlerThread線程來(lái)處理事件,當(dāng)開(kāi)發(fā)者調(diào)用 track(String eventName, JSONObject properties) 方法時(shí), 主線程切換到HandlerThread 當(dāng)中,并先將事件存入數(shù)據(jù)庫(kù)。然后看SDK中是否累計(jì)到了40個(gè)事件,如果累計(jì)到40個(gè)事件的話,就合并它們上報(bào)到后臺(tái)。
當(dāng)開(kāi)發(fā)者設(shè)置為debug模式,或者手動(dòng)調(diào)用 flush 接口時(shí),可以立即上報(bào)累計(jì)的所有事件,不過(guò)由于只有一條線程,所以如果在flush的時(shí)候,前面的事件還沒(méi)有處理完成,SDK會(huì)間隔1分鐘再次去處理后面的這些事件。
開(kāi)發(fā)者可以設(shè)置累計(jì)上報(bào)的事件數(shù)量閾值、事件阻塞時(shí)再次嘗試上報(bào)的時(shí)間間隔等。這種方案比較基礎(chǔ),相信大部分開(kāi)發(fā)者都接觸過(guò),不需要過(guò)多分析。
三、全埋點(diǎn)
3.1 AOP基礎(chǔ)
Mixpanel現(xiàn)在的Android SDK沒(méi)有提供這個(gè)功能,但是神策Android SDK提供了,實(shí)現(xiàn)方式是依賴AOP。那么什么是AOP?
在軟件業(yè),AOP為Aspect Oriented Programming的縮寫(xiě),意為:面向切面編程,通過(guò)預(yù)編譯方式和運(yùn)行期動(dòng)態(tài)代理實(shí)現(xiàn)程序功能的統(tǒng)一維護(hù)的一種技術(shù)。AOP是OOP的延續(xù),是軟件開(kāi)發(fā)中的一個(gè)熱點(diǎn),也是Spring框架中的一個(gè)重要內(nèi)容,是函數(shù)式編程的一種衍生范型。利用AOP可以對(duì)業(yè)務(wù)邏輯的各個(gè)部分進(jìn)行隔離,從而使得業(yè)務(wù)邏輯各部分之間的耦合度降低,提高程序的可重用性,同時(shí)提高了開(kāi)發(fā)的效率。(from baidu baike)
簡(jiǎn)而言之,AOP是可以通過(guò)預(yù)編譯方式和運(yùn)行期動(dòng)態(tài)代理實(shí)現(xiàn)在不修改源代碼的情況下給程序動(dòng)態(tài)統(tǒng)一添加功能的一種技術(shù)。
Sensors Analytics AndroidSDK全埋點(diǎn)的實(shí)現(xiàn)就是通過(guò)在代碼編譯階段,找到源代碼中需要上報(bào)事件的位置,插入SDK的事件上報(bào)代碼。它用到的框架是 AspectJ 。
說(shuō)到這里,必須簡(jiǎn)單了解一下AspectJ以及它里面的一些概念.它是AOP的領(lǐng)跑者,在很多地方我們可以看到它的身影,例如JakeWartson大神貢獻(xiàn)的一個(gè)注解日志和性能調(diào)優(yōu)框架 Hugo ,在Spring框架里面也大量應(yīng)用到AspectJ。我理解AspectJ里面的主要幾個(gè)概念有:
- JPoint: 代碼切點(diǎn)(就是我們要插入代碼的地方)
- Aspect: 代碼切點(diǎn)的描述
- Pointcut: 描述切點(diǎn)具體是什么樣的點(diǎn),如函數(shù)被調(diào)用的地方( Call(MethodSignature) )、函數(shù)執(zhí)行的內(nèi)部( execution(MethodSignature) )
- Advice: 描述在切點(diǎn)的什么位置插入代碼,如在Pointcut前面( @Before )還是后面( @After ),還是環(huán)繞整個(gè)Pointcut( @Around )
由此可見(jiàn),在實(shí)現(xiàn)AOP功能時(shí),需要做下面幾件事:
- 定義一個(gè)Aspect,這個(gè)Aspect里面必須有Pointcut和Advice兩個(gè)屬性
- 編寫(xiě)在匹配到符合Pointcut和Advice描述的代碼時(shí),需要注入的代碼
- 在代碼編譯時(shí),通過(guò)特殊的java編譯器(Aspect的ajc編譯器),找到符合我們定義的Aspect的代碼,將需要注入的代碼插入到Advice指定的位置。
如果你對(duì)AspectJ有了解的話,已經(jīng)可以猜到SDK內(nèi)部是怎么實(shí)現(xiàn)全埋點(diǎn)的了;如果沒(méi)有接觸,我覺(jué)得也不用急于全面地去學(xué)習(xí)AspectJ,因?yàn)镾DK內(nèi)部只用到了AspectJ當(dāng)中的一小部分功能而已,可以直接看下面的分析。
3.2 全埋點(diǎn)-技術(shù)實(shí)現(xiàn)
神策SDK里面是如何監(jiān)測(cè)View點(diǎn)擊事件呢?我把SDK代碼簡(jiǎn)化一下進(jìn)行分析,有下面幾個(gè)步驟:
3.2.1 定義一個(gè)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)
- *
- *@paramjoinPoint JoinPoint
- *@throwsThrowable Exception
- */
- @After("execution(* android.view.View.OnClickListener.onClick(android.view.View))")
- public void onViewClickAOP(final JoinPoint joinPoint)throws Throwable {
- AopUtil.sendTrackEventToSDK(joinPoint, "onViewOnClick");
- }
- }
這段Aspect的代碼定義了: 在執(zhí)行android.view.View.OnClickListener.onClick(android.view.View)方法原有的實(shí)現(xiàn)后面,需要插入 AopUtil.sendTrackEventToSDK(joinPoint, "onViewOnClick"); 這段代碼。
AopUtil.sendTrackEventToSDK(joinPoint, "onViewOnClick"); 這段代碼做的事情就是點(diǎn)擊事件的上報(bào)。因?yàn)樯癫逽DK將全埋點(diǎn)功能和主SDK包分離成了兩個(gè)jar包,所以通過(guò)AopUtil工具去調(diào)用真正的事件上報(bào)代碼,這里不細(xì)述其實(shí)現(xiàn),下面直接看這段代碼背后真正的點(diǎn)擊上報(bào)實(shí)現(xiàn)。
- SensorsDataAPI.sharedInstance().track(AopConstants.APP_CLICK_EVENT_NAME, properties);
可以看到AOP實(shí)現(xiàn)的點(diǎn)擊監(jiān)測(cè),***也走 track 方法進(jìn)行上報(bào)了。
3.2.2 使用ajc編譯器向源代碼中插入Aspect代碼
采用AspectJ框架編寫(xiě)的代碼,想要注入原來(lái)的工程的代碼,需要在 /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中,把上面這段腳本編寫(xiě)成了一個(gè) gradle插件 ,開(kāi)發(fā)者只需要在 app/build.gradle 引用這個(gè)插件即可。
- apply plugin: 'com.sensorsdata.analytics.android'
3.2.3 完成代碼插入,查看插入之后的效果
完成上面兩步,就可以實(shí)現(xiàn)在 android.view.View.OnClickListener.onClick(android.view.View) 方法中插入我們的數(shù)據(jù)上報(bào)代碼了。我們?cè)赿emo代碼中加一個(gè)Button,并給它set一個(gè)OnClickListener,編譯一下代碼,查看 /build/intermediates/classes/debug/ 里面class文件,經(jīng)過(guò)ajc編譯之后,原始代碼中插入了Aspect的代碼,并調(diào)用了 ViewOnClickListenerAspectj 里面的 onViewClickAOP 方法。
- public class MainActivityextends 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的基本用法就是這樣,SensorsAndroidSDK借助AspectJ插入了Aspect代碼,這是一種靜態(tài)的方式。靜態(tài)的全埋點(diǎn)方案,本質(zhì)上是對(duì)字節(jié)碼進(jìn)行修改,插入事件上報(bào)的代碼。
修改字節(jié)碼,除了這種方案之外,還有Android Gradle插件提供的trasform api(1.5.0版本以上)、ASM、Javassist。在網(wǎng)易樂(lè)得的埋點(diǎn)方案,Nuwa熱修復(fù)項(xiàng)目都可以見(jiàn)到這些技術(shù)的實(shí)踐。
3.3 AspectJ相關(guān)資料
- Aspect Oriented Programming in Android: https://fernandocejas.com/2014/08/03/aspect-oriented-programming-in-android/
- AOP之AspectJ全面剖析in Android: http://www.jianshu.com/p/f90e04bcb326
- 滬江開(kāi)源了一個(gè)叫做AspectJX的插件,擴(kuò)展了AspectJ,除了對(duì)src代碼進(jìn)行AOP,還支持kotlin、工程中引用的jar和aar進(jìn)行AOP: https://github.com/HujiangTechnology/gradle_plugin_android_aspectjx
- 關(guān)于 Spring AOP (AspectJ) 你該知曉的一切: http://blog.csdn.net/javazejian/article/details/56267036
3.4 其他思路
上面介紹的是以AspectJ為代表的 “靜態(tài)Hook” 實(shí)現(xiàn)方案,有沒(méi)有其他辦法可以不修改源代碼,只是在App運(yùn)行的時(shí)候去 “動(dòng)態(tài)Hook” 點(diǎn)擊行為的處理呢?答案是肯定的,在Java的世界,還有反射大法啊,下面看一下怎么實(shí)現(xiàn)點(diǎn)擊事件的替換吧。
在 android.view.View.java 的源碼( API>=14 )中,有這么幾個(gè)關(guān)鍵的方法:
- // getListenerInfo方法:返回所有的監(jiān)聽(tīng)器信息mListenerInfo
- ListenerInfogetListenerInfo(){
- if (mListenerInfo != null) {
- return mListenerInfo;
- }
- mListenerInfo = new ListenerInfo();
- return mListenerInfo;
- }
- // 監(jiān)聽(tīng)器信息
- 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;
- // 我們非常熟悉的方法,內(nèi)部其實(shí)是把mListenerInfo的mOnClickListener設(shè)成了我們創(chuàng)建的OnclickListner對(duì)象
- public void setOnClickListener(@Nullable OnClickListener l){
- if (!isClickable()) {
- setClickable(true);
- }
- getListenerInfo().mOnClickListener = l;
- }
- /**
- * 判斷這個(gè)View是否設(shè)置了點(diǎn)擊監(jiān)聽(tīng)器
- * 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);
- }
通過(guò)上面幾個(gè)方法可以看到,點(diǎn)擊監(jiān)聽(tīng)器其實(shí)被保存在了 mListenerInfo.mOnClickListener 里面。那么實(shí)現(xiàn) Hook點(diǎn)擊監(jiān)聽(tīng)器 時(shí),只要將這個(gè) mOnClickListener 替換成我們包裝的 點(diǎn)擊監(jiān)聽(tīng)器代理對(duì)象 就行了。簡(jiǎn)單看一下實(shí)現(xiàn)思路:
1. 創(chuàng)建點(diǎn)擊監(jiān)聽(tīng)器的代理類
- // 點(diǎn)擊監(jiān)聽(tīng)器的代理類,具有上報(bào)點(diǎn)擊行為的功能
- class OnClickListenerWrapperimplements View.OnClickListener{
- // 原始的點(diǎn)擊監(jiān)聽(tīng)器對(duì)象
- private View.OnClickListener onClickListener;
- public OnClickListenerWrapper(View.OnClickListener onClickListener){
- this.onClickListener = onClickListener;
- }
- @Override
- public void onClick(View view){
- // 讓原來(lái)的點(diǎn)擊監(jiān)聽(tīng)器正常工作
- if(onClickListener != null){
- onClickListener.onClick(view);
- }
- // 點(diǎn)擊事件上報(bào),可以獲取被點(diǎn)擊view的一些屬性
- track(APP_CLICK_EVENT_NAME, getSomeProperties(view));
- }
- }
2. 反射獲取一個(gè)View的mListenerInfo.mOnClickListener,替換成代理的點(diǎn)擊監(jiān)聽(tīng)器
- // 對(duì)一個(gè)View的點(diǎn)擊監(jiān)聽(tīng)器進(jìn)行hook
- public void hookView(View view) {
- // 1. 反射調(diào)用View的getListenerInfo方法(API>=14),獲得mListenerInfo對(duì)象
- 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對(duì)象
- 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. 創(chuàng)建代理的點(diǎn)擊監(jiān)聽(tīng)器對(duì)象
- View.OnClickListener mOnClickListenerWrapper = new OnClickListenerWrapper(mOnClickListener);
- // 4. 把mListenerInfo的mOnClickListener設(shè)成新的onClickListenerWrapper
- onClickListenerField.set(mListenerInfo, mOnClickListenerWrapper);
- // 用這個(gè)似乎也可以:view.setOnClickListener(mOnClickListenerWrapper);
- }
注意,如果是 API<14 的話,mOnClickListener直接是直接以一個(gè)Field保存在View對(duì)象中的,沒(méi)有ListenerInfo,因此反射的次數(shù)要更少一些。
3. 對(duì)App中所有的View進(jìn)行Hook
我們?cè)诜治龅氖侨顸c(diǎn),那么怎樣把App里面所有的View點(diǎn)擊都Hook到呢?有兩種方式:
***種:當(dāng)Activity創(chuàng)建完成后,開(kāi)始從Activity的DecorView開(kāi)始自頂向下深度遍歷ViewTree,遍歷到一個(gè)View的時(shí)候,對(duì)它進(jìn)行hookView操作。這種方式有點(diǎn)暴力,由于這里面遍歷ViewTree的時(shí)候用到了大量反射,性能會(huì)有影響。
第二種:比***種方式稍微優(yōu)秀一些,來(lái)源是一個(gè)Github上的開(kāi)源庫(kù) AndroidTracker (Kotlin實(shí)現(xiàn))。他的處理方式是當(dāng)Activity創(chuàng)建完成后,在DecorView中添加一個(gè)透明的View作為子View,在這個(gè)子View的onTouchEvent方法中,根據(jù)觸摸坐標(biāo)找到屏幕中包含了這個(gè)坐標(biāo)的View,再對(duì)這些View嘗試進(jìn)行hookView操作。 這種方式比較取巧,首先是拿到了手指按下的位置,根據(jù)這個(gè)位置來(lái)找需要被Hook的View,避免了在遍歷ViewTree的同時(shí)對(duì)View進(jìn)行反射。具體實(shí)現(xiàn)是在遍歷ViewTree中的每個(gè)View時(shí),判斷這個(gè)View的坐標(biāo)是否包含手指按下的坐標(biāo),以及View是否Visible,如果滿足這兩個(gè)條件,就把這個(gè)View保存到一個(gè)ArrayList hitViews。然后再遍歷這個(gè)ArrayList里面的View,如果一個(gè)View#hasOnClickListeners返回true,那么才對(duì)他進(jìn)行hookView操作。
整體來(lái)看,動(dòng)態(tài)Hook的思路用到了反射,難免對(duì)程序性能產(chǎn)生影響,如果要采用這種方式實(shí)現(xiàn)全埋點(diǎn)方案,還需要好好評(píng)估。
四、可視化埋點(diǎn)
4.1 可視化埋點(diǎn)-技術(shù)實(shí)現(xiàn)
可視化埋點(diǎn),需要經(jīng)過(guò)兩個(gè)步驟,可以由非技術(shù)人員操作完成。***步,使用嵌入了Mixpanel/SensorsSDK的App連接后臺(tái),當(dāng)手機(jī)App與后臺(tái)同步時(shí),后臺(tái)管理界面上會(huì)顯示和手機(jī)App一樣的界面,用戶可以在管理界面上用鼠標(biāo)選擇需要監(jiān)測(cè)的元素,設(shè)置事件名稱,需要監(jiān)測(cè)的元素屬性等(據(jù)說(shuō)有些SDK的圈選操作是在手機(jī)上進(jìn)行的,不管是什么方式本質(zhì)上是一樣的,需要保存一份配置到后臺(tái))。第二步,嵌入了SDK的App啟動(dòng)時(shí),會(huì)從服務(wù)器獲取到一份配置,再根據(jù)這份配置去檢測(cè)App中的界面及其元素,滿足配置的條件時(shí)向服務(wù)器上報(bào)事件。下面以Mixpanel、SensorsdataSDK為例,簡(jiǎn)單分析一下技術(shù)方案的實(shí)現(xiàn)。
4.1.1 圈選需要監(jiān)測(cè)的元素,保存配置
1.創(chuàng)建WebSocket連接后臺(tái)
采用WebSocket連接是因?yàn)橐屖謾C(jī)和后臺(tái)長(zhǎng)時(shí)間保持連接,是一個(gè)持續(xù)的雙向通信。連接到后臺(tái)時(shí),把手機(jī)的設(shè)備信息發(fā)送到后臺(tái)。
2.把App界面截圖發(fā)送到后臺(tái)
創(chuàng)建Socket連接后,在主線程中,對(duì)App中啟動(dòng)的Activity進(jìn)行掃描,找到界面的RootView(其實(shí)是DecorView)。在查找RootView的同時(shí),會(huì)對(duì)RootView進(jìn)行截圖,截圖時(shí)采用反射調(diào)用View類 createSnapshot 方法。
截圖之后,SDK內(nèi)部會(huì)判斷圖片的hash值,如果圖片發(fā)生了變化,會(huì)采用遞歸的方法深度遍歷Activity的ViewTree,遍歷同時(shí)讀取View的屬性(id、top、left、width、height、class名稱、layoutRules等等)。
***,將上面收集到數(shù)據(jù)發(fā)送到連接的后臺(tái),由后臺(tái)解析之后,把App界面展示在Web頁(yè)面。用戶可以在這個(gè)Web頁(yè)面圈選需要監(jiān)測(cè)的元素,設(shè)置這個(gè)元素的時(shí)間名稱(event_type和event_name),并保存這個(gè)配置。
4.1.2 獲取配置,監(jiān)測(cè)元素的行為,上報(bào)事件
1.獲取配置
SDK啟動(dòng)時(shí),會(huì)從服務(wù)器拉取一份JSON格式的配置,保存到sharedPreference里,同時(shí)SDK會(huì)掃描 android.R 文件里面的資源id和資源的name并保存起來(lái)。
SDK得到配置之后,解析成JSON對(duì)象,讀取 event_bindings 字段,再進(jìn)一步讀取 events 字段,這個(gè)字段下面包含了一個(gè)數(shù)組,數(shù)組的每個(gè)元素都描述了一類事件,并包含了這類事件需要監(jiān)測(cè)的元素所在的Activity和元素的路徑。這份配置基本上是這樣的一個(gè)結(jié)構(gòu):
- event_bindings: {
- events:[
- {
- target_activity: ""
- event_name: ""
- event_type: ""
- path: [
- {
- prefix:
- view_class:
- index:
- id:
- id_name:
- },
- ...
- ]
- }
- ]
- }
收到了這份配置之后,SDK會(huì)把根據(jù)每個(gè)event信息,生成一個(gè) ViewVisitor 。 ViewVisitor 的作用就是把 path 數(shù)組里面指向的所有View元素都找到,并且根據(jù)event_type,給這個(gè)View元素設(shè)置相應(yīng)的行為監(jiān)測(cè)器,當(dāng)這個(gè)View發(fā)生指定行為時(shí),監(jiān)測(cè)器就會(huì)監(jiān)測(cè)到,并上報(bào)行為。
生成ViewVisitor之后,SDK內(nèi)部是以 Map 結(jié)構(gòu)保存它們的,這也比較容易理解。
2.監(jiān)測(cè)元素,上報(bào)事件
ViewVisitor 是怎么監(jiān)測(cè)元素的產(chǎn)生的行為呢?答案就是 View.AccessibilityDelegate 。
在Android SDK里面,AccessibilityService)為我們提供了一系列的事件回調(diào),幫助我們指示一些用戶界面的狀態(tài)變化。我們可以派生輔助功能類,進(jìn)而對(duì)不同的AccessibilityEvent進(jìn)行處理,我們看下AccessibilityEvent里面有哪些事件類型:
- /**
- * Represents the event of clicking on a {@linkandroid.view.View} like
- * {@linkandroid.widget.Button}, {@linkandroid.widget.CompoundButton}, etc.
- */
- public static final int TYPE_VIEW_CLICKED = 0x00000001;
- /**
- * Represents the event of long clicking on a {@linkandroid.view.View} like
- * {@linkandroid.widget.Button}, {@linkandroid.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
- * {@linkandroid.widget.AdapterView}.
- */
- public static final int TYPE_VIEW_SELECTED = 0x00000004;
- /**
- * Represents the event of setting input focus of a {@linkandroid.view.View}.
- */
- public static final int TYPE_VIEW_FOCUSED = 0x00000008;
- /**
- * Represents the event of changing the text of an {@linkandroid.widget.EditText}.
- */
- public static final int TYPE_VIEW_TEXT_CHANGED = 0x00000010;
- ...
以點(diǎn)擊事件 TYPE_VIEW_CLICKED 為例 ,當(dāng)Activity界面的RootView開(kāi)始繪制的時(shí)候(ViewTreeObserver.OnGlobalLayoutListener的onGlobalLayout回調(diào)時(shí)),ViewVisitor也會(huì)開(kāi)始尋找指定的View,并給這個(gè)View設(shè)置新的AccessibilityDelegate。簡(jiǎn)單看一下這個(gè)新的View.AccessibilityDelegate是怎么寫(xiě)的:
- private class TrackingAccessibilityDelegateextends View.AccessibilityDelegate{
- ...
- public TrackingAccessibilityDelegate(View.AccessibilityDelegate realDelegate){
- mRealDelegate = realDelegate;
- }
- public View.AccessibilityDelegategetRealDelegate(){
- return mRealDelegate;
- }
- ...
- @Override
- public void sendAccessibilityEvent(View host,int eventType){
- if (eventType == mEventType) {
- fireEvent(host); // 事件上報(bào)
- }
- if (null != mRealDelegate) {
- mRealDelegate.sendAccessibilityEvent(host, eventType);
- }
- }
- private View.AccessibilityDelegate mRealDelegate;
- }
- ...
可以看到在SDK的 TrackingAccessibilityDelegate#sendAccessibilityEvent 方法里面,發(fā)出了事件上報(bào)。
那么View在點(diǎn)擊方法的內(nèi)部實(shí)現(xiàn)里有調(diào)用 sendAccessibilityEvent 方法嗎?通過(guò)View處理點(diǎn)擊事件時(shí)調(diào)用的 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;
- }
由此可以見(jiàn)在RootView開(kāi)始繪制的時(shí)候,給View注冊(cè)AccessibilityDelegate可以監(jiān)測(cè)到它的點(diǎn)擊事件。
4.2 可視化埋點(diǎn)的難點(diǎn)和問(wèn)題
上面簡(jiǎn)單分析了Mixpanel和SensorsSDK可視化埋點(diǎn)的基本實(shí)現(xiàn),里面還有一個(gè)難點(diǎn)需要仔細(xì)琢磨,那就是 如何唯一標(biāo)識(shí)App中的一個(gè)View? 需要記錄View的哪些信息,如何生成View的唯一ID,保證在不同手機(jī)上這些ID是固定的,而且保證App每次啟動(dòng),ID也不會(huì)變化,同時(shí)ID也要能應(yīng)對(duì)一定程度的界面調(diào)整。
另外在網(wǎng)上看到有網(wǎng)友提出,setAccessibilityDelegate來(lái)監(jiān)測(cè)View的點(diǎn)擊對(duì)大多數(shù)廠商的機(jī)型和版本都是可以的,但是有部分機(jī)型是無(wú)法成功捕獲監(jiān)控到點(diǎn)擊事件。從View的標(biāo)識(shí)生成,以及監(jiān)測(cè)原理來(lái)講,這個(gè)方案的穩(wěn)定性存在一些疑問(wèn)。
4.3 參考資料
- sensorsdata git,包含了Android、iOS、js、JAVA等多個(gè)版本的SDK: https://github.com/sensorsdata
- Mixpanel git,包含了Android、iOS、js、JAVA等多個(gè)版本的SDK: https://github.com/mixpanel
- 網(wǎng)易移動(dòng)端數(shù)據(jù)收集和分析博客: http://www.jianshu.com/c/ee326e36f556
五、總結(jié)
***簡(jiǎn)單總結(jié)一下幾種方案的優(yōu)缺點(diǎn)和使用場(chǎng)景,在實(shí)際應(yīng)用中多種方式配合使用,平衡效率和可靠性,適合自己的業(yè)務(wù)才是***的。