Android 依赖注入与 Hilt

依赖注入能显著降低大型 Android 项目的耦合度,而 Hilt 把这件事做成了编译期可校验的标准方案。本文用 Hilt 2.52 与 KSP 落地依赖注入:应用与页面入口注解体系、模块与安装组件的绑定方式、组件层级与作用域、限定符与入口点用法,以及同视图模型、后台任务的集成和测试写法。文中附编译期错误解读表,并与手写依赖注入做取舍对比,帮你判断何时该引入框架。

当一个 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@ViewModelScopedViewModel 存活期ViewModel 内共享对象
ActivityComponent@ActivityScopedActivityActivity 级对象
FragmentComponent@FragmentScopedFragmentFragment 级对象
ServiceComponent@ServiceScopedServiceService 级对象

@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
)
限定符类型生命周期
@ApplicationContextApplication Context与进程同寿,安全
@ActivityContextActivity 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 里的数据库、网络层的客户端都能以单例形式统一管理,替换实现、编写测试也变成声明式的一行注解。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Android 开发」更多文章

  1. Kotlin Multiplatform 跨平台共享
  2. Android R8 混淆与 Baseline Profile
  3. Android 测试体系:单元测试、Espresso 与 Compose 测试