ASM 是什么?
AOP(面向切面编程),是一种编程思想,但是它的实现方式有很多,比如:Spring、AspectJ、JavaAssist、ASM 等。
ASM 是一个 Java 字节码操控框架。它能被用来动态生成类或者增强既有类的功能。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。Java class 被存储在严格格式定义的 .class 文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。ASM 从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。
简单点说,通过 javac 将 .java 文件编译成 .class 文件,.class 文件中的内容虽然不同,但是它们都具有相同的格式,ASM 通过使用访问者(visitor)模式,按照 .class 文件特有的格式从头到尾扫描一遍 .class 文件中的内容,在扫描的过程中,就可以对 .class 文件做一些操作了,有点黑科技的感觉
所以ASM 就是一个字节码操作库,可以大大降低我们操作字节码的难度
Android 的打包过程
如图所示是Android打包流程,.java文件->.class文件->.dex文件,只要在红圈处拦截住,拿到所有方法进行修改完再放生就可以了,而做到这一步也不难,Google官方在Android Gradle的1.5.0 版本以后提供了 Transfrom API, 允许第三方 Plugin 在打包 dex 文件之前的编译过程中操作 .class 文件,我们做的就是实现Transform进行.class文件遍历拿到所有方法,修改完成对原文件进行替换。
原理概述
我们可以自定义一个Gradle Plugin,然后注册一个Transform对象,在tranform方法里,可以分别遍历目录和jar包,然后我们就可以遍历当前应用程序的所有.class文件,然后在利用ASM框架的相关API,去加载响应的.class 文件,并解析,就可以找到满足特定条件的.class文件和相关方法,最后去修改相应的方法以动态插入埋点字节码,从而达到自动埋点的效果。
DEMO
本范例尝试对点击android中的普通点击事件进行一个拦截,并在其中插入代码。
1、创建android工程,只写一个简单点击事件即可(
代码..略
2、创建plugin lib module
1、修改plugin的gradle
apply plugin: 'groovy'
apply plugin: 'maven'
dependencies {
compile gradleApi()
compile localGroovy()
compile 'org.ow2.asm:asm:6.0'
compile 'org.ow2.asm:asm-commons:6.0'
compile 'org.ow2.asm:asm-analysis:6.0'
compile 'org.ow2.asm:asm-util:6.0'
compile 'org.ow2.asm:asm-tree:6.0'
compileOnly 'com.android.tools.build:gradle:3.2.1', {//这里注意需要保持版本一致,否则会报错
exclude group:'org.ow2.asm'
}
}
repositories {
jcenter()
}
//调试模式下在本地生成仓库(也可推入自己已有的maven仓库)
uploadArchives {
repositories.mavenDeployer {
//本地仓库路径,以放到项目根目录下的 repo 的文件夹为例
repository(url: uri('../repo'))
//groupId ,自行定义
pom.groupId = 'com.canzhang.android'
//artifactId
pom.artifactId = 'bury-point-com.canzhang.plugin'
//插件版本号
pom.version = '1.0.0-SNAPSHOT'
}
}
2、在main目录下新建groovy包
groovy 是一种语言,和java语法比较类似
3、创建transform类
这个类的作用就是在被编译成dex之前能够拦截到.class文件,然后找到匹配我们需求的,进行修改调整。
/**
* Google官方在Android Gradle的1.5.0 版本以后提供了 Transfrom API,
* 允许第三方 Plugin 在打包 dex 文件之前的编译过程中操作 .class 文件,
* 我们做的就是实现Transform进行.class文件遍历拿到所有方法,修改完成对原文件进行替换。
*/
class AnalyticsTransform extends Transform {
private static Project project
private AnalyticsExtension analyticsExtension
AnalyticsTransform(Project project, AnalyticsExtension analyticsExtension) {
this.project = project
this.analyticsExtension = analyticsExtension
}
/**
* /返回该transform对应的task名称(编译后会出现在build/intermediates/transform下生成对应的文件夹)
* @return
*/
@Override
String getName() {
return AnalyticsSetting.PLUGIN_NAME
}
/**
* 需要处理的数据类型,有两种枚举类型
* CLASSES 代表处理的 java 的 class 文件,RESOURCES 代表要处理 java 的资源
* @return
*/
@Override
Set getInputTypes() {
return TransformManager.CONTENT_CLASS
}
/**
* 指 Transform 要操作内容的范围,官方文档 Scope 有 7 种类型:
* 1. EXTERNAL_LIBRARIES 只有外部库
* 2. PROJECT 只有项目内容
* 3. PROJECT_LOCAL_DEPS 只有项目的本地依赖(本地jar)
* 4. PROVIDED_ONLY 只提供本地或远程依赖项
* 5. SUB_PROJECTS 只有子项目。
* 6. SUB_PROJECTS_LOCAL_DEPS 只有子项目的本地依赖项(本地jar)。
* 7. TESTED_CODE 由当前变量(包括依赖项)测试的代码
* @return
*/
@Override
Set getScopes() {
//点进去可以看到这个包含(项目、项目依赖、外部库)
//Scope.PROJECT,
//Scope.SUB_PROJECTS,
//Scope.EXTERNAL_LIBRARIES
return TransformManager.SCOPE_FULL_PROJECT
// return Sets.immutableEnumSet(
// QualifiedContent.Scope.PROJECT,
// QualifiedContent.Scope.SUB_PROJECTS)
}
@Override
boolean isIncremental() {//是否增量构建
return false
}
//这里需要注意,就算什么都不做,也需要把所有的输入文件拷贝到目标目录下,否则下一个Task就没有TransformInput了,
// 如果是此方法空实现,最后会导致打包的APK缺少.class文件
@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
_transform(transformInvocation.context, transformInvocation.inputs, transformInvocation.outputProvider, transformInvocation.incremental)
}
void _transform(Context context, Collection inputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
if (!incremental) {
outputProvider.deleteAll()
}
/**Transform 的 inputs 有两种类型,一种是目录,一种是 jar 包,要分开遍历 */
inputs.each { TransformInput input ->
/**遍历目录*/
input.directoryInputs.each { DirectoryInput directoryInput ->
/**当前这个 Transform 输出目录*/
File dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
File dir = directoryInput.file
if (dir) {
HashMap modifyMap = new HashMap<>()
/**遍历以某一扩展名结尾的文件*/
dir.traverse(type: FileType.FILES, nameFilter: ~/.*\.class/) {
File classFile ->
if (AnalyticsClassModifier.isShouldModify(classFile.name, analyticsExtension)) {
File modified = AnalyticsClassModifier.modifyClassFile(dir, classFile, context.getTemporaryDir())
if (modified != null) {
/**key 为包名 + 类名,如:/cn/data/autotrack/android/app/MainActivity.class*/
String ke = classFile.absolutePath.replace(dir.absolutePath, "")
modifyMap.put(ke, modified)//修改过后的放到一个map中然后在写回源目录,覆盖原来的文件
}
}
}
FileUtils.copyDirectory(directoryInput.file, dest)
modifyMap.entrySet().each {
Map.Entry en ->
File target = new File(dest.absolutePath + en.getKey())
if (target.exists()) {
target.delete()
}
FileUtils.copyFile(en.getValue(), target)
en.getValue().delete()
}
}
}
/**遍历 jar*/
input.jarInputs.each { JarInput jarInput ->
String destName = jarInput.file.name
/**截取文件路径的 md5 值重命名输出文件,因为可能同名,会覆盖*/
def hexName = DigestUtils.md5Hex(jarInput.file.absolutePath).substring(0, 8)
/** 获取 jar 名字*/
if (destName.endsWith(".jar")) {
destName = destName.substring(0, destName.length() - 4)
}
/** 获得输出文件*/
File dest = outputProvider.getContentLocation(destName + "_" + hexName, jarInput.contentTypes, jarInput.scopes, Format.JAR)
def modifiedJar = AnalyticsClassModifier.modifyJar(jarInput.file, context.getTemporaryDir(), true, analyticsExtension)
if (modifiedJar == null) {
modifiedJar = jarInput.file
}
FileUtils.copyFile(modifiedJar, dest)
}
}
}
}
3、创建插件类
/**
* 可以通过配置主工程目录中的gradle.properties 中的
* canPlugin.disablePlugin字段来控制是否开启此插件
*/
class AnalyticsPlugin implements Plugin {
void apply(Project project) {
//这个AnalyticsExtension 以及canPlugin名称,可以提供我们在外层配置一些参数,从而支持外层扩展
AnalyticsExtension extension = project.extensions.create("canPlugin", AnalyticsExtension)
//这个可以读取工程的gradle.properties 里面的can.disablePlugin 字段,控住是否注册此插件
boolean disableAnalyticsPlugin = false
Properties properties = new Properties()
if (project.rootProject.file('gradle.properties').exists()) {
properties.load(project.rootProject.file('gradle.properties').newDataInputStream())
disableAnalyticsPlugin = Boolean.parseBoolean(properties.getProperty("disablePlugin", "false"))
}
if (!disableAnalyticsPlugin) {
println("------------您开启了全埋点插桩插件--------------")
AppExtension appExtension = project.extensions.findByType(AppExtension.class)
//注册我们的transform类
appExtension.registerTransform(new com.canzhang.plugin.AnalyticsTransform(project, extension))
} else {
println("------------您已关闭了全埋点插桩插件--------------")
}
}
}
到这里插件和gradle的tranform类我们都创建好了,下面需要看该怎么修改我们想修改的类了。
4、ASM中的ClassVisitor
ClassVisitor:主要负责遍历类的信息,包括类上的注解、构造方法、字段等等。
所以我们可以在这个类中筛选出符合我们条件的类或者方法,然后去修改,实现我们的目的。
比如我们本例子就是为了找到实现了View$OnClickListener
接口的类,然后遍历这个类,并找到重写后的onClick(View v)
方法。
这里就细节贴代码了,不懂得地方可以看注释
/**
* 使用ASM的ClassReader类读取.class的字节数据,并加载类,
* 然后用自定义的ClassVisitor,进行修改符合特定条件的方法,
* 最后返回修改后的字节数组
*/
class AnalyticsClassVisitor extends ClassVisitor implements Opcodes {
//插入的外部类具体路径
private String[] mInterfaces
private ClassVisitor classVisitor
private String mCurrentClassName
AnalyticsClassVisitor(final ClassVisitor classVisitor) {
super(Opcodes.ASM6, classVisitor)
this.classVisitor = classVisitor
}
private
static void visitMethodWithLoadedParams(MethodVisitor methodVisitor, int opcode, String owner, String methodName, String methodDesc, int start, int count, List paramOpcodes) {
for (int i = start; i < start + count; i++) {
methodVisitor.visitVarInsn(paramOpcodes[i - start], i)
}
methodVisitor.visitMethodInsn(opcode, owner, methodName, methodDesc, false)
}
/**
* 这里可以拿到关于.class的所有信息,比如当前类所实现的接口类表等
* @param version 表示jdk的版本
* @param access 当前类的修饰符 (这个和ASM 和 java有些差异,比如public 在这里就是ACC_PUBLIC)
* @param name 当前类名
* @param signature 泛型信息
* @param superName 当前类的父类
* @param interfaces 当前类实现的接口列表
*/
@Override
void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
super.visit(version, access, name, signature, superName, interfaces)
mInterfaces = interfaces
mCurrentClassName = name
AnalyticsUtils.logD("当前的类是:" + name)
AnalyticsUtils.logD("当前类实现的接口有:" + mInterfaces)
}
/**
* 这里可以拿到关于method的所有信息,比如方法名,方法的参数描述等
* @param access 方法的修饰符
* @param name 方法名
* @param desc 方法签名(就是(参数列表)返回值类型拼接)
* @param signature 泛型相关信息
* @param exceptions 方法抛出的异常信息
* @return
*/
@Override
MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
MethodVisitor methodVisitor = super.visitMethod(access, name, desc, signature, exceptions)
String nameDesc = name + desc
methodVisitor = new AnalyticsDefaultMethodVisitor(methodVisitor, access, name, desc) {
@Override
void visitEnd() {
super.visitEnd()
}
@Override
void visitInvokeDynamicInsn(String name1, String desc1, Handle bsm, Object... bsmArgs) {
super.visitInvokeDynamicInsn(name1, desc1, bsm, bsmArgs)
}
@Override
protected void onMethodExit(int opcode) {//方法退出节点
super.onMethodExit(opcode)
}
@Override
protected void onMethodEnter() {//方法进入节点
super.onMethodEnter()
if ((mInterfaces != null && mInterfaces.length > 0)) {
//如果当前类实现的接口有View$OnClickListener,并且当前进入的方法是onClick(Landroid/view/View;)V
//这里如果不知道怎么写,可以写个demo打印一下,就很快知道了,这里涉及一些ASM和Java中不同的写法。
if ((mInterfaces.contains('android/view/View$OnClickListener') && nameDesc == 'onClick(Landroid/view/View;)V')) {
AnalyticsUtils.logD("插桩:OnClickListener nameDesc:" + nameDesc + " currentClassName:" + mCurrentClassName)
//这里就是插代码逻辑了
methodVisitor.visitVarInsn(ALOAD, 1)
methodVisitor.visitMethodInsn(INVOKESTATIC, "com/canzhang/asmdemo/sdk/MySdk", "onViewClick", "(Landroid/view/View;)V", false)
}
}
}
@Override
AnnotationVisitor visitAnnotation(String s, boolean b) {
return super.visitAnnotation(s, b)
}
}
return methodVisitor
}
}
要插入的代码
public class MySdk {
/**
* 常规view 被点击,自动埋点
*
* @param view View
*/
@Keep
public static void onViewClick(View view) {
Log.e("Test","成功插入 666666:"+view);
}
}
核心代码分析
@Override
protected void onMethodEnter() {//方法进入节点
super.onMethodEnter()
if ((mInterfaces != null && mInterfaces.length > 0)) {
//如果当前类实现的接口有View$OnClickListener,并且当前进入的方法是onClick(Landroid/view/View;)V
//这里如果不知道怎么写,可以写个demo打印一下,就很快知道了,这里涉及一些ASM和Java中不同的写法。
if ((mInterfaces.contains('android/view/View$OnClickListener') && nameDesc == 'onClick(Landroid/view/View;)V')) {
AnalyticsUtils.logD("插桩:OnClickListener nameDesc:" + nameDesc + " currentClassName:" + mCurrentClassName)
//这里就是插代码逻辑了
methodVisitor.visitVarInsn(ALOAD, 1)
methodVisitor.visitMethodInsn(INVOKESTATIC, "com/canzhang/asmdemo/sdk/MySdk", "onViewClick", "(Landroid/view/View;)V", false)
}
}
}
当方法进入的时候,如果判断符合我们的条件,则进行方法插入。
- 问题1:
nameDesc
为啥这么写。
nameDesc == 'onClick(Landroid/view/View;)V'
为什么是这样写的,后面的V是个什么东东。
首先grovvy中是可以使用==号来判断字符串是否相等的,其次方法名是和java有一些差异,这个我们可以深入去了解这些差异学习,就可以理解为何这么写。还有一种简单的方法,可以直接打印日志的方式来快速知道我们需要的方法应该怎么写。
入参对应关系表
例子
- 问题2: 这插入的是什么鬼,怎么有点看不懂,如何知道怎么插。
ASM就是帮助我们操作字节码的,封装了一些api可供我们调用,这个转换可以使用一个插件 ASM Bytecode outline ,android studio 可以下载此插件(参考教程
)。
5、创建配置文件
按照如图所示创建对应路径和配置文件com.canzhang.plugin.properties
,这里需要注意
- 配置文件的名字:
com.canzhang.plugin
就是插件的名称,就是稍后我们生成插件后,引用此插件的module需要声明的那个:apply plugin: 'com.canzhang.plugin' - 配置内容就是我们插件的的包名和类名
# 此文件名为插件引用名,下面这行则是对应的插件路径
implementation-class=com.canzhang.plugin.AnalyticsPlugin
6、然后我们就可以运行构建plugin了
构建好之后我们就可以在本地看到这样一个文件夹
7、使用插件
- 项目gradle配置(配置本地仓库、并引入插件)
buildscript {
repositories {
google()
jcenter()
//本地调试仓库
maven {
url uri('repo')
}
}
dependencies {
classpath 'com.android.tools.build:gradle:3.2.0'
//引用插件
classpath 'com.canzhang.android:canzhang_plugin:1.0.0-SNAPSHOT'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
- 主module gradle 配置
apply plugin: 'com.canzhang.plugin'
然后运行编译之后,就可以看到我们打印的日志了。
更多细节待续....
Demo:https://github.com/gudujiucheng/ASMDemo.git
注意事项:
- 没有生成插件之前,要把依赖去掉,不然跑不起来
主module屏蔽
apply plugin: 'com.canzhang.plugin'
主工程的gradle屏蔽
classpath 'com.canzhang.android:canzhang_plugin:1.0.0-SNAPSHOT'
屏蔽之后先build项目成功后,在触发生成插件,然后在放开屏蔽的两项,就可以了
- 插件插入不存在的代码也是不会报错的,因为是在编译后插入的,直到运行的时候才会报错,所以要注意插入代码的正确性。
参考文章:
本文主要是用于记录,参考自神策全埋点教程
https://www.jianshu.com/p/9039a3e46dbc
https://www.jianshu.com/p/c2c1d350d245
https://www.jianshu.com/p/16ed4d233fd1