Hilt 依赖注入引擎与底层架构解析
Hilt:Android 专属的“中央厨房”
在开发复杂的大型应用时,各个类之间会产生错综复杂的依赖关系。比如一个 ViewModel 需要依赖 Repository,而 Repository 又依赖 Database 和 NetworkClient。如果不使用依赖注入(Dependency Injection, DI),我们的代码里会充满手动实例化的 new 关键字(或 NetworkClient())。这不仅导致代码高度耦合,更让单元测试变得举步维艰。
生活类比:厨师与中央厨房
- 无 DI:厨师(Activity)做菜前,必须自己去菜市场买番茄,去肉店买牛肉(手动
new依赖)。- 传统 DI(Dagger 2):公司建了一个中央厨房,但你必须自己画厨房的施工图纸,并明确规定哪个门进菜,哪个门出菜(编写复杂的 Component 和生命周期绑定代码)。
- Hilt:Google 官方直接送了你一个精装版中央厨房。所有的区域(对应 Android 的各个组件生命周期)都已经划分好。厨师只要在胸牌上贴个标签(
@AndroidEntryPoint),并在需要牛肉的地方贴个条子(@Inject),做菜时牛肉就会自动出现在案板上。
Hilt 是基于 Dagger 2 构建的,它将 Dagger 2 那些令人头疼的样板代码(Boilerplate)进行了极简的封装,提供了 Android 平台标准的依赖注入解决方案。
核心组件与使用范式
Hilt 的核心优势在于声明式。你只需要通过注解“声明”意图,所有的“连接”工作都在编译期由 Hilt 自动完成。
1. 触发代码生成:@HiltAndroidApp
所有使用 Hilt 的应用必须包含一个带有 @HiltAndroidApp 注解的 Application 类。
@HiltAndroidApp
class MyApplication : Application()
这个注解是整个 Hilt 引擎的“启动开关”。在编译期间,Hilt 会生成一个继承自 Application 的基类(如 Hilt_MyApplication),并在其中初始化整个应用的依赖注入图(Dependency Graph)的基础——SingletonComponent。
2. 注入 Android 组件:@AndroidEntryPoint
对于 Activity、Fragment、View、Service 或 BroadcastReceiver,使用 @AndroidEntryPoint 标记,Hilt 就会为其提供依赖。
@AndroidEntryPoint
class UserActivity : AppCompatActivity() {
// 字段注入:Hilt 会自动为这个字段赋值
@Inject lateinit var userRepository: UserRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 在这里,userRepository 已经被实例化并赋值,可以直接使用
userRepository.getUserInfo()
}
}
3. 告诉 Hilt 如何制造对象:@Inject 与 @Module
Hilt 虽然是中央厨房,但也需要你提供“菜谱”。
场景一:你可以修改类的构造函数
直接在构造函数上添加 @Inject,告诉 Hilt:“如果你需要 UserRepository,就调用这个构造函数,而它需要的 api,你也帮我找来。”
// 构造函数注入
class UserRepository @Inject constructor(
private val api: UserApi
) { ... }
场景二:接口或第三方类(无法修改构造函数)
如果是 Retrofit 的实例,或者是接口 UserApi 的具体实现,你无法在其源码上加 @Inject。此时必须使用 @Module 告知 Hilt。
@Module
@InstallIn(SingletonComponent::class) // 明确这个模块安装在全局单例组件中
object NetworkModule {
@Provides
@Singleton // 确保整个应用只有一个 Retrofit 实例
fun provideRetrofit(): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.example.com")
.build()
}
@Provides
fun provideUserApi(retrofit: Retrofit): UserApi {
return retrofit.create(UserApi::class.java)
}
}
深入底层:Hilt 到底施了什么魔法?
Hilt 最神奇的地方在于:我们在 UserActivity 中声明了 @Inject lateinit var userRepository: UserRepository,并没有调用任何类似 Hilt.inject(this) 的方法,为什么在 onCreate 里它就不为空了?
这涉及 Hilt 的两大核心机制:编译期代码生成(APT/KSP)与字节码插桩(Bytecode Transformation)。
字节码插桩:@AndroidEntryPoint 的隐秘替换
在传统的 Dagger 2 中,我们必须在 onCreate 的最开始手动写下 DaggerAppComponent.create().inject(this)。Hilt 为了消除这行代码,利用 Gradle 插件(dagger.hilt.android.plugin)在编译期玩了一个“狸猫换太子”的把戏。
-
编译期生成基类: Hilt 在编译期间(APT/KSP 阶段),会扫描到带有
@AndroidEntryPoint的UserActivity,然后悄悄生成一个抽象基类Hilt_UserActivity,该类继承自你原本继承的AppCompatActivity。 -
基类中的注入逻辑: 生成的基类大致长这样:
public abstract class Hilt_UserActivity extends AppCompatActivity { private boolean injected = false; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { inject(); // 【核心】在 super.onCreate 之前执行注入! super.onCreate(savedInstanceState); } protected void inject() { if (!injected) { injected = true; // 从 ActivityComponent 中取出依赖,赋值给 UserActivity 的字段 ((UserActivity_GeneratedInjector) generatedComponent()).injectUserActivity(UnsafeCasts.unsafeCast(this)); } } } -
字节码级别的父类替换: 在代码编译成字节码后(Transform 阶段),Hilt Gradle 插件会修改
UserActivity.class的字节码,将其父类从AppCompatActivity强行修改为生成的Hilt_UserActivity。
正因如此,当系统调用 UserActivity.onCreate() 时,首先会调用 super.onCreate(),进而触发 Hilt_UserActivity.onCreate() 中的 inject() 方法,完成了字段的赋值。这正是 Hilt 能够做到“无侵入注入”的根本原因。
Component 层级与生命周期映射
Hilt 预设了 Android 的 Component 树结构。每一个 Component 对应着一个 Android 原生组件的生命周期。
graph TD
S[SingletonComponent<br/>随 Application 创建/销毁] --> AR[ActivityRetainedComponent<br/>随 ViewModel 存活跨越配置更改]
AR --> A[ActivityComponent<br/>随 Activity 创建/销毁]
AR --> V[ViewModelComponent<br/>随 ViewModel 销毁]
A --> F[FragmentComponent<br/>随 Fragment 创建/销毁]
A --> View[ViewComponent]
F --> VF[ViewWithFragmentComponent]
组件包含关系(箭头代表向下依赖):
子 Component 可以获取父 Component 中的依赖,反之不行。例如,Fragment 中的对象可以依赖全局单例(SingletonComponent),但 Application 级别的单例绝不能依赖 Fragment 中的对象,否则会导致严重的内存泄漏。
作用域注解(Scopes):
默认情况下,每次 @Inject 请求,Hilt 都会创建一个全新的实例。
如果你希望在某个 Component 的生命周期内复用同一个实例,需要使用对应的 Scope 注解:
@Singleton:全应用唯一。@ActivityScoped:在同一个 Activity 实例中唯一。@FragmentScoped:在同一个 Fragment 实例中唯一。
性能警告:不要滥用作用域。被作用域标记的对象会被缓存在对应的 Component 中直到生命周期结束。如果不必要地滥用
@Singleton或@ActivityScoped,会导致本可以被回收的对象常驻内存。
最佳实战:Hilt 与 ViewModel
在 MVVM 架构中,ViewModel 是核心枢纽。在 Hilt 出现之前,由于 ViewModel 的实例化是由 ViewModelProvider.Factory 负责的,为 ViewModel 传递带参构造函数极为痛苦。
Hilt 提供了 @HiltViewModel,优雅地解决了这个问题:
@HiltViewModel
class MainViewModel @Inject constructor(
private val userRepository: UserRepository,
private val savedStateHandle: SavedStateHandle // Hilt 甚至能自动注入 SavedStateHandle
) : ViewModel() { ... }
原理解析:
当你使用 by viewModels() 获取 ViewModel 时,Hilt 内部会使用一个自定义的 HiltViewModelFactory 拦截 ViewModel 的创建。它会通过 ViewModelComponent 查找到对应的依赖图谱,实例化 UserRepository,并组装出 MainViewModel,最终交还给 Android 的 ViewModel 框架管理。
架构权衡:Hilt vs Koin
在 Android 领域,另一个大热的依赖注入框架是 Kotlin 专属的 Koin。它们代表了两种截然不同的设计哲学:
| 对比维度 | Hilt (Dagger 2 后台) | Koin |
|---|---|---|
| 底层原理 | 编译时依赖图生成 (APT/KSP) | 运行时服务定位器 (Service Locator 模式 + 延迟初始化) |
| 错误发现时机 | 编译期:依赖关系缺失会导致编译失败(Fail Fast),绝不会带着注入 Bug 上线。 | 运行期:如果没有提供依赖,编译可以通过,但跑到对应页面时会直接 Crash (NoBeanDefFoundException)。 |
| 运行性能 | 极高:生成的代码等价于手写注入,全是没有反射的硬编码。 | 中等:通过 Map 查找实例,有极微小的运行时开销。 |
| 构建速度 | 较慢:每次改动涉及依赖关系都需要 KSP 重新分析和生成大量中间代码。 | 极快:几乎不影响编译速度,因为它不生成代码。 |
为什么大型工业级项目更偏向 Hilt? 对于大型 App,依赖链路可能长达数十层。如果使用 Koin,开发者很难通过肉眼验证所有的依赖是否都被正确配置,一个小小的遗漏可能只有在 QA 测试特定深层链路时才会暴露。而 Hilt 在编译期的拓扑排序和全量检查,赋予了架构极强的确定性和安全性。牺牲一部分编译时间换取绝对的运行时安全,在企业级工程中是一笔划算的买卖。
总结
Hilt 的本质是将 Android 错综复杂的生命周期树与 Dagger 的 Component 树进行了硬映射,并通过 Gradle 字节码插桩技术抹平了框架的侵入感。它让我们能够以极低的门槛享受 Dagger 强大的编译期依赖检查与纯净的运行时性能,是现代工业级 Android 架构不可或缺的底层基建。