上次写过自定义gradle插件入门的博客, 介绍了最基本的gradle构建和简单的自定义gradle, 运用生命周期函数、task依赖和扩展配置等。在上篇博客的基础上, 这篇博客继续研究下自定义gradle插件。
动态编译技术在开源框架中的应用非常的广泛,现在市面上的插件化框架,热修复框架几乎都使用了动态编译技术,原理几乎都是在编译期间动态的在class文件中注入或者修改代码。
AOP技术应用
1、日志记录:业务埋点
2、持久化
3、性能监控:性能日志
4、数据校验:方法的参数校验
5、缓存:内存缓存和持久缓存
6、权限检查:业务权限(如登陆,或用户等级)、系统权限(如拍照定位)
7、异常处理
业内实现AOP的技术方案有APT, AspectJ, Javassist/Asm, 他们具体作用时机可以看下图描述
APT在compile任务前,修改java文件;
AspectJ在java --> class阶段,修改java代码;
Javassist和ASM 在.class --> .dex阶段,都是修改的.class
APT Processor -> process {....}
AspectJ javaCompile.doLast { ... }
Javassist和asm MyTransform -> transform { ... }
Android官方从gradle 1.5版本开始,Android官方提供了Gradle Transform技术用于在项目构建阶段,即由class到dex转换期间修改class文件的一套api,借用这套api,开发者可以很容易的完成字节码插桩、代码注入技术等注入技术。
官方文档:http://google.github.io/android-gradle-dsl/javadoc/, 今天主要介绍基于javassist的插入和修改class字节码。
Gradle, Transfrom, Task, Plugin
看过我上一篇博文推荐的文章就知道,Gradle是通过一个个Task执行完成整个流程的,其中肯定也有将所有class打包成dex的task。
(在gradle plugin 1.5 以上和以下版本有些不同)
1.5以下,preDex这个task会将依赖的module编译后的class打包成jar,然后dex这个task则会将所有class打包成dex
1.5以上,preDex和Dex这两个task已经消失,取而代之的是TransfromClassesWithDexForDebug
Plugin
Gradle中除了Task这个重要的api,还有一个就是Plugin。
Plugin的作用是什么呢,这一两句话比较难以说明。
Gralde只能算是一个构建框架,里面的那么多Task是怎么来的呢,谁定义的呢?
是Plugin,细心的网友会发现,在module下的build.gradle文件中的第一行,往往会有apply plugin : 'com.android.application'亦或者apply plugin : 'com.android.library'。
com.android.application:这是app module下Build.gradle的
com.android.library:这是app依赖的module中的Builde.gradle的
就是这些Plugin为项目构建提供了Task,使用不同的plugin,module的功能也就不一样。
可以简单的理解为: Gradle只是一个框架,真正起作用的是plugin。而plugin的主要作用是往Gradle脚本中添加Task。
当然,实际上这些是很复杂的东西,plugin还有其他作用这里用不上。
要 hook 字节码文件,我们这边需要考虑以下几个事情。
字节码文件怎么获取到?
引入Transform
Transfrom是Gradle 1.5以上新出的一个api,其实它也是Task,不过定义方式和Task有点区别。 对于热补丁来说,Transfrom反而比原先的Task更好用。
在Transfrom这个api出来之前,想要在项目被打包成dex之前对class进行操作,必须自定义一个Task,然后插入到predex或者dex之前,在自定义的Task中可以使用javassist或者asm对class进行操作。
而Transform则更为方便,Transfrom会有他自己的执行时机,不需要我们插入到某个Task前面。Tranfrom一经注册便会自动添加到Task执行序列中,并且正好是项目被打包成dex之前。
而本文就是使用Gradle1.5以上版本,下面则是Google对Transfrom的描述文档。 http://tools.android.com/tech-docs/new-build-system/transform-api 有时候会访问不了,你可能需要一把梯子……
Task的inputs和outputs
Gradle可以看做是一个脚本,包含一系列的Task,依次执行这些task后,项目就打包成功了。 而Task有一个重要的概念,那就是inputs和outputs。 Task通过inputs拿到一些东西,处理完毕之后就输出outputs,而下一个Task的inputs则是上一个Task的outputs。
例如:一个Task的作用是将java编译成class字节码,这个Task的inputs就是java文件的保存目录,outputs就是编译后的class的输出目录,它的下一个Task的inputs就会是编译后的class的输出目录(outputs)了。
Transform的原理与应用
介绍如何应用Transform之前,我们先介绍Transform的原理
每个Transform其实都是一个gradle task,Android编译器中的TaskManager将每个Transform串连起来,第一个Transform接收来自javac编译的结果,以及已经拉取到在本地的第三方依赖(jar, aar),还有resource资源,注意这里的resource并非android项目中的res资源,而是asset目录下的资源。这些编译的中间产物,在Transform组成的链条上流动,每个Transform节点可以对class进行处理再传递给下一个Transform。我们常见的混淆,Desugar等逻辑,它们的实现如今都是封装在一个个Transform中,而我们自定义的Transform,会插入到这个Transform链条的最前面。
但其实,上面这幅图,只是展示Transform的其中一种情况。而Transform其实可以有两种输入,一种是消费型的,当前Transform需要将消费型型输出给下一个Transform,另一种是引用型的,当前Transform可以读取这些输入,而不需要输出给下一个Transform,比如Instant Run就是通过这种方式,检查两次编译之间的diff的。至于怎么在一个Transform中声明两种输入,以及怎么处理两种输入,后面将有示例代码。
如何改变字节码文件?
这边引入了一个第三方库 javassist 去改变字节码文件。
我们写一个简单的自定义Transform,让我们对Transform可以有一个更具体的认识:
class OkHttpTransform extends Transform {
Project mProject
OkHttpTransform(Project project) {
mProject = project
InjectorUtils.getInstance().init(project)
}
@Override
String getName() {
return "OkHttpTransform"
}
/**
* Returns the type(s) of data that is consumed by the Transform. This may be more than one type.
* This must be of type {@link QualifiedContent.DefaultContentType}
*/
@Override
Set getInputTypes() {
return TransformManager.CONTENT_CLASS
}
/**
* Returns the scope(s) of the Transform. This indicates which scopes the transform consumes.
*/
@Override
Set getScopes() {
return TransformManager.SCOPE_FULL_PROJECT
}
/**
* Returns whether the Transform can perform incremental work.
* If it does, then the TransformInput may contain a list of changed/removed/added files, unless
* something else triggers a non incremental run.
*/
@Override
boolean isIncremental() {
return false
}
void transform(TransformInvocation transformInvocation)
throws TransformException, InterruptedException, IOException {
// Just delegate to old method, for code that uses the old API.
// noinspection deprecation
def inputs = transformInvocation.getInputs()
inputs.each { TransformInput input ->
input.directoryInputs.each { DirectoryInput directoryInput->
// TODO 这里可以对input的文件做处理,比如代码注入!
// 获取output目录
def outputProvider = transformInvocation.outputProvider
def dest = outputProvider.getContentLocation(directoryInput.name,
directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
CLog.info("directoryInput.file:" + directoryInput.file)
// 将input的目录复制到output指定目录
FileUtils.copyDirectory(directoryInput.file, dest)
}
input.jarInputs.each {JarInput jarInput->
if (jarInput != null) {
// CLog.info("jarInput.file:" + jarInput.file)
// 保存 目标jar和 将被植入的内容jar 到ClassPool,后续操作该class
if (InjectorUtils.instance.needInsertClassPool(jarInput.file)) {
InjectorUtils.instance.insertClassPath(jarInput.file)
}
}
}
// 连续两个遍历不能合并
input.jarInputs.each { JarInput jarInput->
if (jarInput != null) {
// TODO 这里可以对input的文件做处理,比如代码注入!
// 重命名输出文件(同目录copyFile会冲突)
def jarName = jarInput.name
def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
if(jarName.endsWith(".jar")) {
jarName = jarName.substring(0,jarName.length()-4)
}
def outputProvider = transformInvocation.outputProvider
def dest = outputProvider.getContentLocation(jarName + md5Name, jarInput.contentTypes, jarInput.scopes, Format.JAR)
if (dest.parentFile && !dest.parentFile.exists()) {
dest.parentFile.mkdirs()
}
if (!dest.exists()) {
dest.createNewFile()
}
// 对jar文件进行修改
File injectJarFile = InjectorUtils.getInstance().inject(jarInput.file)
File inputFile = injectJarFile != null ? injectJarFile : jarInput.file
FileUtils.copyFile(inputFile, dest)
}
}
}
}
}
大体思路:
1. 遍历所有的jar或者aar, 将要操作的jar文件添加到ClassPool中;
2. 对将待操作的jar解压到unzipDir目录下,操作其字节码进行代码注入,完成后将unzipDir目录下的class重新打成jar包放在outputJar目录下。
class InjectorUtils {
private static InjectorUtils sInstance
private static final String KEY_INJECTOR = "okhttphooker"
private static final String FILE_SUFFIX_JAR = ".jar"
private static final String DEST_CLASS_LJINSPECTIONSIGN = "com/ke/okhttphelper/OkHttpHooker.class"
// OkHttp3
private static final String DEST_CLASS_OKHTTP3 = "okhttp3/OkHttpClient.class"
private static List sValidJarList
private static ClassPool sClassPool
private Project mProject
synchronized File inject(File jar) {
File destFile = null
String destType = getDestJarType(jar)
if (destType == null) {
return destType
}
/** 在主module的injector目录下生成解压注入后的classes文件和jar包 */
def outJarName = jar.name.substring(0, jar.name.length() - FILE_SUFFIX_JAR.length())
def injectorBaseDir = new StringBuilder().append(mProject.buildDir.toString())
.append(File.separator).append(KEY_INJECTOR)
.append(File.separator).append(outJarName).toString()
File rootFile = new File(injectorBaseDir)
rootFile.mkdirs()
FileUtils.clearFile(rootFile)
// 执行注入
destFile = executeInject(destType,jar, rootFile, destFile)
return destFile
}
private String getDestJarType(File jar) {
String destType = null
if (!isValidateJar(jar)) {
return destType
}
// okhttp3
JarFile jarFile = new JarFile(jar)
def entry = jarFile.getJarEntry(DEST_CLASS_OKHTTP3)
if (entry != null) {
destType = DEST_CLASS_OKHTTP3
return destType
}
return destType
}
private File executeInject(String destType, File jar, File rootFile, File destFile) {
/** jar文件: 解压 -- 遍历注入代码 -- 重新打jar包 */
/**
* 将jar解压到unzipDir目录下,操作其字节码进行代码注入,完成后将unzipDir目录下的class打成jar包放在outJarDir目录下
* */
File unzipDir = new File(rootFile, "classes")
File zipJarDir = new File(rootFile, "jar")
JarFile jarFile = new JarFile(jar)
// 找到了目标类
// 1、解压jar
if (!FileUtils.hasFiles(unzipDir)) {
FileUtils.unzipJarFile(jarFile, unzipDir)
}
// 2、开始注入文件,需要注意,insertClassPath后边跟的根目录,没后缀,className后完整类路径,也没后缀
insertClassPath(unzipDir)
// 3、开始注入,去除.class后缀
realInject(destType, unzipDir.absolutePath)
// 4、判断classes文件夹下是否有文件
if (FileUtils.hasFiles(unzipDir)) {
/** 注入字节码后重新打jar包 */
destFile = new File(zipJarDir, jar.name)
FileUtils.clearFile(destFile)
/** 将注入后的class打包成jar包 */
FileUtils.zipJarFile(unzipDir, destFile)
}
FileUtils.clearFile(unzipDir)
jarFile.close()
return destFile
}
private void realInject(String destType, String unzipDirPath) {
if (StringUtils.isEmpty(destType) || StringUtils.isEmpty(unzipDirPath)) {
CLog.info("realInject >> destType: " + destType + "; unzipDirPath: " + unzipDirPath)
return
}
if(DEST_CLASS_OKHTTP3.equals(destType)){
try {
CtClass ctClass = injectOkHttpClientBuilderConstructor()
if (ctClass != null) {
ctClass.writeFile(unzipDirPath)
ctClass.detach()
}
} catch (NotFoundException e) {
CLog.info("inject OkhttpClient\$Builder Constructor failure!!! " + e.toString())
}
try {
CtClass ctClass2 = injectOkHttpRealCall()
if (ctClass2 != null) {
ctClass2.writeFile(unzipDirPath)
ctClass2.detach()
}
} catch (NotFoundException e) {
CLog.info("inject OkHttp Http1Codec readHeaderLine failure!!! " + e.toString())
}
}
}
private CtClass injectOkHttpClientBuilderConstructor() {
CtClass ctBuilderClass = sClassPool.getCtClass("okhttp3.OkHttpClient\$Builder")
if (ctBuilderClass == null) {
CLog.info("not found okhttp3.OkHttpClient\$Builder !!!")
return null
}
CtConstructor ctConstructor = ctBuilderClass.getDeclaredConstructor(null)
if (ctConstructor == null) {
CLog.info("not found okhttp3.OkHttpClient\$Builder Constructor !!!")
return ctBuilderClass
}
String injectValue = "com.ke.okhttphelper.OkHttpHooker.headerInterceptor(this);"
ctConstructor.insertAfter(injectValue)
CLog.info("injected: " + ctBuilderClass.getName() + "; ctConstructor:" + ctConstructor.getName() + " succeed!")
return ctBuilderClass
}
private CtClass injectOkHttpRealCall() {
CtClass ctRealCallClass = sClassPool.getCtClass("okhttp3.RealCall")
if (ctRealCallClass == null) {
CLog.info("not found okhttp3.RealCall !!!")
return null
}
CtMethod cm = ctRealCallClass.getDeclaredMethod("getResponseWithInterceptorChain")
cm.instrument(
new ExprEditor() {
public void edit(MethodCall m) throws CannotCompileException {
// CLog.info(m.className + " , " + m.methodName)
if ("okhttp3.Interceptor\$Chain".equals( m.className) && "proceed".equals(m.methodName)) {
String replaceValue = "\$_ = \$proceed(\$\$);"
replaceValue += "com.ke.okhttphelper.OkHttpHooker.hookResponseWithInterceptor(originalRequest, client.connectTimeoutMillis());"
m.replace(replaceValue)
CLog.info("injected: " + ctRealCallClass.getName() + "; " + cm.getName() + " succeed!")
}
}
})
CtClass[] param = new CtClass[1]
param[0] = sClassPool.get("okhttp3.Callback")
CtMethod cmEnqueue = ctRealCallClass.getDeclaredMethod("enqueue", param)
cmEnqueue.instrument(
new ExprEditor() {
public void edit(MethodCall m) throws CannotCompileException {
// CLog.info(m.className + " , " + m.methodName)
if ("okhttp3.Dispatcher".equals( m.className) && "enqueue".equals(m.methodName)) {
String replaceValue = "\$proceed(\$\$);"
replaceValue += "com.ke.okhttphelper.OkHttpHooker.hookEnqueue(originalRequest, client.readTimeoutMillis());"
m.replace(replaceValue)
CLog.info("injected: " + ctRealCallClass.getName() + "; " + cm.getName() + " succeed!")
}
}
})
return ctRealCallClass
}
void insertClassPath(File path) {
if (null != path) {
CLog.info("insertClassPath: " + path.getAbsolutePath())
if (path.directory) {
sClassPool.appendPathList(path.absolutePath)
} else {
sClassPool.appendClassPath(path.absolutePath)
}
}
}
void insertClassPath(File path) {
if (null != path) {
CLog.info("insertClassPath: " + path.getAbsolutePath())
if (path.directory) {
sClassPool.appendPathList(path.absolutePath)
} else {
sClassPool.appendClassPath(path.absolutePath)
}
}
}
boolean needInsertClassPool(File jar) {
boolean validate = false
if (!isValidateJar(jar)) {
return validate
}
JarFile jarFile = new JarFile(jar)
for (String key : sValidJarList) {
def entry = jarFile.getJarEntry(key)
if (entry != null) {
validate = true
break
}
}
if (validate) {
CLog.info("validateJar: " + jar.name)
}
return validate
}
private boolean isValidateJar(File jar) {
boolean validate = false
if (null == jar || !jar.exists()) {
return validate
}
/** jar文件是否合法 */
try {
ZipFile zipFile = new ZipFile(jar)
zipFile.close()
} catch (Exception e) {
return validate
}
return true
}
}
库单独构建后会在build/outputs/aar目录下生成xxx.aar产物, jar包产物的位置在 build/intermediates/intermediate-jars/目录下。
补充2个 Javassist 知识点:
如何修改方法体?
1.获得一个CtMethod实例,即class中的一个方法。
2.调用CtMethod实例的instrument(ExprEditor editor)
方法,并传递一个ExprEditor
实例(A translator of method bodies.)
3.在ExprEditor实例中覆盖edit(MethodCall m)
方法,这里可以调用MethodCall的replace()
方法来更改方法体内的代码。
修改方法体的原理?
调用CtMethod的instrument()
,方法体会被逐行进行扫描,从第一行扫描到最后一行。发现有方法调用或表达式时(object creation),edit()
会被调用,根据edit()
内的replace()
方法来修改这一行代码。
ctCls.getDeclaredMethods().each { }
,经过对修改方法体的背景知识的了解,我们再看这段插桩代码实现就能看懂了:
遍历class中声明的全部方法
调用每个方法的instrument方法
扫描方法中的每一行表达式,如果这一行表达式的调用方为此类的super类,那么就分两种情况做处理:
1.返回类型为void时,调用MethodCall的replace方法,替换这一行代码为super.' + call.getMethodName() + '($$);
,其中$$
是所有方法参数的简写,例如:m($$
)等同于m($,$
,...)。
2.返回类型非void时,调用MethodCall的replace方法,替换这一行代码为$_ = super.' + call.getMethodName() + '($$);
,其中特殊变量$
_代表的是方法的返回值。因为方法调用是有返回值的,所以statement必须将返回值赋值给它,这是javassist.expr.MethodCall方法的明确要求。
如果你想在某个方法的某个表达式前后插入方法,则修改的souce如下:
{ before-statements;
$
_ = $proceed($$
);
after-statements; }
$
_ = $proceed($$
); 表示按原样调用
$
_ 代表方法的返回值
$$
代表原始方法的所有入参
Javassist提供了一些特殊的变量来代表特定含义:
注:在不同的 javassist 方法中使用时,这些特殊变量代表的含义可能会略有不同。参见:javassist tutorial
全部的类遍历完后,将ctCls对象写回到class文件中。这样就全部完成了class文件的Activity顶级父类动态注入。
CtClass.detach()
,最后调用detach()方法,把CtClass object 从ClassPool中移除,避免当加载过多的CtClass object的时候,会造成OutOfMemory的异常。因为ClassPool是一个CtClass objects的装载容器。加载CtClass object后,默认是不释放的。
关于Jar包中的class注入:在initClassPool时已经把Jar做了unzip,解压出也是一堆.class文件,其他处理逻辑同上。也就是说,你引用的第三方sdk中的jar,以及你依赖的库中的jar,都会被注入器撸一遍。
Plugin中的注册
boolean hasApp = project.getPlugins().hasPlugin(AppPlugin.class)
if (hasApp) {
def appExtension = project.getExtensions().getByType(AppExtension.class)
appExtension.registerTransform(new OkHttpTransform(project), Collections.EMPTY_LIST)
}