一、控制反转与依赖注入的概念
控制反转(IoC, Inversion of Control)是一种设计原则,最通俗的理解是:不要让类自己创建它所依赖的对象,而是由外部(容器/框架)注入进来。依赖注入(DI, Dependency Injection)是 IoC 的一种具体实现方式。
1.1 DRY 原则下的 DI 价值
在传统写法中,类自己控制依赖的创建:
public class NewsRepository { |
使用依赖注入后:
public class NewsRepository { |
依赖注入的三个核心好处:
- 可测试性:可以在测试中传入 mock 对象。
- 松耦合:NewsRepository 不需要知道 NewsApi 是如何创建的。
- 可替换性:更换实现只需修改注入配置,无需改动依赖方代码。
- 集中管理:对象的创建和生命周期管理集中在 Module 中,符合 DRY(Don’t Repeat Yourself)原则。
1.2 DI 与 Service Locator 的区别
很多人将依赖注入与服务定位器混为一谈:
// Service Locator 模式(主动拉取) |
关键区别:
- Service Locator:类主动向全局容器请求依赖。缺点:依赖关系不透明(不知道 NewsViewModel 需要哪些依赖),测试时必须同时 mock ServiceLocator。
- Dependency Injection:依赖被动注入。优点:依赖关系在构造函数中清晰可见,测试时直接传入 mock 对象即可。
二、三种注入方式
2.1 构造函数注入(推荐方式)
public class UserViewModel { |
构造函数注入的优势是:(1) 依赖在对象创建时就确定,不会出现 NPE;(2) 可以将依赖声明为 final;(3) 便于发现循环依赖——如果 A 依赖 B 且 B 依赖 A,编译时会检测到。
2.2 字段注入
public class MainActivity extends AppCompatActivity { |
字段注入的缺点:(1) 依赖不可 final;(2) 容易出现忘记触发注入的情况;(3) 在单元测试中难以替换依赖。Android 中主要用于 Activity/Fragment 等不能控制构造函数创建的对象。
2.3 方法注入
public class MainActivity extends AppCompatActivity { |
方法注入使用较少,主要用于向后兼容(已经有 setter)或需要对注入的依赖做额外处理。
三、编译期 DI:Dagger/Hilt 原理
Dagger(https://github.com/google/dagger)是 Android 生态中最主流的编译期 DI 框架,由 Google 维护。其核心原理是通过 APT(Annotation Processing Tool)在编译期生成工厂类,完全避免运行时的反射开销。
3.1 核心概念
Component(组件):依赖注入的容器和桥梁。定义哪些模块提供什么依赖,注入给哪些目标。
|
Module(模块):提供对象创建逻辑的地方,尤其是对第三方库(Retrofit、Room)的实例化。
|
Scope(作用域):控制依赖的生命周期。@Singleton 是内置的作用域,此外可自定义:
|
作用域确保:同一个 Component 实例中,被同一作用域标记的依赖只会创建一次。
Qualifier(限定符):区分同类型的多个依赖实例。
|
3.2 Component 的实现原理:生成代码解析
Dagger 为每个 @Component 注解的接口生成一个 DaggerXXXComponent 实现类。以下是实际的生成代码解析:
// 生成的 DaggerAppComponent.java(简化) |
Double Check 模式在 Scoped Provider 中的应用
作用域绑定时,Dagger 生成的 Provider 使用 Double Check 确保线程安全:
// 生成的有 Scoped Provider(如 @Singleton) |
Dagger 生成的每个 @Provides 方法对应一个 Factory 类:
// 生成的 NetworkModule_ProvideNewsApiFactory.java |
3.3 Subcomponent(子组件)
Subcomponent 用于将 Component 的生命周期与 Activity/Fragment 绑定:
|
Subcomponent 继承了父 Component 的所有依赖,同时有自己的作用域和生命周期。当 Activity 销毁时,Subcomponent 也被释放,其管理的依赖随之被 GC。
Subcomponent 的源码生成:Dagger 为 Subcomponent 生成的实现类实现了父 Component 的所有 provision 方法,并额外实现了 Subcomponent 自身的 Module 方法。关键点在于:父 Component 通过返回 Subcomponent.Factory 的 bound instance 方法将运行时上下文传递进去。
3.4 Binds 与 Provides 的区别
|
@Binds 比 @Provides 更高效的原因:Dagger 可以直接使用实现类的 @Inject 构造函数或生成 Provider,不需要实际调用一个方法体。对于 @Provides,生成的代码必须调用 module 的 provides 方法。
3.5 Dagger 的编译期验证
Dagger 在编译期验证依赖图的完整性和正确性,这是其相对于运行时 DI 框架(如 Koin、Guice)的最大优势:
- 缺失绑定检测:如果某个类需要注入但没有任何 Module 提供,编译失败。
- 循环依赖检测:
A(B)和B(A)的构造注入会直接报编译错误。 - 作用域一致性:如果一个 unscoped 的依赖被注入到 scoped 的 Component 中,编译警告(通过 Dagger 的
@Component.Builder的 bound instance 可以绕过,但显式声明了意图)。 - Nullable 正确性:
@Nullable注入与 null 检查的一致性验证。
四、Hilt:Dagger 的官方 Android 封装
Hilt 在 Dagger 基础上的关键增强:
4.1 预定义 Component 层级
SingletonComponent (@Singleton) |
4.2 @AndroidEntryPoint 自动注入
|
Hilt 通过 Gradle Transform 为 @AndroidEntryPoint 修改基类,在 onCreate 前执行注入逻辑。具体是在字节码层面为 Activity 插入 Hilt_MainActivity 作为中间父类。
4.3 @HiltViewModel 与 Jetpack 集成
|
Hilt 使用 ViewModelComponent 管理 ViewModel 的依赖生命周期,确保 ViewModel 在配置变更后存活。
4.4 @ViewModelInject 与 SavedStateHandle
|
Hilt 使用 @Assisted 区分”由 DI 框架提供的依赖”和”由外部提供的依赖”。SavedStateHandle 是后者——由 ViewModel 框架在创建时提供,不同 ViewModel 实例有不同的 handle。
五、Dagger/Hilt 的构建速度挑战
5.1 KAPT vs KSP
KAPT(Kotlin Annotation Processing Tool)会生成 Java stub 文件,导致编译时间显著增加。KSP(Kotlin Symbol Processing)直接处理 Kotlin 符号,速度快 2-3 倍。
Dagger 2.37+ 已开始实验性支持 KSP。迁移方式:
// 将 kapt 替换为 ksp |
5.2 模块化项目的 Dagger 配置
大型项目中,为每个模块创建独立的 Component/Module 会增加编译负担。推荐使用:
@Module(includes = ...)组合多个 Module@Component(dependencies = ...)声明 Component 依赖关系(类似 Subcomponent,但 Component 之间互相独立,不继承 provision 方法)
六、运行时 DI:Koin
Koin(https://github.com/InsertKoinIO/koin)是 Kotlin 编写的轻量级 DI 框架,基于 Kotlin DSL 和函数式编程,无需 APT。
// Koin 模块定义 |
Koin 通过 Kotlin 的 inline + reified 实现类型解析,在运行时通过映射表查找依赖。其优点是配置简洁,无需 kapt(编译速度更快);缺点是依赖在运行时解析,编译期无法发现依赖未绑定的错误。
七、Dagger vs Koin vs Hilt
| 特性 | Dagger | Koin | Hilt |
|---|---|---|---|
| 原理 | 编译期 APT | 运行时 Service Locator | 编译期 APT(封装 Dagger) |
| 编译速度 | 慢(kapt) | 快(无 kapt) | 慢但封装简单 |
| 配置复杂度 | 高 | 低 | 低(自动化) |
| 错误发现 | 编译期 | 运行时 | 编译期 |
| Kotlin 支持 | 一般 | 原生 | 优良 |
| 性能 | 无反射开销 | 有轻微运行时开销 | 无反射开销 |
| 生产推荐 | 中大型项目 | 中小型/快速原型 | 官方推荐 |
八、面试常问题目
Q1: Dagger 的 @Component 和 @Subcomponent 有什么区别?
@Component 是独立的依赖容器,有自己的作用域和生命周期,通常对应 Application 级别。@Subcomponent 是 Component 的子容器,继承父 Component 的所有依赖,但有自己的作用域(如 @ActivityScope),生命周期与其宿主(如 Activity)绑定。当 Activity 销毁时,Subcomponent 被释放,其管理的 @ActivityScope 依赖也随之释放,防止内存泄漏。
Q2: Dagger 如何解决循环依赖问题?
当 A 的构造函数依赖 B,B 的构造函数依赖 A 时,形成循环依赖。Dagger 会在编译期检测到这种循环并报错 “Found a dependency cycle”。解决方法是:将其中一个依赖改为 Provider<T> 或 Lazy<T> 延迟注入,或者引入第三个类 C 打破循环,或者将其中一个注入方式从构造函数注入改为字段注入。
Q3: @Binds 和 @Provides 有什么不同?为什么 @Binds 方法必须是 abstract 的?
@Binds 用于接口到实现的绑定,方法必须是 abstract 的且只有一个参数(实现类型),Dagger 直接知道”当请求接口时,返回这个实现”即可,无需方法体。@Provides 用于需要 new 对象或进行复杂创建逻辑的场景,方法必须有方法体。@Binds 比 @Provides 更高效,因为 Dagger 不需要调用方法体,直接使用实现类的构造函数。
Q4: Hilt 相对 Dagger 做了哪些简化?
Hilt 自动生成了 Component 层级(SingletonComponent → ViewModelComponent → ActivityComponent → FragmentComponent 等),无需手动定义 @Subcomponent;自动注入 Android 类(@AndroidEntryPoint 标记 Activity/Fragment 等),无需手动调用 inject();预定义 @ApplicationContext 和 @ActivityContext 限定符;集成 Jetpack ViewModel,一行 @HiltViewModel 即可。
Q5: 为什么依赖注入框架对测试如此重要?
依赖注入使单元测试的 Arrange 阶段变得简单:可以直接传入 mock 依赖而不需要修改源代码。在传统代码中,实例化一个 NewsViewModel 可能需要在内部创建 Retrofit、OkHttp、Gson、Room 等一系列对象,测试时必须准备完整的 Android 环境。DI 将依赖外置后,测试只需:val vm = NewsViewModel(mockRepo),极大降低了测试编写成本,也使得测试更聚焦于被测单元本身。
参考源码路径:
- Dagger:
https://github.com/google/dagger - Dagger APT 核心:
dagger-compiler/src/main/java/dagger/internal/codegen/ComponentProcessor.java - Hilt:
https://dagger.dev/hilt/ - Hilt Gradle Plugin:
hilt-android-gradle-plugin/src/main/kotlin/dagger/hilt/android/plugin - Koin:
https://github.com/InsertKoinIO/koin - Guice(Google 的运行时 DI):
https://github.com/google/guice - KSP:
https://github.com/google/ksp


