当一个 Activity 的构造函数里堆了十几个 new,或者一个工具类到处 getInstance() 时,依赖注入就不再是「架构洁癖」而是维护性问题。Hilt 把 Dagger 的能力包装成 Android 友好的注解,用编译期代码生成换取零反射、可校验的依赖图。本文从注解到层级、从测试到排错,把 Hilt 的完整用法讲清。
一句话总结: Hilt 的价值不是「少写几行 new」,而是把依赖关系变成一张编译期可校验的图——连不上就在编译时报错,而不是运行时崩溃。
一、为什么需要依赖注入
不用 DI 时,依赖关系被硬编码在类内部,带来三个具体问题:难以替换实现(测试时无法注入 fake)、难以复用(一个类被绑死在某个具体实现上)、难以看清依赖(要读完构造才知道一个类依赖什么)。方案上,手动 new 与 ServiceLocator 都缺乏编译期校验,手写工厂样板代码多;只有 Hilt 既少样板又能编译期校验,因此适合中大型项目。
DI 的核心收益是「依赖倒置」:高层模块依赖接口而非实现,由外部把实现注入进来。Hilt 只是把这件事自动化了。
二、依赖与 KSP 配置
Hilt 2.51 起全面支持 KSP,替代了老旧的 KAPT。
// gradle/libs.versions.toml
[versions]
hilt = "2.52"
ksp = "2.0.21-1.0.28"
hiltNavigation = "1.2.0"
[libraries]
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigation" }
// app/build.gradle.kts(两个插件需先在根目录以 apply false 声明)
plugins {
id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android")
}
dependencies {
implementation(libs.hilt.android)
ksp(libs.hilt.compiler) // 用 ksp 而非 kapt
implementation(libs.hilt.navigation.compose)
androidTestImplementation("com.google.dagger:hilt-android-testing:2.52")
kspAndroidTest("com.google.dagger:hilt-compiler:2.52")
}
坑: Hilt Gradle 插件(
com.google.dagger.hilt.android)与注解处理器是两个东西,前者负责字节码改写(让@AndroidEntryPoint生效),后者负责生成依赖图代码,缺一不可。
三、三个核心入口注解
3.1 @HiltAndroidApp
标注在 Application 类上,触发 Hilt 生成整个应用的依赖容器,是所有依赖的根。
@HiltAndroidApp
class App : Application()
忘记加这个注解,运行时会直接抛异常:Hilt Activity must be attached to an @HiltAndroidApp Application。
3.2 @AndroidEntryPoint
标注在 Activity、Fragment、View、Service、BroadcastReceiver 上,让它们可以从依赖图中获取注入。
@AndroidEntryPoint
class ArticleActivity : AppCompatActivity() {
@Inject lateinit var repository: ArticleRepository
}
3.3 @HiltViewModel
标注在 ViewModel 上,使其支持构造注入,并可被 by viewModels() 获取。
@HiltViewModel
class ArticleViewModel @Inject constructor(
private val repository: ArticleRepository
) : ViewModel() {
val articles: StateFlow<List<Article>> = repository.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
}
这三者构成 Hilt 的「入口三件套」:Application 打地基,Android 组件接入,ViewModel 承接。
四、构造注入 @Inject
@Inject 标注在构造函数上,Hilt 就知道如何创建该类型。
class ArticleRepository @Inject constructor(
private val api: ArticleApi,
private val dao: ArticleDao,
private val dispatcher: CoroutineDispatcher
) {
suspend fun refresh() {
val remote = api.list()
dao.insertAll(remote.map { it.toEntity() })
}
}
只要依赖的类型本身也可被注入(或被 @Provides 提供),Hilt 就能递归地把整条链搭起来。构造注入是最推荐的方式:类不依赖 Hilt 的注解之外的任何东西,测试时可手动 new。
权衡: 构造注入要求依赖在构造时就能确定;若某依赖需延迟创建(如只在某个方法里用),可用
dagger.Lazy<T>或Provider<T>包装,避免提前实例化。
五、模块:提供第三方或接口类型
当类型无法加 @Inject 构造(如 Retrofit、OkHttp、Room 数据库)或是接口时,用 @Module 声明如何提供。
5.1 @Provides
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideJson(): Json = Json { ignoreUnknownKeys = true }
@Provides
@Singleton
fun provideOkHttp(): OkHttpClient =
OkHttpClient.Builder().connectTimeout(10, TimeUnit.SECONDS).build()
@Provides
@Singleton
fun provideRetrofit(client: OkHttpClient, json: Json): Retrofit =
Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
@Provides
@Singleton
fun provideArticleApi(retrofit: Retrofit): ArticleApi =
retrofit.create(ArticleApi::class.java)
}
这个 NetworkModule 就是第 8 篇 Android 网络层与 Retrofit 实践
里那套客户端的 Hilt 化写法:把 OkHttpClient、Retrofit、Json 都做成单例交给容器管理。
5.2 @Binds 绑定接口
接口无法 new,用 @Binds 把一个实现绑定到接口上。它比 @Provides 更高效:不生成额外的工厂方法体,只做类型映射。
interface ArticleRepository { suspend fun refresh() }
class DefaultArticleRepository @Inject constructor(
private val api: ArticleApi,
private val dao: ArticleDao
) : ArticleRepository {
override suspend fun refresh() { /* ... */ }
}
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindArticleRepository(impl: DefaultArticleRepository): ArticleRepository
}
@Binds 方法必须是抽象方法,且所在模块必须是 abstract class 或 interface。
| 注解 | 用于 | 模块类型 | 是否生成实例代码 |
|---|---|---|---|
@Provides | 第三方类、需构造逻辑 | object / class | 是 |
@Binds | 接口到实现、无逻辑映射 | abstract class / interface | 否 |
六、Component 层级与作用域
Hilt 预置了一套与 Android 生命周期对齐的 Component,每个 Component 有自己的作用域。
| Component | 作用域注解 | 生命周期 | 典型绑定 |
|---|---|---|---|
| SingletonComponent | @Singleton | 应用级 | 数据库、OkHttp、Retrofit |
| ActivityRetainedComponent | @ActivityRetainedScoped | 跨配置变更 | ViewModel 依赖 |
| ViewModelComponent | @ViewModelScoped | ViewModel 存活期 | ViewModel 内共享对象 |
| ActivityComponent | @ActivityScoped | Activity | Activity 级对象 |
| FragmentComponent | @FragmentScoped | Fragment | Fragment 级对象 |
| ServiceComponent | @ServiceScoped | Service | Service 级对象 |
@InstallIn 决定模块被装进哪个 Component。默认常用 SingletonComponent,但把 Activity 级对象装进 Singleton 会泄漏——作用域必须与生命周期匹配。
一句话总结: 作用域是「这个实例活多久」的声明。
@Singleton装进 Application 级容器,活得和进程一样久;把带 Context 的对象误标@Singleton是内存泄漏的头号原因。
七、限定符:消歧同类型依赖
当同一种类型需要多个实例(如两个不同的 OkHttpClient、两个 String),用限定符区分。
7.1 @Named
@Module
@InstallIn(SingletonComponent::class)
object ClientModule {
@Provides
@Named("auth")
fun provideAuthClient(): OkHttpClient =
OkHttpClient.Builder().addInterceptor(AuthInterceptor()).build()
@Provides
@Named("logging")
fun provideLoggingClient(): OkHttpClient =
OkHttpClient.Builder().addInterceptor(HttpLoggingInterceptor()).build()
}
class ApiClient @Inject constructor(
@Named("auth") private val authClient: OkHttpClient
)
7.2 自定义 @Qualifier
@Named 用字符串,拼写错误编译期无法发现。自定义 @Qualifier 更安全:
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AuthClient
@Provides
@AuthClient
fun provideAuthClient(): OkHttpClient = OkHttpClient.Builder().build()
class ApiClient @Inject constructor(
@AuthClient private val client: OkHttpClient
)
推荐用自定义 @Qualifier:IDE 能跳转、能重构、拼错就编译不过。
八、Context 注入
Hilt 预置了两个 Context 限定符,避免自己造。
class TokenStore @Inject constructor(
@ApplicationContext private val context: Context
)
| 限定符 | 类型 | 生命周期 |
|---|---|---|
@ApplicationContext | Application Context | 与进程同寿,安全 |
@ActivityContext | Activity Context | 与 Activity 同寿,不可存于 Singleton |
@ActivityContext 注入的对象绝不能持有超过 Activity 的生命周期,否则泄漏。
九、@EntryPoint
有些类不受 Hilt 管理(如第三方 SDK 回调、ContentProvider、自定义 View),无法用 @AndroidEntryPoint,此时用 @EntryPoint 手动取依赖。
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AppEntryPoint { fun articleRepository(): ArticleRepository }
val entryPoint = EntryPointAccessors.fromApplication(
context.applicationContext, AppEntryPoint::class.java
)
val repository = entryPoint.articleRepository()
@EntryPoint 是逃生舱口,不是常规写法。能用构造注入就别用它。
十、与 ViewModel、WorkManager 集成
10.1 ViewModel
@HiltViewModel 配合 by viewModels() 自动完成注入,无需工厂。
@AndroidEntryPoint
class ArticleActivity : AppCompatActivity() {
private val viewModel: ArticleViewModel by viewModels()
}
在 Compose 中用 hiltViewModel():
@Composable
fun ArticleScreen(viewModel: ArticleViewModel = hiltViewModel()) {
val articles by viewModel.articles.collectAsStateWithLifecycle()
// ...
}
10.2 WorkManager
Worker 由系统实例化,需用 @HiltWorker 与 HiltWorkerFactory。
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val repository: ArticleRepository
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
repository.refresh()
return Result.success()
}
}
需要在 Application 上实现 Configuration.Provider 并返回 HiltWorkerFactory,同时在 Manifest 中移除默认的 WorkManagerInitializer。
十一、测试
Hilt 提供专门的测试支持,让单测与仪器测试都能替换依赖。
@HiltAndroidTest
class ArticleViewModelTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@Inject lateinit var repository: FakeArticleRepository
@Before fun setUp() = hiltRule.inject()
@Test fun `shows articles`() {
// 用 FakeArticleRepository 替换真实实现
}
}
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class]
)
abstract class FakeRepositoryModule {
@Binds
abstract fun bindRepository(impl: FakeArticleRepository): ArticleRepository
}
仪器测试需要自定义 HiltTestApplication 并在 testInstrumentationRunner 中指定。这是 Hilt 相比手写 DI 的明显优势:替换依赖是声明式的,不必改生产代码。
十二、Hilt 与手写 DI 的取舍
| 维度 | Hilt | 手写 DI / ServiceLocator |
|---|---|---|
| 编译期校验 | 有,依赖缺失即报错 | 无,运行时才崩 |
| 样板代码 | 少(注解即可) | 多(手写工厂) |
| 学习成本 | 高(Component 层级、作用域) | 低 |
| 构建耗时 | 增加(注解处理) | 无 |
| 测试替换 | 声明式,简单 | 需手动替换 |
结论:中大型项目、多人协作、依赖关系复杂时选 Hilt;个人小项目、依赖极简时,手写一个 AppContainer 反而更透明。不要把 DI 框架当成「必须有」的教条。
十三、编译期校验与错误解读
Hilt 在编译期构建依赖图,出错时给出具体错误。常见几类:
| 错误信息关键词 | 含义 | 修复 |
|---|---|---|
cannot be provided without an @Provides-annotated method | 缺少绑定 | 补 @Provides 或 @Inject 构造 |
is not assignable to | 类型不匹配 | 检查 @Binds 的返回类型 |
@AndroidEntryPoint Activity must extend ComponentActivity | 基类不对 | 继承 ComponentActivity 或 AppCompatActivity |
MissingBinding | 依赖图缺环 | 看完整链条定位缺失节点 |
Hilt Activity must be attached to an @HiltAndroidApp | 缺根 | 给 Application 加 @HiltAndroidApp |
MissingBinding 最让人头疼,因为报错点往往是链条末端。阅读技巧:从错误里「谁需要这个类型」开始,顺着构造函数向上找,缺失的通常是某个第三方类没写 @Provides。
一句话总结: Hilt 的错误信息像编译器的模板报错,第一遍难读,但读多了会发现它精确指出了「谁需要什么、缺了什么」。
十四、常见坑清单
| 坑 | 表现 | 规避方式 |
|---|---|---|
| 忘记 @HiltAndroidApp | 启动即崩 | Application 必须标注 |
| 用 kapt 而非 ksp | 构建慢 | 换 KSP |
| 把 Activity Context 注入 Singleton | 内存泄漏 | 用 @ApplicationContext |
| 作用域与生命周期错配 | 实例早于/晚于预期 | 作用域对齐 Component |
| 同类型多实例无限定符 | DuplicateBindings | 用 @Qualifier |
| @Binds 方法非抽象 | 编译报错 | 模块改 abstract class |
| Worker 未用 HiltWorkerFactory | 依赖为空 | 配置 Configuration.Provider |
| 测试未加 @HiltAndroidTest | 注入失败 | 补注解与 HiltAndroidRule |
| 模块未 @InstallIn | 绑定不生效 | 补 @InstallIn |
小结
Hilt 把「依赖从哪来、活多久、给谁用」三个问题,收敛成注解加作用域的声明。落地时记住四件事:入口三件套缺一不可(@HiltAndroidApp、@AndroidEntryPoint、@HiltViewModel),能用构造注入就别用字段注入,接口绑定优先 @Binds、第三方类才用 @Provides,作用域必须与 Component 生命周期对齐。搭好这套容器后,Android 数据持久化与 Room
里的数据库、网络层的客户端都能以单例形式统一管理,替换实现、编写测试也变成声明式的一行注解。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。