浅谈Android ASM自动埋点方案实践
作者:C6C
这段时间想到一个有趣的功能,就是在Android的代码编译期间进行一些骚操作,来达到一些日常情境下难以实现的功能,比如监听应用中的所有onClick点击时间,或者监听某些方法的运行耗时,如果在代码中一个方法一个方法修改会很蛋疼,所以想通过Gradle插件来实现在应用的编译期间进行代码插入的功能。
1、AOP的概念
其实这已经涉及到AOP(Aspect Oriented Programming),即面向切面编程,在编译期间对代码进行动态管理,以达到统一维护的目的。
AOP切面
举个栗子,Android开发我们都知道,在项目越来越大的时候,应用可能被分解为多个模块,如果你要往所有模块的方法里头加一句‘我是大傻叼'的Toast,那是不是得跪。所以最好的方式是想办法在编译的时候拿到所有方法,往方法里头怼一个Toast,这样还不会影响到运行期间性能。
2、Transform
Android打包流程
如图所示是Android打包流程,.java文件->.class文件->.dex文件,只要在红圈处拦截住,拿到所有方法进行修改完再放生就可以了,而做到这一步也不难,Google官方在Android Gradle的1.5.0 版本以后提供了 Transfrom API, 允许第三方 Plugin 在打包 dex 文件之前的编译过程中操作 .class 文件,我们做的就是实现Transform进行.class文件遍历拿到所有方法,修改完成对原文件进行替换。
/** * 自动埋点追踪,遍历所有文件更换字节码 */ public class AutoTransform extends Transform { @Override String getName() { return "AutoTrack" } @Override Set<QualifiedContent.ContentType> getInputTypes() { return TransformManager.CONTENT_CLASS } @Override Set<QualifiedContent.Scope> getScopes() { return TransformManager.SCOPE_FULL_PROJECT } @Override boolean isIncremental() { return false } @Override public void transform( @NonNull Context context, @NonNull Collection<TransformInput> inputs, @NonNull Collection<TransformInput> referencedInputs, @Nullable TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException { //此处会遍历所有文件 /**遍历输入文件*/ inputs.each { TransformInput input -> /** * 遍历jar */ input.jarInputs.each { JarInput jarInput -> ... } /** * 遍历目录 */ input.directoryInputs.each { DirectoryInput directoryInput -> ... } } }
3、Gradle插件实现
通过Transform提供的api可以遍历所有文件,但是要实现Transform的遍历操作,得通过Gradle插件来实现,关于Gradle插件的知识可以看相关博客,也可以直接看博主的项目 Luffy 。编写Gradle插件可能需要一点Goovy知识,具体编写直接用java语言写也可以,Goovy是完全兼容java的,只截取插件入口部分实现PluginEntry.groovy
class PluginEntry implements Plugin<Project> { @Override void apply(Project project) { ... //使用Transform实行遍历 def android = project.extensions.getByType(AppExtension) registerTransform(android) ... } def static registerTransform(BaseExtension android) { AutoTransform transform = new AutoTransform() android.registerTransform(transform) }
4、字节码编写
完成上面的操作以后就剩下一件事了,那就是拿到.class文件了,大家都知道.class文件是字节码格式的,操作起来难度是相当于大的,所以需要一个字节码操作库来减轻难度,那就是ASM了。
4.1、ASM简介
ASM 可以直接产生二进制的class 文件,也可以在增强既有类的功能。Java class 被存储在严格格式定义的 .class文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。
4.2、具体使用ASM
ASM框架中的核心类有以下几个:
- ClassReader:该类用来解析编译过的class字节码文件。
- ClassWriter:该类用来重新构建编译后的类,比如说修改类名、属性以及方法,甚至可以生成新的类的字节码文件。
- ClassVisitor:主要负责 “拜访” 类成员信息。其中包括标记在类上的注解,类的构造方法,类的字段,类的方法,静态代码块。
- AdviceAdapter:实现了MethodVisitor接口,主要负责 “拜访” 方法的信息,用来进行具体的方法字节码操作。
- ClassVisitor的全部方法如下,按一定的次序来遍历类中的成员。
ClassVisitor的全部方法如下,按一定的次序来遍历类中的成员。
ClassVisitor全部api
在ClassVisitor中根据你的条件进行判断,满足条件的类才会修改其中方法,比如要统计点击事件的话,需要实现View$OnClickListener接口的类才会遍历其中的方法进行操作。
class AutoClassVisitor extends ClassVisitor { AutoClassVisitor(final ClassVisitor cv) { super(Opcodes.ASM4, cv) } @Override void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { //进行需要满足类的条件过滤 ... super.visit(version, access, name, signature, superName, interfaces) } @Override void visitInnerClass(String name, String outerName, String innerName, int access) { // 内部类信息 ... super.visitInnerClass(name, outerName, innerName, access) } @Override MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { // 拿到需要修改的方法,执行修改操作 MethodVisitor methodVisitor = cv.visitMethod(access, name, desc, signature, exceptions) MethodVisitor adapter = null ... adapter = new AutoMethodVisitor(methodVisitor, access, name, desc) ... return methodVisitor } @Override void visitEnd() { //类中成员信息遍历介绍 ... super.visitEnd() } }
在MethodVisitor中根据对已经拿到的方法进行修改了。
MethodVisitor adapter = new AutoMethodVisitor(methodVisitor, access, name, desc) { boolean isAnnotation = false @Override protected void onMethodEnter() { super.onMethodEnter() //进入方法时可以插入字节码 ... } @Override protected void onMethodExit(int opcode) { super.onMethodExit(opcode) //退出方法前可以插入字节码 ... } /** * 需要通过注解的方式加字节码才会重写这个方法来进行条件过滤 */ @Override AnnotationVisitor visitAnnotation(String des, boolean visible) { ... return super.visitAnnotation(des, visible) } }
5、实战演练
以上就是总体的思路了,现在就通过 Luffy 根据具体需求实战一下,比如说在onClick方法点击的耗时(自动埋点也是一样的道理,只不过换了插桩的方法)。
5.1、插件配置
先打包一下插件到本地仓库进行引用,在项目的根build.gradle加入插件的依赖
dependencies { classpath 'com.xixi.plugin:plugin:1.0.1-SNAPSHOT' }
在app的build.gradle中
apply plugin: 'apk.move.plugin' xiaoqingwa{ name = "小傻逼" isDebug = true //具体配置 matchData = [ //是否使用注解来找对应方法 'isAnotation': false, //方法的匹配,可以通过类名或者实现的接口名匹配 'ClassFilter': [ ['ClassName': null, 'InterfaceName':null, 'MethodName':null, 'MethodDes':null] ], //插入的字节码,方法的执行顺序visitAnnotation->onMethodEnter->onMethodExit 'MethodVisitor':{ MethodVisitor methodVisitor, int access, String name, String desc -> MethodVisitor adapter = new AutoMethodVisitor(methodVisitor, access, name, desc) { boolean isAnnotation = false @Override protected void onMethodEnter() { super.onMethodEnter() //使用注解找对应方法的时候得加这个判断 } @Override protected void onMethodExit(int opcode) { super.onMethodExit(opcode) //使用注解找对应方法的时候得加这个判断 } /** * 需要通过注解的方式加字节码才会重写这个方法来进行条件过滤 */ @Override AnnotationVisitor visitAnnotation(String des, boolean visible) { return super.visitAnnotation(des, visible) } } return adapter } ] }
要是使用演示的话,因为还没上传到jcenter库,所以只能本地仓库打包插件,记得要先把依赖都注释掉,插件打包完成后再启用,不然会编译不过去的。
xiaoqingwa{}
里头的配置信息先不用管,等会会讲到,主要是为了能够不修改插件进行动态更换插桩的方法。
5.2、应用测试
插件配置好了之后就可以测试一下效果了,先写一个耗时统计的工具类
TimeCache.java
/** * Author:xishuang * Date:2018.01.10 * Des:计时类,编译器加入指定方法中 */ public class TimeCache { public static Map<String, Long> sStartTime = new HashMap<>(); public static Map<String, Long> sEndTime = new HashMap<>(); public static void setStartTime(String methodName, long time) { sStartTime.put(methodName, time); } public static void setEndTime(String methodName, long time) { sEndTime.put(methodName, time); } public static String getCostTime(String methodName) { long start = sStartTime.get(methodName); long end = sEndTime.get(methodName); long dex = end - start; return "method: " + methodName + " cost " + dex + " ns"; } }
大概思路就是使用HashMap来临时保存对应方法的时间,退出方法时获取时间差。
在一个方法的前后插入时间统计的方法,这个具体的过程要怎么操作呢,因为class文件是字节码格式的,ASM也是进行字节码操作,所以必须先把插入的代码转换成字节码先。这里推荐一个字节码查看工具Java Bytecode Editor,导入.class文件就可以看到对应字节码了。
比如我们要插入的代码如下:
private void countTime() { TimeCache.setStartTime("newFunc", System.currentTimeMillis()); TimeCache.setEndTime("newFunc", System.currentTimeMillis()); Log.d("耗时", TimeCache.getCostTime("newFunc")); }
先把.java文件编译成.class文件,用Java Bytecode Editor打开
插入代码的字节码
然后根据其用ASM提供的Api一一对应的把代码填进来加到onMethodEnter和onMethodExit中。
//方法前加入 methodVisitor.visitMethodInsn methodVisitor.visitLdcInsn(name) methodVisitor.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false) methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/TimeCache", "setStartTime", "(Ljava/lang/String;J)V", false) //方法后加入 methodVisitor.visitLdcInsn(name) methodVisitor.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false) methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/TimeCache", "setEndTime", "(Ljava/lang/String;J)V", false) methodVisitor.visitLdcInsn("耗时") methodVisitor.visitLdcInsn(name) methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/TimeCache", "getCostTime", "(Ljava/lang/String;)Ljava/lang/String;", false) methodVisitor.visitMethodInsn(INVOKESTATIC, "android/util/Log", "d", "(Ljava/lang/String;Ljava/lang/String;)I", false)
在app的build.gradle中配置得到的字节码,最后设置一下过滤条件,最终的代码如下:
build.gradle
xiaoqingwa{ name = "小傻逼" isDebug = true //具体配置 matchData = [ //是否使用注解来找对应方法 'isAnotation': false, //方法的匹配,可以通过类名或者实现的接口名匹配 'ClassFilter': [ ['ClassName': 'com.xishuang.plugintest.MainActivity', 'InterfaceName': 'android/view/View$OnClickListener', 'MethodName':'onClick', 'MethodDes':'(Landroid/view/View;)V'] ], //插入的字节码,方法的执行顺序visitAnnotation->onMethodEnter->onMethodExit 'MethodVisitor':{ MethodVisitor methodVisitor, int access, String name, String desc -> MethodVisitor adapter = new AutoMethodVisitor(methodVisitor, access, name, desc) { boolean isAnnotation = false @Override protected void onMethodEnter() { super.onMethodEnter() //使用注解找对应方法的时候得加这个判断 // if (!isAnnotation){ // return // } methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/MainActivity", "notifyInsert", "()V", false) methodVisitor.visitLdcInsn(name) methodVisitor.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false) methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/TimeCache", "setStartTime", "(Ljava/lang/String;J)V", false) } @Override protected void onMethodExit(int opcode) { super.onMethodExit(opcode) //使用注解找对应方法的时候得加这个判断 // if (!isAnnotation){ // return // } methodVisitor.visitLdcInsn(name) methodVisitor.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false) methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/TimeCache", "setEndTime", "(Ljava/lang/String;J)V", false) methodVisitor.visitLdcInsn("耗时") methodVisitor.visitLdcInsn(name) methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/TimeCache", "getCostTime", "(Ljava/lang/String;)Ljava/lang/String;", false) methodVisitor.visitMethodInsn(INVOKESTATIC, "android/util/Log", "d", "(Ljava/lang/String;Ljava/lang/String;)I", false) } /** * 需要通过注解的方式加字节码才会重写这个方法来进行条件过滤 */ @Override AnnotationVisitor visitAnnotation(String des, boolean visible) { // if (des.equals("Lcom/xishuang/annotation/AutoCount;")) { // println "注解匹配:" + des // isAnnotation = true // } return super.visitAnnotation(des, visible) } } return adapter } ] }
'isAnotation'
表示是否使用注解的方式找到对应方法,这里false,因为我们现在是通过具体类信息来判断的。
'ClassFilter'
表示过滤条件,其中 'ClassName'
和 'InterfaceName'
用于判断哪些类中的方法可以遍历其中的方法进行匹配修改,不满足的话就不会进行方法名匹配了,这些感兴趣的童鞋都可以改插件自定义扩展。
'MethodName'
和 'MethodDes'
是方法名和方法描述符,可以唯一确定一个方法名,满足类过滤条件的就会进行方法匹配,例如我们要统计的点击事件 onClick(View v)
。
意思就是继承自 android/view/View$OnClickListener
的类或者类名是 'com.xishuang.plugintest.MainActivity'
就可以进行方法的遍历,然后方法满足 onClick(View v)
就会进行代码插入操作。
设置完之后rebuild一下就可以了,可以通过日志看下具体信息, isDebug = true
可以开启日志打印。
日志
通过日志可以看到我们设置的字节码确实插桩成功,现在再看一下编译后的文件验证一下,具体位置是:app\build\intermediates\transforms\AutoTrack\debug\folders
编译后的.class文件
其中的notifyInsert()是我用来弹Toast额外调试用的,请忽略。在手机上点击一下按钮测试一下,发现确实记录下点击的耗时时间,完成。
5.3、注解匹配
除了以上的方式来查找修改的方法之外,还可以通过注解来查找,切换很简单,只需要改一下app的build.gradle文件就可以了,项目中也有栗子,添加了一个注解类。
/** * Author:xishuang * Date:2018.1.9 * Des:时间统计注解 */ @Target(ElementType.METHOD) public @interface AutoCount { }
然后在对应的方法上添加你自定义的注解
@AutoCount private void onClick() { try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } @AutoCount @Override public void onClick(View v) { if (v.getId() == R.id.button) { Toast.makeText(this, "我是按钮", Toast.LENGTH_SHORT).show(); } }
修改一下build.gradle中的配置文件
xiaoqingwa{ name = "小傻逼" isDebug = true //具体配置 matchData = [ //是否使用注解来找对应方法 'isAnotation': true, //方法的匹配,可以通过类名或者实现的接口名匹配 'ClassFilter': [ ['ClassName': 'com.xishuang.plugintest.MainActivity', 'InterfaceName': 'android/view/View$OnClickListener', 'MethodName':'onClick', 'MethodDes':'(Landroid/view/View;)V'] ], //插入的字节码,方法的执行顺序visitAnnotation->onMethodEnter->onMethodExit 'MethodVisitor':{ MethodVisitor methodVisitor, int access, String name, String desc -> MethodVisitor adapter = new AutoMethodVisitor(methodVisitor, access, name, desc) { boolean isAnnotation = false @Override protected void onMethodEnter() { super.onMethodEnter() //使用注解找对应方法的时候得加这个判断 if (!isAnnotation){ return } methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/MainActivity", "notifyInsert", "()V", false) methodVisitor.visitLdcInsn(name) methodVisitor.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false) methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/TimeCache", "setStartTime", "(Ljava/lang/String;J)V", false) } @Override protected void onMethodExit(int opcode) { super.onMethodExit(opcode) //使用注解找对应方法的时候得加这个判断 if (!isAnnotation){ return } methodVisitor.visitLdcInsn(name) methodVisitor.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false) methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/TimeCache", "setEndTime", "(Ljava/lang/String;J)V", false) methodVisitor.visitLdcInsn("耗时") methodVisitor.visitLdcInsn(name) methodVisitor.visitMethodInsn(INVOKESTATIC, "com/xishuang/plugintest/TimeCache", "getCostTime", "(Ljava/lang/String;)Ljava/lang/String;", false) methodVisitor.visitMethodInsn(INVOKESTATIC, "android/util/Log", "d", "(Ljava/lang/String;Ljava/lang/String;)I", false) } /** * 需要通过注解的方式加字节码才会重写这个方法来进行条件过滤 */ @Override AnnotationVisitor visitAnnotation(String des, boolean visible) { if (des.equals("Lcom/xishuang/annotation/AutoCount;")) { println "注解匹配:" + des isAnnotation = true } return super.visitAnnotation(des, visible) } } return adapter } ] }
关键代码在于把 'isAnotation'
设为 true
,然后在 visitAnnotation
方法中添加你的注解类匹配,也就是这句 des.equals("Lcom/xishuang/annotation/AutoCount;")
代码,注解类的描述符,运行效果和上面差不多,但是不会打印日志,因为通过注解来查找方法会遍历每个方法,打印信息太多电脑会爆炸。
具体的信息可以看下源码,已共享到github上,在这里讲了下大概的思路和代码框架,只是起到抛砖引玉的作用,更有趣的玩法大家可以自己修改一下插件来实现。
github地址: Luffy 。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持脚本之家。