Android 全埋点解决方案(一)

一、埋点方案总结
AppStart 、AppEnd 全埋点方案

  • AppClick全埋点方案1: 代理View.OnclickListener
  • AppClick全埋点方案2: 代理Window.Callback
  • AppClick全埋点方案3: 代理View.AccessibilityDelegate
  • AppClick全埋点方案4: 透明层
  • AppClick全埋点方案5: AspectJ
  • AppClick全埋点方案6: ASM
  • AppClick全埋点方案7: JavaSsist
  • AppClick全埋点方案8: AST

二、埋点事件简介

  • AppStart 事件
    是指app启动,同时包括冷启动和热启动,热启动是指应用程序从后台恢复。
  • AppEnd 事件
    是指app退出,包括正常退出、home退到后台、被强杀、崩溃等场景。
  • AppViewScreen 事件
    是指App页面浏览,切换Activity或者Fragment
  • AppClick 事件
    是指App的点击事件,所有的view的点击事件

三、AppClick事件的全埋点整体解决思路
就是要自动找到 那个被点击事件的控制处理逻辑(后文统称原处理逻辑),利用一定的技术处理,来对原处理逻辑进行 "拦截" ,或者在原处理逻辑执行前面或执行后面 "插入" 相应的埋点代码,从而达到自动埋点的效果。

在编译器对Java代码的处理流程中,可以采用不同的埋点方案。

                  APT                  AspectJ                ASM
JavaCode ----------.java ----------- .class ----------- .dex
                   AST                                             Javassit

四、全埋点综合方案考虑因素

  • 效率
    • 静态代理
      通过Gradle Plugin 在应用程序编译期间 “插入”代码或者修改代码(.class)。比如AspectJ、ASM、JavaSsist、AST等均属于这种方式。
    • 动态代理
      在代码运行的时候(Runtime)去进行代理。例如:View.OnClickListener、Window.Callback、View.AccessbilityDelegate等方案均属于这种方式。

静态代理明显优于动态代理,因为静态代理是在程序编译阶段处理的,不会对应用程序的整体性能有太大影响,而动态代理是在程序运行阶段发生的,所以对程序性能会有一定的影响。

  • 兼容性
    Android生态系统一直在飞速发展,有不同的开发语言(Java、Kotlin、Flutter),不同的Java版本(Java7、Java8)、混合开发、不同的Gradle版本,以及Lambda、D8、Instant Run、DataBinding、Fragemnt等,都会给兼容性带来影响。

  • 扩展性
    随着业务快速发展,数据分析不断提高,我们自动采集要求越来越高等。

五、埋点实现思路

  • AppViewScreen 事件
    ActivityLifecycleCallbacks是Application的一个内部接口,是从API14(Android 4.0)开始提供的。它提供了生命周期的监听。
    public interface ActivityLifecycleCallbacks {

        /**
         * Called as the first step of the Activity being created. This is always called before
         * {@link Activity#onCreate}.
         */
        default void onActivityPreCreated(@NonNull Activity activity,
                @Nullable Bundle savedInstanceState) {
        }

        /**
         * Called when the Activity calls {@link Activity#onCreate super.onCreate()}.
         */
        void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState);

        /**
         * Called as the last step of the Activity being created. This is always called after
         * {@link Activity#onCreate}.
         */
        default void onActivityPostCreated(@NonNull Activity activity,
                @Nullable Bundle savedInstanceState) {
        }

        /**
         * Called as the first step of the Activity being started. This is always called before
         * {@link Activity#onStart}.
         */
        default void onActivityPreStarted(@NonNull Activity activity) {
        }

        /**
         * Called when the Activity calls {@link Activity#onStart super.onStart()}.
         */
        void onActivityStarted(@NonNull Activity activity);

        /**
         * Called as the last step of the Activity being started. This is always called after
         * {@link Activity#onStart}.
         */
        default void onActivityPostStarted(@NonNull Activity activity) {
        }

        /**
         * Called as the first step of the Activity being resumed. This is always called before
         * {@link Activity#onResume}.
         */
        default void onActivityPreResumed(@NonNull Activity activity) {
        }

        /**
         * Called when the Activity calls {@link Activity#onResume super.onResume()}.
         */
        void onActivityResumed(@NonNull Activity activity);

        /**
         * Called as the last step of the Activity being resumed. This is always called after
         * {@link Activity#onResume} and {@link Activity#onPostResume}.
         */
        default void onActivityPostResumed(@NonNull Activity activity) {
        }

        /**
         * Called as the first step of the Activity being paused. This is always called before
         * {@link Activity#onPause}.
         */
        default void onActivityPrePaused(@NonNull Activity activity) {
        }

        /**
         * Called when the Activity calls {@link Activity#onPause super.onPause()}.
         */
        void onActivityPaused(@NonNull Activity activity);

        /**
         * Called as the last step of the Activity being paused. This is always called after
         * {@link Activity#onPause}.
         */
        default void onActivityPostPaused(@NonNull Activity activity) {
        }

        /**
         * Called as the first step of the Activity being stopped. This is always called before
         * {@link Activity#onStop}.
         */
        default void onActivityPreStopped(@NonNull Activity activity) {
        }

        /**
         * Called when the Activity calls {@link Activity#onStop super.onStop()}.
         */
        void onActivityStopped(@NonNull Activity activity);

        /**
         * Called as the last step of the Activity being stopped. This is always called after
         * {@link Activity#onStop}.
         */
        default void onActivityPostStopped(@NonNull Activity activity) {
        }

        /**
         * Called as the first step of the Activity saving its instance state. This is always
         * called before {@link Activity#onSaveInstanceState}.
         */
        default void onActivityPreSaveInstanceState(@NonNull Activity activity,
                @NonNull Bundle outState) {
        }

        /**
         * Called when the Activity calls
         * {@link Activity#onSaveInstanceState super.onSaveInstanceState()}.
         */
        void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState);

        /**
         * Called as the last step of the Activity saving its instance state. This is always
         * called after{@link Activity#onSaveInstanceState}.
         */
        default void onActivityPostSaveInstanceState(@NonNull Activity activity,
                @NonNull Bundle outState) {
        }

        /**
         * Called as the first step of the Activity being destroyed. This is always called before
         * {@link Activity#onDestroy}.
         */
        default void onActivityPreDestroyed(@NonNull Activity activity) {
        }

        /**
         * Called when the Activity calls {@link Activity#onDestroy super.onDestroy()}.
         */
        void onActivityDestroyed(@NonNull Activity activity);

        /**
         * Called as the last step of the Activity being destroyed. This is always called after
         * {@link Activity#onDestroy}.
         */
        default void onActivityPostDestroyed(@NonNull Activity activity) {
        }
    }

所以,我们可以直接在onResume里面做一个页面信息的统计。

  • AppStart 、 AppEnd 埋点方案
    最好的方案还是使用ActivityLifecycleCallbacks,监听onResume表示AppStart。但是由于应用程序会有多个进程,会导致无法判断当前进程是出于前台还是后台。我们可以采用ContentProvider+SharedPreferences的方案来解决跨进程数据共享问题。
    然后当应用程序被强杀、崩溃的时候,我们该如何判断呢?对于一个应用程序,如果一个页面退出30s内,没有其他页面显示出来,我们就认为应用程序处于后台了,也就是发生了AppEnd事件。

  • AppClick 事件
    1.代理View.OnClickListener
    监听OnResume生命周期,我们会写一个WrapperOnClickListener类,来代理点击事件,以及在后面插入对应的统计代码。我们可以通过activity.getWindow().getDecorView()来获取根布局,然后通过遍历根布局来获取当前设置了点击事件的OnclickListener对象,这里通过反射view的getListenerInfo方法拿到ListenerInfo的mOnclickListener字段。反射效率比较低,然后高版本的兼容性也会有些问题。然后把OnclickListener对象交给WrapperOnclickListener触发。
    基本上实现了埋点,但是在onResume后动态addview,无法注入埋点代码。可以采用ViewTreeObserver.OnGlobalLayoutListener来解决这个问题。在onGlobalLayout回调中重新调用上面的步骤,也就是重新遍历布局,找到有点击事件的view,把点击事件交给WrapperOnClickListener处理,并插入对应的统计代码。
    缺点:

    • 由于使用反射,效率比较低,对App的整体性能有一定的影响,也可能会引入兼容性问题;
    • Application.ActivityLifecycleCallbacks 要求是API 14+;
    • View.hasOnClickListeners()要求API 15+;
    • removeOnGlobalLayoutListener要求API 16+;
    • 无法采集Activity之上的View的点击,比如Dialog,PopupWindow等。

2.Window.Callback
Window.callback是Window类的一个内部类。该接口包含了一系列类似于dispatchXXX和onXXX是接口。当用户点击某个控件时,就会回调Window.Callback中的dispatchTouchEvent(MotionEvent event)方法。

原理概述
在Application中初始化埋点sdk,然后注册监听Application.ActivityLifecycleCallbacks的onCreate,获取当前的Activity对象,通过activity拿到当前的window,activity.getWindow(),再通过window.getCallback()可以拿到Window.callback对象。然后使用自定义的WrapperWindowCallbcak代理这个Window.Callback对象。WrapperWindowCallbcak里面主要是重写了dispatchTouchEvent(MotionEvent event)方法,通过MotionEvent参数(点击的坐标)找到被点击的那个view,并插入埋点代码,最后在调用原有的dispatchTouchEvent(MotionEvent event)方法,即达到“插入”埋点代码的效果。
缺点:

  • 由于使用反射,效率比较低,对App的整体性能有一定的影响,也可能会引入兼容性问题;
    • Application.ActivityLifecycleCallbacks 要求是API 14+;
    • View.hasOnClickListeners()要求API 15+;
    • removeOnGlobalLayoutListener要求API 16+;
    • 无法采集Dialog、Popupwindow等游离于Activity之外的控件的点击事件

3.Accesibility
辅助功能,Android系统通过辅助功能帮助一些功能损失的人更好的使用APP。我们知道,点击事件是会调用performClik()的,里面调用了mOnclickListener.onClick之后,还会调用到sendAccessibilityEvent(AccessbilityEvent.TYPE_VIEW_CLICKED),它里面是调用了mAccessbilityDelegate对象的sendAccessibilityEvent方法,并传入View对象和mAccessbilityDelegate.TYPE_VIEW_CLICKED参数。

原理概述
首先还是通过Application来监听activity的onResume方法,拿到DecordView,然后遍历所有view,设置自定义的SensorsDataAccessbilityDelegate代理当前View.sendAccessbiityEvent方法。在布局改变的时候做上面相同的操作(监听ViewTreeObserve)。在自定义SensorsDataAccessbilityDelegate中会调用原有的sendAccessibilityEvent方法,并判断是否是AccessbilityEvent.TYPE_VIEW_CLICKED类型,如果是,说明有点击事件,就做对应的代码插入。

缺点

  • 由于使用反射,效率比较低,对App的整体性能有一定的影响,也可能会引入兼容性问题;
  • Application.ActivityLifecycleCallbacks 要求是API 14+;
  • View.hasOnClickListeners()要求API 15+;
  • removeOnGlobalLayoutListener要求API 16+;
  • 无法采集Dialog、Popupwindow等游离于Activity之外的控件的点击事件
  • 辅助功能需要用户手动开启,在部分android Rom上辅助功能可能会失效。

4.透明层
原理概述
由于Android的事件分发都是会经过onTouchEvent方法。我们可以获取到当前的Activity,在布局的最上层添加一个自定义透明的view。重写view的onTouchEvent方法,获取当前点击的坐标,从RootView中找到点击的view,然后交给自定义的WrapperOnClickLitener处理。

  • Application.ActivityLifecycleCallbacks 要求是API 14+;
  • View.hasOnClickListeners()要求API 15+;
  • removeOnGlobalLayoutListener要求API 16+;
  • 无法采集Dialog、Popupwindow等游离于Activity之外的控件的点击事件
  • 每次点击都需要遍历RootView,效率比较低。

5.AspectJ
AOP,面向切面编程,AspectJ实际上是其中的一种。对于ApsectJ不了解的可以自行了解。也需要使用到 Gradle plugin 不了解可以自行学习一下。

原理概述
我们可以把AspectJ的处理脚本放到我们自定义的插件里面,然后编写相应的切面类,再定义合适的PointCut用来匹配我们的织入目标的方法(listener对象的相应回调方法),比如Android.view.View.OnClickListener的onClick方法,就可以在编译期间插入埋点代码,从而达到自动埋点即全埋点的效果。
缺点

  • 无法织入第三方库
  • 由于定义的切点依赖编程语言,目前该方案我无法兼容Lambda语法
  • 会有一些兼容性方面的问题,比如:D8、Gradle4.x等。

6.ASM
ASM可以在.class 文件打包成.dex文件之前修改.class文件。
Gradle Transform 可以在编译的时候遍历所有.class文件,并可以转换成所有需要的.class输出。

原理概述
定义一个Gradle Plugin,然后注册一个Transform对象。在transform方法里面,可以分别遍历目录和jar包,然后我们就可以遍历当前应用程序所有的.class文件。然后再利用ASM框架的API,去加载相应的.class文件、解析.class文件,然后可以找到符合条件的.class文件和相关方法,最后去修改相应的方法以动态插入埋点字节码,从而达到自动埋点的效果。

缺点:目前来看ASM是最完美的方法,没有什么缺点。

7.Javassist
java字节码以二进制的形式存储在.class文件中,每一个.class文件包含一个java类和接口。Javassist框架就是一个用来处理java字节码的类库。它可以在一个已编译好的类中添加新的方法,或者修改已有的方法,并且不需要对字节码方面有深入的了解。
javassist可以绕过编译,直接操作字节码,从而实现代码的注入。所以,使用javassist框架的最佳时机就是构建工具Gradle将源文件编译成.class文件之后,在将.class打包成.dex文件之前。

原理概述
跟上面ASM的原理一样,只是把ASM换成了javassist。

8.AST
APT是Annotation Processing Tool 的缩写,即注解处理器,是一种处理注解的工具。确切来说,它是javac的一个工具,用来在编译时扫描和处理注解。注解处理器以java代码作为输入,以生成.java文件作为输出。简单来说,就是在编译期间通过注解生成.java文件。

AST,是Abstract Syntax Tree的缩写,即“抽象语法树”,是编辑器对代码的第一步加工之后的结果,是一个树形式表示的源代码。源代码的每个元素映射到一个节点或者子树。
java的编译分为三个阶段:
第一阶段:所有的源文件都会被解析成语法树。
第二阶段:调用注解解析器,即APT模块。如果注解解析器产生了新的源文件,新的源文件也要参与编译。
第三个阶段:语法树会被分析并转化为类文件。

原理概述
JavaTXT-->词语法分析-->生成AST-->编译字节码、
通过操作AST,可以达到修改源代码的功能。
在自定义的注解解析器的process方法里,通过roundEnvironment.getRootElements()方法可以拿到所有的Element对象,通过tree.getTree(element)方法可以拿到对应的抽象语法树(AST),然后我们自定义一个TreeTranslator,在visitMethodDef里面即可对方法进行判断。如果是目标处理方法,则通过AST框架的相关API即可插入埋点代码,从而实现全埋点效果。
缺点

  • com.sun.tools.javac.tree相关语法晦涩,理解难度大,要求有一定的编译原理基础
  • APT无法扫描其他的module,导致AST无法处理其他module
  • 不支持Lambda语法
  • 带有返回值的方法,很难把埋点代码插入到方法之后

本文参考资料《Android 全埋点解决方案》

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容