字节码插桩技术---Transform配合ASM进行插桩(三)
上篇博客简单介绍了使用ASM进行字节码插桩的过程,但是仅仅依靠上篇博客的技术点,是无法在Android项目中使用的,有一个阻碍点就是由于class文件最后被打包到了dex文件中,无法像上篇文章那样,拿到准确的class文件路径。这篇文章,我会详细介绍如何在Android项目中进行字节码插桩
一、Android打包流程
android开发的同学应该都知道这张图,这是Android打包的流程图
图中的每一个过程,都对应了一个Task,我们可以通过Task的doFirst方法和doLast方法分别监听任务执行的开始和结束过程。
字节码插桩是对class文件进行的,所以图中的两个标记分别是源文件编译为class文件和class文件打包为dex。按理说,源文件编译为class文件之后(doLast)和class文件打包为dex之前(doFirst)都是很好的切入点,但是源文件编译为class文件这个点会存在以下的问题:
(1)class文件生成有多个切入点
我们的项目中可能会有两种语言:Java和Kotlin。Debug模式下,Java文件编译为class文件的指令为:compileDebugJavaWithJavac;Kotlin文件编译为class文件的指令为:compileDebugKotlin。所以如果我们选用源文件编译为class文件这个点的话,需要监听多条指令
(2)多依赖库问题
如果我们项目中有app和otherlibrary,app依赖otherlibrary,执行编译工作时,会有如下的指令输出(以Java文件编译成class为例):
app:compileDebugJavaWithJavac
otherlibrary:compileDebugJavaWithJavac
多个库,会有多条指令输出。而我们执行字节码插桩时,相关的插桩代码我们会写在app的build中,所以我们只能监听到app module的指令输出。而它所依赖库的指令,并监听不到。
综上所述,源文件编译为class文件的过程不可取。而class文件打包为dex这个点不会有上面的问题,指令只有一条:
app:dexBuilderDebug
注意:
低版本的gradle,指令为:
app:transformClassesWithDexBuilderForDebug
二、在app的build.gradle中进行插桩
先做准备工作,在项目的build.gradle添加gradle plugin依赖,因为在这个库里,有ASM的库,这样我们在app的build.gradle中就可以使用ASM进行插桩了
dependencies {
classpath 'com.android.tools.build:gradle:3.6.3'
}
首先,我们先对class文件打包为dex的任务进行监听(以下的方法都是和android指令同级)
afterEvaluate {
android.getApplicationVariants().all {
variant ->
String variantName = variant.name
//首字母大写 debug变为Debug/release变为Release
String capitalizeName = variantName.capitalize()
//jar和class打包成dex的任务
Task task = project.getTasks().findByName("dexBuilder"+capitalizeName)
if(task!=null){
//打包之前执行插桩
task.doFirst {
execute(task)
}
}
}
}
variantName:这个值就是我们打包的类型debug或者release
task:我们所说的class打包成dex的任务,要注意的是dex中不仅仅只包含了class,还有jar包。所以这个任务的输入参数为class和jar文件
doFirst:这个方法我们上面说过了,是执行这个任务前的回调
具体的执行在execute方法中,我们看一下execute方法,内容如下:
static void execute(Task task){
FileCollection files = task.getInputs().getFiles()
filesIterator(files.iterator())
}
//注释1
static void filesIterator(Iterator<File>files){
while (files.hasNext()){
File file = files.next()
//注释2
if(file.isDirectory()){
filesIterator(file.listFiles().iterator())
}else{
String filePath = file.getAbsoluteFile()
if(filePath.endsWith(".class")){
//注释3
executeClass(filePath)
}else if(filePath.endsWith(".jar")){
//注释4
executeJava(filePath)
}
}
}
}
注释1这个方法就是就是遍历每一个文件,
注释2这里会判断文件是否为文件夹,如果是文件夹的话,继续进行遍历;否则的话,判断文件是否为class,如果是class的话,执行注释3的executeClass,如果是jar包,执行executeJava
这里需要注意一下:在旧的gradle版本中,执行打包的指令输出为app:transformClassesWithDexBuilderForDebug,这时候的class包含了各个依赖库的class,比如app依赖了自己的otherlibrary,这时候的class也包含了otherlibrary的class文件。但是在新的版本中,otherlibrary会先打包成jar文件,执行dexBuilderDebug时,再将jar打包进dex。所以,如果我们想在自己项目的class文件中进行字节码修改,那么需要在处理jar文件时,需要筛选出其他库的jar,这个我们介绍executeJava的时候再说。我们先看一下executeClass方法:
static void executeClass(String filePath){
try{
FileInputStream is = new FileInputStream(filePath)
//注释1
byte[] byteCode =asm(is)
is.close()
FileOutputStream os = new FileOutputStream(filePath)
os.write(byteCode)
os.close()
}catch(Exception e){
e.printStackTrace()
}
}
这里的处理过程:先拿到class文件的输入流,然后使用asm工具进行改造,之后再重新写入覆盖源文件。核心的操作时asm方法:
static byte[] asm(InputStream inputStream){
ClassReader classReader = new ClassReader(inputStream)
ClassWriter classWriter = new ClassWriter(classReader,0)
ClassVisitor classVisitor = new ClassVisitor(Opcodes.ASM7,classWriter){
@Override
MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
MethodVisitor methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions)
return new MyMethodVisitor(Opcodes.ASM7,methodVisitor,access,name,descriptor)
}
}
classReader.accept(classVisitor,0)
return classWriter.toByteArray()
}
static class MyMethodVisitor extends AdviceAdapter{
//方法名称
String name
protected MyMethodVisitor(int api, MethodVisitor methodVisitor, int access, String name, String descriptor) {
super(api, methodVisitor, access, name, descriptor)
this.name = name
}
@Override
protected void onMethodExit(int opcode) {
//注释1
if("<init>".equals(name)&&opcode==Opcodes.RETURN){
Type type1 = Type.getType("Ljava/lang/System;");
Type type2 = Type.getType("Ljava/io/PrintStream;");
//对应字节码指令getstatic,
getStatic(type1,"out",type2);
//对应字节码指令ldc
visitLdcInsn("ASMTest=====>test");
//对应字节码指令invokevirtual
invokeVirtual(Type.getType("Ljava/io/PrintStream;"),new Method("println","(Ljava/lang/String;)V"));
}
super.onMethodExit(opcode)
}
}
这里的内容上篇文章字节码插桩技术---ASM的使用(一)_紫气东来_life的博客-优快云博客我有说过,感兴趣的同学可以看一看
注释1这里的逻辑为:向构造方法的末尾插入一句输出代码
我们再说一下关于jar包的处理,我看一下executeJava方法:
static void executeJava(String filePath){
//注释1
if(!filePath.contains("build/intermediates/runtime_library_classes_jar/debug/classes.jar")){
return
}
try {
File srcFile = new File(filePath)
//注释2
File distFile = new File(srcFile.getParent(), srcFile.getName() + ".bak")
JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(distFile))
JarFile jarFile = new JarFile(srcFile)
Enumeration<JarEntry> entries = jarFile.entries()
//注释3
while (entries.hasMoreElements()) {
JarEntry jarEntry = entries.nextElement();
// 读jar包中的class
jarOutputStream.putNextEntry(new JarEntry(jarEntry.getName()))
InputStream is = jarFile.getInputStream(jarEntry)
//注释4
byte[] byteCode = asm(is)
jarOutputStream.write(byteCode)
jarOutputStream.closeEntry()
}
jarOutputStream.close()
jarFile.close()
srcFile.delete()
//注释5
distFile.renameTo(srcFile)
}catch(IOException e){
e.printStackTrace()
}
}
注释1这里就是我上面所说的筛选条件,如果是自己项目的依赖库,那么jar的路径会包含上面的路径信息;注释2这里根据原jar创建了一个备份文件,改造的信息会重新写入到这个备份文件中,之后会将原jar包删除,再将备份文件重新命名为原jar包的名称;注释3的while循环就是遍历jar中的class文件,因为一个jar中可能会包含多个文件;注释4上面说过了;注释就是备份文件重新命名为原jar包的名称。其他的都是一些基本的IO操作。
至此,class文件的改造就已经完成了。可以使用dex2jar,jd-GUI等工具去查看,这里我就不贴结果了。项目下载