Android开发中Javassist的妙用

Javassist

Java字节码以二进制的形式存储在.class文件中,每一个class文件包含一个Java类或接口。Javassist框架就是一个用来处理Java字节码的类库。它可以在一个已经编译好的类中添加新的方法,或者修改已有的方法,并且不需要对字节码方面有深入的了解。
Javassist可以绕过编译,直接操作字节码,从而实现代码的注入。所以,使用Javassist框架的最佳时机就是在构建工具gradle将源文件编译成class文件之后,在将class打包成dex文件之前。

Javassist基础

  • 读写字节码
    在Javassist框架中,class文件是用类Javassist.CtClass表示的。一个CtClass对像可以处理一个class文件。
    下面举一个简单的例子。
ClassPool pool=ClassPool.getDefault();
CtClass aClass = pool.get("com.test.A");
aClass.setSuperclass("java.lang.Object");
aClass.writeFile();

在上面这个例子中,我们首先获取一个ClassPool对像。ClassPool是CtClass对像的容器,可以按需读取类文件用来创建并保存CtClass对像,以便之后可能会被使用到。
为了修改类的定义,首先需要使用ClassPool.get()方法从ClassPool中获取一个CtClass对像。使用getDefault方法获取的ClassPool对像使用的是默认系统的类搜索路径。
ClassPool是一个存储CtClass的Hash表,类的名称作为Hash表的key。ClassPool的get方法会从Hash表查找key对应的CtClass对像。如果根据对应的key没有找到CtClass对像,get方法就会创建并返回一个新的CtClass对像,这个对像同时也会保存在Hash表中。
从ClassPool中获取的CtClass对像是可以被修改的。在上面的例子中,将A类的父类改为Object。调用writeFile方法后,这项修改会被写入原始类文件中。writeFile方法会将CtClass对象转换成类文件并写到本地磁盘。同时,也可以使用toBytecode方法来获取修改过的字节码。比如:

byte[] b = aClass.toBytecode();

也可以使用toClass方法直接将CtClass对象转换成Class对象,比如:

Class clazz= aClass.toClass();

toClass方法请求当前线程的ClassLoader加载CtClass对象所代表的类文件,它返回的是该类文件的Class对象。

  • 冻结类
    如果一个CtClass对象通过writeFile、toBytecode、toClass等方法被转换成一个类文件,此CtClass对象就会被冻结起来,不再允许修改,这是因为一个类只能被jvm加载一次。
    其实,一个冻结的CtClass对象也可以被解冻,比如:
aClass.writeFile();
aClass.defrost();//解冻
aClass.setSuperClass();//因为这个类已经解冻了,所以可以更改该类
  • 类搜索路径
    通过ClassPool.getDefault()获取的ClassPool是使用JVM的类搜索路径。如果程序运行在Tomcat等服务器上,ClassPool可能无法找到用户的类,因为Web服务器使用多个类加载器作为系统类加载器。在这种情况下,ClassPool必须添加额外的类搜索路径,比如:
ClassPool pool = ClassPool.getDefault();
pool.insertClassPath(new ClassPath(this.getClass()));

在上面的代码示例中,将this指向的类添加到ClassPool的类加载路径中。你可以使用任意Class对象来代替this.getClass(),从而将Class对象添加到类加载路径中,也可以注册一个目录作为搜索路径。比如:

ClassPool pool = ClassPool.getDefault();
pool.insertClassPath("/usr/local/Library");
  • ClassPool
    ClassPool是CtClass对象的容器。因为编译器在编译引用CtClass代表的Java类的源代码时,可能会引用CtClass对象,所以一旦一个CtClass被创建,它就会被保存在ClassPool中。

  • 避免内存溢出
    如果CtClass对象的数量变得非常多,ClassPool有可能会导致巨大的内存消耗。为了避免这个问题,我们可以从ClassPool中显式删除不必要的CtClass对象。如果对CtClass对象调用detach方法,那么该CtClass对象将会从ClassPool中删除。比如:

CtClass aClass = ...;
aClass.writeFile();
aClass.detach();

在调用detach方法之后,就不能再调用这个CtClass对象的任何有关方法了。如果调用了ClassPool的get方法,ClassPool会再次读取这个类文件,并创建一个新的CtClass对象。

  • 注解(Annotations)
    CtClass、CtMethod、CtField和CtConstructor均提供了getAnnotations方法,用于读取对应类型上添加的注解。

  • 在方法体中插入代码
    CtMethod和CtConstructor均提供了insertBefore、insertAfter、addCatch等方法。它们可以把用Java编写的代码片段插入到现有的方法体中。Javassist包括一个用于处理源代码的小型编译器,它接收用Java编写的源代码,然后将其编译成Java字节码,并内联到方法体中。
    也可以按行号来插入代码段(如果行号表包含在类文件中)。向CtMethod和CtConstructor中的insertAt方法提供源代码和原始类定义中的源文件的行号,就可以将编译后的代码插入到指定行号位置。
    insertBefore、insertAfter、insertAt、addCatch等方法都能接收一个表示语句或语句块的String对象。一个语句是一个单一的控制结构,比如if和while,或者以分号结尾的表达式。语句块是一组用{}包围的语句。
    语句和语句块可以引用字段和方法,但不允许访问在方法中声明的局部变量,尽管在块中声明一个新的局部变量是允许的。
    传递给方法insertBefore、insertAfter、addCatch、insertAt的String对象是由Javassist的编译器编译的。

由于编译器支持语言扩展,所以以$开头的几个标识符都有特殊的含义:

  • $0、$1、$2,…
    传递给目标方法的参数使用$1、$2,…来访问,而不是原始的参数名称。$1表示第1个参数,$2表示第2个参数,以此类推。这些变量的类型与参数类型相同。$0等价于this指针。如果方法是静态的,则$0不可用。
  • $args
    变量$args表示所有参数的数组。该变量的类型是Object类型的数组。如果参数类型是原始类型(如int、boolean等),则该参数值将被转换为包装器对象(如java.lang.Integer)以存储在$args中。因此,如果第一个参数的类型不是原始类型,那么$args[0]等于$1。
  • $$
    变量$$是所有参数列表的缩写,用逗号分隔。
  • $_
    CtMethod中的insertAfter方法是在方法的末尾插入编译的代码。在传递给 insertAfter的语句中,不但可以使用特殊符号,如$0、$1,也可以使用$_来表示方法的结果值。
    该变量的类型是方法的返回类型。如果返回结果类型是void,那么$_的类型为Object,$_的值为null。虽然由insertAfter插入的编译代码通常在方法返回之前执行,但是当方法抛出异常时,也可以执行。要在抛出异常时执行它,insertAfter的第二个参数asFinally必须为true。如果抛出异常,$_的值为0或null。在编译代码的执行终止后,最初抛出的异常被重新抛出给调用者。注意,$_的值不会被抛给调用者,而是被抛弃。
  • addCatch
    addCatch插入方法体抛出异常时执行的代码,控制权会返回给调用者。在插入的源代码中,异常用$e表示。
CtMethod m = ...;
CtClass etype = ClassPool.getDefault().get("java.io.IOException");
m.addCatch("{System.out.println($e);throw $e}",etype);

转换成对应的java代码如下:

try{
  //the original method body
}catch(java.io.IOException e){
   System.out.println(e);
   throw e;
}

注意,插入的代码片段必须以throw或return语句结束。

案例

首先,创建一个buildSrc文件夹,引入依赖

apply plugin: 'groovy'//gradle会根据插件的名称,找到这个插件并且调用插件里面的apply方法

repositories {
    maven { url 'https://maven.aliyun.com/repository/google' }
    maven {
        url 'https://maven.aliyun.com/nexus/content/groups/public/'
    }
//    jcenter()
}

sourceSets {
    main {
        groovy {
            srcDir 'src/main/groovy'
        }
    }
}

dependencies {
//    implementation gradleApi() //buildSrc目录下可以不引用gradleApi
    implementation 'com.android.tools.build:gradle:3.4.3'
}

group 'com.brett.gradle'
//version '1.0.2'

然后,在resources文件夹下面创建一个properties

implementation-class=com.brett.gradle.ReleaseHelperPlugin

创建一个插件:

package com.brett.gradle;

import com.android.build.gradle.AppExtension;
import com.android.build.gradle.api.ApplicationVariant
import com.brett.gradle.tasks.GenerateApkTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.invocation.Gradle

import java.util.regex.Matcher
import java.util.regex.Pattern;


class ReleaseHelperPlugin implements Plugin<Project> {

    private static final String sPluginExtensionName = "releaseHelper";
    private static final String ANDROID_EXTENSION_NAME = "android";
    private Project project;


    @Override
    public void apply(Project project) {
        this.project = project;
       // project.getExtensions().create(sPluginExtensionName, CustomExtension.class,project);
        project.getExtensions().findByType(AppExtension).registerTransform(new ReleaseHelperTransform(project))
    }
}
class ReleaseHelperTransform extends Transform {
    private static Project project

    ReleaseHelperTransform(Project project) {
        this.project = project
    }

    @Override
    String getName() {
        return "ReleaseHelperTransform"
    }

    /**
     * 需要处理的数据类型,有两种枚举类型
     * 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() {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    @Override
    boolean isIncremental() {
        return false
    }

    @Override
    void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
        if (!incremental) {
            outputProvider.deleteAll()
        }

        /**Transform 的 inputs 有两种类型,一种是目录,一种是 jar 包,要分开遍历 */
        inputs.each { TransformInput input ->
            /**遍历 jar*/
            input.jarInputs.each { JarInput jarInput ->
                /**重命名输出文件(同目录copyFile会冲突)*/
                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)
                }
                def dest = outputProvider.getContentLocation(destName + hexName, jarInput.contentTypes, jarInput.scopes, Format.JAR)
                FileUtils.copyFile(jarInput.file, dest)

                context.getTemporaryDir().deleteDir()
            }

            /**遍历目录*/
            input.directoryInputs.each { DirectoryInput directoryInput ->
                CodeInjects.inject(directoryInput.file.absolutePath, project)

                def dest = outputProvider.getContentLocation(directoryInput.name,
                        directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
                /**将input的目录复制到output指定目录*/
                FileUtils.copyDirectory(directoryInput.file, dest)
            }
        }
    }
}

import javassist.ClassPool
import javassist.CtClass
import javassist.CtMethod
import org.gradle.api.Project

public class CodeInjects {
    private final static ClassPool pool =  ClassPool.getDefault();

    public static void inject(String path, Project project){

        //当前路径加入类池,不然找不到这个类
        pool.appendClassPath(path)

        //project.android.bootClasspath 加入android.jar,不然找不到android相关的所有类
        pool.appendClassPath(project.android.bootClasspath[0].toString())

        File dir = new File(path)
        if(dir.isDirectory()){
            //遍历目录
            dir.eachFileRecurse {File file->
                String filePath = file.absolutePath
                println("CodeInjects filePath:"+filePath)
                if(file.getName().equals("MainActivity.class")){

                    //获取MainActivity.class
                    CtClass ctClass = pool.getCtClass("com.sogou.teemo.test_use_gradle_plugin.MainActivity");
                    println("CodeInjects ctClass = "+ctClass)

                    if(ctClass.isFrozen()){
                        ctClass.defrost()
                    }

                    //获取到onCreate方法
                    CtMethod ctMethod = ctClass.getDeclaredMethod("onCreate");
                    println("CodeInjects 方法名 = " + ctMethod)

                    String insetBeforeStr = """ android.widget.Toast.makeText(this,"插件中自动生成的代码",android.widget.Toast.LENGTH_SHORT).show();
                                            """

                    ctMethod.insertAfter(insetBeforeStr)

                    ctClass.writeFile(path)

                    ctClass.detach()//释放

                }
            }
        }


    }


}

参考资料

https://github.com/jboss-javassist/javassist/wiki/Tutorial-1

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值