用 KSP + Hilt 自动注入 Android ViewBinding

用 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。它由四部分组成:

  1. Android ViewBinding 负责生成 xxxBinding.java
  2. KSP 负责在编译阶段启动处理器;
  3. JavaParser 负责读取生成的 Java 文件,解析出包名和类名;
  4. 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 下查看两类文件:

  1. Android 生成的 ActivityMainBinding.java 等 ViewBinding 类;
  2. 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-androidhilt-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 把重复的依赖注册工作自动化。

相关推荐
律宏阔1 小时前
两台 Android 开发板的硬件序列号完全相同,如何使用 ADB 连接其中的一台?
android
AI2中文网2 小时前
Ai2 Starter 新版上线:Android 15、Hyper-V 与更快的模拟器体验
android
挂科边缘2 小时前
Android Studio 保姆级安装教程,Windows 电脑上安装 Android Studio
android·windows·android studio
Danny_姜3 小时前
Android Studio Quail 4 稳定版发布:本地 Gemma 4 + Android Skills
android·ide·android studio
平头哥AI13 小时前
Day 22 _ 包装错误别丢链_%w、errors.Is 与 errors.As
android·服务器·学习·golang·go
聚美智数13 小时前
图片广告检测-图片审核-图片广告识别-图像广告检测
android
早睡早起身体好12316 小时前
用 XGrammar 约束大模型工具调用:解决参数为空的问题
android·人工智能·神经网络·机器学习·自然语言处理·vllm
大佐不会说日语~18 小时前
Android 16 下 uni-app APP 提示“网络连接失败”的排障与修复
android·uni-app
2501_9151063219 小时前
苹果App Store上架费用及流程全面解析
android·ios·小程序·https·uni-app·iphone·webview