3. Hilt的编译期注解处理流程

讲Hilt的运行时注入开销之前,必须先搞懂它在编译期干了什么。说白了,Hilt本质上就是Dagger的“保姆版”,所有依赖注入的代码都是在你按下Build按钮的那一刻生成的。我刚开始用Hilt时总觉得它很神秘,好像魔法一样——后来翻了源码才明白,背后就是一套标准的javac注解处理器流程。

3.1 注解处理器的触发时机

Hilt使用Google的hilt-compiler模块,在编译期通过javax.annotation.processing.AbstractProcessor处理你的源码。它主要扫描三类注解:

  • 入口点注解:@HiltAndroidApp、@AndroidEntryPoint、@HiltViewModel等
  • 模块注解:@Module + @InstallIn
  • 绑定注解:@Provides、@Binds、@EntryPoint等

处理是多轮(Round)的。第一轮收集所有带注解的类,第二轮开始生成骨架代码。我见过一个项目因为用KSP(Kotlin Symbol Processing)替代javac后编译速度翻倍,但Hilt官方直到2023年才稳定支持KSP,之前一直依赖javac。

3.2 流水线:从注解到组件

整个流程可以用四个阶段概括。我习惯用一张图来理解它:

Hilt编译期注解处理流程 1. 源码扫描 @HiltAndroidApp 2. 信息收集 模块+入口点注册表 3. 验证与优化 依赖图完整性检查 4. 代码生成 Hilt_xxx.java 💡 每个阶段都可能发生多轮处理(Round),Hilt会等到所有源码都扫描完毕才生成最终代码。 我建议在build.gradle中开启 kapt.verbose=true 来查看处理进度。

现在我把每个阶段拆开聊,加点实战经验。

3.3 源码扫描 & 信息收集

注解处理器首次运行时,会遍历所有被@HiltAndroidApp、@AndroidEntryPoint等标注的类。它并不直接生成代码,而是先构建一个“元数据注册表”。我记得在某个中型项目里,Activity、Fragment、ViewModel加起来60多个入口点,扫描阶段只用了不到200ms,倒是不用担心。

关键数据包括:

  • 每个入口点的类名、包名、父类信息
  • 每个模块的安装范围(@InstallIn指定的组件)
  • 每个@Provides方法的返回类型和参数依赖
  • 每个@Binds接口与其实现类的映射

这个阶段最容易出的问题——我遇到过——就是模块循环引用。比如Module A引用了Module B的类型,Module B又引用了Module A的类型,Dagger会在验证阶段直接抛DependencyCycleException。Hilt不会替你解决循环依赖,它只是如实报告。

3.4 验证与优化

信息收集完后,Hilt会调用Dagger的部分底层逻辑进行依赖图验证。这里有个隐藏开销:它需要模拟所有组件的构造路径,确保每个依赖都有唯一的提供者。

为什么说“隐藏”?因为大多数开发者看不到中间产物。你可以通过打开kapt.stashMode=KAPT来保留生成的stash文件,里面记录了整个依赖图。我曾在调试一个奇怪的编译错误时,靠翻这些临时文件找到了某个@Qualifier冲突。

提示: 如果你在编译日志里看到类似 [WARN] Hilt: Multiple bindings found for class xxx,说明验证期发现了歧义绑定。Hilt会优先选取带有@Named或自定义@Qualifier的那个,否则直接报错。

3.5 代码生成——真正的重量级操作

验证通过后,处理器开始生成Java文件。每个@AndroidEntryPoint类都会生成一个同名前缀Hilt_的基类。例如:

// 你写的类
@AndroidEntryPoint
class MyActivity : AppCompatActivity() { ... }

// 编译后生成的类
public abstract class Hilt_MyActivity extends AppCompatActivity {
  @Override
  protected void onCreate(...) {
    inject();
    super.onCreate(savedInstanceState);
  }
  
  private void inject() {
    // 通过Dagger组件注入
    DaggerMyActivityComponent.builder()
      .applicationContext(getApplicationContext())
      .build()
      .inject(this);
  }
}

你以为只有一个类? 错了。每个入口点还会生成:

  • 对应的Dagger子组件(如DaggerMyActivityComponent)
  • 每个@Provides方法对应的工厂类(如MyModule_ProvideApiServiceFactory)
  • 每个@Binds对应的委托类
  • 组件依赖的@Module子类(带_HiltModules后缀)

我统计过一个项目,50个入口点+30个Module,生成了大约450个Java文件,总代码量约2万行。这些文件会在下一轮编译中被编译器处理。所以Hilt的编译时间基本上和代码复杂度成正比,而不是和你的业务代码量成正比。

⚠️ 避坑指南: 我曾经在项目里用@Binds @IntoSet批量声明了上百个实现,结果生成的Factory类暴增到编译内存不足。解决方案是把部分@IntoSet改为手动@Provides集合的方式,把生成逻辑推迟到运行时。记住:Hilt在编译期生成的类越多,内存压力越大。

3.6 组件树的嵌套结构

Hilt生成的Dagger组件是树状的:

组件 对应的入口点 生命周期
SingletonComponent@HiltAndroidAppApplication
ActivityRetainedComponent@AndroidEntryPoint ActivityActivity保留(旋转配置不销毁)
ActivityComponent@AndroidEntryPoint ActivityActivity
FragmentComponent@AndroidEntryPoint FragmentFragment
ViewModelComponent@HiltViewModelViewModel

每个子组件都会继承父组件的依赖,但Hilt会在生成代码时将父组件作为构造参数传入。这就是为什么你在@Module里可以通过@InstallIn(ActivityComponent.class)访问SingletonComponent提供的依赖——Dagger在生成子组件时已经拿到了父组件的引用。

3.7 一次我踩过的坑

有一次项目升级Hilt版本后,编译直接爆出Too many files错误。排查后发现是因为我们一个公共Module被@InstallIn到了SingletonComponent,而这个Module提供了几十个@Provides方法,每个方法都生成了一个Factory。当这个Module被多个App模块引用时,Hilt会为每个App模块都重复生成一套相同的Factory。后来我们把公共Module抽取成独立的带@HiltModule的library库,问题解决了你想想看,重复生成是Hilt编译开销的一个重要来源,尤其是在多模块项目里。

所以我的建议是:

  • 尽量把@Module放在共用性最高的library模块中,而不是每个app模块都复制一份
  • 减少不必要的@Provides方法数量,考虑用@Binds替代简单委托
  • 监控每次编译的类文件数量增长,如果某次重构后生成的类文件翻倍,说明可能有重复生成

好了,以上就是Hilt编译期注解处理的完整流程。搞懂这个,你才能理解为什么有些注入看起来“免费”,实际上编译时已经替你干了一大堆脏活。下一块我们聊聊运行时注入的开销,那才是用户真正感知到的延迟。不过在那之前,先把编译期的账算清楚。