KSP实战:用Kotlin符号处理替代kapt,让Android编译快一倍
2026/9/10 4:17:01 网站建设 项目流程

我接手过一个多模块Android项目,编译时间长期在三分钟以上。平时改一行代码,少说也要等四五十秒,大部分时间都花在构建日志里反复出现的kapt stub generationAnnotation processing上。后来我把整套注解处理从kapt切到了KSP,全量编译时间基本砍了一半,增量构建也稳了很多,不会再出现"改一行Kotlin却把所有类重新处理一遍"的尴尬局面。

这里说的KSP,全称是Kotlin Symbol Processing,是Kotlin官方推出的编译期符号处理框架。它的定位和kapt一样,都是在编译阶段扫描源码里的注解和声明,然后生成新的代码,但处理模型和kapt完全不同。这篇文章我会从KSP到底做了什么说起,然后带你把一个最小可用的Processor从零跑通,再聊聊符号解析、代码生成、增量构建里那些真正容易踩的坑。适合正在用kapt、想迁移的Android开发者,也适合想自己写编译期生成工具的人参考。

1. 不要急着写代码:KSP是怎么解决kapt那些老问题的

1.1 kapt慢在"翻译"这一步

kapt全称是Kotlin Annotation Processing Tool,早期Kotlin没有自己的注解处理能力,只能借用Java的注解处理器接口。Java处理器只认Java代码,所以kapt得先把Kotlin源码"翻译"成一种叫Java stub的中间产物,本质上就是一堆只保留了结构、没有方法体的Java文件。

为了让Java注解处理器能看懂,kapt要把Kotlin的语法结构全部重新表达一遍。问题在于,Kotlin的很多特性在Java模型里根本没有对应物,比如data class、可空类型、伴生对象、扩展函数、属性委托,这些信息在stub转换过程中要么丢失,要么被强行降级成Java能表达的形式。所以你在Kotlin注解里写的val user: User?,到了Java处理器里可能就变成了一个被擦除的引用,类型得不完整,处理逻辑就要做很多防御性判断。

翻译这个过程本身还会产生大量中间文件,每个模块的Kotlin编译器都要跑一遍完整的stub生成。多模块项目里,每个模块各自翻译、各自处理、各自再编译一遍,时间全耗在这了。而且stub生成破坏了Kotlin编译器的增量能力,经常导致很小的改动触发大面积重复处理。

1.2 KSP直接读Kotlin符号,不走翻译

KSP不是把Kotlin翻译给谁看,它是作为Kotlin编译器的插件直接运行在编译管道里的。它拿到的就是编译前端的符号解析结果,你写的类是啥样它就长啥样,可空类型、data class、伴生对象、扩展属性,一概不丢。

所以KSP处理器的开发体验比kapt舒服得多。写KSP Processor的时候,你直接面对Kotlin的类型系统,判断一个类是否为data class,检查某个属性是否可空,这些操作都是API原生支持的,不再需要从Java模型里反推。

性能差距主要是省掉了stub生成和javac process两套机制叠加的消耗。KSP官方给的数据是比kapt快约2倍,实际项目中如果预处理逻辑复杂,差距会更明显。KSP2是基于Kotlin编译器新架构的实现,在Kotlin 2.0之后逐步成为默认版本,对Kotlin Multiplatform的支持也更完整,整体处理速度比KSP1还有提升。

1.3 生态支持现状:该不该迁移

决定迁移前,先看看你项目里依赖的注解处理库是否支持KSP。目前主流库基本都支持了:Room、Moshi、Hilt、AutoService、kotlinx.serialization这些都有KSP接入方式。如果你的项目主要依赖这些库,迁移成本很低,基本就是把kapt换成ksp依赖声明。

如果项目里还有你自己写的自定义注解处理器,那这部分需要重写,因为KSP API和kapt的Java注解处理API完全不同,代码不能平移,只能重新实现。纯Java项目的注解处理则没必要迁到KSP,Java项目继续用现有的javac处理机制没有性能劣势。

2. 从零搭一个最小可运行的Processor

2.1 版本匹配是第一个大坑

KSP插件版本和Kotlin版本是强绑定的,版本号格式一般是kotlinVersion-kspVersion,比如2.0.21-1.0.28。前面是Kotlin版本,后面是KSP自身的发布号。用错版本最常见的报错就是Kotlin编译器版本不兼容,有时候报错信息还不那么容易看懂。

我建议在项目根目录的libs.versions.toml里统一管理版本,比如:

[versions] kotlin = "2.0.21" ksp = "2.0.21-1.0.28" [plugins] kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

然后在根模块的build.gradle.kts里声明插件版本:

plugins { alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.ksp) apply false }

KSP2在新版本里已经默认启用,早期版本需要手动开启的情况我这里就不展开,升级时多注意插件release note即可。

2.2 模块划分:annotations、processor、app

一个典型的KSP项目至少分三个模块:存放注解定义的annotations模块、存放处理器代码的processor模块、实际使用注解和生成代码的业务模块。

annotations模块就是一个普通的Kotlin/Android library,里面只放注解:

package com.example.annotations @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.BINARY) annotation class Factory(val name: String = "")

processor模块是纯JVM模块,不需要Android插件,只需要Kotlin JVM插件和KSP插件依赖:

plugins { id("org.jetbrains.kotlin.jvm") alias(libs.plugins.ksp) } dependencies { implementation("com.google.devtools.ksp:symbol-processing-api:2.0.21-1.0.28") implementation(project(":annotations")) }

注意processor模块要能拿到symbol-processing-api,同时因为处理器代码里要用getAnnotationsByType读取注解默认值,也需要依赖annotations模块。业务模块里则通过ksp配置把处理器挂到编译流程上:

plugins { id("com.android.application") alias(libs.plugins.kotlin.android) alias(libs.plugins.ksp) } dependencies { api(project(":annotations")) ksp(project(":processor")) }

这里用ksp(project(":processor"))而不是implementation,是因为处理器属于编译期依赖,不应该出现在运行时classpath里。

2.3 注册Provider和ServiceLoader

KSP找处理器的机制用了Java的ServiceLoader。Processor需要先实现一个SymbolProcessorProvider,然后在resources目录里配置服务发现文件。

Provider的实现很简单:

package com.example.processor import com.google.devtools.ksp.processing.SymbolProcessor import com.google.devtools.ksp.processing.SymbolProcessorEnvironment import com.google.devtools.ksp.processing.SymbolProcessorProvider class FactoryProcessorProvider : SymbolProcessorProvider { override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { return FactoryProcessor( codeGenerator = environment.codeGenerator, logger = environment.logger ) } }

然后在processor/src/main/resources/META-INF/services/下创建一个名为com.google.devtools.ksp.processing.SymbolProcessorProvider的文件,文件内容写入Provider全限定名:

com.example.processor.FactoryProcessorProvider

这一步漏掉的话,KSP会静默忽略你的处理器,不报任何错误,编译也正常,只是什么都不生成,排查起来很烦,务必检查。

2.4 写一个最简单的Processor

Processor的核心方法是process(resolver)。resolver是KSP给你的查询入口,可以从里面拿到所有带指定注解的符号。这里我们的逻辑很简单:找到所有标了@Factory的类,给每个类生成一个对应的Factory对象。

package com.example.processor import com.example.annotations.Factory import com.google.devtools.ksp.getAnnotationsByType import com.google.devtools.ksp.processing.CodeGenerator import com.google.devtools.ksp.processing.Dependencies import com.google.devtools.ksp.processing.KSPLogger import com.google.devtools.ksp.processing.Resolver import com.google.devtools.ksp.processing.SymbolProcessor import com.google.devtools.ksp.symbol.KSAnnotated import com.google.devtools.ksp.symbol.KSClassDeclaration class FactoryProcessor( private val codeGenerator: CodeGenerator, private val logger: KSPLogger ) : SymbolProcessor { override fun process(resolver: Resolver): List<KSAnnotated> { val symbols = resolver.getSymbolsWithAnnotation("com.example.annotations.Factory") val classes = symbols.filterIsInstance<KSClassDeclaration>().toList() classes.forEach { declaration -> generateFactory(declaration) } return emptyList() } private fun generateFactory(declaration: KSClassDeclaration) { val packageName = declaration.packageName.asString() val className = declaration.simpleName.asString() val factoryName = "${className}Factory" val annotation = declaration.getAnnotationsByType(Factory::class).firstOrNull() val entryName = annotation?.name?.takeIf { it.isNotBlank() } ?: className val dependencies = Dependencies(false, declaration.containingFile ?: return) codeGenerator.createNewFile(dependencies, packageName, factoryName).bufferedWriter().use { writer -> writer.println("package $packageName") writer.println() writer.println("object $factoryName {") writer.println(" fun create(): $className {") writer.println(" return $className()") writer.println(" }") writer.println() writer.println(" const val key: String = \"$entryName\"") writer.println("}") } logger.info("Generated factory: $packageName.$factoryName") } }

运行./gradlew :app:kspDebugKotlin之后,生成的文件会出现在app/build/generated/ksp/debug/kotlin/目录下,包名和类名就是代码里写的那个。这个路径在IDE里可能需要手动sync一下才会被索引到,但编译阶段是能正常引用的。

值得一提的是,这个示例故意用了最简单的String拼接生成代码,够用但不好维护。真实项目代码结构复杂后,建议引入KotlinPoet来生成,它有类型安全的Kotlin代码构建API,处理缩进、import、泛型这类问题比手拼字符串靠谱得多。

3. 符号解析容易翻车的地方:Resolver与KSClassDeclaration

3.1 三种获取符号的入口各有各的坑

Resolver是你在KSP里获取源码信息的主要入口,最常用的有三个方法。

第一个是getSymbolsWithAnnotation(annotationName),按注解的全限定名查找符号。要注意这个全限定名必须写完整,漏了包名或者类名写错,返回结果为空,但没有任何报错。另外返回的是Sequence<KSAnnotated>,是惰性的,如果你打算遍历多次,最好先toList()再消费,否则每次遍历都可能重新执行查询,在一些特殊场景下会影响性能甚至产生不一致的结果。

第二个是getAllFiles(),返回所有Kotlin文件对应的KSFile。有些代码生成器不是基于注解的,而是需要扫描所有文件的全部声明,那就用这个入口。它同样返回Sequence<KSFile>

第三个是getNewFiles(),只返回本轮新增的文件。这个主要用于多轮处理,刚生成的代码文件会在下一轮通过它暴露出来。对大多数只做简单代码生成的Processor,用不到这个API,但了解它对理解KSP的处理模型很有帮助。

3.2 KSClassDeclaration:名字和成员都藏着细节

拿到KSClassDeclaration之后,最常踩的坑是qualifiedName可能为null。KSP里的local class,也就是定义在函数体内部的类,就没有全限定名。所以取名字时要养成先判断的习惯:

val qualifiedName = declaration.qualifiedName?.asString() ?: return

simpleNamepackageName是分别获取的,simpleName只是类名不带包名,packageName需要单独调packageName.asString()

遍历成员时,getDeclaredProperties()getAllProperties()差别很大。前者只返回当前类自己声明的属性,后者包含从父类继承来的。泛型化场景下,继承属性会带父类的泛型参数,处理起来更麻烦,所以不是所有场景都适合用getAllProperties()

另外对于objectcompanion objectdata class这类特殊声明,classKind属性会告诉你具体类型,不同的kind在代码生成时的处理方式也不一样。比如处理object时就不应该生成ClassName()调用,而应该直接引用ClassName.INSTANCE

3.3 类型解析要自己补课

KSP里类型引用KSTypeReference和实际类型KSType是分开的。一个属性声明里写的List<String>,拿到的是KSTypeReference,要调用resolve()才能得到KSType,然后才能判断它是不是List、泛型参数是什么。resolve()可能返回null,所以代码里要做空安全处理。

判断一个类是否实现了某个接口,不能只看直接父类型。superTypes返回的是直接父类和接口的引用列表,如果继承链比较深,需要递归解析。示例代码如下:

fun isImplementing(resolver: Resolver, declaration: KSClassDeclaration, targetName: String): Boolean { val visited = mutableSetOf<String>() fun check(current: KSClassDeclaration): Boolean { val name = current.qualifiedName?.asString() ?: return false if (!visited.add(name)) return false val superTypes = current.superTypes.toList() for (superType in superTypes) { val resolved = resolver.resolve(superType) ?: continue if (resolved.declaration.qualifiedName?.asString() == targetName) return true val parentDeclaration = resolved.declaration as? KSClassDeclaration ?: continue if (check(parentDeclaration)) return true } return false } return check(declaration) }

还有一个点是可空类型。KSP里KSType.isMarkedNullable可以判断声明是否标记了?,这两者在代码生成时差异很大,生成String和生成String?的代码语义完全不同。如果你要生成工厂函数,构造函数参数如果是可空类型,create方法里传参时要补null值,否则编译不过。

4. 代码生成阶段要过的坎:增量、多轮与文件命名

4.1 CodeGenerator创建文件时,Dependencies参数决定增量能力

代码生成不能直接往磁盘写文件,必须通过CodeGenerator.createNewFile()。这个方法签名里有几个参数:Dependencies、包名、文件名、扩展名。

Dependencies是很多新手初次接触时最容易忽略的参数,它直接影响增量构建是否生效。构造方式如下:

Dependencies( aggregating = false, sources = listOf(declaration.containingFile) )

aggregating的含义很关键。如果设为true,表示生成的文件是"聚合"结果,与多个输入文件相关,任何一个输入文件变化,这个生成文件都会重新生成。如果设为false,表示生成文件只和sources里指定的文件相关,只有那些文件变了才重新生成。

你可能会想,那我全部设false不就能获得最大增量收益吗?不是这么简单的。如果一个Processor要收集所有标了@Factory的类,然后生成一个统一的注册表类,那么注册表的内容和所有输入都相关,必须用aggregating=true。如果你错误地声明了false,某些类变更后注册表没有重新生成,编译期不会报错,运行时才会暴露问题,而且

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询