一、埋点方案总结
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 全埋点解决方案》