Hilt 依赖注入到底是怎么工作的?

说实话,我第一次接触 Hilt 的时候,也以为它只是个“加注解,自动搞依赖”的黑魔法。但后来线上出了个诡异崩溃——某个 Fragment 里的 ViewModel 死活拿不到,排查了半天发现是编译生成的代码把我们坑了。从那次之后,我就下定决心把 Hilt 在编译期干了什么彻底摸清楚。今天我们就从地基开始,把 Hilt 的工作原理、核心注解、以及编译时到底生成了什么代码,一条条理清楚。

Hilt 的本质:Dagger 的“开箱即用”版

Hilt 底层就是 Dagger,这点很多文章都说过。但我想强调一个关键区别——Dagger 需要你手动写 Component、Subcomponent 和桥梁代码,Hilt 则在编译期帮你自动生成这些。说白了,Hilt = Dagger + 标准化组件层次 + 一键集成。

我个人习惯用一张图来理解这个关系:

Hilt 依赖注入核心流程 编译时:apt/KSP 处理注解 扫描 @HiltAndroidApp、@AndroidEntryPoint、@Inject、@Module 等 Hilt 预定义组件层次 SingletonComponent → ActivityRetainedComponent → ViewModelComponent → ActivityComponent → FragmentComponent → ViewComponent 编译期生成 Hilt_XXX 类 如 Hilt_MainActivity、Hilt_MyFragment、AppComponentFactory 等 运行时:通过 Factory 注入依赖 注意 * 每个 Activity/Fragment 对应一个 Hilt_ 基类

嗯,这张图涵盖了 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 在编译期和运行时到底多消耗了多少资源和时间。这不是什么玄学,是可以用排查工具量化的。