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里藏着很多好东西,比如loggeroptionscodeGenerator。我刚开始做项目时,总喜欢把日志打得到处都是,后来发现用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里,你得写一堆TypeMirrorElement的转换代码。但在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里写TypeMirrorerasure方便太多。

注意: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会在下一轮继续处理。如果你返回了所有符号,就会陷入死循环。我刚开始就犯过这个错,编译直接卡死。
  • 小心空指针KSAnnotatedcontainingFile可能是null,尤其是那些来自依赖库的符号。访问前一定要判空。
  • 日志是你的好朋友:KSP的logger支持infowarnerror三个级别。我习惯在关键步骤打info日志,方便排查问题。
核心要点:
  • SymbolProcessor是KSP的入口,实现process()方法即可
  • Resolver负责扫描和解析符号,resolve()用于获取完整类型信息
  • Dependencies管理增量编译,aggregating参数决定粒度
  • 善用codeGenerator生成文件,记得传入正确的依赖

好了,SymbolProcessor的核心内容就这些。说白了,它就是一套“扫描-解析-生成”的流程。你只要把这三个环节搞明白,剩下的就是业务逻辑的堆砌了。

SymbolProcessor 核心流程 1. 扫描符号 getSymbolsWithAnnotation() 2. 解析类型 resolve() / isAssignableFrom() 3. 生成文件 codeGenerator Dependencies 管理 aggregating = true/false | sources = [...] 增量编译:只处理变更文件,提升编译速度 聚合模式:任一依赖变更,全部重新处理 ⚠ 不要返回未处理的符号,避免死循环

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