项目在打包流程中,会经历将.java文件编译成.class字节码文件,然后将.class字节码文件编译成.dex文件这两个步骤,如图2-30所示。
图2-30 .java文件编译流程
在这两个步骤中,我们可以通过以下几种技术来实现对源码的修改。
❑APT:也就是注解处理器,可以作用于预编译阶段、编译期、运行时3个阶段,常见的Override注解就是预编译时注解,作用于流程预编译阶段。有名的ButterKnife框架则属于编译时注解,在编译过程中会帮我们自动生成findViewById这种重复性代码。
❑AspectJ:AspectJ可在将.java文件编译成.class字节码文件的阶段修改文件。AspectJ会通过专门的编译器将新增的字节码插入原文件的字节码中,以此来完成对源码功能或逻辑的增强操作,但是不会直接修改原文件的字节码而改变原有的逻辑。
❑ASM和Javaassist:这两款工具都可在将.class字节码文件编译成.dex文件的阶段修改.class字节码文件。它们都可以对原文件的字节码进行修改从而修改源码的功能或者逻辑。两者的区别在于,ASM的灵活性更高,可以精确控制字节码的生成和修改过程,但开发者需要对字节码的结构和操作有一定了解;Javaassist隐藏了复杂的底层字节码,开发者可以直接操作Java类、方法、字段等,无须直接操作字节码。
在Android中使用最广泛的还是通过ASM来修改代码,使用ASM对Java字节码进行修改、转换或增强的过程就是字节码操作。字节码操作在实际项目中的使用非常广泛,常用于功能扩展、性能优化、动态调试等。
为了让读者对字节码操作有进一步的了解,这里以字节码操作来实现一个简单案例:为原函数行增加打印hello world日志的能力。这种不修改原函数的逻辑,只增强函数能力的操作称为插桩操作。
Android是通过Gradle脚本来进行打包和项目编译的,那么如何在Gradle中使用ASM来实现插桩呢?实际上Android在通过Gradle编译项目时,在一些特定的阶段会将项目中编译后的java文件的字节码回调给Gradle中的脚本进行进一步处理,这个阶段被称为Transform阶段。因此我们可以编写一个自定义的Gradle脚本并注册到Transform这一阶段中,在自定义的脚本中,我们便能得到项目中的字节码,并通过ASM对字节码中的方法进行插桩。整个插桩过程主要有两个步骤:一是将自定义脚本注册到Transform阶段;二是在自定义脚本中通过ASM进行插桩。
1.Transform脚本注册
我们先看第一个步骤——Transform自定义脚本的注册,实现流程如下。
1)在根目录下新建buildSrc模块,并在该模块的gradle文件中引入ASM库,因为笔者示例程序中的gradle脚本是用Groovy语言编写的,所以还需要在库依赖配置中通过implementation localGroovy()引入groovy库,并通过apply plugin:'groovy'代码来开启groovy插件。对于Android项目来说,buildSrc是一个特殊的模块,并不需要我们进行模块引入配置,项目能自动识别该模块。
2)接着就可以开始编写Gradle脚本了。新建脚本MyAsmPlugin,它继承自Gradle提供的Plugin基类;在apply回调方法中获取AppExtension,并通过registerTransform函数将我们自定义的My AsmTransform脚本注册到Transform阶段。AppExtension是Android程序的配置和属性的扩展对象,通过AppExtension可以进行构建类型、依赖项、签名、资源处理等各项设置。
3)在buildSrc的resouces/META-INF.gradle-plugins/目录中新建“MyAsmPlugin(插件名).properties”文件,如图2-31所示,并在该文件中配置入口脚本,接着在app模块的gradle脚本中通过apply plugin:'MyAsmPlugin'开启配置的脚本。
图2-31 gradle脚本配置
此时便完成了自定义Transform脚本的注册,当项目开始编译且运行到Transform阶段时,便会正常执行我们的自定义脚本。
2.进行字节码插桩
前面我们已经将自定义的Transform脚本注册完成,接着需要在自定义的MyAsmTransform脚本中修改源码中的方法并插入打印hello world的逻辑。
MyAsmTransform脚本继承了系统的Transform基类,在transform回调方法中可以通过遍历得到项目中所有的.class字节码文件,当得到这些.class字节码文件后,就可以通过ASM提供的能力进行字节码操作,来插入我们自己的逻辑了。
ASM提供了ClassReader、ClassVisitor、ClassWriter这3个类来配合完成字节码操作。其中ClassReader用于读取和解析类的字节码并触发相应的回调方法给ClassVisitor,ClassVisitor则在回调函数中操作这些字节码文件,最后ClassWriter对修改后的字节码进行写回操作,此时我们就可以实现代码逻辑了。如下所示,代码中将通过文件遍历得到的.class字节码文件转换成流并传递给ClassReader,并且在自定义的TestClassVisitor中进行字节码操作。
TestClassVisitor继承了ASM的ClassVisitor类,ClassVisitor提供了visitMethod的回调方法。在该回调方法中,我们又可以进一步使用AdviceAdapter来将方法访问的逻辑分为进入方法onMethodEnter和退出方法onMethodExit两个时机。因此,我们可以在进入方法的时机,通过ASM库提供的用于在方法字节码中进行访问和指令修改的MethodVisitor对象,来为方法增加hello world的打印逻辑。代码如下。
到这里,我们在项目的所有方法中增加了hello world日志输出功能。关于字节码的详细规则,不需要专门记忆,可以通过javap指令将Java代码转换成可以阅读的字节码,或者通过AS的插件直接查看Java代码的字节码。至此,我们就通过ASM字节码操作完成了一个简单的方法插桩的案例,建议读者自己操作一遍,以加深对ASM的理解。
读者需要注意的是,从AGP(Android Gradle Plugin)7.0开始,注册自定义Gradle任务的方式发生了很大的变化,需要通过AndroidComponentsExtension来注册,并且Transform也被BytecodeTransformationPlugin替代了。因为AGP 7.0的普及率还不是很高,并且很多字节码插桩的开源库也都是7.0版本以下的,笔者就不在这里展开讲解AGP 7.0及以上版本的用法了,感兴趣的读者可以自己去调研其中的变化点或者完成AGP 7.0及以上版本字节码插桩的适配。
3.使用开源框架
由于通过手写字节码的方式来进行插桩并不是很容易理解,因此容易出错,也有较高的学习成本,幸运的是有很多开源的字节码插桩工具对字节码操作进行了封装,并提供更简单的插桩方式来完成字节码插桩。笔者这里介绍一款简单易用且成熟的字节码操作开源框架——Lancet,并通过它来快捷实现字节码插桩。
Lancet的原理也是通过ASM对字节码进行修改,但我们并不需要手动完成字节码的修改,而只需要通过注解的形式说明要修改的点,框架会自动帮我们完成字节码的修改。我们看一下官方文件里面提供的例子,通过下面简单的几行代码,就可以实现将所有Log.i(tag,msg)方法的代码替换为Log.i(tag,msg+"lancet")。
在这段代码中,注解@TargetClass指定了将要被织入代码的目标类android.util.Log,注解@Proxy指定了将要被织入代码的目标方法i,织入方式为Proxy,表示对原方法进行替换,最后调用Origin.call()执行Log.i()这个原方法。关于Lancet的用法,笔者在这里不做过多的介绍,官方文档讲解得很详细,读者可以自己去查看。
虽然利用Lancet实现字节码操作是非常容易的事情,包括字节跳动内部也广泛地使用Lancet进行字节码操作,但是因为Lancet库长时间没有更新,所以并不兼容较新的AGP,如果我们需要兼容较新的AGP,可以下载Lancet源码后进行修改和适配,字节跳动内部使用的Lancet也是经过修改和适配后的版本。当然,如果我们不想自己适配,GitHub上也有很多开源作者发布了适配最新AGP的Lancet库,读者在GitHub上搜索一下就能找到。