Android编译期插桩,让程序自己写代码(三)

6,651 阅读8分钟

前言

Android编译期插桩,让程序自己写代码(一)中我介绍了APT技术。

Android编译期插桩,让程序自己写代码(二)中我介绍了AspectJ技术。

本文是这一系列的最后一篇,介绍如何使用Javassist在编译期生成字节码。老规矩,直接上图。

一、Javassist

Javassist是一个能够非常方便操作字节码的库。它使Java程序能够在运行时新增或修改类。操作字节码,Javassist并不是唯一选择,常用的还有ASM。相较于ASMJavassist效率更低。但是,Javassist提供了更友好的API,开发者们可以在不了解字节码的情况下使用它。这一点,ASM是做不到。Javassist非常简单,我们通过两个例子直观的感受一下。

1.1 第一个例子

这个例子演示了如何通过Javassist生成一个class二进制文件。

public class Main {

    static ClassPool sClassPool = ClassPool.getDefault();

    public static void main(String[] args) throws Exception {
        //构造新的Class MyThread。
      	CtClass myThread = sClassPool.makeClass("com.javassist.example.MyThread");
	//设置MyThread为public的
        myThread.setModifiers(Modifier.PUBLIC);
        //继承Thread
        myThread.setSuperclass(sClassPool.getCtClass("java.lang.Thread"));
        //实现Cloneable接口
        myThread.addInterface(sClassPool.get("java.lang.Cloneable"));

        //生成私有成员变量i
        CtField ctField = new CtField(CtClass.intType,"i",myThread);
        ctField.setModifiers(Modifier.PRIVATE);
        myThread.addField(ctField);

        //生成构造方法
        CtConstructor constructor = new CtConstructor(new CtClass[]{CtClass.intType}, myThread);
        constructor.setBody("this.i = $1;");
        myThread.addConstructor(constructor);

        //构造run方法的方法声明
        CtMethod runMethod = new CtMethod(CtClass.voidType,"run",null,myThread);
        runMethod.setModifiers(Modifier.PROTECTED);
        //为run方法添加注Override注解
        ClassFile classFile = myThread.getClassFile();
        ConstPool constPool = classFile.getConstPool();
        AnnotationsAttribute overrideAnnotation = new AnnotationsAttribute(constPool,AnnotationsAttribute.visibleTag);
        overrideAnnotation.addAnnotation(new Annotation("Override",constPool));
        runMethod.getMethodInfo().addAttribute(overrideAnnotation);
        //构造run方法的方法体。
      	runMethod.setBody("while (true){" +
                "            try {" +
                "                Thread.sleep(1000L);" +
                "            } catch (InterruptedException e) {" +
                "                e.printStackTrace();" +
                "            }" +
                "            i++;" +
                "        }");

        myThread.addMethod(runMethod);

        //输出文件到当前目录
        myThread.writeFile(System.getProperty("user.dir"));
    }
}

运行程序,当前项目下生成了以下内容:

反编译MyThread.class,内容如下:

package com.javassist.example;

public class MyThread extends Thread implements Cloneable {
    private int i;
    public MyThread(int var1) {
        this.i = var1;
    }

    @Override
    protected void run() {
        while(true) {
            try {
                Thread.sleep(1000L);
            } catch (InterruptedException var2) {
                var2.printStackTrace();
            }
            ++this.i;
        }
    }
}

1.2 第二个例子

这个例子演示如何修改class字节码。我们为第一个例子中生成的MyTread.class扩展一些功能。

public class Main {

    static ClassPool sClassPool = ClassPool.getDefault();

    public static void main(String[] args) throws Exception {
        //为ClassPool指定搜索路径。
        sClassPool.insertClassPath(System.getProperty("user.dir"));

        //获取MyThread
        CtClass myThread = sClassPool.get("com.javassist.example.MyThread");

        //将成员变量i变成静态的
        CtField iField = myThread.getField("i");
        iField.setModifiers(Modifier.STATIC|Modifier.PRIVATE);

        //获取run方法
        CtMethod runMethod = myThread.getDeclaredMethod("run");
        //在run方法开始处插入代码。
        runMethod.insertBefore("System.out.println(\"开始执行\");");
      
        //输出新的二进制文件
        myThread.writeFile(System.getProperty("user.dir"));
    }
}

运行,再反编译MyThread.class,结果如下:

package com.javassist.example;

public class MyThread extends Thread implements Cloneable {
    private static int i;
    public MyThread(int var1) {
        this.i = var1;
    }

    @Override
    protected void run() {
        System.out.println("开始执行");
        while(true) {
            try {
                Thread.sleep(1000L);
            } catch (InterruptedException var2) {
                var2.printStackTrace();
            }
            ++this.i;
        }
    }
}

编译期插桩对于Javassist的要求并不高,掌握了上面两个例子就可以实现我们大部分需求了。如果你想了解更高级的用法,请移步这里。接下来,我只介绍两个类:CtClassClassPool

1.3 CtClass

CtClass表示字节码中的一个类。CtClass为我们提供了可以构造一个完整Class的API,例如继承父类、实现接口、增加字段、增加方法等。除此之外,CtClass还提供了writeFile()方法,方便我们直接输出二进制文件。

1.4 ClassPool

ClassPool是CtClass的容器。ClassPool可以新建(makeClass)或获取(get)CtClass对象。在获取CtClass对象时,即调用ClassPool.get()方法,需要在ClassPool中指定查找路径。否则,ClassPool怎么知道去哪里加载字节码文件呢。ClassPool通过链表维护这些查找路径,我们可以通过insertClassPath()\appendClassPath()将路径插入到链表的表头\表尾。

Javassist只是操作字节码的工具。要实现编译期生成字节码还需要Android Gradle为我们提供入口,而Transform就是这个入口。接下来我们进入了Transform环节。

二、Transform

Transform是Android Gradle提供的,可以操作字节码的一种方式。App编译时,我们的源代码首先会被编译成class,然后再被编译成dex。在class编译成dex的过程中,会经过一系列Transform处理。

上图是Android Gradle定义的一系列TransformJacocoProguardInstantRunMuti-Dex等功能都是通过继承Transform实现的。当前,我们也可以自定义Transform

2.1 Transform的工作原理

我们先来了解多个Transform是如何配合工作的。直接上图。

Transform之间采用流式处理方式。每个Transform需要一个输入,处理完成后产生一个输出,而这个输出又会作为下一个Transform的输入。就这样,所有的Transform依次完成自己的使命。

Transform的输入和输出都是一个个的class/jar文件。

2.1.1 输入(Input)

Transform接收输入时,会把接收的内容封装到一个TransformInput集合中。TransformInput由一个JarInput集合和一个DirectoryInput集合组成。JarInput代表Jar文件,DirectoryInput代表目录。

2.1.2 输出(Output)

Transform的输出路径是不允许我们自由指定的,必须根据名称、作用范围、类型等由TransformOutputProvider生成。具体代码如下:

 String dest = outputProvider.getContentLocation(directoryInput.name,
                        directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)

2.2 自定义Transform

2.2.1 继承Transform

我们先看一下继承Transform需要实现的方法。

public class CustomCodeTransform extends Transform {
    @Override
    public String getName() {
        return null;
    }

    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return null;
    }

    @Override
    public Set<? super QualifiedContent.Scope> getScopes() {
        return null;
    }

    @Override
    public boolean isIncremental() {
        return false;
    }
  
    @Override
    public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation);
    }
}
  • getName():Transform起一个名字。

  • getInputTypes():Transform要处理的输入类型。DefaultContentType提供了两种类型的输入方式:

    1. CLASSES: java编译后的字节码,可能是jar包也可能是目录。
    2. RESOURCES: 标注的Java资源。

    TransformManager为我们封装了InputTypes。具体如下:

        public static final Set<ContentType> CONTENT_CLASS = ImmutableSet.of(CLASSES);
        public static final Set<ContentType> CONTENT_JARS = ImmutableSet.of(CLASSES, RESOURCES);
        public static final Set<ContentType> CONTENT_RESOURCES = ImmutableSet.of(RESOURCES);
    
  • getScopes():Transform的处理范围。它约定了Input的接收范围。Scope中定义了以下几种范围:

    1. PROJECT: 只处理当前项目。
    2. SUB_PROJECTS: 只处理子项目。
    3. PROJECT_LOCAL_DEPS: 只处理项目本地依赖库(本地jars、aar)。
    4. PROVIDED_ONLY: 只处理以provided方式提供的依赖库。
    5. EXTERNAL_LIBRARIES: 只处理所有外部依赖库。
    6. SUB_PROJECTS_LOCAL_DEPS: 只处理子项目的本地依赖库(本地jars、aar)
    7. TESTED_CODE: 只处理测试代码。

    TransformManager也为我们封装了常用的Scope。具体如下:

    public static final Set<ScopeType> PROJECT_ONLY = 
            ImmutableSet.of(Scope.PROJECT);
    
    public static final Set<Scope> SCOPE_FULL_PROJECT =
            Sets.immutableEnumSet(
                    Scope.PROJECT,
                    Scope.SUB_PROJECTS,
                    Scope.EXTERNAL_LIBRARIES);
    
    public static final Set<ScopeType> SCOPE_FULL_WITH_IR_FOR_DEXING =
            new ImmutableSet.Builder<ScopeType>()
                    .addAll(SCOPE_FULL_PROJECT)
                    .add(InternalScope.MAIN_SPLIT)
                    .build();
    
    public static final Set<ScopeType> SCOPE_FULL_LIBRARY_WITH_LOCAL_JARS =
            ImmutableSet.of(Scope.PROJECT, InternalScope.LOCAL_DEPS);
    
  • isIncremental(): 是否支持增量更新。

  • transform(): 这里就是我们具体的处理逻辑。通过参数TransformInvocation,我们可以获得输入,也可以获取决定输出的TransformOutputProvider

    public interface TransformInvocation {
       /**
         * Returns the inputs/outputs of the transform.
         * @return the inputs/outputs of the transform.
         */
        @NonNull
        Collection<TransformInput> getInputs();
      	
       /**
         * Returns the output provider allowing to create content.
         * @return he output provider allowing to create content.
         */
        @Nullable
        TransformOutputProvider getOutputProvider();
    }
    
2.2.2自定义插件,集成Transform

下面到了集成Transform环节。集成Transform需要自定义gradle 插件。写给Android 开发者的Gradle系列(三)撰写 plugin介绍了自定义gradle插件的步骤,我们跟着它就可以实现一个插件。然后就可以将CustomCodeTransform注册到gradle的编译流程了。

class CustomCodePlugin implements Plugin<Project> {
    @Override
    void apply(Project project) {
         AppExtension android = project.getExtensions().getByType(AppExtension.class);
      	 android.registerTransform(new RegisterTransform());
    }
}

三、一个简易的组件化Activity路由框架

在Android领域,组件化经过多年的发展,已经成为一种非常成熟的技术。组件化是一种项目架构,它将一个app项目拆分成多个组件,而各个组件间互不依赖。

既然组件间是互不依赖的,那么它们就不能像普通项目那样进行Activity跳转。那应该怎么办呢?下面我们就来具体了学习一下。

我们的Activity路由框架有两个module组成。一个module用来提供API,我们命名为common;另一个module用来处理编译时字节码的注入,我们命名为plugin

我们先来看一下common。它只有两个类,如下:

public interface IRouter {
    void register(Map<String,Class> routerMap);
}
public class Router {

    private static Router INSTANCE;
    private Map<String, Class> mRouterMap = new ConcurrentHashMap<>();

    //单例
    private static Router getInstance() {
        if (INSTANCE == null) {
            synchronized (Router.class) {
                if (INSTANCE == null) {
                    INSTANCE = new Router();
                }
            }
        }
        return INSTANCE;
    }

    private Router() {
        init();
    }
    //在这里字节码注入。
    private void init() { }

    /**
     * Activity跳转
     * @param context
     * @param activityUrl Activity路由路径。
     */
    public static void startActivity(Context context, String activityUrl) {
        Router router = getInstance();
        Class<?> targetActivityClass = router.mRouterMap.get(activityUrl);

        Intent intent = new Intent(context,targetActivityClass);
        context.startActivity(intent);
    }
}

common的这两个类十分简单。IRouter是一个接口。Router对外的方法只有一个startActivity

接下来,我们跳过plugin,先学习一下框架怎么使用。假如我们的项目被拆分成app、A、B三个module。其中app是一个壳工程,只负责打包,依赖于A、B。A和B是普通的业务组件,A、B之间互不依赖。现在,A组件中有一个AActivity,B组件想跳转到AActivity。怎么做呢?

在A组件中新建一个ARouterImpl实现IRouter

public class ARouterImpl implements IRouter {

    private static final String AActivity_PATH = "router://a_activity";

    @Override
    public void register(Map<String, Class> routerMap) {
        routerMap.put(AActivity_PATH, AActivity.class);
    }
}

在B组件中调用时,只需要

Router.startActivity(context,"router://a_activity");

是不是很神奇?其实奥妙就在plugin中。编译时,pluginRouterinit()中注入了如下代码:

private void init() { 
		ARouterImpl var1 = new ARouterImpl();
  	var.register(mRouterMap);
}

plugin中的代码有点多,我就不贴出来了。这一节的代码都在这里

这个Demo非常简单,但是它对于理解ARouter、WMRouter等路由框架的原理十分有用。它们在处理路由表的注册时,都是采用编译期字节码注入的方式,只不过它们没有使用javassit,而是使用了效率更高的ASM。它们用起来更方便是因为,它们利用APT技术把路径和Activity之间的映射变透明了。即:类似于Demo中的ARouterImpl这种代码,都是通过APT生成的。