【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 明确区分了 ApplicationContextActivityContext,避免 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 组件生命周期深度绑定是其不可替代的核心优势。

参考资料

相关推荐
星间都市山脉5 小时前
Android STS(Security Test Suite)完整介绍与测试流程
android·java·linux·windows·ubuntu·android studio·androidx
Yeyu5 小时前
你真的了解AIDL吗? 附:AIDL 与 Binder 交互全解析
android
dualven_in_csdn7 小时前
一键起飞调用示例
android·java·javascript
故渊at8 小时前
第十板块:Android 系统稳定性与调试 | 第二十五篇:Watchdog 与 ANR 的系统级监控
android·watchdog·系统稳定性·anr·超时监控
故渊at8 小时前
第十板块:Android 系统稳定性与调试 | 第二十六篇:Systrace 与 Perfetto 的系统级性能分析
android·perfetto·性能分析·systrace·系统稳定性
吕工-老船长19989 小时前
20260610----S905Y5(Android14)-----连接网络自动更新时间,时间设置为24小时
android
杉氧10 小时前
Kotlin 协程深度解析④:架构实战——在 MVVM/MVI 中的进阶应用
android·kotlin
Ab_stupid10 小时前
CTF-Android培训笔记
android·笔记