一、前言
上一期在AOP概念以及常见手段(一)中我们明确了下AOP的概念,并学习了APT、Transform的基本使用和原理,下面再看下编译器预处理的这两种方式:
- Transform + Javassit
- APT + AST
二、使用Javassist实现Transform
上一期介绍Transform时只了解了ASM方式,ASM功能强大,可以实现各种需求,不过它要求我们对字节码的规范有一定程度的了解,学习成本较高,这里我们再了解一下Javassist。
简单介绍
Javassist(java assist)也是一种修改字节码的工具,它不仅可以像ASM一样基于字节码修改,也可以在源代码级面进行修改,也就是说可以像平时写代码一样去修改原java类,而不需要熟悉字节码规范,这样就大大减少了我们的学习成本。
示例Demo
1. 注入ClassPath
Javassist允许我们绕开字节码直接修改源码,那它就必须读取原来的Class类文件,这样它才可以生成出最终的字节码。
ClassPool用于存储需要处理的类文件地址或ClassLoader,我们需要在transform处理每一个input时将路径注入到ClassPool中,否则修改时很可能会报错 NotFountException。
abstract class BaseTransform(private val extension: BaseExtension) : Transform() {
protected val classPool: ClassPool = ClassPool.getDefault().apply {
// 重点!! 注入android.jar
appendClassPath(extension.bootClasspath[0].absolutePath)
}
@Throws(TransformException::class, InterruptedException::class, IOException::class)
final override fun transform(transformInvocation: TransformInvocation) {
...
transformInvocation.inputs.forEach { input ->
input.jarInputs.forEach { jarInput ->
// 注入jar地址
classPool.appendClassPath(jarInput.file.absolutePath)
...
}
input.directoryInputs.forEach { directoryInput ->
// 注入directory地址
classPool.appendClassPath(directoryInput.file.absolutePath)
...
}
}
}
}
复制代码
2. 处理单个类
Transform中找到单个需要处理的类,然后通过className找到对应类进行修改
/**
* 处理完成后还是通过ByteArray写回transform的输出路径
*/
override fun handleFileBytes(className: String): ByteArray {
val targetClass = classPool.get(className)
handleClass(targetClass)
return targetClass.toBytecode()
}
override fun handleClass(targetClass: CtClass) {
// 是否是Activity的子类
if (!targetClass.subclassOf(classPool["android.app.Activity"])) {
return
}
val onCreateMethods = targetClass.getDeclaredMethods("onCreate")
for (onCreateMethod in onCreateMethods) {
// 判断是否是onCreate生命周期方法
val params = onCreateMethod.parameterTypes
if (params.size != 1 || params[0] != classPool["android.os.Bundle"]) {
continue
}
// 真正的处理
try {
case2(targetClass, onCreateMethod)
} catch (e: CannotCompileException) {
println("$name.handleClass CannotCompileException: $onCreateMethod")
}
}
}
/**
* 插入到方法后面
* 注意
* 1. 不管当前类是否是kotlin,只能插入java代码
* 2. 必须是可用的表达式, 不能是编译不过的代码, 比如单个括号
*/
private fun case1(onCreateMethod: CtMethod) {
classPool.importPackage("android.widget.Toast")
onCreateMethod.insertAfter(
"""
showJavassistToast();
Toast.makeText(this, JAVASSIST_SINGE_MSG, Toast.LENGTH_LONG).show();
"""
)
}
/**
* 整体catch
* 注意:addCatch 必须在最后补充return, 否则注入报错 no basic block;
*/
private fun case2(targetClass: CtClass, onCreateMethod: CtMethod) {
classPool.importPackage("android.util.Log")
onCreateMethod.addCatch(
"""
Log.e("${targetClass.name}", "空指针了我擦:\n" + Log.getStackTraceString(${'$'}e));
return;
""", classPool.get("java.lang.NullPointerException")
)
}
复制代码
以上代码实现了对所有Activity的onCreate方法添加try-catch。
可以用来做哪些处理
按之前的了解,Javassist存在一定的能力限制,所以我们更偏向ASM。不过通过对Javassist-API的简单梳理,发现它基本上也可以实现所有的需求:
- 修改语句:CtMethod.instrument(CodeConverter()),CtMethod.instrument(ExprEditor)
- 插入语句:CtMethod.insertBefore, CtMethod.insertAfter, CtMethod.insertAt
- 整体替换:CtMethod.setBody
- 整体catch:CtMethod.addCatch
- 修改修饰符:CtMethod/CtField/CtConstructor.setModifiers
- …
更详细的API请见官方文档:github.com/jboss-javas…
还能运行时修改?
在看文档的时候发现了彩蛋,javassist竟然还可以实现运行时动态修改代码,而且有两种方式:
-
CtClass
同时提供了Class<?> toClass(ClassLoader loader)
,这样我们可以直接写业务代码时修改任意代码,然后通过toClass构建出修改后对象。不过注意,这里不是AOP类型的对原类文件做修改,而是随用随构建,只对当前有效。 -
动态代理 ProxyFactory,突破JDK提供的
Proxy和InvocationHandler
,可以处理动态代理类。但测试发现白高兴一场?,对于android如果使用ProxyFactory的话会崩溃,原因在于Android修改了JDK代码,SecurityManager.getClassContext
必定返回null导致空指针。val proxyFactory = ProxyFactory().apply { superclass = ProxyTest::class.java setFilter { it.name == "test" } } val proxyClass = proxyFactory.createClass() // 空指针崩溃 (proxyClass as Proxy).setHandler { self, thisMethod, _, args -> val result = thisMethod.invoke(self, args) as String return@setHandler result + result } val proxyTest = proxyClass.newInstance() as ProxyTest 复制代码
Javassist处理原理
总结一下
Javassist与ASM相比学习成本比较低,而且基本可以满足所有的需求,后续有Transform可以考虑优先采用Javassist。
另外Javassist可以运行时动态修改类文件,修改三方库bug时也可以考虑使用Javassist处理。
三、抽象语法树AST
AST(Abstract syntax tree)的核心就是把Code转化为一种描述(对象、方法、运算符、流程控制语句、声明/赋值、内部类等),让我们可以在运行时比较方便的查看、修改Code,其中转化过程可以认为一种运行时编译。
作用方式
身边的应用
IDE里面的Lombok插件、语法高亮、格式化代码、自动补全、代码混淆压缩都是利用了AST。
线上体验AST:astexplorer.net/
利用AST扩展APT
按照我们之前的了解,注解处理器APT只能用来生成代码,无法修改代码,但有了AST就不一样了。
IDE里面的Lombok插件的原理就是利用APT动态修改AST,给现有类增加了新的逻辑。
原理我们现在都明白了,下面直接写个Hello World试试水。
示例Demo
-
javasdk/Contents/Home/lib/tools.jar
中提供了AST相关的API,我们需要引用这个库 -
生成AST
class ASTProcessor : AbstractProcessor() { // 生成的AST private lateinit var trees: Trees // 用于生成新代码 private lateinit var treeMaker: TreeMaker // 用于构建命名 private lateinit var names: Names override fun init(processingEnv: ProcessingEnvironment?) { super.init(processingEnv) if (processingEnv is JavacProcessingEnvironment) { trees = Trees.instance(processingEnv) treeMaker = TreeMaker.instance(processingEnv.context) names = Names.instance(processingEnv.context) } } } 复制代码
-
修改AST
override fun process(typeElementSet: MutableSet, roundEnvironment: RoundEnvironment): Boolean { for (typeElement in typeElementSet) { val elements = roundEnvironment.getElementsAnnotatedWith(typeElement) for (element in elements) { // 找到Element对应的子树 val jcTree = trees.getTree(element) as JCTree jcTree.accept(myVisitor) } } return false } private val myVisitor = object : TreeTranslator() { /** * 访问者模式里的类定义visit */ override fun visitClassDef(tree: JCClassDecl) { super.visitClassDef(tree) // defs指定义的内容,包含方法、参数、内部类等 for (jcTree in tree.defs) { // 如果是参数 if (jcTree is JCVariableDecl) { tree.defs.append(makeGetterMethod(jcTree)) } } } } /** * 构建get方法 */ private fun makeGetterMethod(variable: JCVariableDecl): JCMethodDecl? { // this val ident = treeMaker.Ident(names.fromString("this")) // this.xx val select = treeMaker.Select(ident, variable.name) // return this.xxx val jcStatement: JCStatement = treeMaker.Return(select) // 把整个表达式塞到代码块里 val jcBlock = treeMaker.Block(0, List.nil<JCStatement?>().append(jcStatement)) return treeMaker.MethodDef( treeMaker.Modifiers(Flags.PUBLIC.toLong()), //public getterMethodName(variable), // getXxx variable.vartype, // return 类型 List.nil<JCTypeParameter>(), // 泛型参数列表 List.nil<JCVariableDecl>(), // 参数列表 List.nil<JCExpression>(), // 异常抛出列表 jcBlock, // 代码块 null ) } 复制代码
效果图
AST+APT实现AOP的限制
经过测试发现,AST操作只可以应用在annotationProcessor,kapt不支持:修改AST后不能生效。
所以在当前我们大规模使用kotlin的现状下,使用AST做AOP的处理还是不可行的。
思考:虽然AST当前不能结合APT做AOP的处理,不过这种修改语法树的方式提供了新的思路,也许可以在其他领域应用。