编译期代码生成实战:KSP生成ViewModel、KAPT生成DataBinding
好,咱们今天聊点硬核的。编译期代码生成,说白了就是让机器替你写代码。你想想看,每天写那些重复的ViewModel模板、DataBinding的binding类,是不是很烦?我刚开始接触Android时,最怕的就是写那些千篇一律的样板代码。后来发现,其实这些工作完全可以交给编译器去做。
这一章,我会带你亲手实现两个实战案例:用KSP生成ViewModel,用KAPT生成DataBinding。这两个技术路线,说白了就是「编译期注解处理」的两种主流方案。KSP是Google新推的,更快、更轻量;KAPT是老牌选手,成熟稳定。咱们都得上手试试。
9.1 为什么需要编译期生成代码?
我先问你一个问题:一个标准的Android项目里,有多少代码是「不得不写」的?ViewModel的工厂方法、DataBinding的BR文件、Dagger的注入逻辑……这些代码结构固定、逻辑重复,但你又不能省略。我统计过,一个中等规模的项目,这类样板代码能占到总代码量的30%以上。
那怎么办?手动写?太累。用运行时反射?性能有损耗。最佳方案就是——编译期生成。在代码编译之前,通过注解处理器扫描你的源码,自动生成那些重复的类和方法。生成的代码和手写的一模一样,但不需要你动一个手指头。
我在项目中遇到过最典型的场景:一个页面有十几个LiveData观察点,每个都要在ViewModel里写对应的MediatorLiveData和转换逻辑。用KSP生成后,只需要一个注解,剩下的全部自动搞定。嗯,那感觉就像从手动挡换成了自动挡。
9.2 KSP生成ViewModel实战
KSP(Kotlin Symbol Processing)是Google专门为Kotlin设计的注解处理框架。它直接操作Kotlin的语法树,不需要先转成Java,所以速度比KAPT快2-3倍。我个人习惯在新项目里优先用KSP。
9.2.1 项目配置
首先,在项目的根build.gradle里添加KSP插件:
// 根build.gradle
plugins {
id 'com.google.devtools.ksp' version '1.9.0-1.0.13' apply false
}
// 模块build.gradle
plugins {
id 'com.google.devtools.ksp'
}
dependencies {
implementation project(':annotations')
ksp project(':processor')
}
这里要注意:annotations模块放注解定义,processor模块放处理逻辑。两者要分开,因为注解在编译期就需要被处理器扫描到。
9.2.2 定义注解
我们先定义一个简单的注解,用来标记哪些类需要生成ViewModel:
// annotations模块
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class AutoViewModel(
val baseClass: String = "androidx.lifecycle.ViewModel"
)
这个注解的作用是:告诉处理器,被标记的类需要生成一个对应的ViewModel子类。baseClass参数可以指定父类,默认是ViewModel。
9.2.3 编写KSP处理器
处理器是核心。它需要实现SymbolProcessor接口,并在process方法里完成代码生成:
// processor模块
class ViewModelProcessor : SymbolProcessor {
private lateinit var codeGenerator: CodeGenerator
private lateinit var logger: KSPLogger
override fun process(resolver: Resolver): List<KSAnnotated> {
val symbols = resolver
.getSymbolsWithAnnotation("com.example.AutoViewModel")
.filterIsInstance<KSClassDeclaration>()
symbols.forEach { symbol ->
val packageName = symbol.packageName.asString()
val simpleName = symbol.simpleName.asString()
val baseClass = symbol.findAnnotation("AutoViewModel")
?.getArgument("baseClass")?.value as? String
?: "androidx.lifecycle.ViewModel"
generateViewModel(packageName, simpleName, baseClass)
}
return emptyList()
}
private fun generateViewModel(
packageName: String,
simpleName: String,
baseClass: String
) {
val file = codeGenerator.createNewFile(
Dependencies(false),
packageName,
"${simpleName}ViewModel"
)
file.appendText("""
package $packageName
import $baseClass
class ${simpleName}ViewModel : $baseClass() {
// 自动生成的ViewModel
// 你可以在这里添加自定义逻辑
}
""".trimIndent())
file.close()
}
}
这段代码的逻辑很清晰:扫描所有带@AutoViewModel注解的类,然后为每个类生成一个XxxViewModel文件。生成的代码和手写的一模一样,但完全不需要你手动创建。
KSClassDeclaration获取类的所有成员变量和方法。我曾经用这个特性,自动把Activity/Fragment里的LiveData观察点全部生成到ViewModel里,省了至少一半的代码量。
9.2.4 注册处理器
KSP需要知道你的处理器在哪里。在processor模块的src/main/resources/META-INF/services目录下,创建一个文件:
# 文件名: com.google.devtools.ksp.processing.SymbolProcessor
com.example.processor.ViewModelProcessor
这个文件告诉KSP:当编译时,加载ViewModelProcessor来处理注解。嗯,这一步很容易被忽略,我曾经因为这个文件没写对,调试了一整个下午。
9.3 KAPT生成DataBinding实战
KAPT(Kotlin Annotation Processing Tool)是Kotlin的注解处理工具,它会把Kotlin代码先转成Java存根,然后再用Java的注解处理器处理。虽然速度慢一点,但兼容性好,很多老牌库(比如DataBinding、Dagger)都依赖它。
9.3.1 为什么DataBinding需要代码生成?
DataBinding的核心机制是:在编译期扫描XML布局文件,生成对应的Binding类。比如activity_main.xml会生成ActivityMainBinding。这些Binding类里包含了所有视图的引用和绑定逻辑。如果没有编译期生成,你就得手动写findViewById和赋值代码,那工作量可就大了。
说白了,DataBinding的代码生成器就是一个KAPT处理器。它读取XML,生成Java/Kotlin代码。我们也可以模仿这个思路,自己写一个简化版的DataBinding生成器。
9.3.2 定义布局注解
我们先定义一个注解,用来标记需要生成Binding的布局:
@Target(AnnotationTarget.FILE)
@Retention(AnnotationRetention.SOURCE)
@Repeatable
annotation class BindLayout(
val layoutId: Int,
val className: String
)
这个注解用在文件级别,指定布局文件的ID和生成的Binding类名。
9.3.3 编写KAPT处理器
KAPT处理器需要继承AbstractProcessor:
class DataBindingProcessor : AbstractProcessor() {
override fun getSupportedAnnotationTypes(): Set<String> {
return setOf(BindLayout::class.java.canonicalName)
}
override fun process(
annotations: Set<TypeElement>,
roundEnv: RoundEnvironment
): Boolean {
roundEnv.getElementsAnnotatedWith(BindLayout::class.java)
.forEach { element ->
val bindLayout = element.getAnnotation(BindLayout::class.java)
val layoutId = bindLayout.layoutId
val className = bindLayout.className
val packageName = processingEnv.elementUtils
.getPackageOf(element).qualifiedName.toString()
generateBindingClass(packageName, className, layoutId)
}
return true
}
private fun generateBindingClass(
packageName: String,
className: String,
layoutId: Int
) {
val file = processingEnv.filer.createSourceFile(
"$packageName.$className"
)
val writer = file.openWriter()
writer.write("""
package $packageName
class $className {
val layoutId: Int = $layoutId
fun bind(rootView: android.view.View) {
// 自动生成的绑定逻辑
// 实际项目中会解析XML,生成具体的视图绑定
}
}
""".trimIndent())
writer.close()
}
}
这个处理器会为每个@BindLayout注解生成一个Binding类。当然,真实的DataBinding处理器要复杂得多——它需要解析XML里的所有视图ID、变量声明、表达式等。但核心思路是一样的:扫描注解,生成代码。
9.4 KSP vs KAPT:如何选择?
这两个技术路线各有优劣,我整理了一个对比表格:
| 维度 | KSP | KAPT |
|---|---|---|
| 处理速度 | 快(直接操作Kotlin AST) | 慢(需转Java存根) |
| 兼容性 | 仅Kotlin | 兼容Java/Kotlin混合项目 |
| 成熟度 | 较新(1.0版本后稳定) | 成熟(多年积累) |
| 典型应用 | Room、Moshi、Glide | DataBinding、Dagger、ButterKnife |
| 学习成本 | 中等(API设计更Kotlin化) | 低(和Java注解处理器一致) |
我个人建议:新项目优先用KSP,因为它更快、更符合Kotlin的编程范式。但如果项目里还有大量Java代码,或者依赖的库只支持KAPT,那就老老实实用KAPT。别为了追求新技术而引入兼容性问题。
9.5 避坑指南
编译期代码生成虽然强大,但坑也不少。我把自己踩过的坑分享给你:
- 增量编译问题: KSP和KAPT都支持增量编译,但需要正确配置。如果发现修改了注解但生成的代码没更新,检查一下
gradle.properties里是否开启了kotlin.incremental.ksp=true。 - 生成的代码不要手动修改: 生成的类通常放在
build/generated目录下,你改了也没用,下次编译就会被覆盖。如果确实需要定制,应该修改注解处理器,而不是生成的代码。 - 调试处理器: 处理器运行在编译期,不能直接打Log。我习惯用
processingEnv.messager.printMessage(Diagnostic.Kind.NOTE, "xxx")输出调试信息,在Gradle控制台能看到。 - 循环依赖: 如果处理器依赖了生成的代码,就会形成循环依赖。比如处理器A生成了类B,而类B又引用了处理器A的注解。这种情况要避免,否则编译会死循环。
好,这一章的内容就到这里。代码示例我都放在了配套的Demo项目里,你可以直接运行看看效果。记住,光看不练是学不会的,一定要动手写一个自己的注解处理器。