Hilt 是 Android 官方推荐的依赖注入方案。它构建在 Dagger 之上,使用注解描述对象如何创建、依赖应该安装到哪个容器、实例是否需要复用,以及 Android Framework 创建的对象从哪里进入依赖图。
学习 Hilt 不能只停留在会写 @Inject、@Module 和 @Singleton。真正需要掌握的是下面这条主线:
1 | Binding:对象怎么创建 |
本文从 Hilt 如何使用注解开始,通过一条完整的网络层、Repository、ViewModel 依赖链,重点讲清 Component、Scope、生命周期、屏幕旋转、父子 Component 可见性以及 Repository 的作用域选择。
一、Hilt 如何使用注解
Hilt 的注解不是运行时“魔法”。构建时,Hilt/Dagger 会读取注解、建立并校验依赖图,然后生成 Factory、Component 和 Android 注入代码;运行时直接调用这些生成代码创建对象。
1 | Kotlin 源码 |
Hilt 主要解决两件事:
- 根据 Binding 建立、校验并生成对象依赖图。
- 提供与 Application、Activity、Fragment、ViewModel、Service 生命周期对应的标准 Component。
没有 Hilt 时,创建登录页面的依赖可能需要手写:
1 | val okHttpClient = OkHttpClient.Builder().build() |
它背后的依赖图是:
1 | LoginViewModel |
Hilt 让开发者描述每个节点“如何创建”和“应该属于哪个生命周期”,再生成上面这段组装逻辑。
常用注解可以按职责分为四组:
| 职责 | 注解 | 作用 |
|---|---|---|
| 应用与 Android 入口 | @HiltAndroidApp、@AndroidEntryPoint、@HiltViewModel |
让 Application、Activity、Fragment、ViewModel 等进入 Hilt 依赖图 |
| 构造 Binding | @Inject |
声明构造函数或注入点 |
| Module Binding | @Module、@Provides、@Binds、@InstallIn |
提供第三方对象、绑定接口并指定 Component |
| 实例复用 | @Singleton、@ActivityRetainedScoped、@ViewModelScoped 等 |
指定同一 Component 实例内的复用范围 |
后续章节会沿着“创建规则、容器、复用范围、生命周期”依次拆解这些注解。
二、接入 Hilt
以下使用 Kotlin DSL 和 KSP。版本会持续更新,请以 Android 官方 Hilt 文档为准,不要长期复制某个固定版本号。
根项目 build.gradle.kts:
1 | plugins { |
应用模块 build.gradle.kts:
1 | plugins { |
如果项目仍使用 kapt,可按照官方文档切换对应处理器配置;新旧方案不要在未确认项目现状时混用。
1. @HiltAndroidApp:应用级入口
1 |
|
并在 Manifest 中注册:
1 | <application |
@HiltAndroidApp 会触发 Hilt 代码生成,并建立应用级依赖容器。它不是在说“Application 是单例”,而是在声明 Hilt 依赖图的应用入口。
2. @AndroidEntryPoint:Android 组件入口
1 |
|
Activity、Fragment、Service 等对象由 Android Framework 创建,开发者无法直接通过构造函数控制它们的实例化过程。@AndroidEntryPoint 让 Hilt 为这些框架对象生成注入代码。
在 Compose 中通常只需给承载界面的 ComponentActivity 添加 @AndroidEntryPoint,不需要给每个 Composable 添加 Hilt 注解。
3. @Inject constructor:告诉 Hilt 如何创建对象
1 | class UserRepository constructor( |
它表达的不是“立即向这里注入对象”,而是:
当依赖图需要
UserRepository时,可以调用这个构造函数;构造前还必须先解决UserApi。
Hilt 会递归查找每个构造参数的 Binding,直到依赖图闭合。常见编译错误 X cannot be provided without an @Inject constructor or an @Provides-annotated method,本质就是依赖图走到 X 时断开了。
4. 为什么构造函数参数可以是 private val
1 | class UserManager constructor( |
Hilt 创建对象时调用的是构造函数:
1 | val manager = UserManager(repository) |
private 只限制外部访问 UserManager.repository,并不妨碍构造函数接收参数。因此普通业务类应优先使用构造函数注入,把依赖保持为私有实现细节。
字段注入则不同:
1 |
|
Activity 已经由系统创建,Hilt 只能在之后从外部给字段赋值,所以 Hilt 注入字段不能声明为 private,否则会产生编译错误。
三、无法构造注入时:@Module、@Provides 与 @Binds
构造函数注入优先,但以下类型无法直接使用:
- 接口没有可调用的构造函数。
- Retrofit、OkHttp、Room 等第三方类无法修改源码。
- 对象必须通过 Builder 或 Factory 创建。
这时需要 Hilt Module。
1. @Module 与 @InstallIn
1 |
|
@Module:该类型中包含对象创建或类型绑定规则。@InstallIn:这些 Binding 安装到哪个 Component。
必须特别注意:
@InstallIn(SingletonComponent::class)只表示 Binding 对应用级 Component 可见,不代表每次请求一定返回同一个实例。
是否复用由 Scope 决定。
2. @Provides:执行创建逻辑
1 |
|
@Provides 方法中:
- 返回值表示它提供的类型。
- 参数表示创建该类型所需的依赖。
- 方法体表示具体创建过程。
Hilt 会先解决 OkHttpClient,再创建 Retrofit,最后创建 UserApi。
3. @Binds:把接口绑定到实现
1 | interface UserRepository { |
1 | class UserRepositoryImpl constructor( |
1 |
|
@Binds 方法的返回类型是依赖方请求的抽象类型,参数类型是实际实现。
可以用下面的顺序选方案:
1 | 自己的普通类 |
四、完整依赖链:从网络层到 ViewModel
Repository:
1 | class DefaultUserRepository constructor( |
ViewModel:
1 |
|
Activity 获取 ViewModel:
1 |
|
Compose 中可在对应导航目的地使用 hiltViewModel() 获取作用域正确的 ViewModel。
此时 Hilt 构建的依赖图是:
1 | LoginViewModel |
ViewModel 必须通过 AndroidX 的 ViewModel 获取机制创建,不能把 @HiltViewModel 直接当普通依赖字段注入,否则会绕过 ViewModelStore 所管理的实例与生命周期。
五、Hilt 最重要的心智模型
Hilt 中最容易混淆的是 Binding、Component、Scope 和 Lifecycle。
1. Binding:对象怎么来
以下三种写法都会贡献 Binding:
1 | @Inject constructor → 构造创建 |
2. Component:Binding 放在哪个容器
Component 可以理解为依赖容器。它持有可见的 Binding,并负责创建或缓存属于自己的 scoped 对象。
3. Scope:同一 Component 实例内是否复用
默认 Binding 是 unscoped。每次请求时,Hilt 可以创建一个新实例。
添加 Scope 后,同一个 Component 实例中的所有请求复用同一个对象。
4. Lifecycle:Component 存活多久
Component 的生命周期限定了其中 scoped 对象最长能存活多久。
最终可以压缩为一句:
Component 是容器,Scope 是容器内对象的复用规则,Lifecycle 是容器的存活时间。
六、Hilt Component 层级与作用域
简化后的核心层级如下:
1 | SingletonComponent |
需要注意:ActivityComponent 与 ViewModelComponent 是兄弟 Component,它们共同继承 ActivityRetainedComponent,不是彼此的父子关系。
常见对应关系如下:
| Component | Scope | 生命周期语义 | 常见对象 |
|---|---|---|---|
SingletonComponent |
@Singleton |
Application 进程范围 | Retrofit、OkHttp、Room、全局会话 |
ActivityRetainedComponent |
@ActivityRetainedScoped |
一个逻辑 Activity,可跨配置变化 | 多个 ViewModel 共享的流程状态 |
ViewModelComponent |
@ViewModelScoped |
单个 ViewModel | ViewModel 专属缓存、状态协作者 |
ActivityComponent |
@ActivityScoped |
具体 Activity 实例 | Navigator、Dialog 管理、Activity UI 协作者 |
FragmentComponent |
@FragmentScoped |
具体 Fragment 实例 | Fragment 专属协作者 |
ServiceComponent |
@ServiceScoped |
具体 Service 实例 | Service 专属对象 |
Scope 必须与 Binding 所在 Component 匹配。例如安装在 ActivityComponent 的 scoped Binding 应使用 @ActivityScoped,不能随意标记成 @Singleton。
七、父子 Component 如何共享依赖
Hilt/Dagger 的可见性规则是:
1 | 子 Component 可以访问自己和祖先 Component 的 Binding |
例如:
1 |
|
1 |
|
1 |
|
它们实际分布在两层:
1 | ActivityRetainedComponent |
从生成代码的角度,可以把 scoped 对象理解为“缓存槽放在对应 Component 中”:
1 | @Singleton → 缓存在 SingletonComponent |
这是一种便于理解的模型。实际生成类名和延迟初始化代码更复杂,但语义一致。
为什么长生命周期对象不能依赖短生命周期对象
下面这种依赖方向不成立:
1 |
|
1 |
|
UserManager 可能在整个 App 进程中存活,而 ActivityNavigator 只属于某个 Activity。当 Activity 销毁后,Singleton 仍然存活,依赖对象却已经过期。
因此应记住:
1 | 短生命周期对象可以依赖长生命周期对象 |
从 Component 结构上看,这也等价于“子级可以访问祖先,祖先不能反向访问子级”。
八、屏幕旋转时谁保留、谁重建
Activity 存在两个容易混淆的概念:
- 具体 Activity 实例:旋转等配置变化时通常会销毁并重建。
- 用户看到的逻辑 Activity:旋转前后仍然是同一个页面流程。
第一次创建:
1 | ActivityRetainedComponent #A |
旋转后:
1 | ActivityRetainedComponent #A ← 保留 |
所以:
1 | 屏幕旋转 |
Hilt 对外保证 ActivityRetainedComponent 跨配置变化存活。实现层面可以把它理解为借助 Activity 的 retained 状态与 AndroidX ViewModelStore 机制:配置变化时复用原来的 Store;宿主真正退出时清理 Store,并通知其中的 ViewModel 不再需要。
因此 @ActivityRetainedScoped 对象本身不需要知道:
- 自己属于哪个 Activity。
- Activity 是否正在旋转。
- 自己何时应该销毁。
这些责任属于 Component 与 Android 生命周期基础设施,而不是普通业务对象。
@ActivityRetainedScoped 与 @ViewModelScoped 的区别
假设一个 Activity 有 UserViewModel 和 OrderViewModel。
1 | ActivityRetainedComponent |
@ActivityRetainedScoped Session:同一逻辑 Activity 下的多个 ViewModel 可以共享。@ViewModelScoped Cache:每个 ViewModel 拥有自己的实例。
它们都可能跨旋转保留,但粒度不同:一个属于逻辑 Activity,一个属于具体 ViewModel。
九、Scope 不会沿依赖链“传染”
假设:
1 |
|
1 | class CheckoutRepository constructor( |
CheckoutRepository 没有 Scope,因此在同一个 ViewModelComponent 中可能发生:
1 | 第一次请求 Repository |
即:
1 | Repository #1 != Repository #2 |
依赖了 scoped 对象,不代表当前 Binding 自动拥有相同 Scope。每个 Binding 的复用规则独立决定。
如果 Repository 自身也必须在单个 ViewModel 内唯一,再显式添加:
1 |
|
不要为了“看起来统一”而给整个依赖链机械地添加相同 Scope,应先判断每个对象是否真的需要实例唯一性。
十、Repository 应不应该加 Scope
Repository 只是架构角色名称,不天然等于 @Singleton 或 @ViewModelScoped。
判断时依次问三个问题:
1 | 1. Repository 内部有没有实例状态? |
1. 无状态 Repository:通常不加 Scope
1 | class ProductRepository constructor( |
它只组合和转发调用,没有缓存、Flow、连接或共享状态。即使存在多个实例,行为通常也相同,因此不必为了 Repository 这个名字添加 Scope。
2. ViewModel 专属状态:@ViewModelScoped
1 |
|
适合只服务于单个 ViewModel 的页面状态或流程缓存。
3. 同一逻辑 Activity 的多个 ViewModel 共享:@ActivityRetainedScoped
1 |
|
适合同一业务流程中多个 ViewModel 共享,而且旋转后不能丢失的状态。不同 Activity 实例不会因此变成全局共享。
4. App 级共享状态:@Singleton
UserRepository 如果管理以下内容,通常适合 @Singleton:
- 当前登录用户。
- Token 与登录状态。
- 用户资料缓存。
- 全局权限或账号切换状态。
- 需要被多个页面观察的同一条
StateFlow。
1 |
|
多个 ViewModel 会观察同一份状态:
1 | LoginViewModel ──────┐ |
如果不加 Scope,则不同消费者可能拿到不同 Repository:
1 | LoginViewModel |
5. @Singleton 的代价
@Singleton 不是免费的性能优化,它表达的是“把这个实例提升到应用级 Component 生命周期”。代价包括:
- 对象及其缓存可能长期占用内存。
- 可变状态的共享范围变大,并发与同步问题更明显。
- 错误持有 Activity、View 或 Fragment 时,生命周期不匹配会更严重。
- 测试中更容易出现全局状态相互影响。
尤其不要让 Singleton 持有 Activity:
1 |
|
正确做法是让长生命周期对象只依赖同级或更长生命周期对象;需要 Context 时,根据真实用途选择 @ApplicationContext 或把 Activity 相关行为留在 Activity 级对象中。
十一、常见误区与排查方式
误区 1:@InstallIn(SingletonComponent::class) 就是单例
不是。@InstallIn 决定可见范围,@Singleton 决定同一 Component 实例内是否复用。
误区 2:所有 Repository 都应该是 Singleton
不是。先判断实例状态及其共享范围。无状态、创建成本低的 Repository 通常不需要 Scope。
误区 3:依赖 Singleton 的对象也会自动变成 Singleton
不会。Scope 不沿依赖链传播。unscoped Repository 可以依赖同一个 Singleton API,但 Repository 自身仍可能有多个实例。
误区 4:ActivityRetainedComponent 就是 ActivityComponent 的长期版本
两者用途不同。ActivityComponent 绑定具体 Activity 实例;ActivityRetainedComponent 绑定跨配置变化的逻辑 Activity,并同时作为 ActivityComponent 与 ViewModelComponent 的父级。
误区 5:ViewModel 可以注入 @ActivityScoped Navigator
通常不可以。两者位于兄弟 Component,且 ViewModel 可能在 Activity 重建后继续存在,持有旧 Activity 级对象会产生生命周期错误。
误区 6:构造函数参数和注入字段都不能 private
构造函数参数可以是 private val;Hilt 字段注入不能是 private。两者是不同注入方式。
误区 7:为了减少 new,对所有对象都加 Scope
Scope 会增加生成代码与运行时管理成本,并延长对象存活时间。只有当实例唯一性影响正确性、同步,或已经测量出创建成本时才添加。
十二、一个实用的 Scope 决策表
| 需求 | 建议 |
|---|---|
| 无状态、轻量、多个实例行为一致 | 不加 Scope |
| 单个 ViewModel 内必须共享同一实例 | @ViewModelScoped |
| 同一逻辑 Activity 的多个 ViewModel 共享并跨旋转保留 | @ActivityRetainedScoped |
| 严格绑定当前 Activity 实例或 Activity Context | @ActivityScoped |
| 整个 App 共享状态或资源 | @Singleton |
选择前再检查两条规则:
- 能使用更小的 Scope,就不要无理由扩大生命周期。
- 长生命周期对象不能依赖短生命周期对象。
十三、推荐学习与源码阅读路线
如果目标是理解 Hilt 而不只是会写几个注解,可以按以下顺序推进:
第一阶段:完成 Hilt 依赖图
- 用
@Inject constructor串起三层普通类。 - 用
@Provides接入 Retrofit。 - 用
@Binds绑定 Repository 接口。
第二阶段:Component 与生命周期
- 比较 Activity 旋转前后的
@ActivityScoped与@ActivityRetainedScoped实例。 - 在一个 Activity 中创建两个 ViewModel,验证
@ViewModelScoped不跨 ViewModel 共享。 - 查看生成的 Component/Factory,确认 scoped 对象缓存在哪一层。
第三阶段:错误定位与生成代码
- 主动制造缺失 Binding、循环依赖与 Scope 不匹配错误,阅读完整依赖链。
- 比较
@Inject constructor、@Provides和@Binds生成的代码。 - 确认父 Component 的 Binding 如何被子 Component 使用。
当你能够把下面这段代码直接翻译成人话时,就已经掌握了 Hilt 的核心:
1 |
|
翻译结果是:
1 | @Provides |
总结
学习 Hilt 的关键不是背诵注解名称,而是始终追问四个问题:
1 | 这个类型的 Binding 怎么提供? |
最终心智模型是:
注解负责声明,处理器负责建立依赖图,Component 负责承载 Binding,Scope 负责实例复用,Android 生命周期负责 Component 的创建与销毁。