Kotlin版注解处理器Annotation Processor

2,690 阅读13分钟

@[toc]

注解处理是为 Java程序生成代码的强大工具。在本文中,将开发一个注解和一个注解处理器,为给定的 Activity 类根据路由参数自动生成路由信息初始化的代码。

注意:本文代码全部是 Kotlin 语言编写。

什么是注解

引用《Java编程思想》第20章的注解定义:

注解(也被称为元数据)为我们在代码中添加信息提供了一种形式化的方法,使我们可以在稍后某个时刻非常方便地使用这些数据。

Java 内置了三种标准注解:

  1. @Override:表示当前的方法定义将覆盖超类中的方法;
  2. @Deprecated:使用此注解使编译器发出警告;
  3. @SuppressWarnings:关闭不当的编译器警告信息;

Java还另外提供了四种注解,专门负责新注解的创建,这些注解也被称为元注解(meta-annotation)。

注解含义
@Target表示该注解可以用于什么地方。可能的 ElementType 包括:CONSTRUCTOR:构造器的声明,FIELD:域声明(包括enum实例),LOCAL_VARIABLE:局部变量声明 ,METHOD:方法声明 ,PACKAGE:包声明 ,PARAMETER:参数声明,TYPE:类、接口(包括注解类型)、或者enum声明
@Retention表示需要在什么级别保存该注解信息。可选的 RetentionPolicy 参数包括:SOURCE:注解将被编译器丢弃,CLASS:注解在class文件中可用,但会被VM丢弃,RUNTIME:VM将在运行期也保留注解,因此可以通过反射机制读取注解的信息
@Documented将此注解包含在Javadoc中。
@Inherited允许子类继承父类中的注解。

创建注解

第一步创建一个新模块来保存我们的注解。

将注解和处理器保存在单独的模块中是一种常见的做法。

选择 FileNewNew Module,然后选择 Java or Kotlin library ,模块名称为annotations,类名Router,语言记得选择 Kotlin,填写信息后完成创建。

模块创建.png

创建了一个注解Router ,代码如下:

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class Router(val url: String)

@Target使用TYPE代表我们将只在上使用Router@Retention使用SOURCE代表 Router 只需要在源代码编译阶段存在。

注解可以有参数,允许我们添加更多信息。

什么是注解处理器

注解处理器可以帮助我们事半功倍,也就是说,更少的代码(注解)神奇地变成了更多的功能。以下是注解处理器的介绍:

  1. 注解处理是javac内置的一个工具,用于在编译时扫描和处理注解。

  2. 它可以创建新的源文件;但是,它不能修改现有的。

  3. 它是轮流完成的。当编译到达预编译阶段时,第一轮开始。如果这一轮生成任何新文件,则下一轮以生成的文件作为其输入开始。这种情况一直持续到处理器处理完所有新文件。

javac 是java语言编程编译器。全称java compiler。javac工具读由java语言编写的类和接口的定义,并将它们编译成字节代码的class文件。

下图说明了该过程:

注解处理.png

编写注解处理器

再次创建一个Java or Kotlin library 模块,模块名称为processor,类名Processor,语言依旧选择Kotlin,处理器需要用到我们的自定义注解。因此,打开 processor/build.gradle 并在依赖项块中添加以下内容:

implementation project(':annotations')

打开 Processor.kt 并将导入和类声明替换为以下内容:

import com.guiying712.annotations.Router
import javax.annotation.processing.AbstractProcessor
import javax.annotation.processing.RoundEnvironment
import javax.annotation.processing.SupportedSourceVersion
import javax.lang.model.SourceVersion
import javax.lang.model.element.Element
import javax.lang.model.element.TypeElement

@SupportedSourceVersion(SourceVersion.RELEASE_8) // 1
class Processor : AbstractProcessor() { // 2

 override fun getSupportedAnnotationTypes() = mutableSetOf(Router::class.java.canonicalName) // 3

 override fun process(annotations: MutableSet<out TypeElement>?,
     roundEnv: RoundEnvironment): Boolean { // 4

   // TODO
   return true // 5
 }
}

下面解释下上面的代码:

  1. @SupportedSourceVersion 指定此处理器支持 Java 8

  2. 所有注解处理器都必须继承 AbstractProcessor 类。

  3. getSupportedAnnotationTypes() 定义了该处理器在运行时查找的一组注解。如果目标模块中的任何元素都没有使用该集合中的注解进行注释,则处理器将不会运行。

  4. process 是在每个注解处理轮次中调用的核心方法。

  5. 如果一切顺利,process 必须返回 true

接下来我们将注册这个注解处理器,为此,我们必须创建一个特殊文件:

我们必须使用 javac 注册处理器,以便编译器知道在编译期间调用它。

展开 processorsrcmain,添加一个名为 resources 的新目录。然后在资源中添加一个子目录并将其命名为 META-INF(必须大写字母)。最后,在META-INF添加一个命名为 services的子目录。在services添加一个空文件并将其命名为 javax.annotation.processing.Processor

注册处理器.png

打开文件javax.annotation.processing.Processor 并将我们创建的处理器的完全限定名称作为其内容。如下所示:

com.guiying712.processor.Processor

现在编译器知道我们的自定义处理器,并将在其预编译阶段运行它。

当然上面的方式实在是过于繁琐,因此Google给我开发了自动注册的工具 AutoService ,打开 processor/build.gradle 依赖项块中添加以下内容:

implementation "com.google.auto.service:auto-service:1.2.1"

打开 Processor.kt 并添加注解 @AutoService(Processor.class),如下所示:

@AutoService(Processor.class)
@SupportedSourceVersion(SourceVersion.RELEASE_8)
class Processor : AbstractProcessor() {

AutoService 将在输出类文件夹中生成文件 META-INF/services/javax.annotation.processing.Processor。该文件将包含:

com.guiying712.processor.Processor

使用注解处理器生成代码

创建一个Android library 模块用来保存路由框架相关的类,模块名称为router,语言依旧选择Kotlin。

然后新建一个类RouterAnnotationHandler,用于处理路由表,所有的路由信息都会交由此类处理,代码如下:

package com.guiying712.router

interface RouterAnnotationHandler {

    fun register(url: String, target: String)

}

再新建一个类RouterAnnotationInit ,用于初始化Router注解标记的路由信息,然后交由handler去处理,代码如下:

package com.guiying712.router

interface RouterAnnotationInit {

    fun init(handler: RouterAnnotationHandler)

}

注解处理器最终生成的代码如下:

package com.guiying712.router.generated

import com.guiying712.router.RouterAnnotationHandler
import com.guiying712.router.RouterAnnotationInit

class AnnotationInit_efb0660a2fd741f3a44a1d521a6f6b18 : RouterAnnotationInit {
  override fun init(handler: RouterAnnotationHandler) {
    handler.register("/demo", "com.guiying712.demo.MainActivity")
    handler.register("/demo2", "com.guiying712.demo.LoginActivity")
  }
}

接下来我们就编写注解处理器生成以上代码。

我们将使用 KotlinPoet 生成 Kotlin 源代码文件,打开 processor/build.gradle 并添加以下依赖项:

implementation 'com.squareup:kotlinpoet:1.10.2'

注解处理创建的源代码文件必须位于一个特殊的文件夹中,这个文件夹的路径是 kapt/kotlin/generated

为了告诉注解处理器将其生成的文件放在那里,在 Processor 类中添加一个伴生对象, 并添加以下代码:

companion object {
 	const val KAPT_KOTLIN_GENERATED_OPTION_NAME = "kapt.kotlin.generated"
}

然后在 process方法第一行中添加以下代码:

val kaptKotlinGeneratedDir = processingEnv.options[KAPT_KOTLIN_GENERATED_OPTION_NAME] ?: return false

这行代码的意思检查处理器是否能够找到必要的文件夹并将文件写入其中。如果可以,处理器将返回提供的使用路径。否则,处理器将中止并从 process 方法返回 false

新建一个文件,并将其命名为 RouterCodeBuilder.kt

class RouterCodeBuilder(
    private val kaptKotlinGeneratedDir: String,
    private val fileName: String,
    private val code: CodeBlock
) {
}

构造函数有三个参数:要生成代码的路径,正在编写的类的名称、代码块。然后在此类中添加一些常量:

private val routerAnnotationInit = ClassName("com.guiying712.router", "RouterAnnotationInit") // 1
private val routerAnnotationHandler = ClassName("com.guiying712.router", "RouterAnnotationHandler") // 2
private val generatedPackage = "com.guiying712.router.generated" // 3

ClassName 是一个 KotlinPoet API 类,它包装了一个类的完全限定名称,在生成的 Kotlin 源文件的顶部创建必要的导入。

  1. 代表要导入的 RouterAnnotationInit 类;
  2. 代表要导入的 RouterAnnotationHandler 类;
  3. 生成类的包名;
 fun buildFile() = FileSpec.builder(generatedPackage, fileName) // 1
        .addInitClass() // 2
        .build()
        .writeTo(File(kaptKotlinGeneratedDir)) // 3

解释下上面的代码::

  1. 定义一个包名generatedPackage,名称为fileName的文件;
  2. 向文件中添加一个名为 fileName 的类;
  3. 将生成的文件写入 kaptKotlinGeneratedDir 文件夹。

KotlinPoet 使用 TypeSpec 来定义类代码。

一个有用的技巧是在 FileSpec.Builder 上创建一个私有扩展函数,以便我们可以将代码片段整齐地插入到在上面创建的buildFile() 方法调用链中。

 private fun FileSpec.Builder.addInitClass() = apply { // 1
        addType(TypeSpec.classBuilder(fileName)  // 2
                .addSuperinterface(routerAnnotationInit) // 3
                .addInitMethod(code) // 3
                .build()
        )
    }

解释下上面的代码::

  1. addInitClassFileSpec.Builder 的一个扩展,它对其执行以下操作:
  2. 向文件中添加一个名为 fileName 的类;
  3. 此 类实现了 routerAnnotationInit 接口;
private fun TypeSpec.Builder.addInitMethod(code: CodeBlock) = apply { // 1
        addFunction(FunSpec.builder("init") // 2
                .addModifiers(KModifier.OVERRIDE) // 3
                .addParameter("handler", routerAnnotationHandler) // 4
                .returns(UNIT) // 5
                .addCode(code) // 6
                .build()
        )
    }

解释下上面的代码::

  1. addInitMethodTypeSpec.Builder 的一个扩展,它对其执行以下操作:
  2. 向类中添加一个名为 init 的方法;
  3. 该方法重写了一个抽象方法;
  4. addParameter 将参数添加到函数定义中,此方法覆盖的 init 方法有一个参数:handler
  5. 返回一个 UNIT
  6. 将代码块添加到方法体中;

最后一步是将其插入处理器。打开 Processor.kt 并将 process 方法的 TODO 替换为:

override fun process(annotations: MutableSet<out TypeElement>?, roundEnv: RoundEnvironment): Boolean {
        if (annotations == null || annotations.isEmpty()) return false // 1

        val kaptKotlinGeneratedDir = processingEnv.options[KAPT_KOTLIN_GENERATED_OPTION_NAME] ?: return false

        val codeBuilder = CodeBlock.Builder() // 2
        val fileName = "AnnotationInit" + "_" + UUID.randomUUID().toString().replace("-", "") // 3

        roundEnv.getElementsAnnotatedWith(Router::class.java)
            .forEach { element ->  // 4 
                val annotation = element.getAnnotation(Router::class.java) // 5
                val url = annotation.url
                val className = element.simpleName.toString() // 6
                val packageName = processingEnv.elementUtils.getPackageOf(element).toString() // 7

                val target = "$packageName.$className" // 8
                codeBuilder.addStatement("handler.register(%S, %S)", url, target)  // 9
            }
        RouterCodeBuilder(kaptKotlinGeneratedDir, fileName, codeBuilder.build()).buildFile() // 10
        return true 
    }

解释下上面的代码:

  1. 如果这一轮次的根元素没有注解,则 annotation 集合将为空,返回 false,表示我们自定义的注解处理器将不在处理;
  2. CodeBlock 是init方法的方法体,由于一个模块中可能会有多个 ActivityRouter注解标注,因此我们需要将所有被Router标注的Activity都收集起来;
  3. 要生成文件的名称是 AnnotationInit 加一个随机的UUID,防止多个模块之间生成的文件重复;
  4. element 是用 Router 注解标注的元素,在本例中是 Activity 类;
  5. 获取到此元素的注解,即 Router 注解,用于获取 Router 中的参数 url;
  6. 获取到此元素的简单名称,即 Activity 的类名;
  7. 获取到此元素的包名,即 Activity 的包名;
  8. 生成目标Activity的全限定名称,例如:com.guiying712.android.MainActivity
  9. 组装方法体。KotlinPoet 有自己的 字符串格式化标志
  10. 根据以上信息生成 RouterInit_X 类 ,切记每个模块只应该生成一个 RouterInit_X 类。

当发出包含字符串文字的代码时,我们可以使用 %S 发出一个字符串,并带有引号和转义。

现在自定义处理器就能找到使用Router注解的代码元素,从中提取数据,然后根据该信息生成新的Kotlin源文件。

在Android项目中使用注解处理器

打开 app/build.gradle 并将下面的依赖项添加到其中:

implementation project(':annotations') // 1
implementation project(':router') // 2
kapt project(':processor') // 3

打开 MainActivity.kt 并使用 Router注解该类:

@Router("/mian")
class MainActivity : AppCompatActivity() {
}

最后构建并运行项目,等结束后打开build文件夹,按照下图查找生成的文件:

生成代码.png

调试注解处理器

1、选择编辑配置:

添加调试.png

2、然后新建 Remote 配置,命名并保存:

远程JVM.png

3、打开 AndroidStudio 的 Terminal ,由于我们是在 Kotlin Kapt 编译期调试代码,所以执行以下命令:

gradlew clean build --no-daemon -Dorg.gradle.debug=true -Dkotlin.compiler.execution.strategy="in-process" -Dkotlin.daemon.jvm.options="-Xdebug,-Xrunjdwp:transport=dt_socket\,address=5005\,server=y\,suspend=n"

Starting Daemon.png

如果是 Java AnnotationProcessor 编译期调试代码,则执行以下命令:

gradlew.bat --no-daemon -Dorg.gradle.debug=true :app:clean :app:compileDebugJavaWithJavac

4、当命令行执行到 Starting Daemon 时,在需要调试的地方打上断点,然后运行 debug 按钮,稍等一下(速度比较慢耐心点) 就会在AbstractProcessor 中进入到断点所在位置,然后就可以一步步进行调试了。

在处理器中记录日志和处理错误

在注解处理时我们可以通过添加日志来打印调试说明、警告和错误,这些消息将与其他构建任务一起显示在构建输出窗口中。

Build Output.png

新建一个 ProcessorLogger.kt,向其添加以下代码:

import javax.annotation.processing.ProcessingEnvironment
import javax.lang.model.element.Element
import javax.tools.Diagnostic

class ProcessorLogger(private val env: ProcessingEnvironment) {

    fun n(message: String, element: Element? = null) { 
        print(Diagnostic.Kind.NOTE, message, element) // 1
    }

    fun w(message: String, element: Element? = null) {
        print(Diagnostic.Kind.WARNING, message, element) // 1
    }

    fun e(message: String, element: Element? = null) {
        print(Diagnostic.Kind.ERROR, message, element) // 1
    }

    private fun print(kind: Diagnostic.Kind, message: String, element: Element?) {
        print("\n")
        env.messager.printMessage(kind, message, element)  // 2

    }
}

解释下上面的代码:

  1. 提供三种级别的日志记录方法:n 表示注释,w 表示警告,e 表示错误;
  2. 三种方法都使用 print,在 element 的位置上打印指定类型的消息。

接下来打开 Processor.kt。在类中添加以下代码:

private val logger by lazy { ProcessorLogger(processingEnv) }

现在就可以用 logger 在注解处理时打印日志了。

分析种类、数据类型和可见性修饰符的代码元素。

开始前,先简单介绍下一些基础的代码元素知识。

Element 表示一个程序元素,比如包、类或者方法。每个元素都表示一个静态的语言级构造(不是虚拟机的运行时构造)。

ExecutableElement表示某个类或接口的方法、构造方法或初始化程序(静态或实例),包括注释类型元素。
PackageElement表示一个包程序元素。提供对有关包及其成员的信息的访问。
TypeElement表示一个类或接口程序元素。提供对有关类型及其成员的信息的访问。注意,枚举类型是一种类,而注释类型是一种接口。
TypeParameterElement表示一般类、接口、方法或构造方法元素的形式类型参数。
VariableElement表示一个字段、enum 常量、方法或构造方法参数、局部变量或异常参数。

可以通过 使用 element.getKind() 方法返回此元素的类型 ElementKind ,以下是 ElementKind 的全部枚举:

类型枚举类型含义
PACKAGE一个包
CLASS没有用更特殊的种类(如 ENUM)描述的类
INTERFACE没有用更特殊的种类(如 ANNOTATION_TYPE)描述的接口。
ENUM一个枚举类型
ENUM_CONSTANT一个枚举常量
ANNOTATION_TYPE一个注解类型
CONSTRUCTOR一个构造方法
FIELD没有用更特殊的种类(如 ENUM_CONSTANT)描述的字段
METHOD方法
PARAMETER方法或构造方法的参数
LOCAL_VARIABLE局部变量
EXCEPTION_PARAMETER异常处理程序的参数
INSTANCE_INIT一个常量初始化程序
STATIC_INIT一个静态初始化程序
TYPE_PARAMETER一个类型参数
OTHER一个为实现保留的元素

由于本文的目的是为了使用 Router注解标注Activity来生成路由表单,因此我们需要检验Router 标注的元素是否符合要求。

在 Processor.kt 添加一个方法来验证 @Router 标注的类是否满足条件:

 private fun validateActivity(element: Element): Boolean {
        (element as? TypeElement)?.let { // 1
            if (!processingEnv.typeUtils.isSubtype(element.asType(), processingEnv.elementUtils.getTypeElement("android.app.Activity").asType())) { // 2
                logger.e("Router注解只能标注Activity", element)
                return false
            }
            val modifiers = it.modifiers
            if (Modifier.ABSTRACT in modifiers) { // 3
                logger.e("Activity不可以是抽象类", element)
                return false
            }
            return true
        } ?: return false
    }

此方法验证三个条件:

  1. 首先,通过检查元素是否为 TypeElement来检查元素是否为类;
  2. 然后检查元素的 类型 是否是 Activity类型,这需要 typeUtilselementUtils
  3. 最后,确保Activity的修饰符不是 ABSTRACT 。

process 方法中使用这个方法:

    override fun process(annotations: MutableSet<out TypeElement>?, roundEnv: RoundEnvironment): Boolean {
        if (annotations == null || annotations.isEmpty()) return false

        val kaptKotlinGeneratedDir = processingEnv.options[KAPT_KOTLIN_GENERATED_OPTION_NAME] ?: return false

        val codeBuilder = CodeBlock.Builder()
        val fileName = "AnnotationInit" + "_" + UUID.randomUUID().toString().replace("-", "")

        roundEnv.getElementsAnnotatedWith(Router::class.java) // 1
            .forEach { element -> // 2
                if (!validateActivity(element)) return false

                val annotation = element.getAnnotation(Router::class.java)
                val className = element.simpleName.toString()
                val url = annotation.url
                val packageName = processingEnv.elementUtils.getPackageOf(element).toString()

                val target = "$packageName.$className"
                codeBuilder.addStatement("handler.register(%S, %S)", url, target)
                logger.n("Router located: $target \n") // 5
            }
        RouterCodeBuilder(kaptKotlinGeneratedDir, fileName, codeBuilder.build()).buildFile()
        return true // 5
    }

最后可以写一个类,然后使用@Router标注此类,构建并运行,看下编译结果。

本文中的源代码如下:

github.com/guiying712/…