Kaithmy带你快速入门APT

1,074 阅读4分钟

APT是什么?

APT全称Annotation Processing Tool,即注解处理器,是一种用来处理注解的工具。在JVMjava文件编译成class文件前便扫描java文件并处理注解生成文件。由于是编译前处理,故通常用来生成源码文件,无论是Java还是Kotlin文件。

APT能干什么?

在编译流程前,代码能干的事儿它都能干,主要用来生成文件,源码文件或者配置文件都行。

APT的优缺点?

  • 优点:很明显,能在进入编译流程前做一些简单的处理,对于有编译前处理文件需求的人来说是个福音
  • 缺点:它的优点当然也是它的缺点,即不能处理编译流程之后的事儿

如何使用APT?

声明注解

新建一个Java library,命名为apt-annotation,并新建一个注解类annotation class BindView,这里我们模仿ButterKnifeclass命名。注解类主要内容如下

package com.kaithmy.apt_annotation

@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.FIELD)

annotation class BindView(
    val value: Int
)

其中变量value我们用于存储需要绑定到组件变量的组件id,如@BindView(R.id.tvSend),当然了,AGP(Android Gradle Plugin)5.0 之后不再保证生成的资源id是不可变的,这里只是为了演示,先这么用吧。如需要类似于ButterKnife的功能,推荐使用Kotlin Android Extension,虽然官方最近也准备将其废弃转为推广ViewBinding

声明注解处理器

新建一个Java library,命名为apt-processor,新建一个注解处理类BindViewProcessor,我们使其 继承抽象类AbstractProcessor,并重写其中的两个方法initprocess。顾名思义,init即为我们的初始化方法,process则为我们处理注解的主要方法,我们处理注解的逻辑主要都在process方法里。同时我们还要重写getSupportedAnnotationTypes方法,他的返回值代表了我们能处理那些注解类型,当然我们也可以重写getSupportedSourceVersion方法来声明支持的JDK版本,这里我们声明支持JDK1.8

   // 支持的注解类型,可以使用@SupportedAnnotationTypes("com.kaithmy.apt_annotation.BindView")代替
    override fun getSupportedAnnotationTypes(): MutableSet<String> {
        return mutableSetOf(BindView::class.java.canonicalName)
    }

    // 支持的JDK版本,可以使用@SupportedSourceVersion(SourceVersion.RELEASE_8)代替
    override fun getSupportedSourceVersion(): SourceVersion {
        return SourceVersion.RELEASE_8
    }

如果嫌弃每次都要重写这两个方法麻烦,我们可以使用注解代替,将注解添加到我们的BindViewProcessor上,如

@SupportedSourceVersion(SourceVersion.RELEASE_8)
@SupportedAnnotationTypes("com.kaithmy.apt_annotation.BindView")
class BindViewProcessor : AbstractProcessor() {
    .......
}

认识init方法及其主要四个工具类

init方法中会传给我们一个ProcessingEnvironment类型的参数processingEnv,顾名思义,它是注解处理环境。

调用其getFiler方法能得到一个Filer类型的filerUtils,即文件管理工具类,操作文件相关的类;

调用其 getTypeUtils方法能得到一个Types类型的typeUtils,即类型处理工具类,主要用于处理类型相关的类;

调用其 getElementUtils方法得到一个Elements类型的elementUtils,即Element处理工具类,主要用于处理由类结构化的Element

调用其 getMessager方法得到一个Messager类型的messagerUtils,即日志工具类,主要用于输出处理过程中的日志

声明api入口

我们先创建一个接口IBindHelper,并提供一个inject方法, 代码如下:

package com.kaithmy.apt_api

interface IBindHelper {
    fun inject(target: Any?)
}

然后创建一个object类型的类KBind,并提供inject方法(可以是其他名字,莫要与接口中的inject搞混),代码如下:

 fun inject(target: Any?) {
        target ?: return
        val qualifiedName = target::class.qualifiedName
        val helperClassName = "$qualifiedName\$AutoBind"

        val helperInstance =
            Class.forName(helperClassName).getConstructor().newInstance() as? IBindHelper ?: return
        helperInstance.inject(target)
    }

大意是通过传递进来的对象的类名qualifiedName找到对应生成的类helperClassName,并通过Class.forName()创建对应示例,再调用其中的inject方法,inject方法中进行findViewById操作并赋值,进而实现注入。

认识process方法,找到注解并保存注解信息

具体流程详见以下代码:

    /**
     * Note 1:
     * process()执行两次是因为首次输入class未输出文件,第二次输入为空输出为空
     *
     * Note 2:
     * 返回值true代表注解已声明处理,并不要求后续Processor处理他们;
     * false则表示注解未声明,并可能要求后续Processor处理他们
     */
    override fun process(
        annotations: MutableSet<out TypeElement>?,
        roundEnv: RoundEnvironment?
    ): Boolean {
        println("process start ========")
        if (annotations.isNullOrEmpty() || roundEnv == null) return false
        // 被BindView注解的所有Element
        val bindViewElements = roundEnv.getElementsAnnotatedWith(BindView::class.java)
        // 通过不同的宿主进行分类,如AActvity,BActivity,AFragment等
        categories(bindViewElements)
        bindMap.forEach {
            // 生成相应的代码
            generateCode(it)
        }
        println("process end ========")
        return true
    }

    /**
     * 收集被注解的信息
     */
    private fun categories(bindViewElements: MutableSet<out Element>) {
        // 过滤出被BindView注解的变量element
        bindViewElements.forEach {
            // 获取对应的上一级element
            val enclosingElement = it.enclosingElement
            // 将views放入bindMap
            var views = bindMap[enclosingElement]
            if (views == null) {
                views = hashSetOf()
                bindMap[enclosingElement] = views
            }

            // 将变量名与id对应起来放入views
            val annotation = it.getAnnotation(BindView::class.java)
            val id = annotation.value
            views.add(
                ViewInfo(it.simpleName.toString(), id, it.asType())
            )
        }
    }

生成目标文件

通过以上步骤,我们已经将对应的注解信息保存了下来,接下来我们需要做的便是生成代码了,生成代码目前由三种方式,其中一种是直接通过StringBuilder字符串拼接生成目标代码,另一个是通过辅助工具来生成目标代码,由于使用的语言不同,故又细分为二,一是JavaPoet,二是KotlinPoet

JavaPoet生成

        val SUFFIX = "\$AutoBind"
        val elementName = entry.key.simpleName.toString()
        val packageName = elementUtils.getPackageOf(entry.key).qualifiedName.toString()

        val methodSpecBuilder = MethodSpec.methodBuilder("inject")
            .addAnnotation(Override::class.java)
            .addModifiers(Modifier.PUBLIC)
            .addParameter(Any::class.java, "target")
            .addCode("$elementName ins = ($elementName)target; \n")

        entry.value.forEach {
            methodSpecBuilder
                .addCode(
                    "ins.set${
                        it.viewName.replaceFirstChar { first ->
                            first.toUpperCase()
                        }
                    }(ins.findViewById(${it.resId})); \n"
                )
        }

        val typeSpec = TypeSpec.classBuilder("$elementName$SUFFIX")
            .addModifiers(Modifier.PUBLIC)
            .addSuperinterface(IBindHelper::class.java)
            .addMethod(methodSpecBuilder.build())
            .build()

        JavaFile.builder(packageName, typeSpec).build().writeTo(filerUtils)

KotlinPoet生成

        val funBuilder = FunSpec.builder("inject")
            .addModifiers(KModifier.OVERRIDE)
            .addParameter("target", Any::class.asTypeName().copy(nullable = true))
            .addCode("val ins = target as? $elementName?:return \n")

        entry.value.forEach {
            funBuilder.addCode(
                "ins.${it.viewName} = ins.findViewById<${it.type}>(${it.resId}) \n"
            )
        }

        val fileSpec = FileSpec.builder(packageName, "$elementName$SUFFIX")
            .addType(
                TypeSpec.classBuilder("$elementName$SUFFIX")
                    .addSuperinterface(IBindHelper::class)
                    .addFunction(funBuilder.build())
                    .build()
            ).build()
        fileSpec.writeTo(filerUtils)

通过SPI机制让processor生效

SPI,即Service Provicer Interface,JDK内置的服务发现机制。在模块apt-processorsrc/main中,我们新建一个文件夹META-INF,在其中新建一个子文件夹services,并在services新建一个文件javax.annotation.processing.Processor,并在文件里写入我们的BindViewProcessor的全类名com.kaithmy.apt_processor.BindViewProcessor,至此,我们的BindViewProcessor已生效。

通过AutoServcie避免繁琐的SPI过程

总觉得以上的操作有点繁琐,那么有什么方法避免这繁琐的步骤呢?我们可以通过引入AutoServcie来避免。首先在apt-processor中的dependencies引入AutoServices依赖,如:

    api "com.google.auto.service:auto-service:1.0-rc7"
    kapt "com.google.auto.service:auto-service:1.0-rc7"

然后在我们的BindViewProcessor上加上类注解@AutoService(Processor::class)即可

如何对进行调试?

当我们需要对processor进行调试该如何操作呢?

配置gradle.properties

将以下内容加入project下的gradle.properties中,

org.gradle.jvmargs=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005
-Dorg.gradle.debug=true

配置Remote Debug

Run/Debug Configurations中 新建一个Remote并命名为任意你想要的名字,这里命名为ProcessorDebug,点击ok保存好后到IDE中选中刚刚新建的ProcessorDebug并点击run或者debug启动。

image.png

断点并build触发

先通过工具栏clean project清除已有的build文件夹防止之前生成的文件造成影响,然后在你想要断点的位置打上断点,正常执行build/assemble即可,或者在terminal中执行命令./gradlew app:build便可正常调试了。

如何接入自定义processor并使用?

在你的app moudle中引入apt-annotationapt-processor两个moudleapt-processor引入时需注意Java使用annotationProcessorKotlin使用kapt

image.png

然后在你需要使用注解的地方加上注解即可,示例如下:

class MainActivity : AppCompatActivity() {
    @BindView(R.id.tvOpen)
    var tvOpen: TextView? = null

    @BindView(R.id.tvClose)
    var tvClose: TextView? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        KBind.bind(this)
        tvOpen?.setOnClickListener {
            Toast.makeText(this, "Open clicked", Toast.LENGTH_SHORT).show()
        }
    }
}

Tips

如何监控各个Gradle Task 耗时并统计总编译耗时?

Gradle其实有提供TaskExecutionListenerBuildListener用于Task的执行回调,通过这两个回调接口我们可以很方便地统计各个Task耗时及总耗时。Task细节就不展开讲了,后面讲trasform再细说,直接在project下的build.gradle中加入以下代码即可


import java.util.concurrent.TimeUnit

class TimingsListener implements TaskExecutionListener, BuildListener {
    private long startTime
    private timings = []

    @Override
    void beforeExecute(Task task) {
        startTime = System.nanoTime()
    }

    @Override
    void afterExecute(Task task, TaskState taskState) {
        def ms = TimeUnit.MILLISECONDS.convert(System.nanoTime() - startTime, TimeUnit.NANOSECONDS)
        timings.add([ms, task.path])
        task.project.logger.log(LogLevel.WARN, "${task.path} took ${ms}ms")
    }

    @Override
    void buildStarted(Gradle gradle) {

    }

    @Override
    void settingsEvaluated(Settings settings) {

    }

    @Override
    void projectsLoaded(Gradle gradle) {

    }

    @Override
    void projectsEvaluated(Gradle gradle) {

    }

    @Override
    void buildFinished(BuildResult buildResult) {
        println("Task timings")
        for (timing in timings) {
            printf "%sms %s\n", timing
        }
    }
}

gradle.addListener(new TimingsListener())