这是我参与8月更文挑战的第8天,活动详情查看:8月更文挑战
APT&JavaPoet实践
APT:
APT全称是Annotation Processing Tool,是Java提供的一种在代码编译时期扫描和处理注解的工具,它会对源代码文件进行扫描检查,找到其中的注解,然后客户端可以自定义注解处理方式,在拿到相关的注解后,根据注解获取一些信息,来实现额外的代码处理。
APT除了可以扫描解析注解,我们还可以在处理注解的过程中间插入根据注解生成新的源代码文件的逻辑,并最终,将新生成的代码文件与原来的代码文件共同编译。
APT的工作流程如下图:在编译期间,APT工具扫描处理注解,可以生成新的Java源文件,新生成的Java源文件,将会与原来的源文件共同进行编译,最终一起得到字节码文件。
APT的工作原理是:在Java代码编译期间,编译器会检查AbstractProcessor的子类,寻找注解处理器,并去调用到子类注解处理器的process方法,将添加了自定义注解的的所有元素(类,接口,成员变量......),都传递到process方法中,使得客户端就可以在process方法中拿到这些元素,根据这些元素去生成对应的代码文件。
包含APT的项目一般分成三个模块工作,纯Java的注解声明库和注解处理库,以及依赖这两个模块的实际业务模块。如下图:
APT的使用非常广泛,在Android这边比较出名的框架比如ButterKnife,EventBus,Dagger2,ARouter这些框架,都用到的APT的技术。
AbstractProcessor:
每一个自定义的注解处理器,都需要去继承自这个抽象基类,并覆写process这个抽象方法。除此之外,一般还会覆写下面这几个方法:
// 这个方法会被自动调用到,传入的processingEnvironment表示的是注解处理器的工作环境
// 通过这个参数,我们可以获取到一些处理过程中非常有用工具
@Override
public synchronized void init(ProcessingEnvironment processingEnvironment) {
super.init(processingEnvironment);
// Filer对象,创建新的文件相关的工具类
mFiler = processingEnv.getFiler();
// Elements对象,用来处理Element的工具
mElementUtils = processingEnv.getElementUtils();
// Messager对象,主要是提供了一些日志输出相关的API
mMessager = processingEnv.getMessager();
// Types对象,用来处理TypeMirror的工具
mTypeUtils = processingEnv.getTypeUtils();
}
// 注解处理器的实际工作逻辑,在这里面添加自定义的扫描,检测,处理,以及生成新java代码的逻辑
// 两个输入参数:
// 第一个参数是声明了需要被处理的注解的set集合
// 第二个参数是:可以通过它获取到被指定注解所注解的元素信息
// 返回值:标明对应注解是否已经被该注解处理器处理完成
// return true,表示这个注解处理器已经处理了对应的注解,后面的其它处理器将不再处理
// return false,表示这个注解处理器没有处理对应的注解,后面的其它处理器可以继续处理
@Override
public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
return true;
}
// 指定哪些注解需要被该注解处理器注册
@Override
public Set<String> getSupportedAnnotationTypes() {
Set<String> types = new HashSet<>();
types.add(Router.class.getCanonicalName()); // 返回注解的完整类名
return types;
}
// 指定你使用的Java版本,一般就是直接使用latestSupported,除非特定必须支持哪一个版本
// 我打印返回的是RELEASE_8
@Override
public SourceVersion getSupportedSourceVersion() {
return SourceVersion.latestSupported();
}
上面提到了Element和TypeMirror。 要理解Element的相关概念,需要基于结构化文件去理解。结构化存储格式的典型例子比如XML,如下:
<employe>
<name>Aaron</name>
<city>London</city>
<age>20</age>
</employe>
对于这一段XML代码,我们可以按照规定的节点,写入和读取雇员的信息,组成一个雇员的基本元素包括name,city,age。上面的文件结构化的存储了雇员的姓名,工作城市,年龄。
对于Java编译器而言,代码中的元素结构其实是固定的,组成代码的基本元素:包,类,函数,字段,变量,代码执行语句等等,Java为这些元素定义了一个基类也就是Element。
一份简单的Java代码可以视为由以下这些基本元素组成:
package me.aaron.apt; // PackageElement
public class Test { // TypeElement
private int i; // VariableElement
private Triangle triangle; // VariableElement
public Test() {} // ExecuteableElement
public void draw( // ExecuteableElement
String s) // VariableElement
{
System.out.println(s);
}
}
-
PackageElement 表示一个包元素。提供对有关包及其成员的信息的访问。
-
ExecutableElement 表示某个类或接口的方法、构造方法或初始化程序(静态或实例),包括注释类型元素。
-
TypeElement 表示一个类或接口元素。提供对有关类型及其成员的信息的访问。注意,枚举类型是一种类(enum编译最终得到的是class产物),而注解类型是一种接口(注解本质就是继承自Annotation的接口)。
-
VariableElement 表示一个字段、enum 常量、方法或构造方法参数、局部变量或异常参数。
-
TypeParameterElement表示类,接口,方法,或者构造方法中的泛型参数。
这些元素类型,提供了对应的方法来获取一些信息。
这里拿最核心的一中元素举例:TypeElement
提供了比如getQualifiedName获取此元素的规范名称(比如me.aarom.apt.Test就是上面Test示例的规范名称),getSuperclass获取此元素类型的直接父类(如果此元素是个接口或者java.lang.Object,那么获取到的就是NoType,表示没有父类型),比如getInterfaces获取此类实现的接口(如果是个接口,则返回他的父接口)。
更多的API可以去看doc文档,介绍它们主要是建立我们可以通过这些元素,获取我们注解处理过程中需要的一些信息的概念。
然后是另一个TypeMirror的概念,我们注意到上面的getInterfaces,getSuperclass,返回的都是TypeMirror相关的对象,这个对象表示的就是Java语言中的类型的概念,包括,基本类型,声明类型(类,接口),数组类型,null类型......
概念介绍了这么多,我们下面通过一个具体的demo来看下。
APT 实践:
纯Java,不涉及Android。
Step1:创建注解module
新建一个router_annotation的Java Library,必须是Java Library。里面放的代码非常简单,就是Annotation注解。
关于注解的相关知识,就不做介绍了。
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.CLASS)
public @interface Router {
String value() default "" ;
}
我定义了一个Router注解,这个注解最终将会注解在Activity上,value表示的是当前注解的Activity在路由表中协议名称。
还有一些build.gradle的配置,AS会自动做好,一般不需要处理。
Step2:创建注解处理器module
新建一个router_apt的注解处理器module,也是纯Java Library。里面核心内容就是我们继承自AbstractProcess的自定义注解处理器。
代码如下:
@AutoService(Processor.class)
public class RouterProcessor extends AbstractProcessor {
@Override
public synchronized void init(ProcessingEnvironment processingEnvironment) {
super.init(processingEnvironment);
// ...
}
@Override
public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
// 会在这里面做代码生成的相关工作。
}
@Override
public Set<String> getSupportedAnnotationTypes() {
Set<String> types = new HashSet<>();
types.add(Router.class.getCanonicalName()); // 返回注解的完整类名
return types;
}
@Override
public SourceVersion getSupportedSourceVersion() {
return SourceVersion.latestSupported();
}
}
注意上面我们给RouterProcessor使用了一个@AutoService的注解,这个注解是帮助我们为当前注解处理器自动生成META-INF的配置信息的注解,需要添加额外依赖才能使用,如果不用@AutoService注解,我们需要自己手动配置META-INF信息,也是我们自定义注解处理器能够被调用到的前提。
META-INF里面存放的是包含我们jar包的一些配置信息,在javac编译的时候,会在jar包的META-INF中查找实现了AbstractProcessor的子类,然后去调用它们。和Android中的Activity需要在Manifest文件中注册才能工作是相似的道理。
最终这个module的build.gradle如下:
apply plugin: 'java-library'
dependencies {
implementation fileTree(dir: 'libs' , include: [ '*.jar' ])
implementation project( ':router_annotation' )
// 辅助我们生成Java代码的框架
implementation 'com.squareup:javapoet:1.12.1'
implementation 'com.google.auto.service:auto-service:1.0-rc2'
}
sourceCompatibility = "7"
targetCompatibility = "7"
一般而言,做完这两步,我们就可以愉快的在processor中处理代码生成的逻辑了,但是有一个很坑的点在于,我自己写demo的时候一直没能成功的生成代码,搜索找到的原因如下:
工程的gradle-wrapper和gradle-plugin需要分别降低到4.10.1,3.2.0
原因:gradle - 3.3 - 3.4 中 javaCompileTask这个任务没有处理 Annotation Processer。也就导致了我们在build的过程中,注解处理器不会得到执行。
JavaPoet:
在注解处理器中,我们可以获取到自定义注解相关的一些元素,这只是第一部分的工作,然后,我们需要通过解析处理这些元素,得到我们想要的信息,并用这些信息,来帮助我们生成Java代码。
生成代码的本质其实就是创建一个*.java文件,并向其中写入代码内容,所以生成代码的方式有很多种。
拿我们的路由框架来说,其实就是将解析得到路由表需要的信息,然后生成一段代码可以将单条的路由信息添加到路由表中,就像这样:
类似这种简单的代码,其实创建一个文件然后写入就行了,但是如果要生成的代码逻辑比较复杂,这个时候需要处理的工作就比较麻烦了,我们可能需要处理的工作是:包名的处理,import依赖关系,类继承关系,接口实现关系,类修饰符,方法名,方法参数,方法返回值,方法修饰符......一大堆工作需要去处理。
所以为了简化这一部分的工作,找到了JavaPoet这个工具。它是一个提供Java Api然后专门用来生成java源文件的开源框架。
那其实下面涉及到的几个概念和Java结构化的概念有点相似。
主要来看下JavaPoet提供的几个关键类对应到Java中的哪些概念:
| JavaFile | 用于构造输出包含一个顶级类的Java文件,是对.java文件的抽象。 |
|---|---|
| TypeSpec | 用于生成类,接口,或者枚举。是对这几个的抽象。 |
| MethodSpec | 用于生成构造函数或方法,是对函数的抽象。 |
| FieldSpec | 用于生成字段,是对字段的抽象。 |
| ParameterSpec | 用于生成方法参数,是对参数的抽象。 |
| AnnotationSpec | 用于创建注解,是对注解的抽象。 |
上面这几个关键类,各自负责生成对应的”对象“,并在最终生成Java代码的时候,转换成相应的代码。同时为了辅助生成代码的代码编写(因为最终写入文件的都是字符,JavaPoet需要知道你在写入的比如是一个叫localVariable的成员变量,还是”localVariable“这一个字符串),JavaPoet提供了下面几个占位符:
| $L | 字面量替换(直接替换) |
|---|---|
| $S | 字符串替换 |
| $T | 类型替换 |
| $N | 名称替换 |
啥意思呢?
// 比如下面就是定义了一个method方法
MethodSpec method = MethodSpec.methodBuilder("method")
.addStatement("$T file;", File.class) // File file;
.addStatement("$L = null;", "file") // file = null;
// file = new File("~/fileName");
.addStatement("file = new File($S);", "~/fileName")
// return file.getName();
.addStatement("return $L.getName();","file")
.returns(String.class)
.build();
// 上面定义了一个mothod方法,如果后面生成的代码需要调用到这个方法,可以用名称替换:
// 比如我们新定义一个invokeMethod方法:
MethodSpec invokeMethod = MethodSpec.methodBuilder("invokeMethod")
.addStatement("$N();",method) // method();
.returns(void.class)
.build();
拿上面的mapping代码举例:RouterProcessor的核心代码:
上面的代码只是最简单的将Activity路由表的映射建立起来,离一个完整的路由框架需要的注解处理器还有很多工作要做,比如支持Service,BroadCastReceiver的映射关系,对一些路由参数可以进行格式上的验证之类的,再比如这个注解处理器模块也可以做一些规划使之更规整(比如对一些常量的统一管理)。
补充,上面我们自己去解析得到模块名,后面发现还可以通过对编译参数进行设置,也可以获取到,在每一个用到路由注解的module的build.gradle脚本中添加编译参数:
javaCompileOptions {
annotationProcessorOptions {
arguments = [moduleName: project.getName()]
}
}
// 然后在注解处理器中就可以获取到了
String moduleName = processingEnv.getOptions().get("moduleName")
总结
通过APT和Java代码的生成,我们完成了路由框架的最基础的工作:路由映射的建立。将模块对应的Uri和实际的Activity的路径的映射,保存在了各个模块的RouterMapping$$XXX这个对象中,后面就是我们通过遍历调用这些生成对象的mapping方法,将这个映射关系输出到Map对象中,这样一张路由表就建立起来了。