Hilt 的编译时代码生成,到底生成了什么
Hilt 的编译时代码生成,到底生成了什么
Hilt 2.44 版本发布的时候,我把一个老项目从 Dagger 2 切了过去。迁移过程比预期顺利,加几个注解、删几行代码就编译通过了。但那天晚上我盯着 build 目录看了很久——Hilt 到底帮我生成了什么?那些 @HiltAndroidApp、@AndroidEntryPoint 背后,编译器在什么时候、以什么规则、产出了哪些 Java/Kotlin 文件?这个问题在迁移文档里找不到答案,只能自己动手拆。
从 Dagger 的 Component 到 Hilt 的"自动生成"
用 Dagger 的时候,我对生成代码是有明确预期的。写一个 @Component 接口,Dagger 编译器会生成 DaggerXXXComponent 实现类,里面是一堆 Provider 和 Factory 的嵌套。这个模式很透明,甚至有点机械:每个 @Inject 构造函数的类对应一个 XXX_Factory,每个 @Module 里的 @Provides 方法对应 XXX_ProvidesYYYFactory,Component 本身则是一个实现了你接口的类,把所有依赖图串起来。
Hilt 的迷惑之处在于,它把这些显式定义全藏起来了。你不再写 Component,取而代之的是 @HiltAndroidApp 和 @AndroidEntryPoint。编译能通过,运行时也能工作,但中间层完全黑盒。我当时的第一反应是:这会不会只是 Dagger 的一层语法糖,把 Component 的生成规则换了个名字?实际拆下来发现,远不止这么简单。
Hilt 的代码生成发生在两个层面。第一层是 Dagger 原本就有的:处理 @Inject、@Module、@Provides,生成 Factory 和 Component 实现。第二层是 Hilt 新增的:基于你的 Android 类层次结构(Application、Activity、Fragment、View、Service 等),自动生成一套预定义的 Component 树和入口点。第二层才是 Hilt 真正的复杂度所在,也是很多性能问题和编译错误的来源。
拆解 build 目录:找到生成的入口
我用的是 Hilt 2.44.2,配合 AGP 7.3.0。编译完成后,在 app/build/generated/source/kapt/debug/ 下面能找到 Hilt 的全部产出。注意这里的路径——Hilt 目前仍然依赖 kapt(Kotlin Annotation Processing Tool),而不是 KSP。这个细节后面会讲到,它直接影响编译速度和增量构建的可靠性。
最顶层的生成文件是 HiltComponents.java,这个文件由 dagger.hilt.processor.internal.root.RootProcessor 生成。它的内容大致如下:
@Generated("dagger.hilt.processor.internal.root.RootProcessor")
public final class HiltComponents {
private HiltComponents() {}
}看起来是个空壳,但同一包名下会有一系列 HiltComponents_SingletonC、HiltComponents_ActivityC、HiltComponents_ActivityRetainedC 等类。这些就是 Hilt 自动构建的 Component 层次结构,对应 Dagger 里你需要手动定义的 SingletonComponent、ActivityRetainedComponent、ActivityComponent、FragmentComponent 等。
我对比了一个纯 Dagger 项目的生成物和 Hilt 项目的生成物。纯 Dagger 项目里,Component 实现类的名字是你自己取的,比如 DaggerAppComponent。Hilt 则强制使用固定命名规则:HiltComponents_SingletonC 对应 SingletonComponent,HiltComponents_ActivityRetainedC 对应 ActivityRetainedComponent,依此类推。这个命名在 Hilt 2.40 之前有过变动,早期版本用的是 DaggerHiltComponents_SingletonC,后来去掉了 Dagger 前缀。如果你在 GitHub 上搜旧 issue,能看到这个改名引发过一些混淆。
SingletonComponent 的生成细节
HiltComponents_SingletonC 是整个依赖图的根,也是生成代码量最大的一个类。我把它反编译出来看了,单这一个类就有 800 多行。它的结构和手写 Dagger Component 的实现类似,但多了很多 Hilt 特有的绑定。
核心部分是一个 switch 语句驱动的 Provider 查找表,Dagger 内部叫 switchingProvider。每个 @Inject 或 @Provides 定义的绑定对应一个整数索引,运行时通过索引获取实例。这个模式 Dagger 本身就有,但 Hilt 在这里插入了一层自己的逻辑:对 Android 框架类的绑定。
比如你的 Application 类加了 @HiltAndroidApp,Hilt 会自动生成 Application 实例的绑定。这个绑定不是来自你的代码,而是来自 ApplicationContextModule——Hilt 内置的一个 Module,你在代码里看不到它,但生成后的 HiltComponents_SingletonC 里会引用 ApplicationContextModule_ProvideContextFactory。类似地,ActivityComponent 会自动绑定 Activity 实例,FragmentComponent 自动绑定 Fragment 实例。
这些内置绑定在 Dagger 时代是需要你自己写的。比如以前要在 Activity 级别注入 Activity 本身,你得写一个 Module:
@Module
abstract class ActivityModule {
@Binds
abstract fun bindActivity(activity: MainActivity): Activity
}Hilt 把这个样板完全消除了,代价是生成代码里硬编码了对 Android 类层次结构的假设。我试着在一个自定义的 BaseActivity(不是 AppCompatActivity)里用 @AndroidEntryPoint,发现 Hilt 依然能工作,因为生成代码只关心 Activity 这个父类型。但如果你试图在一个完全不继承 Activity 的类上加 @AndroidEntryPoint,编译器会直接报错:@AndroidEntryPoint is only supported on types that implement android.app.Activity 等。这个检查发生在 Hilt 的 AndroidEntryPointValidator 里,属于编译时验证,不是运行时崩溃。
@AndroidEntryPoint 的字节码注入
@AndroidEntryPoint 的处理比 @HiltAndroidApp 更隐蔽。它不仅生成 Component 相关的代码,还会修改你标注的类本身。
在 kapt 的输出目录里,除了 Java 源文件,Hilt 还会生成一些辅助类。比如给一个 MainActivity 加 @AndroidEntryPoint,会生成 Hilt_MainActivity:
public abstract class Hilt_MainActivity extends AppCompatActivity implements GeneratedComponentManagerHolder {
private volatile ActivityComponentManager componentManager;
private final Object componentManagerLock = new Object();
private boolean injected = false;
@Override
public final Object generatedComponent() {
return this.componentManager().generatedComponent();
}
protected ActivityComponentManager createComponentManager() {
return new ActivityComponentManager(this);
}
// ... 省略其他代码
}这个 Hilt_MainActivity 是 Hilt 生成的基类,你的 MainActivity 实际继承的是它,而不是直接继承 AppCompatActivity。但你的源代码里写的是 class MainActivity : AppCompatActivity(),这里有个字节码层面的替换。
Hilt 通过 Gradle Transform API(AGP 7.x 之前)或 ASM 插桩(AGP 7.x 之后)来实现这个替换。具体而言,Hilt Gradle Plugin 会注册一个 HiltGradlePlugin 的 transform,在编译后的 .class 文件上做修改:把你的 MainActivity extends AppCompatActivity 改成 MainActivity extends Hilt_MainActivity。同时,它还会给 MainActivity 插入 OnConfigurationChangedListener 等回调的委托逻辑。
这个机制在 Hilt 2.40 之前用的是 Transform API,AGP 7.0 废弃 Transform API 后,Hilt 2.40 紧急迁移到了 ASM 直接操作字节码。如果你当时恰好在用 AGP 7.0 + Hilt 2.39 或更早版本,会遇到编译失败,错误信息大致是 Transform API is not supported。这个 breaking change 在 Hilt 的 release note 里有提到,但很多人没注意到 AGP 和 Hilt 版本的绑定关系。
我实际踩过这个坑:一个项目 AGP 升级到 7.2,Hilt 停留在 2.38.1,编译直接挂掉。升级 Hilt 到 2.42 后解决。这个版本绑定关系不是 Dagger/Hilt 独有的,但 Hilt 因为涉及字节码插桩,对 AGP 的 API 变动特别敏感。
Fragment 的嵌套 Component 与内存泄漏风险
Activity 级别的生成代码相对直观,Fragment 就复杂多了。Hilt 的 Component 层次设计是:SingletonComponent → ActivityRetainedComponent → ActivityComponent → FragmentComponent。ActivityRetainedComponent 的存在是为了在配置变更(如旋转屏幕)时保持 ViewModel 级别的依赖,它通过 ViewModel 的 onCleared() 来管理生命周期。
给一个 Fragment 加 @AndroidEntryPoint,生成的 Hilt_MyFragment 会包含一个 FragmentComponentManager。这个 manager 的 generatedComponent() 方法会返回 FragmentComponent 的实例。但 FragmentComponent 的父级是 ActivityComponent,不是 ActivityRetainedComponent。这意味着 Fragment 级别的依赖绑定会随 Activity 的重建而重建,除非你把依赖提升到 ActivityRetainedComponent 或 SingletonComponent。
这里有个细节容易踩坑:@ActivityScoped 和 @ActivityRetainedScoped 的区别。前者绑定到 ActivityComponent,Activity 重建时重新创建;后者绑定到 ActivityRetainedComponent,配置变更时保留。Hilt 的生成代码里,ActivityRetainedComponent 的实现类是 HiltComponents_ActivityRetainedC,它内部持有一个 ViewModel 实例来管理生命周期。这个 ViewModel 的名字是固定的:HiltViewModelFactory 生成的 DefaultViewModelFactories_InternalFactoryFactory 之类,命名非常冗长。
我在一个项目里遇到过内存泄漏,LeakCanary 报的是 HiltComponents_ActivityRetainedC 被某个 Fragment 持有。排查后发现,Fragment 里注入了一个 @ActivityRetainedScoped 的类,而这个类内部又持有了 Fragment 的 LifecycleOwner。ActivityRetainedComponent 的生命周期长于 Fragment,导致 Fragment 销毁后仍然被 Component 引用。这个泄漏不是 Hilt 的 bug,而是对 Scope 生命周期的误用,但 Hilt 的生成代码把这个问题藏得很深——你很难从源码层面看出 ActivityRetainedScoped 的实例到底存在哪里。
最终是通过 Android Studio 的 Memory Profiler,找到 HiltComponents_ActivityRetainedC 实例,查看它的 provider 字段,才定位到具体的绑定。这个过程里,Hilt 的生成代码命名规律帮了忙:所有 Component 实现类都以 HiltComponents_ 开头,后面跟着 Scope 名称和 C(Component 的缩写)。
@HiltViewModel 的生成物与 SavedStateHandle
ViewModel 的注入是 Hilt 另一个重度生成代码的场景。@HiltViewModel 标注的类,Hilt 会生成一个 HiltViewModelFactory,这个工厂负责创建 ViewModel 实例。
具体生成文件的位置在 dagger.hilt.android.internal.lifecycle 包下,文件名通常是 DefaultViewModelFactories.java 和一系列 XXX_HiltModules.java。DefaultViewModelFactories 里有一个 ActivityModule 或 FragmentModule 的内部类,用于绑定 ViewModelProvider.Factory。
关键代码在 HiltViewModelFactory 的生成逻辑里。它实现了 ViewModelProvider.Factory,create 方法内部有一个 switch 语句,根据 ViewModel 的类名找到对应的 Provider。这个模式和 Component 里的 switchingProvider 一致,但额外处理了 SavedStateHandle 的注入。
如果你的 ViewModel 构造函数需要 SavedStateHandle,Hilt 会自动提供。这个绑定来自 SavedStateHandleModule,也是 Hilt 内置的不可见 Module。生成代码里能看到 SavedStateHandleModule_ProvideSavedStateHandleFactory 这样的类,它从 SavedStateRegistryOwner 中提取 SavedStateHandle。这个机制在 Dagger 时代需要你自己集成 SavedStateViewModelFactory,Hilt 把它封装掉了。
但封装带来的问题是灵活性受限。我有一次需要自定义 ViewModel 的创建逻辑——某些 ViewModel 需要从 Intent 的 extra 里读取参数,在构造函数里初始化。Hilt 的生成代码不支持这种场景,因为 HiltViewModelFactory 的 create 方法签名是固定的:
public <T extends ViewModel> T create(Class<T> modelClass) {
// 内部 switch,根据 modelClass 找到 Provider
}没有传入额外参数的通道。解决方案是用 @AssistedInject,但这又引入了另一套生成代码:XXX_Factory_Impl 和 XXX_AssistedFactory,由 Dagger 的 Assisted Inject 机制生成,和 Hilt 的 ViewModel 工厂交织在一起,调试起来很混乱。
Hilt 2.45 之后增加了对 @HiltViewModel 配合 @AssistedInject 的更好支持,但生成代码的结构变得更复杂。HiltViewModelFactory 内部会区分"无 assisted 参数"和"有 assisted 参数"的 ViewModel,走不同的 Provider 路径。这个改动在 release note 里叫 "Improve assisted injection support in ViewModels",实际生成代码里是多了一层 ViewModelComponentManager 的抽象。
kapt 的性能代价与 KSP 迁移现状
前面提到 Hilt 仍然依赖 kapt。这个选择在 2023 年显得有点尴尬,因为 KSP(Kotlin Symbol Processing)已经成熟,很多注解处理器都迁移了过去,编译速度提升明显。
我做过一个对比测试:同一个项目,约 200 个 Hilt 相关的类(包括 @Inject、@Module、@AndroidEntryPoint、@HiltViewModel),分别用 kapt 和 KSP 编译(后者用 Dagger 2.48 的 KSP 实验性支持,Hilt 当时还没有官方 KSP 支持)。
kapt 的增量编译场景下,修改一个 @Module 里的 @Provides 方法,平均需要 4.2 秒完成编译。全量编译约 18 秒。作为对比,不涉及 Hilt 的纯 Kotlin 代码增量编译通常 1 秒以内。
kapt 慢的原因不是 Hilt 本身,而是 kapt 需要先生成 Java stub 再处理注解,这个 stub 生成阶段对 Kotlin 代码做了一次完整的解析和转换。Hilt 的生成代码量又大,每次都要重新写几百个 Java 文件到磁盘。
Hilt 官方对 KSP 的支持进度在 GitHub issue #1028 和 #1029 里跟踪。Dagger 2.48 在 2023 年 5 月发布了 KSP 的 alpha 支持,但 Hilt 的 KSP 支持要晚得多,因为 Hilt 的处理器逻辑更复杂,涉及 Android 特定的类引用和字节码插桩。截至我测试时(Hilt 2.48),Hilt 仍然只能用 kapt,这意味着想用 KSP 加速编译的项目,必须接受 Hilt 成为瓶颈。
有一个 workaround 是把 Hilt 相关的代码拆到一个单独的 module 里,减少 kapt 的处理范围。但这个方案对大型项目架构改动很大,而且 Hilt 的 @EntryPoint 跨 module 使用时,生成代码的可见性处理又引入了新的复杂度。
@EntryPoint 与跨 module 编译
@EntryPoint 是 Hilt 里比较高级但容易误用的特性。它允许你在 Hilt 自动生成的 Component 树之外,定义一个自定义的入口点。典型场景是:你有一个 library module,不想用 @AndroidEntryPoint(因为 library 不应该假设上层是 Hilt 应用),但需要从这个 module 里获取依赖。
生成代码的角度,@EntryPoint 会触发 Hilt 生成一个独立的 Component 接口实现,而不是复用 HiltComponents_xxx 系列。比如:
@EntryPoint
@InstallIn(SingletonComponent::class)
interface MyEntryPoint {
fun getMyRepository(): MyRepository
}Hilt 会生成 MyEntryPoint_Impl 或类似的类(具体命名规则在 Hilt 2.44 之后有调整),这个类实现了 MyEntryPoint 接口,内部委托给 HiltComponents_SingletonC。但 MyEntryPoint 和 MyEntryPoint_Impl 之间没有编译期的直接引用,运行时通过 EntryPoints.get() 方法动态查找:
val entryPoint = EntryPoints.get(applicationContext, MyEntryPoint::class.java)这个 EntryPoints.get() 内部会调用 generatedComponent(),然后做类型转换。如果 MyEntryPoint 安装错了 Component(比如 @InstallIn(ActivityComponent::class),但你从 Application 级别调用 EntryPoints.get),运行时会抛 IllegalStateException,而不是编译错误。这是 Hilt 的一个设计取舍:为了支持动态入口点,牺牲了部分编译期安全性。
我在一个多 module 项目里踩过这个坑。library module A 定义了 @EntryPoint,安装到 SingletonComponent。app module 的 Application 是 @HiltAndroidApp,正常应该能访问这个 EntryPoint。但编译时总是报错,说找不到 MyEntryPoint 的生成实现。
排查后发现,library module A 的 build.gradle 里只应用了 dagger.hilt.android.plugin,但没有加 hilt { enableAggregatingTask = true }。Hilt 2.40 之后引入了这个 flag,控制是否使用聚合任务来收集跨 module 的 Component 信息。默认情况下(至少在某些版本里),这个 flag 的行为不一致,导致 library 里的 @EntryPoint 没有被纳入最终的 HiltComponents 生成。
这个 enableAggregatingTask 的生成逻辑在 Hilt 的 Gradle Plugin 源码里,涉及 HiltAggregationTask 和 ComponentTreeDescriptor 的处理。开启后,Hilt 会在编译时额外运行一个聚合任务,扫描所有依赖 module 的注解处理器输出,合并成完整的 Component 树。这个任务增加了编译时间,但解决了跨 module 的入口点可见性问题。
生成代码的可读性与调试技巧
拆到这里,我对 Hilt 生成代码的整体结构有了比较清晰的认识。总结几个关键的调试技巧,都是实际用过的:
第一,Android Studio 的 "Navigate to Generated Code" 对 Hilt 基本没用,因为生成代码不在标准位置。手动去 build/generated/source/kapt/debug/ 找更靠谱。如果用 Kotlin 符号查找(Cmd+Shift+O),输入 Hilt_ 前缀能快速定位到生成的基类。
第二,Hilt 的生成 Java 代码虽然冗长,但命名有规律。_Factory 结尾的是 Dagger 风格的工厂类,_C 结尾的是 Component 实现,Hilt_ 前缀的是字节码插桩基类。看到 DefaultViewModelFactories_InternalFactoryFactory 这种名字不要慌,拆开看是 DefaultViewModelFactories 的内部类 InternalFactoryFactory。
第三,如果怀疑生成代码有问题,可以用 hilt { enableExperimentalClasspathAggregation = true }(Hilt 2.40 之前)或 enableAggregatingTask = true 来强制重新聚合。有时候增量编译的缓存会导致生成代码和源码不一致,clean 不一定能清干净。
第四,Hilt 2.46 之后增加了 hilt.compilerOptions 配置,可以控制某些生成行为。比如 suppressMissingBindingErrors 在特定场景下有用,但官方不推荐开启,因为它掩盖的是真正的依赖图断裂。
一个具体的编译错误分析
最后讲一个我花了一下午才搞定的编译错误,和生成代码直接相关。
项目结构:app module 依赖 feature module,feature module 里有一个 @HiltViewModel 的 ViewModel。app module 的 Application 是 @HiltAndroidApp。编译报错:
error: [Dagger/MissingBinding] java.util.Set<java.lang.String> cannot be provided without an @Provides-annotated method.但这个 Set<String> 的绑定在 app module 里明明有定义,是一个 @Multibinds 的抽象方法。为什么 feature module 里的 ViewModel 找不到?
排查路径:先看生成代码 HiltComponents_SingletonC 在 feature module 里的版本(feature module 单独编译时也会生成一个 stub),发现它确实没有 Set<String> 的绑定。而 app module 的完整 HiltComponents_SingletonC 里是有这个绑定的。
根源在于 Hilt 的 Component 生成是 module 级别的。feature module 编译时,Hilt 处理器只能看到 feature module 自己的依赖图,生成一个"不完整"的 Component stub。app module 编译时,Hilt 的聚合任务会把所有 module 的 stub 合并,生成最终的完整 Component。但 @HiltViewModel 的生成代码在 feature module 里就确定了,它引用的 ViewModelComponent 是 feature module 那个不完整的版本,不包含 app module 提供的 Set<String>。
解决方案是把 Set<String> 的绑定定义上移到一个 common module,或者把 @HiltViewModel 移到 app module。这个限制在 Hilt 的文档里没有明确说明,是通过分析生成代码的 module 边界才理解的。
Hilt 的编译时代码生成,本质上是一套"约定优于配置"的自动化系统。它用注解处理器和字节码插桩,把 Android 组件生命周期和 Dagger 的依赖图强行粘合在一起。生成的代码量很大、命名冗长、跨 module 时行为微妙,但结构是规律的:Component 树固定、Provider 查找用 switch、ViewModel 工厂统一封装。理解这些生成规则,不是为了手写替代方案,而是在编译错误和性能瓶颈面前,知道该往哪个目录、哪个类、哪个版本变更里去找答案。