依赖注入:Hilt/Dagger2的核心原理与实战,解耦的艺术
依赖注入,说白了就是「我要什么,你给我什么,我自己不去 new」。这个概念我刚开始接触时也觉得玄乎,直到在项目里被耦合代码折磨得够呛,才真正体会到它的价值。
今天咱们聊聊 Hilt 和 Dagger2。这两兄弟是 Android 生态里最主流的依赖注入框架。Dagger2 是 Google 对 Dagger 的 fork 版本,而 Hilt 则是 Google 在 Dagger2 基础上封装的「Android 专用版」。我个人习惯直接上 Hilt,因为它省去了很多模板代码。
为什么需要依赖注入?
先看一个反面教材。假设你有一个网络请求的类:
class UserRepository {
private val api = Retrofit.Builder()
.baseUrl("https://api.example.com")
.build()
.create(ApiService::class.java)
fun getUser(): User {
return api.getUser()
}
}
这段代码有什么问题?耦合太紧。UserRepository 直接 new 了 Retrofit 实例。如果你想换一个 baseUrl,或者想用 Mock 数据做测试,就得改 UserRepository 的源码。你想想看,一个项目里几十个这样的类,改起来得多痛苦。
依赖注入的核心思想就是:把对象的创建和组装交给外部容器。类只负责声明「我需要什么」,不关心「谁来给我」。
Dagger2 的核心原理
Dagger2 的工作原理其实不复杂。它通过注解在编译期生成代码,帮你完成对象的创建和注入。我把它拆成三个核心概念:
- @Module:告诉 Dagger「这个对象怎么创建」
- @Component:告诉 Dagger「谁需要这些对象」
- @Inject:告诉 Dagger「这里要注入」
举个例子。你有一个网络模块:
@Module
class NetworkModule {
@Provides
@Singleton
fun provideRetrofit(): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.example.com")
.build()
}
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService {
return retrofit.create(ApiService::class.java)
}
}
然后在 Component 里声明:
@Singleton
@Component(modules = [NetworkModule::class])
interface AppComponent {
fun inject(activity: MainActivity)
}
最后在 Activity 里用 @Inject 声明:
class MainActivity : AppCompatActivity() {
@Inject
lateinit var apiService: ApiService
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 注意:必须在 super.onCreate 之前调用
(application as MyApp).appComponent.inject(this)
}
}
嗯,这里要注意:Dagger2 的注入时机很关键。如果你在 onCreate 之后才调用 inject,有些字段可能还没准备好。我曾经踩过这个坑,导致空指针异常排查了半天。
Hilt:Dagger2 的 Android 专属封装
Hilt 的出现,说白了就是 Google 觉得 Dagger2 在 Android 上用起来太啰嗦。你想想看,每个 Activity、Fragment 都要写 Component、写 inject 方法,烦不烦?
Hilt 帮你做了两件事:
- 自动生成 Component:你不需要手动写 Component 接口了
- 生命周期绑定:@HiltViewModel、@AndroidEntryPoint 这些注解帮你自动管理作用域
用 Hilt 改造上面的例子:
@HiltAndroidApp
class MyApplication : Application()
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var apiService: ApiService
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 不需要手动 inject 了,Hilt 自动处理
}
}
Module 的写法也简化了:
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideRetrofit(): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.example.com")
.build()
}
}
看到区别了吗?Hilt 用 @InstallIn 指定这个 Module 的作用域,@HiltAndroidApp 和 @AndroidEntryPoint 帮你省去了 Component 的声明。我个人觉得,Hilt 让依赖注入的门槛降低了不少。
作用域与生命周期
依赖注入里有个容易混淆的概念:作用域。说白了就是「这个对象活多久」。
| 作用域 | 生命周期 | 适用场景 |
|---|---|---|
| @Singleton | 整个应用进程 | 全局配置、数据库实例 |
| @ActivityScoped | Activity 生命周期 | Activity 相关的 Presenter |
| @FragmentScoped | Fragment 生命周期 | Fragment 相关的 ViewModel |
| @ViewModelScoped | ViewModel 生命周期 | 与 ViewModel 绑定的数据 |
举个例子。你的网络请求客户端应该是全局单例,因为创建 Retrofit 实例开销不小。但某个 Activity 里的对话框,就应该随着 Activity 销毁而销毁,避免内存泄漏。
实战:解耦的架构设计
依赖注入不只是为了少写几行 new 代码。它真正的价值在于解耦。我分享一个实际项目的做法。
假设你的 App 有多个数据源:网络、本地数据库、缓存。传统写法可能是:
class UserRepository {
private val remote = RemoteDataSource()
private val local = LocalDataSource()
private val cache = CacheDataSource()
fun getUser(): User {
// 先查缓存,再查本地,最后查网络
}
}
这种写法的问题在于:UserRepository 直接依赖了具体的数据源实现。如果你想换一个数据源,或者想加一个 Mock 数据源做测试,就得改 UserRepository 的代码。
用依赖注入改造后:
interface UserDataSource {
fun getUser(): User
}
class RemoteDataSource @Inject constructor() : UserDataSource {
override fun getUser(): User { /* 网络请求 */ }
}
class LocalDataSource @Inject constructor() : UserDataSource {
override fun getUser(): User { /* 本地数据库 */ }
}
class UserRepository @Inject constructor(
private val remote: UserDataSource,
private val local: UserDataSource
) {
fun getUser(): User {
// 策略模式:先尝试本地,再尝试网络
}
}
看到好处了吗?UserRepository 不再依赖具体实现,而是依赖接口。你想换数据源?只需要在 Module 里改一下 @Provides 的返回值就行。你想做单元测试?直接传入 Mock 数据源。
避坑指南
依赖注入用起来爽,但坑也不少。我总结几个常见的:
- 循环依赖:A 依赖 B,B 依赖 A。Dagger 会报编译错误。解决办法是引入一个中间接口,或者用 @Lazy 延迟加载。
- 作用域不匹配:@Singleton 的 Module 里不能提供 @ActivityScoped 的对象。编译期会报错,但很多人看不懂错误信息。
- 注入时机:Hilt 的 @AndroidEntryPoint 只能在 Android 组件上用。普通类要用 @HiltViewModel 或者手动注入。
我曾经在一个项目里遇到循环依赖的问题。两个 Service 互相调用,Dagger 编译直接报错。后来我引入了一个事件总线,把双向依赖改成了单向事件通知,问题就解决了。
总结
依赖注入不是银弹,但它确实是解耦的好工具。Hilt 和 Dagger2 的核心思想是一样的:把对象的创建和组装交给框架,让类只关注自己的业务逻辑。
我个人建议:新项目直接用 Hilt,省心省力。老项目如果已经在用 Dagger2,也没必要强行迁移,除非你被模板代码折磨得受不了。
最后说一句:依赖注入的代码写起来可能比直接 new 多几行,但维护起来会轻松很多。你想想看,当你的项目从 10 个类膨胀到 100 个类时,依赖注入带来的解耦价值就会体现出来。