ASM 是什么?
AOP(面向切面编程),是一种编程思想,但是它的实现方式有很多,比如:APT、AspectJ、JavaAssist、ASM 等。
ASM 和 Javassist类似,也是一个 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<QualifiedContent.ContentType> 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<QualifiedContent.Scope> 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<TransformInput> 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<String, File> 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<String, File> 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<Project> {
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<Integer> 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了
构建好之后我们就可以在本地看到这样一个文件夹
这里如果想开放此插件给到其他工程使用,则可以提交repo到githup,然后按照下方配置流程进行配置(步骤7),另外需要额外配置仓库地址
maven { url "https://raw.githubusercontent.com/gudujiucheng/ASMDemo/master/repo" }
其中:https://raw.githubusercontent.com/
为固定路径,gudujiucheng
为Github用户名,ASMDemo
为项目名,master/repo
为仓库相对路径。
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'
然后运行编译之后,就可以看到我们插桩的代码了。
插桩前的代码:
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
findViewById(R.id.tv_test).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
Toast.makeText(MainActivity.this, "普通点击事件", Toast.LENGTH_SHORT).show();
}
});
}
}
如下图所示,可以看到具体插桩后的字节码,可以点击查看(注意如果插桩的class是jar包内的,则需要自行反编译jar进行查看(推荐一个简单易用的反编译工具:https://github.com/linchaolong/ApkToolPlus),或者调整插件,使输出一份class到指定文件夹查看)。
更多细节待续....
注意事项:
- 没有生成插件之前,要把依赖去掉,不然跑不起来
主module屏蔽
apply plugin: 'com.canzhang.plugin'
主工程的gradle屏蔽
classpath 'com.canzhang.android:canzhang_plugin:1.0.0-SNAPSHOT'
屏蔽之后先build项目成功后,在触发生成插件,然后在放开屏蔽的两项,就可以了
关于混淆:关于混淆可以不用担心。混淆其实是个ProguardTransform,在自定义的Transform之后执行。
插件插入不存在的代码也是不会报错的,因为是在编译后插入的,直到运行的时候才会报错,所以要注意插入代码的正确性。
-
出现莫名其妙的错误,如
RuntimeException
这里asm不同版本的api,有时候会做api版本限制,要检查下,自己的api版本是否错误:(发生这些错误的原因,主要是因为我们写死的版本,和项目实际应用的asm版本不相同导致的)
比如下面这些版本限制触发的异常:(这里只是抛出了异常,并没有很细致的提示,所以需要留意看错误日志)
其他细节
- 上文是用
groovy
来写的(groovy的编译错误提示不是很好,建议用其他语言写),也可以使用java
或者kotlin
来写,可以选择自己熟悉的语法,这几种语言最后都会转换成字节码,通过jvm来执行。 - 如果用于项目,可以考虑参考其他框架进行一些增量编译和多线程并发处理文件等方面的优化,提高编译速度,可参考:https://github.com/Leaking/Hunter
参考文章:
本文主要是用于记录,参考自神策全埋点教程
//www.greatytc.com/p/9039a3e46dbc
//www.greatytc.com/p/c2c1d350d245
//www.greatytc.com/p/16ed4d233fd1