Hilt 依赖注入:原理与最佳实践

> 一句话收益:深入理解 Hilt 的代码生成机制与组件作用域,彻底告别手写 Dagger 样板代码,写出可测试、可维护的 Android 应用。
> 适用版本:Hilt 2.48+、Android API 21+、Kotlin 1.9+
> 阅读时长:约 18 分钟
1. 从一个真实 Bug 切入
你的应用崩溃了,日志这样写道:
java.lang.IllegalStateException: Expected @ActivityRetainedScoped but found @ActivityScoped
at dagger.hilt.android.ActivityRetainedComponentManager.get(...)
这个错误的根源是:把一个 @ActivityScoped 的依赖注入进了 @ActivityRetainedScoped 的 ViewModel。Hilt 的作用域体系是其核心,也是新手踩坑最集中的地方。
理解这个错误,你需要先搞清楚 Hilt 的组件层次和生命周期模型。
2. Hilt 全景:它替你做了什么
Hilt 是 Google 在 Dagger2 基础上封装的 Android 专属 DI 框架,本质是编译期代码生成。
2.1 Dagger vs Hilt 对比
| 维度 | Dagger2 | Hilt |
|------|---------|------|
| Component 定义 | 手写接口 + @Component | 自动生成,用 @HiltAndroidApp 触发 |
| 注入点声明 | 调用 component.inject(this) | @AndroidEntryPoint 注解 |
| ViewModel 注入 | 手写 Factory | @HiltViewModel + by viewModels() |
| 测试替换 | 手写 TestComponent | @UninstallModules + @BindValue |
| 学习成本 | 高 | 中 |
2.2 组件层次(从大到小)
ApplicationComponent
└── ActivityRetainedComponent ← ViewModel 在这层
└── ActivityComponent
├── FragmentComponent
│ └── ViewWithFragmentComponent
└── ViewComponent
└── ServiceComponent
关键规则 :子组件可以使用父组件提供的依赖,反之不行。这就是上面那个 crash 的根本原因 ------ ActivityScoped 的 repo 想注入进 ActivityRetainedScoped 的 ViewModel,形成了向上引用。
3. 核心原理:代码生成流程
3.1 注解处理器做了什么
Hilt 使用 dagger.hilt.android.processor.HiltProcessor(基于 KSP 或 KAPT)在编译期做三件事:
-
扫描
@HiltAndroidApp、@AndroidEntryPoint、@HiltViewModel等注解 -
生成
Hilt_XXX基类(如Hilt_MainActivity),你的MainActivity继承自它 -
组装
DaggerXxxComponent,调用链在ApplicationComponentManager内部// 你写的
@HiltAndroidApp
class MyApp : Application()
// Hilt 生成的(简化)
abstract class Hilt_MyApp : Application(), GeneratedComponentManagerHolder {
private val componentManager = ApplicationComponentManager(this)
override fun getComponentManager() = componentManager
override fun onCreate() {
componentManager.generatedComponent() // 初始化 DaggerMyApp_HiltComponents_C
super.onCreate()
}
}
3.2 @Inject 构造器注入链路
UserRepository(@Inject constructor(
private val api: UserApi,
private val db: AppDatabase
))
编译期
│
▼
UserRepository_Factory (implements Factory
)
├── get() → new UserRepository(api.get(), db.get())
└── 被 HiltComponents 的 Provider 持有
│
▼
注入点
@AndroidEntryPoint
class ProfileFragment : Fragment() {
@Inject lateinit var repo: UserRepository // DI 框架在 onAttach() 中注入
}
3.3 ViewModel 注入的特殊处理
@HiltViewModel 注解的 ViewModel 不走普通 inject 路径,而是通过 HiltViewModelFactory:
ActivityRetainedComponent
└── ViewModelComponent ← 每个 ViewModel 实例独立的 sub-component
└── @ViewModelScoped ← 仅此 ViewModel 实例共享
by viewModels() 在 Activity/Fragment 中触发时,Hilt 替换了默认的 ViewModelProvider.Factory,从 ViewModelComponent 中获取依赖。
4. 代码示例
4.1 标准模块定义
// 绑定接口到实现(抽象模块,不能有 @Provides 方法)
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
// @Binds 用于接口绑定:告诉 Hilt "需要 UserRepository 时,给 UserRepositoryImpl"
@Binds
@Singleton
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
}
// 提供第三方依赖(具体模块,用 @Provides)
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient =
OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.build()
@Provides
@Singleton
fun provideRetrofit(client: OkHttpClient): Retrofit =
Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
@Provides
@Singleton
fun provideUserApi(retrofit: Retrofit): UserApi =
retrofit.create(UserApi::class.java)
}
4.2 错误写法 → 问题 → 正确写法
❌ 错误写法:在 ViewModel 中注入 ActivityScoped 依赖
// 错误:@ActivityScoped 不能被 @ActivityRetainedScoped 使用
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val tracker: AnalyticsTracker // AnalyticsTracker 是 @ActivityScoped
) : ViewModel()
问题 : ViewModel 存活于 ActivityRetainedComponent(配置变更后仍存在),而 @ActivityScoped 的实例在 Activity 销毁时随之消亡。Hilt 在编译时会直接报错。 ✅ 正确写法:上移到 @Singleton 或换用 @ViewModelScoped
// 方案1:将 AnalyticsTracker 提升为 @Singleton
@Singleton
class AnalyticsTracker @Inject constructor(
@ApplicationContext private val context: Context
)
// 方案2:如果仅此 ViewModel 使用,改为 @ViewModelScoped
@ViewModelScoped
class AnalyticsTracker @Inject constructor(...)
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val tracker: AnalyticsTracker // 现在作用域匹配
) : ViewModel()
❌ 错误写法:在 abstract Module 中混用 @Provides
@Module
@InstallIn(SingletonComponent::class)
abstract class MixedModule {
@Binds abstract fun bindRepo(impl: RepoImpl): Repo
@Provides // ❌ 普通方法不能出现在 abstract class 里
fun provideOkHttp(): OkHttpClient = OkHttpClient()
}
✅ 正确写法:用 companion object 兼容两者
@Module
@InstallIn(SingletonComponent::class)
abstract class RepoModule {
@Binds abstract fun bindRepo(impl: RepoImpl): Repo
@Module
companion object {
@Provides
@Singleton
fun provideOkHttp(): OkHttpClient = OkHttpClient()
}
}
5. 最佳实践
5.1 始终通过接口暴露依赖
做法 :Module 中用 @Binds 绑定接口与实现,注入点使用接口类型。 原因 :接口解耦使得测试时可以无缝替换 Fake 实现,无需反射或 PowerMock。 对比 :如果直接注入具体类 UserRepositoryImpl,测试中你必须 Mock 它的所有依赖,或者整体替换 Module。使用接口只需 @BindValue @JvmField val repo: UserRepository = FakeUserRepository()。
5.2 合理选择作用域,默认不加 Scope
做法 :只有确实需要共享同一实例的依赖才加 @Singleton / @ViewModelScoped,默认保持无作用域。 原因 : @Singleton 意味着该依赖在应用全生命周期存活,过度使用会导致内存无法释放,还会增加单元测试的耦合度。 对比 :无作用域的依赖每次创建新实例,天然线程安全; @Singleton 的依赖需要自己保证线程安全。
5.3 用限定符而不是传裸 Context
做法 :
class MyManager @Inject constructor(
@ApplicationContext private val context: Context
)
原因 :Hilt 明确区分了 ApplicationContext 和 ActivityContext,避免 Activity 泄漏。 对比 :如果把 Activity 的 Context 存到 @Singleton 类中,就是经典内存泄漏。
5.4 测试中使用 @UninstallModules 精准替换
做法 :
@HiltAndroidTest
@UninstallModules(NetworkModule::class)
class ProfileFragmentTest {
@BindValue @JvmField val mockApi: UserApi = mockk()
@get:Rule val hiltRule = HiltAndroidRule(this)
}
原因 : @UninstallModules 是精准手术刀,仅替换指定模块,其余保持真实实现。 对比 :如果整体替换所有 Module,测试过度隔离,无法验证真实依赖的集成行为。
5.5 优先 KSP 替代 KAPT
做法 : build.gradle.kts 中用 ksp("com.google.dagger:hilt-compiler:...") 替代 kapt(...)。 原因 :KSP 比 KAPT 快 2x 以上,Kotlin 2.x 已推荐优先使用 KSP。 对比 :KAPT 需要 Kotlin stub 生成,在大型项目中是编译性能瓶颈。
6. 常见坑点
坑1:@Singleton 持有 Activity 引用导致内存泄漏
现象 :Memory Profiler 中看到 Activity 实例无法被 GC,持有链显示来自某个 Manager。 原因 : @Singleton 对象生命周期等于 Application,如果持有 Activity Context,Activity 销毁后内存无法释放。 复现 :
@Singleton
class ToastManager @Inject constructor(
private val context: Context // 如果是 ActivityContext
)
解决 :改用 @ApplicationContext,或将类降级为 @ActivityScoped。
坑2:@EntryPoint 访问时传错 ComponentManager
现象 : EntryPoints.get(...) 抛出 IllegalStateException: No entry point found for ... 原因 : @EntryPoint 必须安装在对应的 Component, EntryPoints.get() 的第一个参数必须是实现了该 Component 的对象。 复现 :
@EntryPoint
@InstallIn(ActivityComponent::class)
interface MyEntryPoint { fun getRepo(): UserRepository }
// 错误:传入 Application 而不是 Activity
EntryPoints.get(applicationContext, MyEntryPoint::class.java)
解决 :传入与 @InstallIn 匹配的 Activity 实例,或将 @InstallIn 改为 SingletonComponent。
坑3:@Binds 与 @Provides 混用编译报错
现象 : error: @Binds methods can only be used in abstract modules 原因 : @Binds 要求抽象方法(无方法体),无法出现在普通 class 中。 解决 :用 companion object 内嵌 @Module,或拆成两个 Module。
坑4:循环依赖导致编译挂起
现象 : kaptGenerateStubs Task 超时,无错误日志。 原因 :A depends on B,B depends on A,Hilt 的 Provider 生成陷入死循环。 复现 :
class A @Inject constructor(val b: B)
class B @Inject constructor(val a: A)
解决 :引入 Lazy 打破循环:
class A @Inject constructor(val b: Lazy)
class B @Inject constructor(val a: A)
**---
坑5:SavedStateHandle 存入非 Parcelable 对象后进程重启数据丢失
现象 :Process death 后恢复,savedStateHandle["key"] 取到 null。 原因 :SavedStateHandle 底层是 Bundle,只能序列化 Parcelable / 基本类型。 解决 :给数据类加 @Parcelize,或只存 ID 后重新 fetch。
7. 总结
- Hilt = Dagger2 + 编译期代码生成 + Android 组件感知 ,核心是生成
Hilt_XXX基类和各级 Component。 - 组件层次决定作用域合法性 :只能从父组件向子组件传依赖,
ActivityRetainedComponent>ActivityComponent>FragmentComponent。 @Bindsvs@Provides:接口绑定用@Binds(需抽象类),第三方或无构造器控制权时用@Provides。- 默认无作用域,按需 Scope :过度使用
@Singleton是内存泄漏和测试难的根源。 - 测试利器 :
@UninstallModules+@BindValue精准替换,结合HiltAndroidRule管理 Component 生命周期。
> 核心结论:Hilt 的价值不是"省代码",而是通过编译期强类型检查将 DI 错误从运行时提前到编译时,与 Android 组件生命周期深度绑定是其不可替代的核心优势。