3. Hilt的编译时处理与运行时开销:编译期生成代码与运行时依赖图解析的区别

说到Hilt,很多人的第一反应是“用注解很方便”。没错,@HiltAndroidApp、@Inject、@Module往那一贴,依赖就自动注入了。但我今天想聊的,是那些看不见的代价——编译时和运行时,Hilt到底在干什么?为什么同一个依赖图,有的项目编译快,有的项目编译慢?这两个阶段的分工,说白了就是一套「提前算好」和「现场查找」的游戏。

我接手过一个遗留项目,每次改一个Module,全量编译就要喝杯咖啡。后来我仔细看了下编译日志,发现Hilt生成的代码量惊人——一个中型模块,光Factory类就生成了400多个。嗯,那时候我才开始真正去理解编译时处理。

编译时处理:Hilt的“离线计算”

Hilt本质上是一个编译期注解处理器。它在javac阶段扫描所有Hilt相关的注解(@HiltAndroidApp、@AndroidEntryPoint、@Module、@Provides等),然后生成一系列辅助类。这个阶段,我把它称为「离线计算」。

  • 生成Component实现类:比如ApplicationComponent、ActivityComponent的实现。
  • 生成Provider类:每个绑定的依赖都会对应一个Provider接口实现,通常命名为Xxx_Factory。
  • 生成成员注入器:比如MainActivity_MembersInjector,负责执行field.inject()。
  • 校验依赖图:有没有循环依赖?有没有缺少绑定?这些都是在编译期完成的,所以运行时基本不会报NoSuchElementException。

这里有一个细节:Hilt会生成一个Hilt_Application或Hilt_Activity等基类。你可能会好奇,为什么继承它们?因为你自己的Application或Activity里写的super.onCreate(),实际上会被Hilt的基类拦截,然后触发依赖图的初始化。

关键点:编译时处理生成的是静态代码,这些代码在编译后就已经确定。你的依赖图是什么样,编译时就写好了一个个的new Xxx()或懒加载工厂。运行时不再需要通过反射扫描注解,这是Hilt相对Dagger一脉相承的优势。

运行时开销:依赖图解析到底解析什么?

很多人以为“运行时依赖图解析”是指Hilt在app启动时动态分析所有依赖关系。其实不是。Hilt的依赖图在编译期已经静态固化在生成的代码里了。运行时要做的事情,是按需初始化和生命周期管理。

举个例子,当你调用@Inject lateinit var someDependency时,Activity的onCreate里会调用MembersInjector.injectMembers(this)。这个injectMembers方法实际上是编译期生成的,它里面就是一行行的this.someDependency = someDependencyProvider.get()。运行时的开销,主要就是provider.get()的调用。

所以,「运行时依赖图解析」这个表述,更准确的说法是运行时依赖图实例化。真正的图结构——谁依赖谁、作用域怎么衰减——编译期已经定好了,运行时只是执行。

阶段 做了什么 开销类型 典型耗时
编译期 注解扫描、生成代码、依赖图校验 CPU + 磁盘IO 大型项目可达10-30秒
运行时 Provider.get()调用、对象构造、成员注入 CPU + 内存分配 一般<10ms(依赖多时可能上百ms)

个人建议:如果你发现app启动变慢,不要立刻怀疑Hilt的运行时效率。先看看编译期生成的代码量——生成的文件数超过1000个,那就要考虑拆模块了。

真正的运行时开销:藏在Provider.get()里

让我们深挖一下运行时开销的真正来源。Hilt生成的Provider对象,本质是个单例工厂。但有些场景下,get()并不只是new一下。

  • 懒加载:Lazy<T>会延迟初始化,第一次调用才构造。如果你有很多懒加载但最终都用上了,那其实没省时间。反而多了一次synchronized判断。
  • 作用域:@Singleton、@ActivityScoped等注解,会让Provider内部持有一个@Nullable实例缓存。每次调用get()都要检查缓存是否为空。
  • Provider自身创建:别忘了,Provider对象本身也是@Inject的产物。Hilt会生成一个Xxx_Factory的构造方法,里面可能还要注入其他依赖。这个链条越长,运行时开销越大。

我曾经排查过一个线上问题:某个页面的onCreate耗时200ms,定位后发现是Activity的注入方法里调用了十几个Provider.get(),每个Provider背后又是一个小型的依赖树。根源就是@Provides方法里return new Xxx(a, b, c),而a、b、c又各自有依赖。这种链式调用在编译期无法扁平化——Hilt只负责生成代码,不会帮你做内联优化。

注意:运行时开销不只是Provider.get()本身。还包括方法调用开销、GC压力。每多一个@Inject的字段,就多一次方法调用和一次内存写操作。对于高频场景(比如自定义View的注入),建议用@ViewScoped或手动注入来减少嵌套。

两者的核心区别:一张图说清楚

我画了张图,把编译时和运行时各自做的活儿拆开来看。你可以把它当作排查Hilt隐藏开销的“地图”。

Hilt:编译时 vs 运行时开销全景图 编译时(编译期处理) 注解扫描(@HiltAndroidApp @Module等) 耗时:100~500ms(中型项目) 生成代码(Factory / MembersInjector) 生成文件数量:10~1000+ 依赖图校验(循环依赖检查) 若不通过,编译失败——早发现 产出:Component实现类 / 基类 增量编译优化空间:修改Module后全量重扫,建议拆模块 运行时(应用执行) Component初始化(onCreate触发) 实例化Singleton等作用域对象 调用 Provider.get() 获取依赖 每个get()检查缓存或new对象 成员注入(MembersInjector) 对@Inject字段进行赋值 作用域生命周期管理(Scope) 优化方向:减少Provider嵌套、使用@Reusable 编译期生成代码 运行时按需执行 核心区别:编译期“算”依赖关系并生成代码,运行时“调”这些代码完成注入

如何感知编译时和运行时的开销?

你可能会问:我作为开发者,怎么知道到底是编译期慢,还是运行时慢?

我的经验是,先看编译日志。在Android Studio的Build Output里搜索Hilt或dagger,看看注解处理花了多久。我曾经在一个模块化的项目里发现,每次改动一个公共Module,都会导致所有依赖该Module的组件重新生成代码。后来我把公共Module拆成独立的Gradle子模块,并设置了kapt.includeCompileClasspath=false,编译时间降低了60%。

运行时开销则适合用Trace工具。在Application.onCreate()和Activity.onCreate()前后打点,就能看到Component#inject的耗时。如果超过50ms,那就值得深入看Provider链了。我有一个习惯:在@HiltAndroidApp的Application里加一个自定义的Timber.tag,打印每个活动组件的注入耗时。

实操技巧:用hilt.useOptimizedComponentLookup=true和hilt.aggressiveMode=true可以进一步减少运行时的查找开销。但注意,aggressiveMode会禁用部分校验,建议在Release构建中使用。