依赖注入: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 销毁而销毁,避免内存泄漏。

注意:作用域用错了会出大问题。我曾经把一个 @ActivityScoped 的对象标注成 @Singleton,结果导致 Activity 泄漏——因为那个对象持有 Activity 的引用,Activity 销毁时无法被 GC 回收。

实战:解耦的架构设计

依赖注入不只是为了少写几行 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 数据源。

小技巧:在写测试时,我习惯用 @Binds 注解来绑定接口和实现。这样 Module 里只需要声明一次,测试时替换实现也方便。

避坑指南

依赖注入用起来爽,但坑也不少。我总结几个常见的:

  • 循环依赖:A 依赖 B,B 依赖 A。Dagger 会报编译错误。解决办法是引入一个中间接口,或者用 @Lazy 延迟加载。
  • 作用域不匹配:@Singleton 的 Module 里不能提供 @ActivityScoped 的对象。编译期会报错,但很多人看不懂错误信息。
  • 注入时机:Hilt 的 @AndroidEntryPoint 只能在 Android 组件上用。普通类要用 @HiltViewModel 或者手动注入。

我曾经在一个项目里遇到循环依赖的问题。两个 Service 互相调用,Dagger 编译直接报错。后来我引入了一个事件总线,把双向依赖改成了单向事件通知,问题就解决了。

总结

依赖注入不是银弹,但它确实是解耦的好工具。Hilt 和 Dagger2 的核心思想是一样的:把对象的创建和组装交给框架,让类只关注自己的业务逻辑

我个人建议:新项目直接用 Hilt,省心省力。老项目如果已经在用 Dagger2,也没必要强行迁移,除非你被模板代码折磨得受不了。

最后说一句:依赖注入的代码写起来可能比直接 new 多几行,但维护起来会轻松很多。你想想看,当你的项目从 10 个类膨胀到 100 个类时,依赖注入带来的解耦价值就会体现出来。