Hilt 依赖注入到底是怎么工作的?
说实话,我第一次接触 Hilt 的时候,也以为它只是个“加注解,自动搞依赖”的黑魔法。但后来线上出了个诡异崩溃——某个 Fragment 里的 ViewModel 死活拿不到,排查了半天发现是编译生成的代码把我们坑了。从那次之后,我就下定决心把 Hilt 在编译期干了什么彻底摸清楚。今天我们就从地基开始,把 Hilt 的工作原理、核心注解、以及编译时到底生成了什么代码,一条条理清楚。
Hilt 的本质:Dagger 的“开箱即用”版
Hilt 底层就是 Dagger,这点很多文章都说过。但我想强调一个关键区别——Dagger 需要你手动写 Component、Subcomponent 和桥梁代码,Hilt 则在编译期帮你自动生成这些。说白了,Hilt = Dagger + 标准化组件层次 + 一键集成。
我个人习惯用一张图来理解这个关系:
嗯,这张图涵盖了 Hilt 从编译到运行的全路径。你想想看,我们写一个 @AndroidEntryPoint,Hilt 就在背后默默生成了一个 Hilt_MainActivity 作为父类,把 Component 创建、注入点绑定全部自动化了。
核心注解,一个都不能少
Hilt 的注解并不多,但每个都有严格的职责。我在项目中遇到过不少“为什么注入无效”的问题,90% 都是注解用错了作用域或缺少 @InstallIn。下面我把最常用的几个列出来,附带我自己的经验标签。
| 注解 | 作用 | 💡 我的一点提醒 |
|---|---|---|
@HiltAndroidApp |
标记 Application,生成 root Component | 只在 Application 类上写一次,少写了直接编译失败 |
@AndroidEntryPoint |
标记 Activity/Fragment/View/Service 等 | 每一个需要注入的 Android 组件都要加,否则注入为 null |
@Inject |
标记构造方法或字段,告诉 Hilt 怎么创建对象 | 构造注入推荐优先使用,减少 Module 代码量 |
@Module |
声明依赖的“提供者”,不能实例化 | 记得加 @InstallIn,否则 Hilt 不知道放哪个组件里 |
@Provides |
在 Module 里提供第三方库或接口实例 | 我喜欢写成 top-level 函数加 @Binds 的模式,更干净 |
@InstallIn |
指定 Module 属于哪个 Component | 选错作用域会导致生命周期不匹配——我曾经因此闹过内存泄漏 |
@HiltViewModel |
配合 Hilt 注入 ViewModel | 必须加在 ViewModel 构造方法上,配合 @Inject 使用 |
编译期到底生成了什么?——动手翻一翻 build 目录
我记得有一次为了定位一个注入失败,我直接打开了 build/generated/source/apt 目录。你会看到类似这些文件:
- Hilt_MainActivity.java —— 继承自
AppCompatActivity,重写了onCreate调用了inject() - MyApplication_HiltComponents.java —— 包含了所有 Component 接口的定义
- MyModule_ProvideXXXFactory.java —— 每个
@Provides方法都会对应一个 Factory 类 - AppComponentFactory.java —— 系统启动时创建 Component 实例的入口
说白了,Hilt 就是一个代码生成器。它把我们在注解里写的“谁依赖谁”翻译成了 Java 代码,而运行时就是通过这些 Factory 调用构造函数。你想想看,这个过程在编译期就完成了,不存在反射注入——这也是 Hilt 性能好的原因。
组件层次与作用域:为什么你拿不到那个 Singleton?
很多新人不理解为什么要分 SingletonComponent、ActivityComponent 这些层级。我举个具体场景:一个网络客户端的实例应该整个 App 共享,就用 @Singleton 放在 @InstallIn(SingletonComponent.class) 的 Module 里;而某个 Activity 专用的数据对象,就放在 @InstallIn(ActivityComponent.class) 里,随 Activity 销毁而销毁。
⚠️ 重要原则:低层 Component 不能访问高层 Component 的绑定。比如 FragmentComponent 里无法注入 Singleton 之外的 Activity 级绑定。我曾经试图在 Fragment 里注入一个 Activity 级别的 Presenter,结果编译期直接报错——Hilt 认为这是不安全的跨作用域依赖。
一个小实验:看看编译后的代码长什么样
我建议你亲手试一下。写一个最简单的 Hilt 项目,编译完后定位到:
app/build/generated/source/apt/debug/你的包名/
打开 Hilt_MainActivity.java,你会看到类似这样的结构:
public abstract class Hilt_MainActivity extends AppCompatActivity {
private boolean injected = false;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
inject();
super.onCreate(savedInstanceState);
}
private void inject() {
if (!injected) {
((MainActivity_GeneratedInjector)
((MainActivity_Application) getApplication())
.generatedComponent())
.injectMainActivity((MainActivity) this);
injected = true;
}
}
}
看到了吗?它通过 Application 转型成 MainActivity_Application,拿到生成的 Component,再调用 injectMainActivity。这一步发生在 super.onCreate() 之前,所以你在自己的 onCreate 里可以直接用 lateinit 的注入字段——已经被赋值好了。
💡 小技巧:如果怀疑注入没生效,可以在 inject() 方法里打个断点,看看它是否真的执行了。我曾经用它抓到一个同事在自定义 Application 里覆盖了 newComponent() 导致注入路径断掉的 bug。
小结一下本章你需要带走的东西
- Hilt = Dagger + 自动化,编译期生成代码,运行时无反射
- 6+ 个核心注解:@HiltAndroidApp、@AndroidEntryPoint、@Inject、@Module、@Provides、@InstallIn、@HiltViewModel
- 组件层次:Singleton → ActivityRetained → ViewModel → Activity → Fragment → View,严格的生命周期绑定
- 生成的文件:Hilt_XXX 基类、XXX_GeneratedInjector、XXXModule_ProvideZZZFactory 等
- 排查入口:build/generated/source/apt 目录
好了,地基打好了。下一层我们要开始讨论那些“看不见的”开销——Hilt 在编译期和运行时到底多消耗了多少资源和时间。这不是什么玄学,是可以用排查工具量化的。