0%

注解处理器APT初探

前言

从早期的 ButterKnife,Dagger2,EventBus 到如今的 ARouter, WMRouter ,SugarAdapter(也许还有其他的,平时关注较多的就是这些),借助注解处理器 APT,各路神仙开发了许多有用的工具、框架,解放了 Android 开发者的双手,让我们少写了很多冗余的代码,也使得组件化这样的开发思路可以大行其道。

试想,如果没有 ButterKnife 这样的神器,那么现在每一个 Android 开发者估计下辈子都会记得 findViewById 这个方法,真是能让人写的吐血的一个方法。(当然,现在上了 Kotlin 的车,这都不是问题了)。

你能想象在 EventBus 尚未诞生的时候,我曾经为了把一个方法的值从某个 RecyclerView 的 Adapter 传递到另一个 Activity 的 Fragment 里写了多少层的回调吗?你能想象一个 Activity(Fragment) 的 onActivityResult 有 100 多行的条件判断(仅仅是返回值按 requestCode 取值,不包括任何业务逻辑),去获取其他页面的各种返回值是多么的让人绝望吗?甚至有些时候还要考虑被启动的 Activity 在特定的启动模式下,onActivityResult 方法回调异常的情况,更是出现了无法实现业务需求的窘境 😢

在组件化开发的早期,类依赖是最最痛苦的问题,两个从业务上已经剥离的组件,却因为要实现页面跳转(简单来说就是依赖 Activity 或 Fragment 类)而不得不产生依赖。路由框架在 Android 中的应用,完美的解决了这个问题。

而这些框架,都用到了注解处理器这项技术,因此这里就来学些一下什么是注解处理器,以及注解处理器可以做什么。

注解处理器 APT 是什么,能做什么 ?

APT 处理哪种类型的注解

注解处理器,顾名思义就是用于处理注解的工具。

理解 Android 中的注解与反射 一文中我们已知,Java 代码在运行时会通过反射的方式处理 注解作用范围@Retention 为 RetentionPolicy.RUNTIME 的注解。而今天的主角 APT 则负责处理注解作用范围 @Retention 为 RetentionPolicy.CLASS 的注解。

APT 的作用

APT(Annotation Processing Tool)即注解处理器,是一种处理注解的工具,确切的说它是 javac 的一个工具,它用来在编译时扫描和处理注解。注解处理器以 Java 代码(或者编译过的字节码)作为输入,生成.java 文件作为输出。简单来说就是在编译期,通过注解生成.java 文件。

注解处理器小试牛刀

注解处理器工程的创建

注解处理器的使用很简单,网上已经有很多的例子和实现,可以参考Android 如何编写基于编译时注解的项目轻松打造一个自己的注解框架 这两篇文章搭建一个合适的注解处理器框架。为了后续描述方便,这里也简单写了一个 Demo ,代码已同步到Github APT-Lite 仓库,可以参考。

一般来说按照上图

  • apt-annotation (java module) 定义所有需要用到的注解(require)
  • apt-processor (java module) 解析注解并生成 java 代码 (require)
  • apt-libray (android module) 定义对外暴露的 API 接口 (option)
  • example (android module) demo (option)

这样定义就足够了。就注解处理器实际要实现的业务功能,apt-library 可能需要定义在其他组件中,甚至某些注解处理要实现的功能,如果和业务有强绑定,这个 api 甚至都可能不存在,example(或 app) 作为验证(演示)注解功能的 module 是可以忽略的,因此注解处理器框架实际上就是一个纯 Java 项目。

关于在工程中这些模块的 build.gradle 中相互依赖关系及依赖哪些库,这里就不展开了,毕竟很简单,具体结合上面的两篇文章可以很轻松的完成配置

小目标(BindView & BindString)

简单起见,我们也从模仿 ButterKnife 开始,简单定义两个注解,BindView 和 BindString ,代码运行后可以实现 变量和 View 的自动绑定。

1
2
3
4
5
6
7
8
9
10
11
12
public class MainActivity extends AppCompatActivity {

@BindView(R.id.content)
@BindString("Hello APT")
TextView mContent;

@BindView(R.id.name)
@BindString("APT Success")
TextView mName;

...else...
}

上述代码,在运行期可以正确的找的 id 为 content 和 name 的 TextView 并分别赋值为 “Hello APT” 和 “APT Success”

apt-annotaion

定义我们需要用到两个注解

  • BindView
1
2
3
4
5
@Retention(RetentionPolicy.CLASS)
@Target(ElementType.FIELD)
public @interface BindView {
int value();
}
  • BindString
1
2
3
4
5
@Retention(RetentionPolicy.CLASS)
@Target(ElementType.FIELD)
public @interface BindString {
String value() default "";
}

apt-processor

apt processor 的实现,是 APT 最核心的内容。但是要做的事情也很简单。就是继承 AbstractProcessor 类,并实现它的四个方法

  • init
  • getSupportedSourceVersion (可以用注解代替)
  • getSupportedAnnotationTypes (可以用注解代替)
  • process

这四个方法的定义及功能,就不展开说了,通过参考资料中的内容,甚至其命名我们就可以知道这几个方法的作用是啥了。
为了方便,我们可以先简单的抽取一层 BaseProcessor。对于一些常用的初始化一些常用的成员变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public abstract class BaseProcessor extends AbstractProcessor {
// 进行日志打印的接口
Messager mMessager;
// Element 元素处理的接口
Elements mElementUtils;
// 帮助文件生成的接口
Filer mFiler;

@Override
public SourceVersion getSupportedSourceVersion() {
return SourceVersion.latestSupported();
}

@Override
public synchronized void init(ProcessingEnvironment processingEnvironment) {
super.init(processingEnvironment);

mFiler = processingEnvironment.getFiler();
mMessager = processingEnvironment.getMessager();
mElementUtils = processingEnvironment.getElementUtils();

mMessager.printMessage(Diagnostic.Kind.NOTE, "init");
}
}

BindViewProcessor

下面就通过 BindViewProcessor 看看如何实现我们的目标。

  • 首先定义要支持的注解类型
1
2
3
4
5
6
7
@Override
public Set<String> getSupportedAnnotationTypes() {
HashSet<String> supportTypes = new HashSet<>();
supportTypes.add(BindView.class.getCanonicalName());
supportTypes.add(BindString.class.getCanonicalName());
return supportTypes;
}
  • process 核心逻辑的处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
mMessager.printMessage(Diagnostic.Kind.NOTE, "processing....");

// 代理方法清空
mProxyProMap.clear();
// 处理注解,完成需要生成代码的前期准备
assembleAnnotations(roundEnvironment);
// 生成代码
genCodeAuto();

mMessager.printMessage(Diagnostic.Kind.NOTE, "process finish ...");
return true;
}

ps: 可能是受到鸿洋老师这篇 Android 如何编写基于编译时注解的项目的影响,现在看到网上注解处理器的文章,都会新建 Proxy 通过代理模式来处理生成代码的核心逻辑,o(╥﹏╥)o。 这里为了方便也是用代理类处理。

  • assembleAnnotations() 对注解进行完全的处理
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
/**
*
* @param roundEnvironment 集中收集所有需要处理的 注解
*/
private void assembleAnnotations(RoundEnvironment roundEnvironment) {
processAnnotations(roundEnvironment, BindView.class);
processAnnotations(roundEnvironment, BindString.class);
}


private void processAnnotations(RoundEnvironment roundEnvironment, Class<? extends Annotation> className) {

RParser parser = RParser.builder(processingEnv)
.setSupportedAnnotations(BindView.class)
.setSupportedTypes("id")
.build();

parser.scan(roundEnvironment);

// 获取所有打上 className 类型注解的元素
Set<? extends Element> elements = roundEnvironment.getElementsAnnotatedWith(className);
// 遍历元素
for (Element element : elements) {
// 因为 BindView 和 BindString 的 target 是 ElementType.FIELD,因此这里强转理论上是安全的,
// 除非故意把注解打在了错误的位置
VariableElement variableElement = (VariableElement) element;
TypeElement classElement = (TypeElement) variableElement.getEnclosingElement();
String fullClassName = classElement.getQualifiedName().toString();

BindCreatorProxy proxy = mProxyProMap.get(fullClassName);
if (proxy == null) {
proxy = new BindCreatorProxy(mElementUtils, classElement);
mProxyProMap.put(fullClassName, proxy);
}

if (className.getCanonicalName().equals(BindView.class.getCanonicalName())) {
// BindView 注解处理
BindView bindAnnotation = variableElement.getAnnotation(BindView.class);
int id = bindAnnotation.value();
// 返回当前的 id 的 human 值,类似 R.id.xxx
String idRes = getBindViewResId(parser, fullClassName, id);
ResModel model = new ResModel(idRes, id, BindView.class);
proxy.putElement(model, variableElement);

} else if (className.getCanonicalName().equals(BindString.class.getCanonicalName())) {
// BindString 注解处理
BindString bindAnnotation = variableElement.getAnnotation(BindString.class);
String str = bindAnnotation.value();
proxy.putElement(str, variableElement);
}
}
}

为了便于理解上面的代码,可以看一下整个 apt-processor 的 UML 类图

总的来说,mProxyProMap 是以注解所在的类名为 key ,BindCreatorProxy 为值的一个 map。这样做为了方便以 @BindView,@BindString
注解所在的类为维度,解析注解生成代码。BindCreatorProxy 中的 putElements() 方法会保存注解对应的 value 和 element 的映射表(即一个 HaspMap)。

  • getBindViewResId()

    这里再来简单说一下 getBindViewResId 这个方法,这里其实是借用 RParser为了便于生成更加友好的代码。因为我们通过 R 文件直接获取到的 View 的 id 一般是无意义的一个数字,因此生成的文件一般会是类似

    1
    target.mName = target.findViewById(2131165246);

    这样的代码,理论上来说,这里的 id 是以什么样的形态存在,其实无所谓,但为了更加友好的阅读体验,我们可以再优化一下注解生成器把生成的代码变成这个样子

    1
    target.mName=target.findViewById(com.engineer.aptlite.R.id.name);

    这样的代码更接近我们最终想要看到的样子。

代码生成

mProxyProMap 已经包含所有我们需要信息,接下来就可以根据这个集合生成代码了。在 APT 处理过程中,一般会选择使用 javapoet 来生成代码,这个库提供了很多 api 方法开发者按照面向对象的方式输出一个包含导包,类声明,变量声明,方法定义等各种信息的类文件。当然,你也可以选择使用字符串拼接的方法,按照你希望的格式进行拼接即可。

  • genCodeAuto()
1
2
3
4
5
6
7
8
9
10
private void genCodeAuto() {
for (String key : mProxyProMap.keySet()) {
BindCreatorProxy proxyPro = mProxyProMap.get(key);
try {
JavaFile javaFile = JavaFile.builder(proxyPro.getPackageName(), proxyPro.generatorJavaCode()).build();
javaFile.writeTo(mFiler);
} catch (Exception e) {}
}
}

这里遍历所有需要生成的类,分别为他们生成文件,proxyPro.generatorJavaCode() 的具体实现

1
2
3
4
5
6
7
8
9
public TypeSpec generatorJavaCode() {
TypeSpec typeSpec = TypeSpec.classBuilder(mBindingClassName)
.addModifiers(Modifier.PUBLIC)
.addModifiers(Modifier.FINAL)
.addMethod(generateViewBindMethod())
.addMethod(generateStringBindMethod())
.build();
return typeSpec;
}

这里使用 javapoet 实现,可以看到他的 API 设计命名还是很清晰的,基本上根据方法命名就可以知道需要做什么事情了,这里的实现就很简单了,具体细节就不展开了,可以参考源码

生成的目标代码

上面我们制定的目标 MainActivity为例,那么最终在编译期生成的代码就是这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.engineer.aptlite;

public final class MainActivity_Binding {
public void bindView(MainActivity target) {
target.mContent=target.findViewById(com.engineer.aptlite.R.id.content);
target.mJump1=target.findViewById(com.engineer.aptlite.R.id.jump_1);
target.mGenCode=target.findViewById(com.engineer.aptlite.R.id.genCode);
target.mJump=target.findViewById(com.engineer.aptlite.R.id.jump);
target.mName=target.findViewById(com.engineer.aptlite.R.id.name);
}

public void bindString(MainActivity target) {
target.mName.setText("APT Success");

target.mContent.setText("Hello APT");

}
}

这个文件在一般会在 /app/build/generated/source/apt/debug/packagename/ 目录下。但其实也不用按照目录找,Android Studio 切换到 Android 目录视图下,在 generatedJava 包下即可非常方便的找到

可以看到和我们手写的基本差不多。代码可以正常运行,说明一切都正常,说明编译期生成的代码是有效的。

总结

使用 APT 可以帮助我们在编译期预先完成一些重复性较强,且很有规律的事情,生成一些辅助性的代码,最终在我们编码的时候,可以借助这些辅助类更高效和准确的完成工作。例如 SugarAdapter 通过在编译期生成的文件,解决了 RecyclerView 需要多种类型的 Item 时,Adapter 实现爆炸的问题。有兴趣的同学可以看看用 SugarAdapter 简化复杂列表的实现

注解处理器在 Kotlin 中的使用注意事项

在常规的 java 版本的 Android 项目中,为了引入带有注解处理器的库,一般会最 build.gradle 的 dependencies 闭包中会使用annotationProcessor 来使用注解处理器,比如 glide 的依赖

1
2
3
4
dependencies {
implementation 'com.github.bumptech.glide:glide:4.9.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.9.0'
}

在 Kotlin 中需要替换为 kapt 了。具体来说,首先需要添加 kapt 的插件

1
apply plugin: 'kotlin-kapt'

然后将所有需要使用 annotationProcessor 的地方替换为 kapt 即可。

参考文档

加个鸡腿呗.