关于 Java 的注解预处理的资料实在是过于稀少,连stackoverflow上都没多少人研究,以致于我这个萌新在尝试使用注解预处理来生成代码时踩了不少坑,正好博客也快长草了,遂决定留一篇文章,希望能够对后来者有所帮助。
本文章同时对一般 Java 项目和 Android 项目适用。
为何使用 Java 注解预处理
诚然,用反射处理注解来替代代码的复制粘贴可以让代码更加简洁、易懂(优雅),但是,反射实在是太慢了。
啥?反射不慢?来来来,一个 Activity 就用几十次反射,要不要和复制粘贴做一下对比?(手动阴险)
那反射这么慢,有没有什么办法?当然就是今天的主题了——代码生成: 让编译器来给你“复制粘贴”,既优雅,又高效(反正生成的代码你也不看)。
如何使用 Java 注解预处理
关于注解预处理的基本使用方法的资料还是很多的,这里就不细说了,概括一下就是:
- 建一个类,继承并实现
javax.annotation.processing.AbstractProcessor
- 建一个
META-INF.services.javax.annotation.processing.Processor
- 在这里写上你的预处理器的完整类名(带包名)
注意:对于 Android 项目,你需要单独建立一个 “Java 类” 项目,不可以直接在原 Android 项目中使用 注解预处理,否则你会发现没有 javax 这个包。
然后,在 Android 项目的build.gradle
中的dependencies
添加annotationProcessor project(':项目名')
处理我们的注解
假定我们要处理的注解名为 ViewAutoLoad
,定义为:
@Retention(RetentionPolicy.CLASS) //保留此注解到编译期
@Target(ElementType.FIELD) //此注解只适用于“字段”
public @interface ViewAutoLoad {
}
本文通过介绍对字段注解的处理来讲述如何实现注解预处理,对于方法,用法其实没啥区别。
然后,重写 process
方法:
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
return true;
}
为啥要留个 return true
? true表示这个注解已经被我们处理过了,编译器不用再调用其他注解处理器了。
然后开始写我们的处理代码,这里就有两种处理注解的办法了:
办法1:一次性全局处理注解
这种方法 不能 知道这个字段(方法)到底是哪个类的,自然也不能获取除了你正在处理的字段(方法)所在类的其他信息,但是用起来方便一些。
获取 全局所有 具有此注解的字段,然后用 processAnnotation
方法逐一处理它们:
roundEnv.getElementsAnnotatedWith(ViewAutoLoad.class).forEach(this::processAnnotation);
这里先讲一些常用操作,假定我们现在在实现上文的 processAnnotation
方法,它的方法签名为:
private void processFormNotEmpty(Element annotatedElement)
获取字段的类型
如果你将来要生成代码或者将注解用作编译时检查,十有八九要用到这个字段的类型。
TypeMirror fieldType = annotatedElement.asType();
获取这个字段的注解,或者注解的值
ViewAutoLoad annotation = annotatedElement.getAnnotation(ViewAutoLoad.class);
现在,你可以直接使用你在注解接口定义的方法了,虽然作为示例的 ViewAutoLoad
没定义任何方法。
假装定义了 value()
: annotation.value()
获取这个字段(方法)的名字
我觉得这个肯定会用吧
Name fieldVarName = annotatedElement.getSimpleName();
//string: fieldVarName.toString();
获取这个方法的修饰符
annotatedElement.getModifiers()
返回一个集合,这个集合装着 javax.lang.model.element.Modifier
这个枚举
办法2:逐类处理注解
虽然麻烦了点,但是这个办法让我们可以知道我们在处理哪个类了。
我们回到 process
方法:
Set<? extends Element> rootElements = roundEnv.getRootElements();
这次我们直接拿到所有编译器处理的类的基础信息了,嗯,没有过滤器。
现在我们得手撸过滤器了,既然是 Set,先遍历走起。
然后怎么过滤呢?这里有一些思路:
- 给字段(方法)上注解的时候就指定好这个类的名称,比如
@Example("com.kenvix.test.TestClass")
注意:不要指定成TestClass.class
,在编译期无法这样读取类名,因为类尚未编译。 - 遍历所有类,通过字段(方法)的一些特征查找这个类
第一种思路
第一种可以是十分简单粗暴了。
String targetName = "com.kenvix.test.TestClass";
Element targetClass = null;
for (Element element : rootElements) {
if(element.toString().equals(targetName)) {
targetClass = element;
break;
}
}
//这里只拿到了类,注解处理方法暂时省略,见下文。
第二种思路
显然,第一种实在是不怎么优雅,第二种方法又有这些思路:
- 先通过包名,滤掉所有绝对不相关的东西,比如 Android 项目中,包名符合
android.*
就可以去掉了。或者只看包名以自己项目开头的。 - 字段(方法)的名称有一些我们可以利用的特征,例如都以类名开头
- 一个类中所有字段(方法)的数量和名称是唯一的。也就是说,不存在两个类同时有一样数量的同名字段。
- 找到有目标注解的类后,将这个类和目标注解直接加到 Map
Map<Element, List<Element>> tasks = new HashMap<>();
for (Element classElement : rootElements) {
if(classElement.toString().startsWith(Environment.TargetAppPackage)) {
List<? extends Element> enclosedElements = classElement.getEnclosedElements();
for(Element enclosedElement : enclosedElements) {
List<? extends AnnotationMirror> annotationMirrors = enclosedElement.getAnnotationMirrors();
for (AnnotationMirror annotationMirror : annotationMirrors) {
if(ViewAutoLoad.class.getName().equals(annotationMirror.getAnnotationType().toString())) { //好像没有其他办法在这里判断是否是目标注解了
if(!tasks.containsKey(classElement))
tasks.put(classElement, new LinkedList<>());
tasks.get(classElement).add(enclosedElement);
}
}
}
}
}
这样,这个 Map<> 中就包含了我们需要的类和这个类持有的字段了,接下来进行处理即可
嗯?效率低?这是编译期,加钱换CPU或用第一种,请(手动滑稽)
生成代码
这里需要用到 javapoet 这个依赖,编辑gradle配置,加入依赖:
implementation 'com.squareup:javapoet:1.8.0'
然后重写 init 方法:
protected Types typeUtil;
protected Elements elementUtil;
protected Filer filer;
protected Messager messager;
protected ProcessingEnvironment processingEnv;
@Override
public synchronized final void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
this.processingEnv = processingEnv;
typeUtil = processingEnv.getTypeUtils();
elementUtil = processingEnv.getElementUtils();
filer = processingEnv.getFiler();
messager = processingEnv.getMessager();
onPreprocessorInit();
messager.printMessage(Diagnostic.Kind.NOTE, "Preprocessor: " + this.getClass().getSimpleName() + " Initialized");
}
回到 process 方法,刚才我们已经拿到了要处理的注解,接下来开始处理这些注解:
JavaPoet 资料到处都是啊,要写还不容易?
我咋取一个不可能导入的包的类型?
这问题还是很常见的,比如我们没法在一个 Java 项目中用 Android 包的东西,但是却需要生成相关的代码.
例如,我们需要用到一个类 AppCompatActivity,它在 android.support.v7.app
这个包,则可以这样写:
ClassName appCompatClass = ClassName.get("android.support.v7.app", "AppCompatActivity");
我咋表示类型通配符、泛型限定?
接上,我们还想表示 ? extends AppCompatActivity
,可以这样写:
MethodSpec.Builder builder = code; //这里是你的方法builder
builder.addTypeVariable(TypeVariableName.get("T", appCompatClass)).addParameter(TypeVariableName.get("T"), "target")
保存我们的生成的代码,并在下一步编译生成的代码
回到 process 方法,加上:
if(roundEnv.processingOver()) {
//创建FormChecker这个类
TypeSpec formChecker = TypeSpec.classBuilder("FormChecker")
.addModifiers(Modifier.PUBLIC, Modifier.FINAL)
.addMethods(methods)
.build();
//创建类文件
JavaFile javaFile = JavaFile.builder("com.kenvix.eg.generated", formChecker)
.addFileComment(getFileHeader())
.build();
try {
javaFile.writeTo(filer);
} catch (IOException ex) {
throw new IllegalStateException(ex.toString());
}
}
对同一个 javaFile
, javaFile.writeTo(filer)
只能调用一次,故需要判断是否为最后一轮注解预处理。
其他的可以看看这篇文章,虽然标题挺扯的(够你🐴)
其他小问题
我咋调试啊
显然这个时候按 IDE 的断点按钮是莫得了。
直接 System.out
或 Logger
也不太好,分分钟被一堆垃圾编译消息淹没。用着还麻烦。
好吧,其实有个简单粗暴的方法,抛个运行时异常嘛,这样就能直接停止编译然后让 IDE 显示我们想要的东西了。
throw new IllegalStateException("something");
IDEA 对 addModifiers(), javaFile.writeTo(filer) 报错
IDEA bug
别理他,编译就行了
想用 getClass() 反射处理?
别想了,类都没编译好呢你get个啥