Android 注解--(二)利用注解技术在编译期生成代码

上篇文章已经讨论了注解的作用和注解的生命周期,那么具体注解是如何简化我们工作,减少我们编写重复的代码的呢?接下我们将学习使用APT,在编译期生成代码。

APT是Annotation-Processing-tool的简写,称为注解处理器,一般来说,自定义注解是在运行时使用的,通过反射获取class上的注解,并进行解析处理,使用apt可以让我们在编译时处理注解(其实不仅仅可以处理注解,而是所有的类信息都可以处理,下面会有演示),我们这一篇一起来学习一下基本的套路。

我们使用Android studio编写一个基本的例子。

1.创建注解工程

同样我们先创建一个Java工程,编写一个注解类MyAnnotation

@Retention(RetentionPolicy.CLASS)
@Target(ElementType.TYPE)
public @interface MyAnnotation {
    String value() default "MyAnnotation";
}

然而这一次我们就不是在反射的时候获取这个注解的信息了,我们要在编译的时候获取这个注解的信息,如何获取,就要使用到注解处理器Processor。

注解处理器Processor

了解一下注解处理器,注解处理器有什么作用呢,首先它会在编译期被调用,可以扫描特定注解的信息,你可以为你自己的的注解注册处理器(如何注册后面会讲),一个特定的注解处理器以java源码作为输入,然后生成一些文件作(通常为java)为输出,这些java文件同样会被编译。这意味着,你可以根据注解的信息和被注解类的信息生成你想生成的代码!

要生成怎么样的文件,我们简单定一下需求:
定义一个注解MyAnnotation,去注解MainActivity,然后处理器扫描生成一个java文件,这个java文件有个输出Hello MyAnnotation的方法,运行的我们的MainAcitivity,然后调用这个java文件的方法。

2.创建Android工程

之前我们已经定义了我们注解MyAnnotation,并且有个默认值value是字符串MyAnnotation。接下来,我们要用这个去注解MainActivity,现在我们是在Java工程,那么我们新创建一个Android工程,里面有个MainActivity,这个工程依赖我们MyAnnotation所在的工程。

@MyAnnotation
public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }
}

接下来我们就要通过自己定义的注解处理器去扫描这个注解进而生成java文件,但是在此之前,我们需要先了解注解处理的工作流程和相关API。

3.创建Compiler工程

AbstractProcessor

AbstractProcessor就是系统抽象出来的处理器类,编写一个MyProcessor继承AbstractProcessor,查看一下里面的方法

public class MyProcessor extends AbstractProcessor{

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

    @Override
    public Set<String> getSupportedAnnotationTypes() {
        return super.getSupportedAnnotationTypes();
    }

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

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        return false;
    }
    
}

我们需要重写的方法有这么几个,
init(ProcessingEnvironment processingEnv) :所有的注解处理器类都必须有一个无参构造函数。然而,有一个特殊的方法init(),它会被注解处理工具调用,以ProcessingEnvironment作为参数。ProcessingEnvironment 提供了一些实用的工具类Elements, Types和Filer。我们在后面将会使用到它们。

process(Set<? extends TypeElement> annoations, RoundEnvironment env) :这类似于每个处理器的main()方法。你可以在这个方法里面编码实现扫描,处理注解,生成 java 文件。使用RoundEnvironment 参数,你可以查询被特定注解标注的元素。

getSupportedAnnotationTypes():在这个方法里面你必须指定哪些注解应该被注解处理器注册。注意,它的返回值是一个String集合,包含了你的注解处理器想要处理的注解类型的全称。换句话说,你在这里定义你的注解处理器要处理哪些注解。

getSupportedSourceVersion() : 用来指定你使用的 java 版本,建议使用SourceVersion.latestSupported()。

接下来你必须知道的事情是:注解处理器运行在它自己的 JVM 中。是的,你没看错。javac 启动了一个完整的 java 虚拟机来运行注解处理器。这意味着什么?你可以使用任何你在普通 java 程序中使用的东西。使用 guava! 你可以使用依赖注入工具,比如dagger或者任何其他你想使用的类库。但不要忘记,即使只是一个小小的处理器,你也应该注意使用高效的算法及设计模式,就像你在开发其他java程序中所做的一样。

注意:在此之前我相信很多同学和我一样把MyProcessor写在了跟MyAnnotation中一样的工程里面,这里有一个compiler工程的分离原则,processor就是我们的compiler工程,这个Processor对于目标工程也就是我们上面的MainActivity所在的Android工程是不需要的,Android主工程所需要的是生成java文件。所以我们需要新建一个java工程作为存放我们MyProcessor的compiler工程,这个工程同样要依赖MyAnnotation所在的注解工程。

注册处理器

我们在编译好的META-INF/services添加我们的处理器路径,谷歌已经提供一个很方便的库,帮助我们做这些东西,我们只需要在处理器工程添加依赖

compile 'com.google.auto.service:auto-service:1.0-rc2'

然后在Myprocessor中添加@AutoService(Processor.class)的注解,这样就完成了我们处理器的注册。


编译成生成的META-INF/services中就注册了我们的MyProcessor


接下来,我们编写一个我们自己的处理器,生成java文件,来讲解一下相关API,以及要注意的事项。

处理后的MyProcessor

public class MyProcessor extends AbstractProcessor{
    //处理Element的工具类
    private Elements mElementUtils;
    //生成文件的工具
    private Filer mFiler;
    //日志信息的输出
    private Messager mMessager;


    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
        super.init(processingEnv);
        mElementUtils = processingEnv.getElementUtils();
        mFiler = processingEnv.getFiler();
        mMessager = processingEnv.getMessager();
    }

    @Override
    public Set<String> getSupportedAnnotationTypes(){
        Set<String> supportedType = new LinkedHashSet<String>();
        supportedType.add(MyAnnotation.class.getCanonicalName());
        return supportedType;
    }

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

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
       //do something
       return false
    }

接下来重点讲一下process方法的实现,之前提到,这个方法中会扫描文件,可以获取到带我们自定义注解的类,通过如下这个方法

//获取MyAnnotation注解的类的集合
Set<? extends Element> set = roundEnv.getElementsAnnotatedWith(MyAnnotation.class);

Element

Element是什么?Element代表着源代码中的各种结构类型。

package com.example;

public class Foo { // TypeElement

    private int a; // VariableElement
    private Foo other; // VariableElement

    public Foo() {} // ExecuteableElement

    public void setA( // ExecuteableElement
        int newA // TypeElement
    ){
        
    }
}

换句话说:Element代表程序中的元素,比如说 包,类,方法。每一个元素代表一个静态的,语言级别的结构。我们可以像这样去解析一个类源码来获取Element。

TypeElement fooClass = ... ;
for (Element e : fooClass.getEnclosedElements()){ // getEnclosedElements获取子Element
    Element parent = e.getEnclosingElement();  //获取父级Element 
}

上面提到我们process方法要扫描我们的指定注解的类,我们process方法简单编写为

public class MyProcessor extends AbstractProcessor{
    ···
    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        //获取MyAnnotation注解的类的集合
        Set<? extends Element> set = roundEnv.getElementsAnnotatedWith(MyAnnotation.class);
        for (Element element : set){
            //这里往往要进行被注解类的匹配判断,看是否符合我们的要求
            // if(isVaild(element))...
            if(element.getKind() == ElementKind.CLASS){
                //TODO:写java文件
            }
        }
        return false;
    }
}

这里返回值的意思是是否停止扫描,在这里一般要结合匹配判断来使用的,因为在这里你根本不知道使用者会怎么使用你的注解,如果注解不正当使用,你需要提示用户出错了(mMessager输出日志),并且返回true,标识不再继续扫描了。我这里因为例子简单,容易控制,主要便于理解注解的原理,所以我省略了匹配判断。

上面element.getKind() == ElementKind.CLASS判断,说明我这个注解使用在类上面,我才拿来输出文件,使用在其他地方不做处理。

接下来就是生成java文件啦,首先脑子里要有生成文件的代码,根据我们的需求,我们首先是会在MainActivity类中使用我们的MyAnnotation注解的,生成的文件名需要区别开来,就在被注解类名后面加上$$HelloWorld,里面还有个输出Hello MyAnnotation的方法,那么我们的文件就生成这样的吧:

public final class MainActivity$$HelloWorld {

    public static void sayHello() {
        System.out.println("Hello MyAnnotation");
    }
}

生成java文件

继续编写我们process的方法,来编写java源文件,这里我使用的是javapoet,具体的使用可以查看他们的github说明文档,写文件非常好用。

@Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {

        Set<? extends Element> set = roundEnv.getElementsAnnotatedWith(MyAnnotation.class);
        for (Element element : set){
            if(element.getKind() == ElementKind.CLASS){
                TypeElement typeElement = (TypeElement) element;
                brewJavaFile(typeElement);
            }
        }
        return true;
    }

因为我们知道我们只会注解在类上面,所以直接把element强转为TypeElement。写文件的代码在brewJavaFile中

private void brewJavaFile(TypeElement pElement){
        //sayHello 方法
        MyAnnotation myAnnotation = pElement.getAnnotation(MyAnnotation.class);
        MethodSpec methodSpec = MethodSpec.methodBuilder("sayHello")
                .addModifiers(Modifier.PUBLIC, Modifier.STATIC).returns(void.class)
                .addStatement("$T.out.println($S)",System.class,"Hello"+myAnnotation.value()).build();

        // class
        TypeSpec typeSpec = TypeSpec.classBuilder(pElement.getSimpleName().toString()+"$$HelloWorld").addModifiers(Modifier.PUBLIC,Modifier.FINAL).addMethod(methodSpec).build();
        // 获取包路径,把我们的生成的源码放置在与被注解类中同一个包路径中
        JavaFile javaFile = JavaFile.builder(mElementUtils.getPackageOf(pElement).getQualifiedName().toString(),typeSpec).build();
        try {
            javaFile.writeTo(mFiler);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

那么我们的生成文件的代码就已经写完了,这里要提一下,我们在编写的过程中,我想看一下这个类的路径运行的时候怎么查看呢,我们没有编写输出日志呀,那么日志如何查看,或者说怎么调试定位错误,这里使用mMessager来输出日志。

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        ...
        TypeElement typeElement = (TypeElement) element;
        String qualifiedName = typeElement.getQualifiedName().toString();
        mMessager.printMessage(Diagnostic.Kind.NOTE,"qualifiedName:"+qualifiedName);
        ...
        return false;
    }

输出如下

OK,我们写完了生成文件代码,终于可以测试一波了,新建一个Android工程,该工程依赖注解工程,至于compiler处理器工程,我们要使用apt 的方式依赖,首先在项目目录下的build.gradle中添加
classpath 'com.neenbedankt.gradle.plugins:android-apt:1.8'这个依赖

buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:2.2.1'
        // apt 
        classpath 'com.neenbedankt.gradle.plugins:android-apt:1.8'
    }
}

然后在Android 工程中,添加这个插件依赖

然后就可以使用apt依赖处理器工程了

apt project(':xxxCompiler')

运行我们的Android工程,查看build生成文件

我们文件如愿生成啦,接下来要做的就是去调用这个sayHello的方法,我们的思路是通过反射生成的类,调用sayHello的方法。

在注解工程中,新建AnnotationApi类,编码如下

public class MyAnnotationApi {
    
    public static void sayHelloAnnotation(Object pTarget){
        String name = pTarget.getClass().getCanonicalName();
        try {
            Class clazz = Class.forName(name+"$$HelloWorld");
            Method method = clazz.getMethod("sayHello");
            method.invoke(null);
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
    }

}

然后在MainActivity中调用sayHelloAnnotation的方法

@MyAnnotation
public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        MyAnnotationApi.sayHelloAnnotation(this);
    }
}

查看输出



至此,我们的需求就已经全部实现了。

总结

总结一下,这个例子可以看到,注解处理器非常强大,通过编译期生成代码,让注解处理器去生成这些代码,可以省去我们编写重复代码的时间。除此之外,我们应该思考我们使用的注解框架,比如说butterkinef是怎么工作的,查看源码或者自己学习编写一下注解框架,进而加深注解和注解框架的理解。

参考:
Annotation-Processing-Tool详解(想深入理解看这个篇,非常详细)
Android利用APT技术在编译期生成代码

推荐阅读更多精彩内容