6、KSP实战:SymbolProcessor的编写、Resolving与Dependencies管理
好,咱们今天来啃KSP最核心的一块——SymbolProcessor。说实话,我刚接触KSP那会儿,觉得它跟APT差不多,不就是换个API嘛。但真正上手写了一个Processor之后,我发现这玩意儿的设计思路完全不一样。尤其是Resolving和Dependencies这块,搞不明白的话,你写出来的Processor要么跑不动,要么跑起来就崩。
咱们一步步来。先搭个架子,再往里填逻辑。
6.1 编写一个最简单的SymbolProcessor
KSP里,你所有的处理逻辑都放在一个实现类里,这个类就叫SymbolProcessor。它只有一个核心方法:process()。
我习惯先写一个空壳子,确保它能被KSP发现并调用。来看代码:
class MyProcessor(private val env: SymbolProcessorEnvironment) : SymbolProcessor {
override fun process(resolver: Resolver): List<KSAnnotated> {
// 这里写你的处理逻辑
return emptyList()
}
}
嗯,这里要注意:SymbolProcessorEnvironment里藏着很多好东西,比如logger、options、codeGenerator。我刚开始做项目时,总喜欢把日志打得到处都是,后来发现用env.logger.warn()比println()靠谱多了——至少它能在Gradle输出里带上前缀,方便定位。
然后你需要把这个Processor注册到META-INF/services里。KSP跟APT一样,也是用SPI机制发现的。文件路径是:
META-INF/services/com.google.devtools.ksp.processing.SymbolProcessor
文件内容就一行:
com.example.processor.MyProcessor
别问我为什么不用注解注册,KSP目前就是这套路。我一开始也踩过这个坑——写好了Processor,跑起来没反应,查了半天才发现忘了写这个文件。
6.2 Resolver:你的“扫描仪”
Resolver是KSP里最重要的对象之一。说白了,它就是你的扫描仪,帮你找到所有需要处理的符号。
我个人最常用的几个API:
getSymbolsWithAnnotation()—— 按注解找符号getAllFiles()—— 获取所有源文件resolve()—— 解析一个引用到它的声明
举个例子,假设你要处理所有被@BindView注解的字段:
override fun process(resolver: Resolver): List<KSAnnotated> {
val symbols = resolver.getSymbolsWithAnnotation("com.example.BindView")
symbols.filterIsInstance<KSPropertyDeclaration>().forEach { prop ->
// 处理每个字段
val annotation = prop.annotations.first {
it.shortName.asString() == "BindView"
}
val viewId = annotation.arguments.first().value as Int
env.logger.info("Found @BindView on ${prop.simpleName.asString()}, id=$viewId")
}
return emptyList()
}
你想想看,如果是在APT里,你得写一堆TypeMirror、Element的转换代码。但在KSP里,KSPropertyDeclaration直接就能拿到注解参数,干净利落。
不过有一点要注意:getSymbolsWithAnnotation()返回的是Sequence,不是List。这意味着你只能遍历一次。我刚开始没注意,遍历了两遍,结果第二遍啥都没拿到——排查了半天才发现是这个问题。
6.3 Resolving:从符号到类型
很多时候,你拿到一个符号还不够,你得知道它到底是什么类型。比如你找到了一个字段,它声明为Button,但实际类型可能是AppCompatButton。这时候就需要resolve()了。
来看一个实际场景:
val prop: KSPropertyDeclaration = ...
val type: KSType = prop.type.resolve()
env.logger.info("Resolved type: ${type.declaration.qualifiedName?.asString()}")
resolve()返回的是KSType,它包含了完整的类型信息。你可以用它来检查类型是否匹配:
val buttonType = resolver.getKSClassDeclarationByName("android.widget.Button")?.asType(emptyList())
if (type.isAssignableFrom(buttonType)) {
// 这个字段是Button或其子类
}
我曾经在做一个依赖注入框架时,需要判断一个字段的类型是不是ViewModel的子类。用isAssignableFrom就搞定了,比APT里写TypeMirror的erasure方便太多。
resolve()是有成本的。每次调用都会触发类型解析,如果你的Processor处理了几百个符号,每个都resolve()一次,编译时间会明显变长。我建议只在需要类型信息时才调用,能缓存就缓存。
6.4 Dependencies管理:别让你的Processor“失忆”
KSP的增量编译机制跟APT不太一样。在APT里,你只需要告诉它哪些文件被修改了。但在KSP里,你得显式声明依赖关系。
为什么要这么做?因为KSP需要知道:如果某个文件变了,你的Processor需不需要重新跑?
来看SymbolProcessor的另一个方法:
override fun finish() {
// 处理结束时的清理工作
}
override fun dependencies(): Dependencies {
return Dependencies(
aggregating = false,
sources = emptyList()
)
}
Dependencies有两个关键参数:
aggregating:是否为聚合型Processor。如果是true,表示你的输出依赖于所有输入文件;只要有一个文件变了,整个Processor就得重跑。sources:显式声明的依赖文件列表。
我举个例子你就明白了。假设你写了一个生成R.java的Processor:
override fun dependencies(): Dependencies {
return Dependencies(
aggregating = true, // 所有资源文件都可能影响输出
sources = getAllResourceFiles(resolver)
)
}
如果设置成aggregating = false,那么KSP会尝试做更细粒度的增量处理——只重新处理那些被修改的文件。这能大幅提升编译速度。
aggregating = true。它虽然慢一点,但不会出错。等你的Processor稳定了,再优化成false。我曾经在一个大项目里把aggregating改成false,编译时间从40秒降到了12秒——但前提是你的Processor逻辑必须正确处理好增量场景。
6.5 实战:一个完整的Processor示例
咱们把上面这些知识点串起来,写一个完整的Processor。它的功能是:找到所有被@AutoRegister注解的类,生成一个注册表文件。
class AutoRegisterProcessor(env: SymbolProcessorEnvironment) : SymbolProcessor {
private val logger = env.logger
private val codeGenerator = env.codeGenerator
override fun process(resolver: Resolver): List<KSAnnotated> {
val symbols = resolver.getSymbolsWithAnnotation("com.example.AutoRegister")
val classes = symbols.filterIsInstance<KSClassDeclaration>().toList()
if (classes.isEmpty()) return emptyList()
// 收集所有类名
val classNames = classes.map { it.qualifiedName?.asString() ?: "" }
// 生成注册表文件
val content = classNames.joinToString("\n")
val file = codeGenerator.createNewFile(
dependencies = Dependencies(true, *classes.map { it.containingFile!! }.toTypedArray()),
packageName = "com.example",
fileName = "AutoRegister",
extensionName = "txt"
)
file.write(content.toByteArray())
file.close()
logger.info("Generated AutoRegister.txt with ${classNames.size} entries")
return emptyList()
}
override fun dependencies(): Dependencies {
return Dependencies(aggregating = true)
}
}
这个例子虽然简单,但涵盖了Processor的核心流程:扫描符号、解析类型、生成文件、管理依赖。你在实际项目中遇到的场景,基本都能套用这个模板。
6.6 避坑指南
最后,分享几个我踩过的坑:
- 不要返回未处理的符号:
process()方法返回的List<KSAnnotated>表示“这次没处理完的符号”,KSP会在下一轮继续处理。如果你返回了所有符号,就会陷入死循环。我刚开始就犯过这个错,编译直接卡死。 - 小心空指针:
KSAnnotated的containingFile可能是null,尤其是那些来自依赖库的符号。访问前一定要判空。 - 日志是你的好朋友:KSP的
logger支持info、warn、error三个级别。我习惯在关键步骤打info日志,方便排查问题。
- SymbolProcessor是KSP的入口,实现
process()方法即可 - Resolver负责扫描和解析符号,
resolve()用于获取完整类型信息 - Dependencies管理增量编译,
aggregating参数决定粒度 - 善用
codeGenerator生成文件,记得传入正确的依赖
好了,SymbolProcessor的核心内容就这些。说白了,它就是一套“扫描-解析-生成”的流程。你只要把这三个环节搞明白,剩下的就是业务逻辑的堆砌了。