用 KSP + Hilt 自动注入 Android ViewBinding
多年前做的小插件 几年前全面使用jetpack compose后就该插件不维护了 仅记录
一、我为什么要做这个工具
Android 的 ViewBinding 已经很好用了。布局文件开启 ViewBinding 后,Android Gradle Plugin 会为每个 XML 布局生成一个类型安全的 Binding 类。
但是在 Activity 中,通常还要重复写一段初始化代码:
kotlin
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
}
}
项目页面一多,每个 Activity、Fragment 都要写一遍 inflate。这段代码没有什么业务价值,却占据了大量重复初始化代码。
我想把它简化成这样:
kotlin
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
}
}
这里并不是在运行时通过反射查找 Binding,而是在编译期扫描 Android 已经生成的 Binding 类,再自动生成一个 Hilt Module,为每个 Binding 提供 @Provides 方法。
最终效果可以概括为:
text
XML 布局
↓ Android Gradle Plugin
ActivityMainBinding.java
↓ KSP + JavaParser
识别包名和类名
↓ KotlinPoet
生成 Hilt Module
↓ Hilt Compiler
@Inject lateinit var binding: ActivityMainBinding
二、整体设计
这个项目本质上是一个 KSP Symbol Processor,而不是一个需要在运行时执行的 Android SDK。它由四部分组成:
- Android ViewBinding 负责生成
xxxBinding.java; - KSP 负责在编译阶段启动处理器;
- JavaParser 负责读取生成的 Java 文件,解析出包名和类名;
- KotlinPoet 负责生成带有 Hilt 注解的 Kotlin Module。
生成的 Module 会安装到 ActivityComponent 中,因此它使用 Activity 的 Context 获取 LayoutInflater,然后调用每个 Binding 类的 inflate(layoutInflater) 方法。
三、先创建 KSP Processor 模块
viewbindingprovider 是一个 Kotlin/JVM 模块,不需要打包成 Android AAR。它依赖 KSP 的处理器 API、JavaParser 和 KotlinPoet:
kotlin
plugins {
kotlin("jvm") version "2.2.0"
}
dependencies {
implementation("com.google.devtools.ksp:symbol-processing-api:2.2.0-2.0.2")
implementation("com.github.javaparser:javaparser-symbol-solver-core:3.27.0")
implementation("com.github.javaparser:javaparser-core:3.27.0")
implementation("com.github.javaparser:javaparser-core-serialization:3.27.0")
implementation("com.squareup:kotlinpoet-ksp:2.2.0")
}
这里的版本需要和使用方的 Kotlin、KSP 版本保持兼容。当前示例使用 Kotlin 2.2.0、KSP 2.2.0-2.0.2。
四、让 KSP 找到我们的处理器
KSP 通过 SymbolProcessorProvider 创建处理器。先实现 Provider:
kotlin
class ViewBindingSymbolProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
return ViewBindingSymbolProcessor(environment)
}
}
然后在以下文件中注册 Provider:
text
src/main/resources/
└── META-INF/services/
└── com.google.devtools.ksp.processing.SymbolProcessorProvider
文件内容只有一行:
text
com.github.provides.ViewBindingSymbolProcessorProvider
这一步很容易漏掉。如果没有这个 ServiceLoader 文件,KSP 虽然能找到依赖,却不会执行我们的处理器。
五、如何找到 ViewBinding 类
1. 为什么需要传入目录
ViewBinding 类不是项目源码,而是 Android 构建过程中生成的 Java 文件。因此处理器需要知道这些文件生成在哪里。
项目定义了一个 KSP 参数:
kotlin
private const val VIEW_BINDING_BUILD_DIRECTORY = "viewBindingBuildDirectory"
处理器启动时读取这个参数:
kotlin
val viewBindingDirectoryPath =
environment.options[VIEW_BINDING_BUILD_DIRECTORY]
?: throw IllegalStateException(
"The \"$VIEW_BINDING_BUILD_DIRECTORY\" parameter was not found in KSP."
)
val viewBindingDirectory = File(viewBindingDirectoryPath)
如果没有配置这个参数,处理器会直接抛异常。这样做虽然严格,但可以避免处理器悄悄扫描了错误目录,最后只生成一个空 Module。
2. 递归扫描文件
不同布局可能生成在多级目录下,所以不能只读取目录的第一层。项目通过递归方法收集所有文件:
kotlin
private fun listFilesRecursively(
directory: File,
fileSet: MutableSet<File>
) {
val files = directory.listFiles() ?: return
for (file in files) {
when {
file.isFile -> fileSet.add(file)
file.isDirectory -> listFilesRecursively(file, fileSet)
}
}
}
接着只处理文件名以 Binding.java 结尾的文件:
kotlin
if (file.name.endsWith("Binding.java")) {
// 解析 ViewBinding 类
}
例如:
text
activity_main.xml → ActivityMainBinding.java
fragment_home.xml → FragmentHomeBinding.java
item_user.xml → ItemUserBinding.java
3. 使用 JavaParser 解析包名和类名
拿到 Java 文件后,用 JavaParser 解析它:
kotlin
val unit = StaticJavaParser.parse(file)
val packageName = unit.packageDeclaration
.get()
.nameAsString
val simpleName = unit
.findFirst(ClassOrInterfaceDeclaration::class.java)
.get()
.nameAsString
viewBindingClassNameSet += ClassName(packageName, simpleName)
这里最终得到的是 KotlinPoet 的 ClassName,例如:
text
com.github.provides.example.databinding.ActivityMainBinding
相比直接拼接字符串,ClassName 可以让 KotlinPoet 正确处理 import 和类型引用,避免生成代码时出现包名错误。
六、用 KotlinPoet 生成 Hilt Module
1. 先生成 Module 的基本结构
项目生成的文件包名是 com.google.dagger.di,类名是 ViewBindingModel。虽然名字中使用了 Model,但它本质上是一个 Hilt Module:
kotlin
val fileBuilder = FileSpec.builder(
"com.google.dagger.di",
"ViewBindingModel"
)
val classBuilder = TypeSpec.classBuilder("ViewBindingModel")
.addAnnotation(moduleAnnotation)
.addAnnotation(installInAnnotation)
对应的注解是:
kotlin
@Module
@InstallIn(ActivityComponent::class)
使用 ActivityComponent 的原因是 Binding 属于页面视图,应该跟随 Activity 的生命周期创建,而不是作为 Application 级别的单例保存。
2. 提供 LayoutInflater
每个 Binding 的 inflate 方法都需要一个 LayoutInflater,所以先提供它:
kotlin
@Provides
fun provideLayoutInflater(
@ActivityContext context: Context
): LayoutInflater {
return ContextCompat.getSystemService(
context,
LayoutInflater::class.java
) ?: throw NullPointerException()
}
这里使用 @ActivityContext,确保拿到的是当前 Activity 的 Context。
3. 为每个 Binding 生成一个 Provides 方法
遍历前面收集到的 ClassName,为每一个类生成一个方法:
kotlin
viewBindingClassNameSet.forEach { className ->
val provideMethod = FunSpec.builder("provide${className.simpleName}")
.addAnnotation(providesAnnotation)
.addParameter("layoutInflater", classLayoutInflater)
.returns(className)
.addStatement("return %T.inflate(layoutInflater)", className)
classBuilder.addFunction(provideMethod.build())
}
%T 是 KotlinPoet 的类型占位符,它会自动生成正确的 import。假设扫描到了 ActivityMainBinding,最终生成的代码大致如下:
kotlin
@Module
@InstallIn(ActivityComponent::class)
public class ViewBindingModel {
@Provides
public fun provideLayoutInflater(
@ActivityContext context: Context
): LayoutInflater {
return ContextCompat.getSystemService(
context,
LayoutInflater::class.java
) ?: throw NullPointerException()
}
@Provides
public fun provideActivityMainBinding(
layoutInflater: LayoutInflater
): ActivityMainBinding {
return ActivityMainBinding.inflate(layoutInflater)
}
}
这样 Hilt 就知道:当某个页面需要 ActivityMainBinding 时,应该调用 ActivityMainBinding.inflate(layoutInflater)。
七、在 Android 项目中接入
下面以当前示例项目为准。
1. 添加插件
在 App 模块应用 Android、Kotlin、KSP 和 Hilt 插件:
kotlin
plugins {
id("com.android.application") version "8.11.1"
id("org.jetbrains.kotlin.android") version "2.2.0"
id("com.google.devtools.ksp") version "2.2.0-2.0.2"
id("com.google.dagger.hilt.android") version "2.57"
}
同时打开 ViewBinding。新项目推荐使用 Android 官方 DSL:
kotlin
android {
buildFeatures {
viewBinding = true
}
}
2. 添加 JitPack 仓库
在 settings.gradle.kts 中加入:
kotlin
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
3. 添加依赖
kotlin
dependencies {
ksp("com.example.www:viewbindingprovider:2.0.0")
implementation("com.google.dagger:hilt-android:2.57")
ksp("com.google.dagger:hilt-compiler:2.57")
}
如果是在当前仓库中本地调试处理器,也可以替换成:
kotlin
dependencies {
ksp(project(":viewbindingprovider"))
}
4. 把 ViewBinding 生成目录传给 KSP
这是接入时最关键的一步:
kotlin
ksp {
arg(
"viewBindingBuildDirectory",
"${layout.buildDirectory
.dir("generated/data_binding_base_class_source_out")
.get()
.asFile}"
)
}
当前处理器默认按照 Android 构建目录中的 generated/data_binding_base_class_source_out 查找生成的 Binding Java 文件。如果升级 AGP 后生成目录发生变化,需要同步修改这里的路径。
5. 配置 Hilt Application
Application 类需要添加 @HiltAndroidApp:
kotlin
@HiltAndroidApp
class App : Application()
并在 AndroidManifest.xml 中声明:
xml
<application
android:name=".App"
... />
6. 在 Activity 中注入 Binding
Activity 添加 @AndroidEntryPoint,然后直接注入对应的 Binding:
kotlin
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
binding.textView.text = "Hello Hilt + ViewBinding"
}
}
注意:工具只负责创建 Binding,不会替你调用 setContentView。页面仍然需要把 binding.root 设置为 Activity 的内容视图。
八、如何验证生成结果
可以执行:
bash
./gradlew :app:assembleDebug
构建成功后,可以在 app/build/generated 下查看两类文件:
- Android 生成的
ActivityMainBinding.java等 ViewBinding 类; - KSP 生成的
ViewBindingModel.kt。
打开生成的 ViewBindingModel.kt,如果能看到下面这种方法,说明处理器已经正常工作:
kotlin
@Provides
fun provideActivityMainBinding(
layoutInflater: LayoutInflater
): ActivityMainBinding {
return ActivityMainBinding.inflate(layoutInflater)
}
九、常见问题
1. 报 viewBindingBuildDirectory 参数不存在
检查 ksp { arg(...) } 是否配置在真正使用处理器的 Android 模块中,并确认参数名完全一致:
text
viewBindingBuildDirectory
2. 没有生成任何 Binding 方法
依次检查:
- 是否开启了
viewBinding; - 传入的目录是否真实存在;
- 目录中是否有以
Binding.java结尾的文件; - 是否执行过一次完整构建;
- 当前 AGP 版本是否改变了生成目录。
3. Hilt 报 MissingBinding
检查以下配置是否齐全:
- Application 是否添加
@HiltAndroidApp; - Activity 是否添加
@AndroidEntryPoint; - 是否添加
hilt-android和hilt-compiler; - KSP 处理器是否真正执行并生成了
ViewBindingModel。
4. Fragment 能不能使用
从依赖关系上看,ActivityComponent 的绑定可以被其子组件使用,因此 Fragment 可以获取这个 Binding。但 Fragment 的 View 生命周期与 Fragment 生命周期不同,直接把 ViewBinding 长时间保存为 Fragment 字段可能导致 View 泄漏。
如果在 Fragment 中使用,仍然要遵循 ViewBinding 的生命周期管理原则,在 onDestroyView 后及时清理引用。对于复杂页面,也要确认注入的 Binding 与当前页面的布局、生命周期完全匹配。
十、这个实现还可以怎样改进
这个项目的目标是先把流程跑通,所以实现比较直接,也留下了一些可以继续优化的地方。
1. 不要在 process 中随意开启后台线程
当前实现使用了:
kotlin
thread {
val fileSpec = generateViewBindingModule(viewBindingClassNameSet)
fileSpec.writeTo(environment.codeGenerator, Dependencies.ALL_FILES)
}
KSP 的处理流程有自己的生命周期,后台线程可能与处理器结束、下一轮处理或编译任务产生竞态。更稳妥的做法是同步生成,或者在确认所有扫描工作完成后统一写出文件。
2. 处理多轮 processing
当前处理器不依赖待处理的 Kotlin 符号,process 返回了空列表。后续如果增加注解扫描或其他符号处理,需要考虑 KSP 的多轮处理机制,避免重复生成同一个文件。
3. 降低对生成目录的耦合
现在的方案直接扫描 AGP 的生成目录,因此对 Android Gradle Plugin 的目录结构有依赖。更长期的方案可以研究 AGP 的公开 Variant/Artifacts API,或者让用户显式传入生成文件集合,从而减少对内部路径的依赖。
4. 过滤规则可以更严格
当前通过 Binding.java 后缀识别类。如果项目中存在其他同样后缀的生成类,也可能被扫描。后续可以进一步检查父类、接口或生成文件内容,确认它确实是 ViewBinding 类。
5. Hilt 组件可以做成可配置项
现在统一安装到 ActivityComponent。对于 Activity、Fragment、Dialog 或自定义组件,可以考虑增加配置项,让使用者选择更合适的 Component 和作用域。
总结
这个工具并没有修改 ViewBinding 的工作方式,而是把原本散落在每个页面中的初始化代码,集中转换成了一个编译期生成的 Hilt Module:
text
扫描 ViewBinding
→ 解析 ClassName
→ 生成 @Provides
→ Hilt 注入
它带来的收益主要是减少样板代码,同时保留 ViewBinding 的类型安全和 Hilt 的依赖注入能力。真正值得复用的思路也不只适用于 ViewBinding:只要某类对象能够在编译期被识别,并且可以生成稳定的 Provider,就可以用 KSP + KotlinPoet 把重复的依赖注册工作自动化。