
我接手过一个多模块Android项目编译时间长期在三分钟以上。平时改一行代码少说也要等四五十秒大部分时间都花在构建日志里反复出现的kapt stub generation和Annotation 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项目的注解处理则没必要迁到KSPJava项目继续用现有的javac处理机制没有性能劣势。2. 从零搭一个最小可运行的Processor2.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和ServiceLoaderKSP找处理器的机制用了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 写一个最简单的ProcessorProcessor的核心方法是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): ListKSAnnotated { val symbols resolver.getSymbolsWithAnnotation(com.example.annotations.Factory) val classes symbols.filterIsInstanceKSClassDeclaration().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与KSClassDeclaration3.1 三种获取符号的入口各有各的坑Resolver是你在KSP里获取源码信息的主要入口最常用的有三个方法。第一个是getSymbolsWithAnnotation(annotationName)按注解的全限定名查找符号。要注意这个全限定名必须写完整漏了包名或者类名写错返回结果为空但没有任何报错。另外返回的是SequenceKSAnnotated是惰性的如果你打算遍历多次最好先toList()再消费否则每次遍历都可能重新执行查询在一些特殊场景下会影响性能甚至产生不一致的结果。第二个是getAllFiles()返回所有Kotlin文件对应的KSFile。有些代码生成器不是基于注解的而是需要扫描所有文件的全部声明那就用这个入口。它同样返回SequenceKSFile。第三个是getNewFiles()只返回本轮新增的文件。这个主要用于多轮处理刚生成的代码文件会在下一轮通过它暴露出来。对大多数只做简单代码生成的Processor用不到这个API但了解它对理解KSP的处理模型很有帮助。3.2 KSClassDeclaration名字和成员都藏着细节拿到KSClassDeclaration之后最常踩的坑是qualifiedName可能为null。KSP里的local class也就是定义在函数体内部的类就没有全限定名。所以取名字时要养成先判断的习惯val qualifiedName declaration.qualifiedName?.asString() ?: returnsimpleName和packageName是分别获取的simpleName只是类名不带包名packageName需要单独调packageName.asString()。遍历成员时getDeclaredProperties()和getAllProperties()差别很大。前者只返回当前类自己声明的属性后者包含从父类继承来的。泛型化场景下继承属性会带父类的泛型参数处理起来更麻烦所以不是所有场景都适合用getAllProperties()。另外对于object、companion object、data class这类特殊声明classKind属性会告诉你具体类型不同的kind在代码生成时的处理方式也不一样。比如处理object时就不应该生成ClassName()调用而应该直接引用ClassName.INSTANCE。3.3 类型解析要自己补课KSP里类型引用KSTypeReference和实际类型KSType是分开的。一个属性声明里写的ListString拿到的是KSTypeReference要调用resolve()才能得到KSType然后才能判断它是不是List、泛型参数是什么。resolve()可能返回null所以代码里要做空安全处理。判断一个类是否实现了某个接口不能只看直接父类型。superTypes返回的是直接父类和接口的引用列表如果继承链比较深需要递归解析。示例代码如下fun isImplementing(resolver: Resolver, declaration: KSClassDeclaration, targetName: String): Boolean { val visited mutableSetOfString() 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的类然后生成一个统一的注册表类那么注册表的内容和所有输入都相关必须用aggregatingtrue。如果你错误地声明了false某些类变更后注册表没有重新生成编译期不会报错运行时才会暴露问题而且