5、Kotlin注解处理器:KSP与KAPT的对比、注解处理器的生命周期

好,咱们今天来聊聊注解处理器。说实话,这个知识点在Android开发里属于那种「平时用不上,一用就离不开」的东西。我自己刚入行那会儿,看到项目里一堆@BindView@Route之类的注解,心里就犯嘀咕:这玩意儿到底是怎么工作的?后来自己动手写了一个注解处理器,才算真正搞明白。

今天咱们重点聊两个东西:一个是KAPT,一个是KSP。另外,注解处理器的生命周期也得搞清楚,不然你写出来的处理器可能连编译都过不了。

5.1 KAPT vs KSP:到底选哪个?

先说说KAPT。KAPT的全称是Kotlin Annotation Processing Tool。说白了,它就是让Kotlin代码也能用上Java的注解处理器。我记得2017年那会儿,Kotlin刚火起来,ButterKnife、Dagger这些库全得靠KAPT才能跑起来。

KAPT的工作原理是这样的:它先把Kotlin代码编译成Java桩文件(stub),然后再让Java的注解处理器去处理这些桩文件。你想想看,这中间多了一层转换,编译速度能快吗?

我曾在项目里遇到过一个问题:一个中等规模的项目,用了KAPT处理Dagger和Room的注解,每次增量编译都要等30秒以上。后来换成KSP,直接降到了10秒以内。差距就是这么明显。

那KSP呢?KSP是Google专门为Kotlin设计的注解处理器。它直接操作Kotlin的语法树,不需要先生成Java桩文件。说白了,就是绕过了中间商,效率自然高。

核心区别一句话总结:

  • KAPT:Kotlin → Java桩文件 → Java注解处理器 → 生成代码
  • KSP:Kotlin语法树 → KSP处理器 → 生成代码

咱们用表格对比一下,更直观:

对比维度 KAPT KSP
编译速度 慢(多一层桩文件生成) 快(直接操作语法树)
Kotlin支持 通过桩文件间接支持 原生支持Kotlin特性
增量编译 支持较差 支持较好
兼容性 兼容所有Java注解处理器 需要专门适配KSP
适用场景 老项目迁移、依赖Java库 新项目、纯Kotlin项目

看到这个表格,你可能会问:那我现在该选哪个?我的建议是:新项目无脑上KSP。老项目如果依赖的库还不支持KSP,那就先用KAPT顶着,等库升级了再切。

小技巧:在build.gradle.kts里配置KSP时,记得加上这个:

ksp {
    arg("room.schemaLocation", "$projectDir/schemas")
}

这样Room生成的数据库schema文件会放到指定目录,方便版本管理。

5.2 注解处理器的生命周期

好,接下来咱们聊聊生命周期。嗯,这里要注意,注解处理器的生命周期和Activity、Fragment那种不一样。它是在编译期运行的,说白了就是一段在javac或kotlinc编译过程中执行的代码。

一个标准的注解处理器生命周期包含四个阶段:

  1. 初始化(init):处理器被创建时调用,一般在这里获取工具类。
  2. 处理(process):核心阶段,处理注解并生成代码。
  3. 轮次(round):可能有多轮处理,每轮处理完如果有新生成的代码,会触发下一轮。
  4. 结束(terminate):所有处理完成后调用,用于清理资源。

我画了一张图,帮你理解这个流程:

注解处理器生命周期 1. 初始化 init() 方法 2. 处理(第1轮) process() 方法 有生成? 下一轮 3. 结束 terminate() 方法 多轮处理:如果第1轮生成了新的代码,编译器会重新扫描并触发第2轮处理 直到某轮没有新代码生成,才进入结束阶段 初始化 处理 结束 判断

看到这张图,你应该能理解为什么注解处理器有时候会跑好几轮了。举个例子,假设你写了一个处理器,它生成了一个新类,而这个新类上也有注解。那编译器就会再跑一轮,让处理器去处理这个新类上的注解。

注意:我曾经踩过一个坑——在process()方法里生成了代码,但没有返回true。结果编译器以为这一轮没有新代码生成,直接结束了。生成的代码根本没被编译进去。所以记住:如果生成了新代码,一定要返回true,告诉编译器还有下一轮。

5.3 实战:写一个简单的KSP处理器

光说不练假把式。咱们写一个最简单的KSP处理器,用来收集所有被@BindView注解的字段。

先定义注解:

@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.SOURCE)
annotation class BindView(val id: Int)

然后写处理器:

class BindViewProcessor : SymbolProcessor {
    override fun process(resolver: Resolver): List<KSAnnotated> {
        // 获取所有被 @BindView 注解的符号
        val symbols = resolver
            .getSymbolsWithAnnotation("com.example.BindView")
            .filterIsInstance<KSPropertyDeclaration>()
        
        // 收集信息
        val bindings = mutableListOf<Pair<String, Int>>()
        symbols.forEach { symbol ->
            val annotation = symbol.annotations
                .first { it.shortName.asString() == "BindView" }
            val id = annotation.arguments
                .first { it.name?.asString() == "id" }
                .value as Int
            bindings.add(symbol.simpleName.asString() to id)
        }
        
        // 生成代码(这里省略具体生成逻辑)
        // ...
        
        return emptyList() // 没有新符号需要处理
    }
}

这段代码看着简单,但有几个关键点:

  • getSymbolsWithAnnotation:获取所有被指定注解标记的符号
  • filterIsInstance:只保留属性声明(字段)
  • annotation.arguments:获取注解的参数值

避坑指南:我曾经在KSP里直接调用symbol.annotations.first(),结果空指针了。原因是有些符号可能没有注解。所以记得加个判空,或者用firstOrNull()

5.4 总结一下

今天咱们聊了KAPT和KSP的区别,说白了就是:KAPT是走Java的老路,KSP是Kotlin的亲儿子。性能上KSP完胜,但兼容性上KAPT更胜一筹。

注解处理器的生命周期其实不复杂:初始化→处理(可能多轮)→结束。记住一点:生成了新代码就返回true,否则返回false。

最后,如果你正在写一个新的Android项目,我强烈建议你直接用KSP。别犹豫,真的。我在两个项目里做过对比,KSP的编译速度比KAPT快了将近3倍。这可不是小数目,你想想看,每天编译几十次,省下来的时间够你喝好几杯咖啡了。


公众号:蓝海资料掘金营,微信deep3321