关注“之家技术”,获取更多技术干货
总篇171篇 2022年第46篇
文章简介
本文详细介绍了如何利用Gradle插件,Transform API和ASM在Android端实现全埋点相关过程以及原理,希望通过本篇文章能使大家对全埋点插件有一个较为全面的了解。
1.全埋点概念和介绍
1.1
全埋点概念
“数据埋点”是数据采集领域的一个术语,它是针对用户的某些行为或事件进行捕获、处理、上报和分析的过程。埋点技术本质上就是在特定的时机去采集用户的行为数据,同时获取必要的行为上下文信息,然后将这些数据信息上报到指定的服务端,最后通过获取到的数据进行筛选和分析,最终可以为后续的产品功能迭代,数据运营,营销价值评判等提供有力、可靠的数据支撑。
全埋点,也叫无埋点、无码埋点、无痕埋点、自动埋点等。它不需要开发工程师写代码或者只写少量的代码,即可自动收集用户的所有或者绝大部分的行为数据。比如全量上报一类通用的事件,如某个界面上所有可点击按钮的点击事件,它不需要开发人员像事件埋点那样再针对某个按钮点击写埋点代码才能上报。所有点击事件上报后,可以从中筛选出所需要的行为数据,并最终提供给产品运营成员进行分析。虽然有这么好的优点,但它的缺点也较明显,上报的数据量很大,因为它拦截的是一类事件,所以和业务无关的点击也会上报上来。
1.2
埋点分类
业内常见的埋点方式主要包括三种,代码埋点,全埋点和可视化埋点。我们看下其它两种:
代码埋点,是最常见的方式,实现逻辑较清晰和简单,可以按照业务直接上报定制化的数据,不会有多余内容,并且能拿到业务的上下文数据,但需要开发人员进行主动调用,维护成本稍大一些。
可视化埋点,门槛较高,基于全埋点方式来实现。它在一定程度上解决了无法结合具体业务上报数据的问题。但在可视化操作时,圈选事件有一定难度,并且事件需要结合代码和UI界面进行实时更新,一旦界面有UI变化,圈选事件可能会失效。
对于三种方式的实现复杂度,全埋点应该介于事件埋点和可视化埋点之间,但它带来的优势是很可观的。它不仅可以满足UV、PV、点击量等常见指标统计的需求,还可以节省开发人力成本,适用于以较小的埋点代价收集尽可能多的用户行为数据的场景。
1.3
全埋点原理概述
我们结合市面上一些主流的全埋点方案以及开源代码分析,总结形成了符合我们业务特点的全埋点方案。此方案包括之家的全埋点SDK和插件(仅Android端需要插件),包括iOS和Android两端都已实现。iOS采用Hook系统方法的方式实现全埋点,而Android没有类似Hook系统方法的机制,所以它的实现要更复杂一些。
iOS和Android两端都继承实现了最常见的四种事件:$AppStart(App启动),$AppEnd(App退出),$AppViewScreen(Activity页面浏览),$AppClick(View点击事件)。对于Android端,其中前三种事件实现原理较为简单,监听并处理Activity 生命周期即可实现。而实现 App 浏览页面(Fragment)和View点击事件的全埋点则要复杂的多,虽然这两个事件要埋点的位置很清楚(例如:Button 的 OnClickListener.onClick 方法触发就可以视为 Button的点击),但是并没有一个像 Application.ActivityLifecycleCallbacks一样全局可以监听的接口对所有的点击事件进行监听。因此,我们需要利用一些技术在原点击事件处理逻辑中“插入”我们想要的埋点代码,从而实现自动埋点的效果。全埋点的实现基本上也都是围绕着如何采集 $AppClick 事件展开的。
因此全埋点的整体解决思路或者实现原理,就变成了找到那个被点击View 的点击处理逻辑,然后利用一定的技术,对原处理逻辑进行“拦截”,或者在原处理逻辑的前面或者后面“插入”自定义的埋点代码,从而达到自动埋点的效果。
1.4
实现方案
为达到自动埋点效果,需要熟悉业界的各种方法,找到最优方案。
业界的全埋点方案整体上可以分为“动态方案”和“静态方案”两种。
动态方案可以理解成程序运行期间,包括,通过反射动态代理View.OnClickListener;代理Window.Callback;代理View.AccessibilityDelegate;基于增加透明层View利用其onTouchEvent方法这四种。
静态方案可以理解为在编译期间实现,包括,AOP方案AspectJ;Transform+ASM插桩;Transform+Javassist;自定义注解器APT+抽象语法树AST,总共也是四种。
综合来说,静态方案明显优于动态方案,它发生在编译期间,不仅效率高,更容易扩展,而且兼容性也比较好。
我们看下这四种静态方案的处理时机和方法,如图1-1所示:
图1-1
可以看到AST是在生成Java文件时做的操作,利用IDE对项目code进行编译生成.java 文件这个时机,通过自定义注解器(APT)来切入我们需要插入代码的点,再通过AST的语法来插入埋点代码。
而AspectJ是在.java编译成字节码时进行代码插入,也实现在编译期间插入埋点代码。
在生成Android APK包时,.class最终会通过dx工具转换为.dex 文件(DEX是Dalvik Executable的缩写,它是Android程序的Java代码在编译成.class之后再经过转化成Dalvik虚拟机能够识别执行的文件,这种文件就叫DEX),gradle在这个编译过程中,允许通过gradle plugin来修改.class文件。这样就可以利用Javassist来操作.class字节码文件,从而实现插入埋点代码。
ASM和Javassist原理基本相同,只是在操作.class文件时用的是ASM。
对于上述这些静态方案,它们都有自身的一些优缺点,比如,AspectJ无法织入第三方库,也无法兼容Lambda语法,还有不支持Gradle4.X等。AST无法扫描其它Moudle,也不支持Lambda语法,并且知识点晦涩,很难维护和使用。Javassist和ASM两者原理基本相同,但是Javassist用到反射,性能上有所牺牲,在操作字节码时ASM 要比 Javassist性能更好。所以ASM框架是一个相对完美的选择,暂时没有发现什么缺点,目前是静态方案,乃至全埋点方案里面最优秀的一种。
我们最终选择的全埋点方案中也是基于Transform +ASM实现的,而通过Transform和ASM实现埋点插桩,得有个实现的“载体”,这个载体就是Gradle插件。
2.Gradle插件
2.1
插件是前提条件
由全埋点的实现方案介绍可知,我们的全埋点采用的是Transform + ASM的静态方案,需要在.class文件转成.dex文件之前找到合适的位置插入我们的自定义埋点代码。而我们针对的是Android的工程代码,包括各种库,代码和资源文件,那它是怎么由这些文件编译生成.dex文件的?我们可以从Android Apk的构建流程中找到答案。以下是Google官方提供的Android Apk构建流程图, 如图 2-1 所示:
图2-1 Android Apk构建流程图
从上图红色箭头可以看到,在生成.class文件之后,使用通过dx工具将所有字节码文件转换并合成一个或多个DEX文件,因此需要在此处对字节码文件做处理。通过遍历所有的字节码文件,找到我们需要处理逻辑的地方,比如,某个类实现了View$OnClickListener接口的onClick方法处,然后在此方法内插入自定义的埋点代码。
这个过程再细化一下可分为两个步骤:
1.在转化成dex文件前获取到全量、可处理的字节码文件流;
2.识别出字节码文件中的特定逻辑并插入自定义的埋点代码。
对于第2步操作,熟悉Spring框架原理的同学可能会眼前一亮,这不是AOP吗?嗯,是的,其实就是利用了面向切面编程的思想。我们把插入代码的地方抽象为切入点,然后在切入点处添加埋点代码。
而实现这样的功能,得需要用到这些关键技术:Gradle 插件,Transform API和ASM。
首先得借助Gradle插件这个载体才能实现上面描述的功能,它是前提条件。我们看一下Gradle 插件是什么?
Gradle是一个非常优秀的自动化项目构建工具,它使用一种基于Groovy的特定领域语言(DSL)来声明项目配置,抛弃了基于XML的各种繁琐配置。它主要面向Java应用,当前支持的语言包括有Java、Groovy、Kotlin和Scala这几种,官方介绍未来会支持更多的语言。
Gradle构建的大部分功能可以通过插件的方式来实现,并且支持使用Groovy语言来自定义Gradle插件。Gradle插件可以应用到各种项目中,通过这样的插件能够扩展项目功能。它既可以做成独立的插件程序,也可以跟随系统的配置文件一起运行。从事Android Studio开发的同学会比较熟悉,它能帮助我们在项目的构建过程中做很多事情,如多渠道打包,自定义输出APK文件名,设置Debug或Release使用特定签名文件,隐藏签名信息,控制日志开关,环境分离等等,以及接下来的配合Transform获取字节码文件。
下面介绍下全埋点插件程序是如何创建,发布和使用,以及插件整体运行流程。
2.2
插件如何生成,发布和使用
我们先看看Gradle 插件是怎么生成的。
►2.2.1 新建moudle并修改
新建module,假设名称为plugin,删除不需要的文件,只保留build.gradle文件,src目录,如下图所示:
►2.2.2 添加依赖
打开plubin模块下的build.gradle文件将原来的内容全部删除,并添加如下代码:
里面的内容就是用groovy语言写的一个gradle脚本,至于groovy,你可以把它理解为Java的一种方言,包括语法,数据类型,类,接口,方法等等都跟Java非常的相似。
►2.2.3编写插件
接下来我们利用groovy来编写插件,在plugin模块的src目录下建立如下路径main/groovy/,然后新建包com/autohome/analytics/android/plugin,最后新建AHAnalyticsPlugin.groovy文件
文件结构如下:
AHAnalyticsPlugin.groovy文件中的大致代码如下所示:
在代码中可以看到,我们通过project.extensions的create方法创建了一个名称为autohomeAnalytics,类型为AHAnalyticsExtension的extension,通过这个extension,我们就可以在上层应用的gradle文件中进行各种配置了,配置表的名称为autohomeAnalytics。这样就可以简单打通上层应用和插件,应用通过修改配置来影响或控制插件,插件需要按照这些配置值来实现各自的功能。
比如,在app的build.gradle中,进行如下设置,
此处含义,debug表示是否调试模式,disablePlug表示是否禁止插件起作用,默认是起作用的,所以置为false表示不禁止。
插件通过如下代码
boolean disableAutohomeAnalyticsPlugin = Boolean.parseBoolean(properties.getOrDefault("autohomeAnalytics.disablePlugin", "false"))
可以读取到disablePlugin对应的值,获得app端插件是否可用。
如果disableAutohomeAnalyticsPlugin条件成立,就会退出插件,不会执行后续的流程了。
相反,我们会通过如下代码,
注册一个Transform。
►2.2.4定义插件名称
在plugin模块的src.main文件夹下,再新建resources文件夹,然后新建META-INF文件夹,接着在META-INF文件夹下新建gradle-plugins文件夹(注意,不要一次性建个resources.META-INF.gradle-plugins这样的目录,必须按上面的顺序一步步的来新建目录,否则将不是这样一个目录层次,这是官方的规则要求)。然后新建一个名称为com.autohome.analytics.android.properties的文件,com.autohome.analytics.android将表示插件的名称,文件内容为
里面的类正是我们之前创建的插件类--AHAnalyticsPlugin。
新建完成后工程结构如下图所示:
►2.2.5生成插件并发布
接下来就是发布到远程仓库,在build.gradle中定义如下脚本,
repository为远程仓库的地址,如果没有远程仓库,可以传到本地目录,如下所示:
插件相关文件将传送到本地项目根目录下的repo文件夹中。
在Android Studio开发环境中,我们在右侧的gradle栏中依次选择:AHAnalyticsPluginAndroid=> plugin => Tasks => upload => uploadArchives,然后执行uploadArchives任务,如下所示,
任务执行完毕后可以在远程仓库看到定义的插件,如下所示:
可以看到,它的内容和远程库基本没什么区别。
►2.2.6使用插件
首先在上层应用工程中添加插件依赖,如下所示,
接下来在app模块下的build.gradle中直接应用插件:
可以看到,这里应用的插件名称为com.autohome.analytics.android,也就是在插件工程中,resources/META-INF/build-plugins文件夹下定义的com.autohome.analytics.android.properties文件的文件名(此文件扩展名为.properties,前面部分为文件名)。
到此,Gradle插件如何创建和使用介绍完毕。
►2.3 插件运行流程
为了方便用户控制插件,之家全埋点插件提供了一些可配置项,如上文2.2.3 提到的autohomeAnalytics,可以由应用app在gradle脚本中进行功能配置。插件会在开始时读取这些配置以决定具体的运行模式。然后结合Transform API和ASM一起实现插入埋点代码的功能,插件的整体运行流程如下图所示:
插件读取配置信息,当设置为非禁用时,将注册一个HAnalyticsTransform类,这个类是Transform的子类,使用其实现的transform方法来遍历当前应用的所有jar文件和目录中的Java字节码文件。我们接下来看下Transform是什么。
3.Gradle Transform
3.1
Transform概述
Google 从 Android Gradle1.5.0版本开始,提供了一组封装好的类Transform API。通过这些API允许第三方插件在Android App打包成.dex文件之前的编译过程中去操作字节码文件。我们只要实现一套自定义的Transform,然后遍历字节码文件的所有方法,对需要的方法进行修改,最后将修改后的文件替换原文件就可达到插入代码的目的。此类比较经典的应用是字节码插桩、代码注入。
一个项目的构建过程中可能有多个Transform,每个Transform其实都是一个gradle task,在编译时,编译器中的TaskManager会将每个Transform串连起来,如图3-1所示:
图 3-1
第一个Transform接收来自javac编译的结果,以及已经拉取到在本地的第三方依赖库(如jar、aar),还有resource资源编译后的结果(各种R.class,资源编译后生成的.class文件)。这些编译的中间产物,在Transform组成的链条上流动,每个Transform节点可以对class文件进行处理,然后再传递给下一个Transform。我们常见的如混淆,multi-dex,Instant-Run,jarMerge等这些逻辑,它们如今的实现都封装成一个个的Transform,而我们自定义的Transform,会插入到这个Transform链条的最前面。
3.2
Transform功能介绍
我们通过了解Transform的类定义,来对其功能进行逐个介绍,它的定义如下:
它是一个抽象类,有四个重要的抽象方法需要子类来实现。还有最重要的transform()方法,它是Transform类最核心的部分,在重载的方法内部需要对输入的.class文件进行处理,然后放到输出目录中。
►3.2.1 设置Transform的名称
getName()用来设置Transform的名称,这是个抽象方法,必须实现。这个方法并不是返回整个Transform任务的名称,而是其中关键的一部分,如下所示,假设我们的子类定义如下,
那么最终编译为Release版本的Transform名称将为:
这个最终名称是怎么来的呢?
在gradle包中有一个叫TransformManager的类,这个类用来管理所有的Transform的子类,里面有一个方法叫getTaskNamePrefix,在这个方法中根据各项设置值来拼接最终Transform的名称,如下所示:
可以看到,首先以“transform”开头,之后拼接transform类getInputTypes()方法返回的 ContentType,这个ContentType代表着这个Transform的输入文件的类型,类型主要有两种,一种是Classes,另一种是Resources,如果有多种类型,各种类型之间使用“And”连接,拼接完成后加上“With”,之后紧跟的就是这个Transform的Name,并且首字母转换成大写,如我们的名称为“autohomeAnalyticsAutoTrack”这里将返回“AutohomeAnalyticsAutoTrack”,接着拼接上“For”,这里最终返回的是task的前缀,所以如果我们编译应用选择的是Release版本,那么最终得到就是“transformClassesWithAutohomeAnalyticsAutoTrackForRelease”这样的task名称。
►3.2.2 Transform处理的数据类型
getInputTypes()方法用来指定Transform处理的数据类型。此方法返回两种类型的Set集合,分别为,
• CLASSES
表示处理jar包或者文件夹中的.class文件,将返回TransformManager.CONTENT_CLASS集合。
• RESOURCES
表示要处理的是标准的Java源文件,将返回TransformManager.CONTENT_RESOURCES集合。
我们自定义的AHAnalyticsTransform类中此方法实现如下所示,
表示仅处理输入类型为.class的文件。
►3.2.3 Transform处理的对象范围
getScopes()方法指定Transform要操作内容的范围,它返回的也是一个Set集合。
官方文档Scope定义有7种类型:
1、PROJECT:只处理当前的项目
2、SUB_PROJECTS:只处理子项目
3、EXTERNAL_LIBRARIES:只处理外部的依赖库
4、TESTED_CODE:只处理测试代码
5、PROVIDED_ONLY:只处理provided-only的依赖库
6、PROJECT_LOCAL_DEPS:只处理当前项目的本地依赖,例如jar, aar(已过期,被EXTERNAL_LIBRARIES替代)
7、SUB_PROJECTS_LOCAL_DEPS:只处理子项目的本地依赖,例如jar, aar(已过期,被EXTERNAL_LIBRARIES替代)
我们重载的方法如下所示,
我们的范围为SCOPE_FULL_PROJECT,再看它的定义为,
可以看到,我们要处理的范围包括当前项目,子项目和外部依赖库三个部分,包含了除测试代码外的几乎所有类型。
►3.2.4是否增量操作
isIncremental()方法表示增量编译开关是否开启。
当我们开启增量编译的时候,相当于input包含了changed/removed/added/notchanged四种状态。针对每种状态需要不同的操作,如下所示:
NOTCHANGED: 当前文件不需处理,甚至复制操作都不用;
ADDED、CHANGED: 正常处理,输出给下一个任务;
REMOVED: 移除outputProvider获取路径对应的文件。
我们重载的方法返回disableAutohomeAnalyticsIncremental,此标志由SDK对外提供API的方法,由应用进行设置。如下所示,
► 3.2.5 核心方法-转换transform()
transform()之前已经提到,它是最重要的一个方法,在它里面实现对所有.class文件的遍历,然后交给ASM进行插桩,生成新文件,然后放到输出目录中。
我们先看transform两个重要的概念:
·TransformInput
·TransformOutputProvider
TransformInput是指输入文件的一个抽象,包括两种类型的集合:
1.DirectoryInput集合
是指以源码的方式参与项目编译的所有目录结构及其目录下的源码文件。
2.JarInput集合
是指以jar包方式参与项目编译的所有本地jar包和远程jar包(此处的jar是泛指,包括jar和aar)。
TransformOutputProvider表示Transform的输出,通过它可以获取到输出路径等信息。
这两个信息都可以通过TransformInvocation类获取到,而transform()方法的参数就是TransformInvocation类型的,所以我们有必要对其详细说下。
TransformInvocation类定义如下,
TransformInvocation包含了输入、输出相关信息。通过getInputs()方法可以得到输入的文件。TransformOutputProvider的getContentLocation()方法可以获取文件的输出目录,如果目录存在的话直接返回,如果不存在就会重新创建一个。
这里要注意,输出路径必须用getContentLocation()方法传入特定的参数获取,而不能自己随意指定,否则下一个任务就无法获取你这次的输出文件,将导致编译失败。
我们看下transform()方法的定义,
它只有一个TransformInvocation类型的参数,输入或输出都由它来提供。
3.3
Transform的注册
我们需要实现Transform类的一个子类,并把该Transform子类创建的对象注入到打包过程中,即可完成Transform的注册。
注入Transform并不复杂,先获取到com.android.build.gradle.AppExtension对象,然后借助上一节介绍的插件程序,调用registerTransform()方法即可注册成功。
注册方法实际上是属于BaseExtension的方法,而AppExtension继承自BaseExtension,它们都属于Gradle包中的类。
在插件中注册Transform对象,如下所示,
3.4
Transform如何操作字节码文件
Transform对象注册完毕,我们就可以通过transform()方法操作字节码了。
我们实现的Transform子类,类名为AHAnalyticsTransform,之前已经多次提到过,它的具体实现如下所示,
transform()方法中调用beforeTransform(),transformClass()和afterTransform()三个方法进行处理。
beforeTransform()和afterTransform()方法用来进行一些配置信息的设置,和资源释放。我们重点看下transformClass()方法。
1.首先,由上图可以看到针对TransformInput类型的输入数据分两种情况进行处理,如果是jar文件就用forEachJar处理;如果是目录,就用forEachDirectory处理。
2.其次,以forEachJar为例,看下它的实现逻辑:
先获取输出文件,然后构造新的输出文件名称。接着判断是否支持增量编译。如果不支持,直接调用transformJar方法。注意在transformClass方法开始处有判断,如果不支持增量编译,已经把所有的输出删除掉了。如果支持,复用之前的输出,那么就需要判断当前文件的状态了。如果是NOTCHANGED,当前InputFile不用任何操作。如果是ADDED、CHANGED,新增或者修改,调用transformJar方法处理。如果是REMOVED,就把之前复用的输出文件强制移除。
3.接着,transformJar()方法会调用modifyJarFile方法, 然后它再调用modifyJar方法,在此方法内会遍历每一个class文件。
看下方法modifyJar()方法的大体实现:
经过对jar包中的文件进行遍历,如果是class文件,不是目录类型,并且允许修改此类,那么将调用modifyClass()方法针对每个class文件进行处理。我们看下modifyClass()方法的实现。
可以看到这里将借助ASM的ClassWriter,ClassVisitor,ClassReader来对字节码文件进行操作了。后面将详细介绍下这些类含义和使用。
4.最后,forEachDirectory方法和forEachJar方法的实现是类似的,只不过从处理jar包换成了处理目录,这里不再赘述。
3.5
Transform操作的整体流程
有了上面的介绍,我们可以得到自定义的Transform的整体流程图了,如下所示:
图中的方法名和源码是一致的,整体流程先是从AHAnalyticsTransform重写的Transform类的transform()方法开始,接着调用beforeTransform(),此方法主要用来通过反射读取SDK中的一些版本和配置信息,然后调用transfromClass开始遍历所有文件,通过判断每个文件类型,区分是Jar文件还是目录,分别开始对对应的JarInpu集合和DirectoryInpu集合进行处理,最终在modifyClass()方法中将遍历所得的字节码文件使用ASM框架方法进行处理。在调用modifyClass()方法后还有一个afterTransform()方法, 这个方法主要用来释放之前申请的一些资源,比较简单。
Transform API支持多线程编译以和增量编译,我们的全埋点插件也实现了这一部分的功能,因此可以看到在遍历JarInput集合和DirectoryInput集合的时候有多层的嵌套处理。ASM框架处理完字节码文件后输出到 TransformOutputProvider,再供下一个 Gradle Task使用。
3.6
Transform输出内容
集成了注册有Transform,并使用ASM对字节码文件进行处理的插件,针对这样的应用,我们看下执行编译命令./gradlew assembleRelease后的输出结果。
如上图所示,在项目的build文件夹中的intermediates中有一个transforms文件夹,即目录/app/build/intermediates/transforms,这里包含所有transform任务构建生成的文件存储的文件夹。其中有名为autohomeAnalyticsAutoTrack的目录,这个名称就是由集成自Transform的AHAnalyticsTransform类的getName()方法返回的字符串得来的。最里面就是以index命名生成的文件,这里的0-55就是文件的index。
可以看到应用引用到的所有的三方jar文件都被输出成以数字,0,1,3…这样命名的jar文件。而项目中目录下的源文件则放到了以56,57命名的目录中。
autohomeAnalyticsAutoTrack目录下还会有一个名为__content__的.json文件。该文件中展示了autohomeAnalyticsAutoTrack中文件目录下的内容,以及每个文件的类型,来源等。如下图所示:
以上就是Transform处理后生成的结果文件。
综上,这一节首先对Transform进行了概述,并对Transform类中要实现的功能逐个进行了介绍,然后介绍了如何注册Transform,详细讲述了是如何操作字节码文件的,并对其操作结果进行了展示。接下我们看下本文的重点ASM。
4.ASM字节码插桩
如果把打包编译流程比喻成一条水管运输水的过程,那增加插件,注册transform以及使用ASM的过程就是相当于给水管在特定位置增加了一个过滤水的功能。
1. .class 文件转成 .dex文件的时机就是这个特定的位置;
2.自定义插件就相当于一套工具,用来切开这个水管,接入一段自己的水管;
3.注册的transform就是接入的那段水管,所有的水需要先流经这段水管,然后再沿着原来的水管继续流淌;
4.ASM就相当于一套过滤装置,它安装在自己这段水管内部,怎么过滤就看ASM的操作了。
可见ASM是整个环节的重中之重。
4.1
ASM是什么?
►4.1.1 概念
ASM,是一个通用的Java字节码操作类库或框架,它被用来动态生成类或者增强既有类的功能。我们知道,一个.java文件经过Java编译器(javac)编译之后会生成一个.class文件,而.class文件存储的是就字节码(ByteCode)数据, ASM所操作对象就是字节码或者.class文件。它提供了一系列工具类集合,可以对字节码进行解析和操作class文件流。它实现的效果类似于将.java文件编译生成.class文件,但它的效率要高于这种方式,因为它的原理是通过直接手动操作jvm的指令集,生成或修改class文件流。
ASM处理字节码有两种表现,一种是完全构造生成一个全新的.class文件;另一种就是改造.class文件,首先将.class文件拆分成多个部分,然后对其中某一个部分的信息进行修改,最后再将多个部分重新组织成一个新的.class文件。我们的全埋点技术实现就是基于后者来做的,这个也正是ASM的Transformation的能力。
它的名字有什么含义呢?一般的,大写字母的组合,可能是某个特定短语中个别单词的首字母,例如,SDK表示“Software Development Kit”软件开发工具包,而ASM并不是多个单词的首字母缩写形式,也没有什么具体意义,仅仅是引用了C语言中的一个叫“__asm__”的关键字,这个关键字在C语言中表示函数可以内嵌汇编程序来实现。
►4.1.2 ASM的能力
ASM操作的是字节码,它能对这个字节码文件对应的类进行各种更改,
包括:
1. 修改当前类的父类,可以让它继承新的父类;
2. 能够修改当前类实现的接口,可以增加实现的接口,也可以删除已实现的接口;
3.可以给当前类添加注解,也可以取消已有的注解;
4. 能对类的变量进行修改,包括增加和删除;
5. 同样可以对类中的方法进行增删改,包括给方法添加注解。
6. 只要符合类的定义,ASM基本都能实现我们改造类的需求。
根据官方的说明,它的整体能力有三部分,如下图所示:
1.Analysis(分析):它能够实现从简单的语法分析到完整的语义分析,可以用来发现应用程序中潜在的bug,检测未使用的代码,逆向工程代码等等。只对已有.class文件进行分析,不会产生新的.class文件。
2.Generation(生产):这个能力可以用在编译器编译过程中,包括传统的编译器,分布式编译器,即时编译器等等。能力使它可以从无到有产生新的.class文件。
3.Transformation(变换):可以用来优化或混淆程序,将调试或性能监控代码插入到应用程序等等。对已有的.class文件进行变换,产生新的.class文件。
►4.1.3历史版本
ASM是一个开源库,它属于OW2组织,OW2是一个独立的,全球性的,开源软件社区。
Java语言在不断发展,ASM也伴随其不断更新版本。所以在选择ASM版本的时候,要注意对应的Java版本来确保兼容性。如下是最新的一个版本对应关系:
可见使用的Java版本不同,就要用相对应的ASM版本。当然,如果不确定Java的版本号,我们可以尽量使用较高的ASM版本,一般都会向下兼容。
►4.1.4使用场景
所谓的“字节码插桩”,其实就是修改已经编译好的.class文件,往里面添加自己的字节码,然后打包的时候打包的是修改后的class文件, ASM这样的工具就是为此类功能而生的。
我们看看它都用在哪些场景中,
1.一些开源框架中。很多开源框架基于ASM实现,比如,CGLib (Code Generation Library)框架就是基于ASM实现的,而被广泛应用的Hibernate(标准的ORM框架),Spring框架都是基于Cglib实现了AOP技术,所以Spring AOP是间接的使用了ASM;
2.JDK当中的Lambda表达式。通过跟踪Lambda表达式的源码,可以查看JDK中内置了ASM的代码,并且使用了ClassWriter类来对Lambda表达式进行了包装生成新类。所以在现阶段的Java 8版本中,Lambda表达式的调用就是通过ASM来实现的;
3.还用在Groovy编译器和Kotlin编译器中;
4.用在Gradle中,可以在运行时生成一些类。
等等。
4.2
为什么选择ASM
ASM可以用来操作字节码,但它并不是唯一的,还有许多其它的操作字节码的类库。比如如下这些,
1.Javassist:Javassist是Java programming assistant的缩写。是一个开源的分析、编辑和创建 Java 字节码的类库。Javassit相对于ASM要简单点,它提供了更高级的API。但是执行效率上比ASM要差一些,因为ASM直接操作字节码,而Javassit用到了反射。
2.Apache Commons BCEL:Apache字节码工程库。其中BCEL为Byte Code Engineering Library首字母的缩写。
3.Byte Buddy:在ASM基础上实现的一个类库。它可以看作是ASM的一个功能扩展。
ASM框架相对用户屏蔽了整个类字节码的偏移量和长度,能够使用户非常灵活和方便得实现对字节码的解析和操作。与其它的操作Java字节码的类库相比,在实现相同的功能前提下,使用ASM,运行速度更快,占用的内存空间也更小。
4.3
引入ASM依赖
使用ASM很简单,在项目的build.gradle中添加如下引用即可。注意,需要把gradle中预置的ASM使用exclude语句排除掉,否则会报多重定义的问题。
4.4
常用对象介绍
从整体结构来看,ASM主要提供了两组API,Core Api 及Tree Api。
Core Api可以基于访问者模式来操作类,主要包括asm.jar、asm-util.jar和asm-commons.jar三个部分。
Tree Api则是基于树节点的模式来操作类,主要包括asm-tree.jar和asm-analysis.jar。
对比两者来看,有如下区别;
1.先有Core Api,后有Tree Api,后者是基于前者基础上实现的;
2.如果之前没有接触过ASM,那么Tree Api提供的类和操作方式会更容易上手。在实现复杂功能时,Tree API比Core API也更容易实现。并且一些复杂场景如果Core API实现不了的, 而Tree API可能能实现。这是Tree API的优势。
3.在实现相同功能时,Core API比Tree API执行的效率要高,花费的时间更少,并且占用的内存也比Tree API少。这个是Core API的优势。
所以,两者各有优势。
我们的全埋点主要是基于Core API来实现,虽然上手比Tree API难一些,但它实现的效率会更高,内存占用更小。
4.5
重要的类介绍
►4.5.1三个重要的类
上一节提到,asm.jar是Core Api一个类库,里面有ASM非常重要的三个类,即ClassReader、ClassVisitor和ClassWriter类,它们也是实现全埋点涉及的三个核心类。
1.ClassReader类,负责读取原始的.class字节码文件里的内容,然后拆分成各个不同的部分。
2.ClassVisitor类,负责对.class文件中某一部分里的信息进行修改。
3.ClassWriter类,负责将各个不同的部分重新组合成一个完整的.class文件。
如果是从无到有生成一个新类的话,不需要ClassReader类的参与,只需要ClassVisitor和ClassWriter。但是要修改一个现有类的话,得需要它们三个类都参与。
修改现有类大体的一个步骤是这样的,首先ClassReader类通过读取字节数组或者.class文件间接的获得字节码数据,接着调用accept()方法,接受一个实现了抽象类ClassVisitor的对象实例作为参数,然后依次调用ClassVisitor中的各个方法。在这个过程中,字节码空间上的偏移被转成各种visitXXX方法,使用者只需要在对应的方法上进行需求操作即可,无需考虑字节偏移。整个过程中ClassReader可以看作是一个事件生产者,它将class解析成byte数组,然后再通过accept方法去按顺序调用绑定对象(继承了ClassVisitor的实例)的方法。而ClassWriter继承自ClassVisitor抽象类,最终负责将对象化的class文件内容重构成一个二进制格式的class字节码文件,因此ClassWriter可以看作是一个事件的消费者。
示例代码如下所示:
可见ClassVisitor是这实现类访问和修改的核心。
►4.5.2 类访问器ClassVisitor
上一节讲述了ClassReader、ClassVisitor和ClassWriter这三个类的作用,它们的关系,可以描述为下图:
可以看到,ClassVisitor在修改类内容时,是关键的一环。
它主要负责访问类的成员信息,包括标记在类上的注解、属性、构造方法、普通方法、静态方法,静态代码块等等。在这里我们可以对需要插桩的类进行过滤处理,通过Transform API拿到字节码文件流,判断是否是我们关心的类,然后对其进行处理。例如,在全埋点中,我们想采集可点击的Button控件的点击事件,那么就只需要对继承了View.OnClickListener接口的类进行处理即可。
ClassVisitor对类中的各个部分进行处理,在工作时,由于其属于Core Api,ASM在内部采用访问者模式会将.class类文件的内容从头到尾扫描一遍,每次扫描到类文件相应的内容时,都会调用ClassVisitor内部相应的方法,并生成一个个对应的操作对象。
比如,
扫描到类文件开始时,会回调ClassVisitor的visit()方法;
扫描到类注解时,会回调ClassVisitor的visitAnnotation()方法;
扫描到类成员时,会回调ClassVisitor的visitField()方法;
扫描到类方法时,会回调ClassVisitor的visitMethod()方法;
···
以此类推。
扫描到相应结构内容时,会回调相应方法,该方法将返回一个对应的字节码操作对象(比如,visitMethod()返回MethodVisitor实例),通过修改这个对象,就可以修改class文件相应结构的内容了。
结构内容和对应的方法如下;
ClassVisitor 的调用遵循下面的调用顺序:
visit
[visitSource][visitModule][visitNestHost][visitPermittedSubclass][visitOuterClass]
(
visitAnnotation |
visitTypeAnnotation |
visitAttribute
)*
(
visitNestMember |
visitInnerClass |
visitRecordComponent |
visitField |
visitMethod
)*
visitEnd
其中,涉及到一些符号,它们的含义如下:
[]: 表示最多调用一次,可以不调用,但最多调用一次;
()和|: 表示在多个方法之间,可以选择任意一个,并且多个方法之间不分前后顺序;
*: 表示方法可以调用0次或多次。
全埋点不会涉及复杂操作,所以我们关注如下方法,
visit
(
visitField |
visitMethod
)*
visitEnd
首先调用visit访问类,然后调用0次或多次visitField和visitMethod,最后调用visitEnd结束。
其中visitMethod方法会返回一个MethodVisitor对象,而MethodVisitor是我们在的方法中注入代码的核心。
►4.5.3 方法访问器MethodVisitor
MethodVisitor主要负责访问方法的信息,用来进行具体的字节码操作。在方法中“插入”代码的过程便是通过的MethodVisitor类的方法来完成。
在MethodVisitor类中,定义了许多的visitXxx()方法,而ClassVisitor类也有visitXxx()这样的方法,两者是不一样的,要注意区分。
这些方法的调用,和ClassVisitor类中的方法一样,也要遵循一定的顺序。
如下所示,
(visitParameter)*
[visitAnnotationDefault]
(visitAnnotation | visitAnnotableParameterCount | visitParameterAnnotation | visitTypeAnnotation | visitAttribute)*
[
visitCode
(
visitFrame |
visitXxxInsn |
visitLabel |
visitInsnAnnotation |
visitTryCatchBlock |
visitTryCatchAnnotation |
visitLocalVariable |
visitLocalVariableAnnotation |
visitLineNumber
)*
visitMaxs
]
visitEnd
其中[],(),|,*符号的含义和上一节ClassVisitor类中提到的符号是一致。
visitCode()表示方法体的开始,我们以它为分界线,这个方法之前的方法负责parameter、annotation和attributes等内容;而visitCode()方法之后,到visitMaxs()之前,则是负责当前方法的“方法体”内的opcode内容。visitCode()是方法体的开始,visitMaxs()是方法体的结束;visitMaxs()之后只有一个方法--visitEnd()方法,它是最后一个进行调用的方法。
一般开发不是涉及这么多方法,精简之后如下所示,
[
visitCode
(
visitFrame |
visitXxxInsn |
visitLabel |
visitTryCatchBlock
)*
visitMaxs
]
visitEnd
精简后调用visitCode()方法一次,调用visitXxxInsn()方法多次,用来构造方法的“方法体”,最后调用一次visitMaxs()方法,一次visitEnd()方法。
4.6
举个例子
下面例子演示了如何利用自定义的ClassVisitor和MethodVisitor,在实现了View.OnClickListener()接口的匿名类中,怎样在重写了public void onClick(View v)方法中,在其返回前插入一行日志的,
先看下AsmDemoClassVisitor的定义,
在这个类中,除了构造方法外,我们只重写了一个visitMethod方法,因为要针对方法插入代码,所以只关注visitMethod。如果针对类中的变量或者注解部分进行修改,可以添加visitField和visitAnnotation方法,具体可以参考上两节介绍的相关方法,更详细的可以查看ASM官网。在visitMethod方法中,我们重写了visitMethod方法,并返回一个自定义的MethodVisitor--AsmDemoMethodVisitor。
注意,我们同时重写了visit方法,只是为了将类实现的所有接口信息保存下来,这些接口信息保存在interfaces字符串数组中,此变量会传递给AsmDemoMethodVisitor。
然后我们看下AsmDemoMethodVisitor的定义,
在AsmDemoClassVisitor类中的visitMethod方法返回的是一个MethodVisitor抽象类的子类AsmDemoMethodVisitor,而这个子类继承的是AdviceAdapter,AdviceAdapter是MethodVisitor的子类。之所以选择继承它是为了要使用onMethodExit()方法,这个方法的作用是,在访问方法退出前执行需要插入的语句,这样插入代码很方便。当然如果我们直接继承MethodVisitor然后重写visitCode方法,效果是一样的。ASM包中预置了很多MethodVisitor的子类,这些子类提供了很多特殊场景的切入口可以供用户选择。
在onMethodExit方法中,我们通过methodName判断方法名是不是onClick,通过methodDes是否等于(Landroid/view/View;)V,来判断方法的描述是不是void onClick(View v)这种结构的,即参数为View,返回值为空,再利用interfaces是否包含android/view/View$OnClickListener来判断当前解析的类是否实现了View 类中的OnClickListener接口。如果以上条件都成立,则说明当前方法符合条件。那就在当前位置插入我们自定义的代码。怎样插入的代码可以参考上面代码中的注释。
我们看下测试代码,执行如下java文件,
我们最终得到的.class文件如下所示,
和原java文件对比下,可以发现在onClick方法最后会从插入一行
Log.e("AsmDemo", var1.toString());
这样的log。
var1对象就是onClick方法的View类型的入参对象。
综上,我们利用自定义的ClassVisitor和MethodVisitor实现了在字节码文件中插入一行Log的功能。当然,要实现类似这样的功能得需要借助ClassReader和ClassWriter,更得需要借助插件和Transform API,这些在之前已经详细介绍,这里不再赘述。
5.总结
本文首先介绍了全埋点的基本概念,以及埋点的分类和全埋点的概要原理和采用的实现方案。接着重点讲述了Android全埋点Gradle插件如何生成,发布和使用。然后对Transform 进行了概述,介绍了Transform类的各种关键方法,以及如何借助Transform API实现对字节码的拦截和处理字节码,Transform操作字节码的整体流程和输出结果。最后,介绍了ASM的概念和能力,指出为什么选择ASM进行字节码插桩,如何使用ASM等,并对参与全埋点功能的几个重要的类进行了介绍,给出了ASM埋点插桩的一个示例。
通过以上各方面的介绍,我们可以了解到是如何利用Gradle插件,Transform API和ASM在Android端来实现全埋点的。希望通过本篇文章能使大家对全埋点插件有一个较为全面的了解,也希望大家能学到其中涉及的一些知识点,拓展自己的知识面。
参考文献
https://asm.ow2.io/
https://developer.android.com/studio/build/index.html?hl=zh-cn#build-process
作者简介
肖剑锋
■ 2018年加入汽车之家,具有多年的移动应用开发经验,目前负责公司采集SDK和汽车人App Android端的开发与维护。
阅读更多:
▼ 关注「之家技术」,获取更多技术干货 ▼