【Android】Hilt 依赖注入:原理与最佳实践
Hilt 依赖注入:原理与最佳实践
>一句话收益:深入理解 Hilt 的代码生成机制与组件作用域,彻底告别手写 Dagger 样板代码,写出可测试、可维护的 Android 应用。
>适用版本:Hilt 2.48+、Android API 21+、Kotlin 1.9+
>阅读时长:约 18 分钟
---
1. 从一个真实 Bug 切入
你的应用崩溃了,日志这样写道:
java.lang.IllegalStateException: Expected @ActivityRetainedScoped but found @ActivityScopedat 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)在编译期做三件事:
1.扫描@HiltAndroidApp、@AndroidEntryPoint、@HiltViewModel等注解
2.生成Hilt_XXX基类(如Hilt_MainActivity),你的MainActivity继承自它
3.组装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 销毁后内存无法释放。复现:@Singletonclass 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:循环依赖导致编译挂起
现象:kaptGenerateStubsTask 超时,无错误日志。原因: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. 总结
1.Hilt = Dagger2 + 编译期代码生成 + Android 组件感知,核心是生成Hilt_XXX基类和各级 Component。
2.组件层次决定作用域合法性:只能从父组件向子组件传依赖,ActivityRetainedComponent>ActivityComponent>FragmentComponent。
3.@Bindsvs@Provides:接口绑定用@Binds(需抽象类),第三方或无构造器控制权时用@Provides。
4.默认无作用域,按需 Scope:过度使用@Singleton是内存泄漏和测试难的根源。
5.测试利器:@UninstallModules+@BindValue精准替换,结合HiltAndroidRule管理 Component 生命周期。
>核心结论:Hilt 的价值不是"省代码",而是通过编译期强类型检查将 DI 错误从运行时提前到编译时,与 Android 组件生命周期深度绑定是其不可替代的核心优势。
---
参考资料
- Hilt 官方文档
- Hilt 与 ViewModel 集成
- Hilt 测试指南
- AOSP 源码:HiltAndroidApp 处理器
- AOSP 源码:ActivityComponentManager
