【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 @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)在编译期做三件事:

  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 销毁后内存无法释放。 复现 :

复制代码
@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. 总结

  1. Hilt = Dagger2 + 编译期代码生成 + Android 组件感知 ,核心是生成 Hilt_XXX 基类和各级 Component。
  2. 组件层次决定作用域合法性 :只能从父组件向子组件传依赖,ActivityRetainedComponent > ActivityComponent > FragmentComponent。
  3. @Binds vs @Provides :接口绑定用 @Binds(需抽象类),第三方或无构造器控制权时用 @Provides。
  4. 默认无作用域,按需 Scope :过度使用 @Singleton 是内存泄漏和测试难的根源。
  5. 测试利器 :@UninstallModules + @BindValue 精准替换,结合 HiltAndroidRule 管理 Component 生命周期。
    > 核心结论:Hilt 的价值不是"省代码",而是通过编译期强类型检查将 DI 错误从运行时提前到编译时,与 Android 组件生命周期深度绑定是其不可替代的核心优势。

参考资料

相关推荐
唐青枫2 小时前
Kotlin Delegation 委托详解
kotlin
北京自在科技3 小时前
谷歌安卓 Gemini 迎来界面改版,Find Hub 接入 Gemini 支持物品记忆查找
android·安卓
YB13755 小时前
Jetpack Compose 动画Modifier.animateItem()
android
2401_885885046 小时前
巴林语音通知接口如何对接?系统告警语音API接入教程
android·前端·网络·python·语音识别
收纳过往7 小时前
数据库加索引的时机:慢查询日志说话
android·开发语言·数据库·python·低代码·rxjava
狂热开发者8 小时前
车载中控要给鸿蒙用户,能不能和 Android 共用一个二维码?
android·华为·harmonyos
.Bryce.8 小时前
T2-LLM评测不等于Agent评测
android·java·人工智能·后端·gpt·agent·1024程序员节
美狐美颜SDK开放平台9 小时前
视频美颜SDK如何应对快速转头与大幅度面部动作?
android·人工智能·ios·音视频·直播美颜sdk·视频美颜sdk
2601_968900779 小时前
玻璃深加工的工艺窗口:4 道工序的温度与时间参数边界
android·java·javascript
好的佩奇0110 小时前
Dart 之类修饰符
android·flutter·dart