编译期代码生成实战: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文件。生成的代码和手写的一模一样,但完全不需要你手动创建。

小技巧: 在KSP处理器里,可以用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、变量声明、表达式等。但核心思路是一样的:扫描注解,生成代码。

注意: KAPT处理器里不要做耗时操作,比如网络请求或文件IO。因为处理器是在编译期运行的,如果卡住了,整个编译过程都会阻塞。我曾经在处理器里写了一个日志输出到文件的操作,结果每次编译都要多花10秒,后来果断去掉了。

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的注解。这种情况要避免,否则编译会死循环。
总结一下: 编译期代码生成是Android高级开发的必备技能。KSP和KAPT各有千秋,选哪个取决于你的项目需求。但无论选哪个,核心思想都是一样的——用机器代替人力,把重复劳动交给编译器。

好,这一章的内容就到这里。代码示例我都放在了配套的Demo项目里,你可以直接运行看看效果。记住,光看不练是学不会的,一定要动手写一个自己的注解处理器。

编译期代码生成核心流程 源码层 @AutoViewModel @BindLayout 处理器层 KSP / KAPT SymbolProcessor / AbstractProcessor 生成层 XxxViewModel XxxBinding 详细流程分解 KSP 流程 1. 扫描 @AutoViewModel 注解 2. 获取 KSClassDeclaration 3. 生成 ViewModel 子类 4. 输出到 build/generated/ksp 5. 编译时自动合并到源码 KAPT 流程 1. 扫描 @BindLayout 注解 2. 获取 TypeElement 3. 生成 Binding 类 4. 输出到 build/generated/source/kapt 5. 编译时自动合并到源码 两者核心区别:KSP直接操作Kotlin AST,KAPT需转Java存根

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