0.导语
Java 作为一门低语法糖的语言,核心在其虚拟机的实现,语言层面提供的“黑科技”并不多,而注解就是其中比较重要的一点。注解在 Java5 中开始加入,在 Java6 中对外暴露出注解处理器的接口供程序员来按需处理注解。现如今,不论是安卓客户端还是Java后端技术栈都使用到了大量注解相关的库和框架,甚至可以说是到了“泛滥成灾”的地步。在其中相对比较重要的便是自定义注解处理器了,本文将从两个方面来进行介绍:
- 注解的通俗解释及使用
- 注解的处理方式,以安卓中的 ButterKnife 库为例,通过运行时期处理注解和编译时期通过注解处理器来处理注解这两种方式,实现和 ButterKnife 库相似的功能
1.什么是注解
注解,通俗的来说,就是像注释一样,是由程序员在代码中加入的一种“标注”,不影响所编写的原有代码的执行。而这种标注(注解)可以被编码用的IDE、编译器、类加载器的代理程序、其他第三方工具以及原有代码运行期间读取和处理,生成一些新的辅助代码或是提示,从而节省时间,提升效率。这些工具读取注解的时机是根据注解的生命周期来定的,注解的生命周期就是其“存在寿命”,分为三种:
-
源代码时期注解,即注解只出现在 .java 文件中,编译后便不再出现在生成的.class 文件中,这一阶段,对注解的处理有两种方式:
- 被 IDE 中代码检查工具读取,如图 1-1中的 1 处,实时地提示程序员缩写代码中的错误,例如 @Override 注解就是用来检查程序员所复写的父类方法的签名一致性的;
- 在原代码编译期间,被程序员在编译器中所注册的注解处理器所读取,如图 1-1中的 2 处,用于生成新的源代码文件参与编译,省去重复地书写样板代码的成本,提升效率,编译之后便被去除,不再出现在 .class 文件中;
-
字节码时期注解,即出现在 .class 文件中的注解,对这类注解的处理主要涉及字节码的修改,需要使用ASM等类库,根据处理时机的不同,也可分为两种方式:
- 在源代码编译后处理:如果当前工程采用了Gradle、Maven等构建工具来构建,则在源代码被编译为 .class 文件后,可以通过相应地脚本调用使用了ASM库编写的第三方工具来读取文件中的注解并修改 .class 文件中的虚拟机指令,没有采用构建工具,也可以通过命令行来手动更新 .class 文件,虚拟机加载.class 文件时不会加载字节码级注解,如图 1-1中的 3 处所示;
- 在类加载时处理:通过代理程序,在类加载器加载.class 文件前,读取文件中的注解修改字节码,但并不保存,即原有的.class文件内容不变,内存中处理过的字节码的类被类加载器直接加载到虚拟机中,同样的并不加载.class 文件中的字节码级注解,如图 1-1中的 4 处所示;
运行时期注解,即注解被类加载器加载到内存当中,和类的其他构成元素一样被放置于元数据区,供堆区中相应的 class 对象访问,换句话说,此时的注解可以通过反射的方式来读取和处理,这时的处理过程往往是由程序员在编码期间就确定的,是运行效率最低但却是最容易实现的一种注解处理方式,如图 1-1中的 5 处所示;
2.处理注解
由前所述,对注解的处理由处理时机的不同可以有不同的实现方式。这里介绍开发中最常用的两种方式,运行时期处理注解和编译时期通过自定义注解处理器来处理,口说无凭,先设立一个目标,ButterKnife 是安卓中很有名的利用注解来进行控件绑定的库,我将通过上述两种注解处理方式来实现类似 ButterKnife 库的功能。
2.1 ButterKnife 的简单介绍
ButterKnife 这个库主要用于安卓端控件绑定的问题。在很多流程类似的GUI程序中,都是先将 xml 等标签语言书写的界面文件读入并解析,在内存中生成视图界面中所有控件所对应的控件树对象,之后将控件树中的控件实体对象和逻辑控制类(安卓中的Activity)中的对象引用进行绑定,需要由程序员手工进行,如下所示:
public class SourceTestActivity extends AppCompatActivity {
private static final String TAG = SourceTestActivity.class.getSimpleName();
// 控件引用
private TextView mTextView;
private Button mButton;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 读入文件,解析生成对象树
setContentView(R.layout.activity_source_test);
// 根据控件id在控件树中找到对应的实体控件对象并绑定
mTextView = findViewById(R.id.tv);
mButton = findViewById(R.id.btn);
// 绑定按键控件的点击事件处理回调
mButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Toast.makeText(v.getContext(), ((TextView) v).getText(), Toast.LENGTH_SHORT).show();
}
});
}
}
界面中有一个文本控件和一个按钮控件,都需要程序员通过 findViewById 和 setOnClickListner 方法来绑定实体 View 和其对应的回调方法。通过 ButterKnife 库可以将代码简化成如下所示:
public class SourceTestActivity extends AppCompatActivity {
private static final String TAG = SourceTestActivity.class.getSimpleName();
// 用注解标记引用需要绑定的控件的id
@BindView(R.id.tv)
TextView mTextView;
@BindView(R.id.btn)
Button mButton;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_source_test);
// 执行绑定过程
ButterKnife.bind(this);
}
// 用注解标记事件回调需要绑定的控件id
@OnClick(R.id.btn)
public void test(View v) {
Toast.makeText(v.getContext(), ((TextView) v).getText(), Toast.LENGTH_SHORT).show();
}
}
在需要绑定实体 View 的引用上加上 @BindView 注解,在需要绑定的回调方法上加上 @OnClick 注解,最后在代码中通过 ButterKnife.bind() 方法来实现绑定。上述例子由于需要绑定的控件和方法较少,看不出优势,但当需要绑定的对象很多是,ButterKnife 库可以帮我们节省很多重复的 findViewById 和 setOnClickListner 方法的书写。
2.2 反射处理运行时注解实现 “ButterKnife”
2.2.1 自定义注解
首先我们需要自定义注解 @BindView 和 @OnClick
BindView
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface BindView {
@IdRes int value();
}
@Target 元注解中的ElementType.FIELD参数设定了@BindView 注解所适用的场景为 Field ,即属性字段;@Retention 元注解中的RetentionPolicy.RUNTIME 参数设定了@BIndView 注解的生命周期为运行时,即 .java .class 加载到内存中三个时期,@BindView 注解一直都存在。
OnClick
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OnClick {
@IdRes int[] value() default {View.NO_ID};
}
OnClick 注解生命周期和 BindView 一致,不同在于适用的目标为方法(ElementType.METHOD)
2.2.2 运行时反射绑定
public class ButterKnife {
/**
* 负责 将容器类中的监听方法绑定到容器类中声明的指定 view 上
*
* @param container 容器类,待绑定方法和被绑定的 view 都位于其中, 一般是 Activity or Fragment
*/
public static void bind(final Activity container) {
// key: view's resId; value: view
SparseArray<View> viewsMap = new SparseArray<>(8);
Class<?> cls = container.getClass();
// 获取已注解的 view 域, 并且帮其 findViewById
for (Field viewField: cls.getDeclaredFields()){
BindView fieldAnnotation = viewField.getAnnotation(BindView.class);
if (fieldAnnotation != null) {
viewField.setAccessible(true);
try {
// 进行 View 绑定
View viewRef = container.findViewById(fieldAnnotation.value());
viewField.set(container, viewRef);
viewsMap.put(fieldAnnotation.value(), viewRef);
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
// 获取已注解的方法域, 并且帮其通过动态代理 setOnClickListener
for(final Method onClickMethod: cls.getDeclaredMethods()){
OnClick methodAnnotation = onClickMethod.getAnnotation(OnClick.class);
if (methodAnnotation != null) {
int[] viewResIds = methodAnnotation.value();
for (int resId : viewResIds) {
View viewToAttach;
if ((viewToAttach = viewsMap.get(resId)) != null) {
onClickMethod.setAccessible(true);
viewToAttach.setOnClickListener(
(View.OnClickListener) Proxy.newProxyInstance(null, new Class[]{View.OnClickListener.class},
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws
InvocationTargetException, IllegalAccessException {
return onClickMethod.invoke(container, args);
}
})
);
}
}
}
}
}
}
绑定方法主要是假的ButterKnife中的静态bind方法,其中做了两件事:
- 首先通过反射遍历了所有的属性字段的,找出其中附加了@BindView注解的域,即View的引用,通过@BindView注解中设置的View的resId找到View实体,并绑定到View引用上,最后将其缓存到SparseArray中。
- 之后,通过反射遍历所有方法域,找出其中附加了@OnClick注解的方法,由于Java中方法不是对象,不能独立存在,必须存在于类中,所以需要通过动态代理机制来代理View.OnClickListener接口,“包裹”所注解的方法,最后通过SparseArray的键值resId找到View实体进行回调方法的绑定。
最后是Activity中的使用,界面中一共是两个按钮控件,绑定同一个回调方法,使用的形式上和ButterKnife库是类似的:
RuntimeTestActivity.java
public class RuntimeTestActivity extends AppCompatActivity {
@BindView(R.id.btn_one)
private Button btnTest1;
@BindView(R.id.btn_two)
private Button btnTest2;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_runtime_test);
ButterKnife.bind(this);
}
@OnClick({R.id.btn_one, R.id.btn_two})
public void onClick(View v) {
Toast.makeText(v.getContext(), ((TextView)v).getText(), Toast.LENGTH_SHORT).show();
}
}
综上,虽然使用的形式上和 ButterKnife 相类似,但原理上却完全不同,我们所写的伪ButterKnife库中的注解都是运行时注解,即源文件中的注解也会出现在最后的运行时内存中,可以通过反射的方式拿到注解,从而获得被注解的域或方法,最后在运行时再进行绑定,由于待绑定的元素都需要通过反射来拿到,故效率较低。
2.3 编译时注解处理器处理注解实现 “ButterKnife”
反射处理运行时注解的效率较低,那我们有没有办法减少反射的调用呢?回顾真ButterKnife库的作用,主要是减少书写findViewById之类的样板代码的书写,如果我们可以提前生成这些绑定方法的模板代码,只是通过反射调用唯一的bind()方法,那就可以适当提升效率,实际上,真ButterKnife库中也是这么做的。首先看伪ButterKnife库工程的结构:
- app为安卓的测试module,用于测试自定义的伪ButterKnife库;
- butterknife-annotation 为包含自定义注解的module;
- butterknife-compiler 为自定义注解处理器的module,这里将注解处理器和所能处理的注解分在两个不同的module,主要是因为注解处理器实际上只是被编译器在编译时调用,并不属于工程,所以在打包时可以将其不打入apk中以节省空间,如果自定义的注解和注解处理器处在同一module,那么使用时,app module中会找不到自定义注解,所以需要将注解独立成butterknife-annotation module ,在 app 和注解处理器 butterknife-compiler module 中分别引用,自定义注解处理器在编译时读取源文件中的注解,生成新的源代码文件参与编译;
- butterknife-library module 负责通过反射调用 butterknife-compiler 中自动生成的代码
2.3.1 自定义注解
BindView
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.SOURCE)
public @interface BindView {
int value();
}
OnClick
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface OnClick {
int value();
}
可以看到和反射式伪ButterKnife库中的自定义注解所不同的是注解生命周期的定义,@Retention(RetentionPolicy.SOURCE)声明了这些注解都是源码级注解,即编译后在 .class 文件中便不存在了,注意建立butterknife-annotation 库时,选择该库为 java library,如下图所示:
最后的module工程结构如下:
2.3.2 自定义注解处理器
自定义注解处理器的基本原理是将程序员自定义的注解处理器注册到编译器中,编译器在编译源文件时调用该注解处理器处理源文件中的注解,处理这些注解时是无法修改源文件的,只能生成新的源文件,生成的新文件会继续调用注解处理器处理,这是一个递归的过程,一轮一轮地处理,直到没有新的源文件生成,我们正是利用这点来自动生成绑定控件的模板代码。大致原理如下图所示:
编译器编译源文件后生成语法树(AST),即结构化后的源文件元素,并将其传入自定义的注解处理器,自定义的注解处理器可以通过RoundEnvironment获得这些语法元素来处理,最后生成字节码文件。想要自己实现注解处理器并注册到编译器中的具体步骤如下:
- 创建java lib butterknife-cmplier和创建 butterknife-annotation的方式一致;
- 创建Processor,代码如下:
BindViewProcessor.java
@SupportedSourceVersion(SourceVersion.RELEASE_7)
@SupportedAnnotationTypes({Constants.FULL_NAME_ANNO_BINDVIEW, Constants.FULL_NAME_ANNO_ONCLICK})
@AutoService(Processor.class)
public class BindViewProcessor extends AbstractProcessor {
private Messager mMessager;
private Elements mElementUtils;
private Map<String, Creator> mProxyMap = new HashMap<>();
@Override
public synchronized void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
mMessager = processingEnv.getMessager();
mElementUtils = processingEnv.getElementUtils();
}
// @Override
// public Set<String> getSupportedAnnotationTypes() {
// HashSet<String> supportTypes = new LinkedHashSet<>();
// supportTypes.add(BindView.class.getCanonicalName());
// return supportTypes;
// }
//
// @Override
// public SourceVersion getSupportedSourceVersion() {
// return SourceVersion.latestSupported();
// }
@Override
public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
mMessager.printMessage(Diagnostic.Kind.NOTE, "processing...");
mProxyMap.clear();
// 获取源文件中带有 BindView 注解的域
Set<? extends Element> elements = roundEnvironment.getElementsAnnotatedWith(BindView.class);
for (Element element : elements) {
VariableElement variableElement = (VariableElement) element;
TypeElement classElement = (TypeElement) variableElement.getEnclosingElement();
String fullClassName = classElement.getQualifiedName().toString();
Creator proxy = mProxyMap.get(fullClassName);
if (proxy == null) {
proxy = new Creator(mElementUtils, classElement);
mProxyMap.put(fullClassName, proxy);
}
BindView bindAnnotation = variableElement.getAnnotation(BindView.class);
int id = bindAnnotation.value();
proxy.putFieldElement(id, variableElement);
}
// 获取源文件中带有 OnClick 注解的方法
elements = roundEnvironment.getElementsAnnotatedWith(OnClick.class);
for(Element element: elements){
ExecutableElement methodElement = (ExecutableElement) element;
TypeElement classElement = (TypeElement) methodElement.getEnclosingElement();
String fullClassName = classElement.getQualifiedName().toString();
Creator proxy = mProxyMap.get(fullClassName);
if (proxy == null) {
proxy = new Creator(mElementUtils, classElement);
mProxyMap.put(fullClassName, proxy);
}
OnClick bindAnnotation = methodElement.getAnnotation(OnClick.class);
int id = bindAnnotation.value();
proxy.putMethodElement(id, methodElement);
}
// 生成java代码
for (String key : mProxyMap.keySet()) {
Creator proxyInfo = mProxyMap.get(key);
JavaFile javaFile = JavaFile.builder(proxyInfo.getPackageName(), proxyInfo.generateJavaCode())
.indent(" ")
.addFileComment("generate file, do not modify!")
.build();
try {
javaFile.writeTo(processingEnv.getFiler());
} catch (IOException e) {
e.printStackTrace();
}
}
mMessager.printMessage(Diagnostic.Kind.NOTE, "process finish ...");
return true;
}
}
Constants.java
public class Constants {
public static final String FULL_NAME_ANNO_BINDVIEW = "com.danielchen.demo.butterknife_annotation.BindView";
public static final String FULL_NAME_ANNO_ONCLICK = "com.danielchen.demo.butterknife_annotation.OnClick";
}
从代码中可以看出,注解处理器主要是继承AbstractProcessor并实现 process() 方法, 可选择实现 init()、getSupportedAnnotationTypes() 、
getSupportedSourceVersion()三个方法,其中,getSupportedAnnotationTypes() 、
getSupportedSourceVersion()是返回本注解处理器所支持的注解和源代码版本,可以在注解处理器上加上@SupportedSourceVersion()
@SupportedAnnotationTypes()注解实现同样的效果,init()方法主要是为了初始化一些全局的工具。
- 向编译器注册本注解处理器,可以手动注册:在main文件夹下,创建路径 META-INF/services/,在其中创建文件javax.annotation.processing.Processor,在文件中加入注解处理器的全限定名;这样做很麻烦,更简便的方法是引入google的auto-service库,gradle 文件中远程依赖 'com.google.auto.service:auto-service:1.0-rc4',在注解处理器类上加上@AutoService(Processor.class)即可,这样会自动创建上述路径和文件,如下图所示:
4.实现注解处理器的process()方法,形参Set是本处理器可处理的注解类型,就是@SupportedAnnotationTypes()中所设置的类型,RoundEnvironment 则提供了本轮次的语法树元素,如果方法返回true,则后续其他处理器不能处理这些注解,否则可以处理,类似点击事件的拦截。上述代码中将读取到的属性字段和方法对应的语法元素存入 Creator,Creator 代码如下:
Creator.java
public class Creator {
private String mBindingClassName;
private String mFullPackageName;
private ClassName mClassName;
private Map<Integer, VariableElement> mVariableElementMap;
private Map<Integer, ExecutableElement> mExecutableElementMap;
public Creator(PackageElement pkgElement, TypeElement classElement) {
mVariableElementMap = new HashMap<>(8);
mExecutableElementMap = new HashMap<>(8);
this.mClassName = ClassName.get(classElement);
this.mFullPackageName = pkgElement.getQualifiedName().toString();
this.mBindingClassName = classElement.getSimpleName().toString() + "_ViewBinding";
}
public String getFullPackageName() {
return mFullPackageName;
}
public void putFieldElement(int id, VariableElement element) {
mVariableElementMap.put(id, element);
}
public void putMethodElement(int id, ExecutableElement element) {
mExecutableElementMap.put(id, element);
}
public TypeSpec generateJavaCode() {
return TypeSpec.classBuilder(mBindingClassName)
.addModifiers(Modifier.PUBLIC)
.addMethod(generateBindMethod())
.build();
}
private MethodSpec generateBindMethod() {
String paramName = "activity";
MethodSpec.Builder methodBuilder = MethodSpec.methodBuilder("bind")
.addModifiers(Modifier.PUBLIC)
.returns(void.class)
.addParameter(mClassName, paramName, Modifier.FINAL);
for (int resId : mVariableElementMap.keySet()) {
VariableElement element = mVariableElementMap.get(resId);
String viewName = element.getSimpleName().toString();
String viewType = element.asType().toString();
methodBuilder.addStatement("$L.$L = ($L)$L.findViewById($L)",
paramName, viewName, viewType, paramName, resId);
if (mExecutableElementMap.containsKey(resId)) {
ExecutableElement methodElement = mExecutableElementMap.get(resId);
String methodName = methodElement.getSimpleName().toString();
ClassName viewClass = ClassName.get("android.view", "View");
ClassName clickClass = ClassName.get("android.view.View", "OnClickListener");
TypeSpec onClickListener = TypeSpec.anonymousClassBuilder("")
.addSuperinterface(clickClass)
.addMethod(MethodSpec.methodBuilder("onClick")
.addAnnotation(Override.class)
.addModifiers(Modifier.PUBLIC)
.addParameter(viewClass, "v")
.returns(TypeName.VOID)
.addStatement("$L.$L(v)", paramName, methodName)
.build())
.build();
methodBuilder.addStatement("$L.$L.setOnClickListener($L)", paramName, viewName, onClickListener);
}
}
return methodBuilder.build();
}
}
Creator 中提供了根据缓存的语法元素生成源代码的方法,这里可以采用用字符串自己拼装的形式,但这样太过复杂且不易维护,所以推荐使用JavaPoet库来生成源代码,可读性更强。
最终, butterknife-compiler 的工程结构如下:
依赖关系如下:
apply plugin: 'java-library'
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
// 远程依赖 auto-service 库, 用于注册自定义注解处理器到编译器中
implementation 'com.google.auto.service:auto-service:1.0-rc4'
// 远程以来 javapoet 库,用于动态生成代码
implementation 'com.squareup:javapoet:1.11.1'
// 本地依赖 声明了自定义注解的 module
implementation project(':butterknife-annotation')
}
sourceCompatibility = "1.7"
targetCompatibility = "1.7"
最终自动生成的源代码如下:
// generate file, do not modify!
package com.danielchen.demo.demo_reflect;
import android.view.View;
import android.view.View.OnClickListener;
import java.lang.Override;
public class SourceTestActivity_ViewBinding {
public void bind(final SourceTestActivity activity) {
activity.mTextView = (android.widget.TextView)activity.findViewById(2131230914);
activity.mButton = (android.widget.Button)activity.findViewById(2131230755);
activity.mButton.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
activity.test(v);
}
});
}
}
2.3.3 反射调用生成的代码
最终通过butterknife-libraray中的 ButterKnife 类来调用自动生成的 SourceTestActivity_ViewBinding 的 bind() 方法,ButterKnfie 代码如下:
public class ButterKnife {
public static void bind(Activity activity) {
try {
Class<?> bindViewClass = Class.forName(activity.getClass().getName() + "_ViewBinding");
Method method = bindViewClass.getMethod("bind", activity.getClass());
method.invoke(bindViewClass.newInstance(), activity);
} catch (ClassNotFoundException | IllegalAccessException | InstantiationException | NoSuchMethodException | InvocationTargetException e) {
e.printStackTrace();
}
}
}
由于自动生成的类名和方法名bind都是约定好的,所以可以这么用,最后在测试代码中调用ButterKnife.bind()即可完成绑定,如下:
public class SourceTestActivity extends AppCompatActivity {
private static final String TAG = SourceTestActivity.class.getSimpleName();
@BindView(R.id.tv)
TextView mTextView;
@BindView(R.id.btn)
Button mButton;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_source_test);
// butterknife-library bind方法中通过反射调用 SourceTestActivity_ViewBinding 中的bind方法完成绑定
ButterKnife.bind(this);
}
@OnClick(R.id.btn)
public void test(View v) {
Toast.makeText(v.getContext(), ((TextView) v).getText(), Toast.LENGTH_SHORT).show();
}
}
综上,实际上,仿照真ButterKnife库的原理,我们是将控件绑定的模板代码先自动生成,运行时在用过一次反射调用即完成绑定,比运行时注解处理的方式的多次反射绑定要好那么一点,当然真ButterKnife库中的实际代码要更复杂些,不过大致原理是这样。
3.总结
本文顺着注解的生命周期大致描述了处理注解的不同时机和处理方法。参照ButterKnife库的原理,对运行时动态处理注解和编译时根据注解生成代码这两种处理注解的方式进行了代码演示,限于本人的水平,写的比较冗长,很多细节也没能描述清楚,之后会将代码传至github,感觉还是看代码理解更快。。。