Android动态编译技术:Plugin Transform Javassist

  • 内容
  • 评论
  • 相关

1. 前言

动态编译技术在开源框架中的应用非常的广泛,现在市面上的插件化框架,热修复框架几乎都使用了动态编译技术,原理几乎都是在编译期间动态的在class文件中注入代码或者或修改。那就让我们来了解一下这高大上的技术吧。

2. 揭开动态编译的神秘面纱

揭开动态编译的神秘面纱只需三步:
  1. 自定义Gradle插件
  2. 介绍Transform API 及 实现步骤
  3. 实现在编译的过程中操作.class文件,对原有代码或者说逻辑进 行一些处理,修改原class代码和动态生成java类

3. 案例demo

插件目录结构:

3.1 自定义Gradle插件

在Gradle中创建自定义插件,Gradle提供了三种方式:
  1. 在build.gradle脚本中直接使用
  2. 在buildSrc中使用
  3. 在独立Module中使用
具体实现请移步:实现Gradle自定义插件

3.2 利用Google提供的Transform API 在编译的过程中操作.class文件

gradle从1.5开始,gradle插件包含了一个叫Transform的API,这个API允许第三方插件在class文件转为为dex文件前操作编译好的class文件,这个API的目标是简化自定义类操作,而不必处理Task,并且在操作上提供更大的灵活性。并且可以更加灵活地进行操作。
官方文档:http://google.github.io/android-gradle-dsl/javadoc/
1) 在我们自定义的gradle插件的build.gradle中引入transform的包,下面会进行代码注入,就一起引入的其他包
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
apply plugin: 'groovy'
 
dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    //build tools
    implementation 'com.android.tools.build:gradle:3.1.3'
    //gradle sdk
    implementation gradleApi()
    //groovy sdk
    implementation localGroovy()
    //transform API 已经移到 gradle-api里面
    //implementation 'com.android.tools.build:transform-api:2.0.0-deprecated-use-gradle-api'
    //javassist
    implementation 'org.javassist:javassist:3.23.1-GA'
    //commons-io
    implementation 'commons-io:commons-io:2.6'
}
 
repositories {
    google()
    jcenter()
    mavenCentral()
}
2) 创建一个类继承Transform 并实现其方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
package com.aoaoyi.plugin.gradle;
 
import com.android.build.api.transform.*;
import com.android.build.gradle.internal.pipeline.TransformManager;
 
import org.gradle.api.Project;
 
import java.io.IOException;
import java.util.Set;
 
/**
 * Created by yuzhenbei on 2018/7/15 08:54
 * <p>
 * Email aoaoyi.com@gmail.com
 */
public class MyTransform extends Transform {
 
    private Project mProject;
 
    public MyTransform(Project p) {
        this.mProject = p;
    }
 
    /**
     * transform的名称
     * transformClassesWithMyClassTransformForDebug 运行时的名字
     * transformClassesWith + getName() + For + Debug或Release
     *
     * @return String
     */
    @Override
    public String getName() {
        return "MyTransform";
    }
 
    /**
     * 需要处理的数据类型,有两种枚举类型
     * CLASSES和RESOURCES,CLASSES代表处理的java的class文件,RESOURCES代表要处理java的资源
     *
     * @return
     */
    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }
 
    /**
     * 指Transform要操作内容的范围,官方文档Scope有7种类型:
     * EXTERNAL_LIBRARIES        只有外部库
     * PROJECT                   只有项目内容
     * PROJECT_LOCAL_DEPS        只有项目的本地依赖(本地jar)
     * PROVIDED_ONLY             只提供本地或远程依赖项
     * SUB_PROJECTS              只有子项目。
     * SUB_PROJECTS_LOCAL_DEPS   只有子项目的本地依赖项(本地jar)。
     * TESTED_CODE               由当前变量(包括依赖项)测试的代码
     *
     * Returns the scope(s) of the Transform. This indicates which scopes the transform consumes.
     */
    @Override
    public Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT;
    }
 
    /**
     * 指明当前Transform是否支持增量编译
     * If it does, then the TransformInput may contain a list of changed/removed/added files, unless
     * something else triggers a non incremental run.
     */
    @Override
    public boolean isIncremental() {
        return false;
    }
 
    /**
     * Transform中的核心方法
     * transformInvocation.getInputs() 中是传过来的输入流,其中有两种格式,一种是jar包格式一种是目录格式。
     * transformInvocation.getOutputProvider() 获取到输出目录,最后将修改的文件复制到输出目录,这一步必须做不然编译会报错
     *
     * @param transformInvocation
     * @throws TransformException
     * @throws InterruptedException
     * @throws IOException
     */
    @Override
    public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation);
 
    }
}
3) 在我们自定义的gradle插件的apply方法中注册自定义的Transform
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.aoaoyi.plugin.gradle
 
import org.gradle.api.Plugin
import org.gradle.api.Project
import com.android.build.gradle.AppExtension
import com.android.build.gradle.AppPlugin
 
class MyPlugin implements Plugin<Project> {
 
    void apply(Project project) {
 
        project.extensions.create('pluginSrc', MyExtension)
 
        project.task('testPlugin').doLast {
            println project.pluginSrc.message
        }
 
        //AppExtension就是build.gradle中android{...}这一块
        def android = project.extensions.getByType(AppExtension)
        //注册一个Transform
        //def transform = new MyTransform(project)
        android.registerTransform(new MyTransform(project))
    }
 
}

3.3 使用Javassist实现在编译的过程中操作.class文件,动态注入代码Javassist是一个可以用来分析、编辑和创建Java字节码的开源类库

我们知道studio会给我们创建一个BuildConfig的类,但是是否知道这个类是怎么生成的呢?下面我们来模拟一下 在app下的build.gradle下可以创建参数列表,然后将参数生成一个java类,在代码中就可以使用了
1
2
3
4
/* 在app.gradle里和android{……},dependencies{……}同一级*/
testCreateJavaConfig{
    str = "动态生成java类的字符串"
}
然后回到我们的自定义的Plugin中实现代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
package com.aoaoyi.plugin.gradle
 
import org.gradle.api.Plugin
import org.gradle.api.Project
import com.android.build.gradle.AppExtension
import com.android.build.gradle.AppPlugin
 
class MyPlugin implements Plugin {
 
    void apply(Project project) {
 
        project.extensions.create('pluginSrc', MyExtension)
 
        project.task('testPlugin').doLast {
            println project.pluginSrc.message
        }
 
        //AppExtension就是build.gradle中android{...}这一块
        def android = project.extensions.getByType(AppExtension)
        //注册一个Transform
        //def transform = new MyTransform(project)
        android.registerTransform(new MyTransform(project))
 
        /**
         * 创建一个Extension,名字叫做testCreateJavaConfig
         * 
         * MyTestClassExtension.groovy
         * 
         * class MyTestClassExtension {
         *       def str = "默认值"
         * }
         */
        project.extensions.create("testCreateJavaConfig", MyTestClassExtension)
 
        //生成一个类
        if (project.plugins.hasPlugin(AppPlugin)) {
            //获取到Extension,Extension就是 build.gradle中的{}闭包
            android.applicationVariants.all { variant ->
                //获取到scope,作用域
                def variantData = variant.variantData
                def scope = variantData.scope
 
                //拿到build.gradle中创建的Extension的值
                def config = project.extensions.getByName("testCreateJavaConfig")
 
                //创建一个task
                def createTaskName = scope.getTaskName("testTask", "myTestPlugin")
                def createTask = project.task(createTaskName)
                //设置task要执行的任务
                createTask.doLast {
                    //生成java类
                    createJavaTest(variant, config)
                }
                //设置task依赖于生成BuildConfig的task,然后在生成BuildConfig后生成我们的类
                String generateBuildConfigTaskName = variant.getVariantData().getScope().getGenerateBuildConfigTask().name
                def generateBuildConfigTask = project.tasks.getByName(generateBuildConfigTaskName)
                if (generateBuildConfigTask) {
                    createTask.dependsOn generateBuildConfigTask
                    generateBuildConfigTask.finalizedBy createTask
                }
            }
 
        }
    }
 
    static void createJavaTest(variant, config) {
        //要生成的内容
        def content = """package com.aoaoyi.hotfix.plugin;
 
/**
 * Created by yuzhenbei on 2018/7/15 22:55
 
 * Email aoaoyi.com@gmail.com
 */
 
public class MyPluginTestClass {
    public static final String str = "${config.str}";
}
"""
 
        //获取到BuildConfig类的路径
        File outputDir = variant.getVariantData().getScope().getBuildConfigSourceOutputDir()
 
        def javaFile = new File(outputDir, "MyPluginTestClass.java")
 
        javaFile.write(content, 'UTF-8')
    }
 
 
}
执行Build APK(s)或者执行run,看一下结果
生成MyPluginTestClass 代码内容如下:

3.4 利用Transform在MainActivity中动态的插入代码

1)下面是MainActivity的代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.aoaoyi.hotfix.ui
 
import android.support.v7.app.AppCompatActivity
import android.os.Bundle
import com.aoaoyi.hotfix.R
import com.aoaoyi.hotfix.jni.Jni
import kotlinx.android.synthetic.main.activity_main.*
 
class MainActivity : AppCompatActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
 
        // Example of a call to a native method
        sample_text.text =  Jni().stringFromJNI()
 
        txtHotFixed.text = "Hi fixed!"
 
    }
 
 
}
2)利用Transform插入代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/**
 * Transform中的核心方法
 * transformInvocation.getInputs() 中是传过来的输入流,其中有两种格式,一种是jar包格式一种是目录格式。
 * transformInvocation.getOutputProvider() 获取到输出目录,最后将修改的文件复制到输出目录,这一步必须做不然编译会报错
 *
 * @param transformInvocation
 * @throws TransformException
 * @throws InterruptedException
 * @throws IOException
 */
@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
	super.transform(transformInvocation)
	Context context = transformInvocation.getContext()
	Collection inputs = transformInvocation.getInputs()
	Collection referencedInputs = transformInvocation.getReferencedInputs()
	TransformOutputProvider outputProvider = transformInvocation.getOutputProvider()
	boolean isIncremental = transformInvocation.isIncremental()
 
	outputProvider.deleteAll()
 
	inputs.each {
		TransformInput input ->
			input.directoryInputs.each {
				DirectoryInput directoryInput ->
					//注入代码
					MyInjects.inject(directoryInput.file.absolutePath, mProject)
					// 获取output目录
					def dest = outputProvider.getContentLocation(directoryInput.name,
							directoryInput.contentTypes, directoryInput.scopes,
							Format.DIRECTORY)
 
					println("" + directoryInput.file + " transform" + dest);
					// 将input的目录复制到output指定目录
					FileUtils.copyDirectory(directoryInput.file, dest)
			}
 
			input.jarInputs.each {
				JarInput jarInput ->
					//jar文件一般是第三方依赖库jar文件
 
					// 重命名输出文件(同目录copyFile会冲突)
					def jarName = jarInput.name
					def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
					if (jarName.endsWith(".jar")) {
						jarName = jarName.substring(0, jarName.length() - 4)
					}
					//生成输出路径
					def dest = outputProvider.getContentLocation(jarName + md5Name,
							jarInput.contentTypes, jarInput.scopes, Format.JAR)
 
					println("jar " + jarInput.file + " transform " + dest)
					//将输入内容复制到输出
					FileUtils.copyFile(jarInput.file, dest)
			}
 
	}
}
3)利用javassist实现代码的注入,创建MyInjects.groovy类,在这个类中我们传入了两个参数,一个是当前变量的文件夹,一个是当前的工程对象,来看一下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.aoaoyi.plugin.gradle
 
import javassist.ClassPool
import javassist.CtClass
import javassist.CtMethod
import org.gradle.api.Project
 
class MyInjects {
    //初始化类池
    private final static ClassPool pool = ClassPool.getDefault()
 
    static void inject(String path,Project project) {
        //将当前路径加入类池,不然找不到这个类
        pool.appendClassPath(path)
        //project.android.bootClasspath 加入android.jar,不然找不到android相关的所有类
        pool.appendClassPath(project.android.bootClasspath[0].toString())
        //引入android.os.Bundle包,因为onCreate方法参数有Bundle
        pool.importPackage("android.os.Bundle")
 
        File dir = new File(path)
        if (dir.isDirectory()) {
            //遍历文件夹
            dir.eachFileRecurse { File file ->
                String filePath = file.absolutePath
                println("filePath = " + filePath)
                if ("MainActivity.class".equals(file.getName())) {
 
                    //获取MainActivity.class
                    CtClass ctClass = pool.getCtClass("com.aoaoyi.hotfix.ui.MainActivity")
                    println("ctClass = " + ctClass)
                    //解冻
                    if (ctClass.isFrozen())
                        ctClass.defrost()
 
                    //获取到OnCreate方法
                    CtMethod ctMethod = ctClass.getDeclaredMethod("onCreate")
 
                    println("方法名 = " + ctMethod)
 
                    String insetBeforeStr = """ android.widget.Toast.makeText(this,"我是被插入的Toast代码~!!",android.widget.Toast.LENGTH_SHORT).show();
                                                """
                    //在方法开头插入代码
                    ctMethod.insertBefore(insetBeforeStr)
                    ctClass.writeFile(path)
                    ctClass.detach()//释放
                }
            }
        }
 
    }
}
4)运行项目就可以出现Toast,这里的MainActivity是使用Kotlin编写的,我暂时没有找到编译后的class文件,不过这里我反编译生成的APK,使用java2samli插件查看MainActivity,找到onCreate方法可以看到动态插入的Toast代码:
可以看出来,代码已经成功注入了。

4. 总结

本篇是让我们了解plugin、transform和javassist结合使用入门,很多插件化等技术都会用到javassist,需要我们更多的深入了解和探索,无论是自定义gradle还是注入代码这些技术都是通往大牛之路的必备技能,有描述错误的地方欢迎童鞋们指出。
相关:
3. Javassist http://www.javassist.org/