(译)Android 中的依赖注入

依赖注入是编程中广泛使用的技术,并且同样适用于 Android 开发.遵循以下依赖注入的原则,为你能够开发一个优秀架构的应用打下基础.

实现依赖注入给你提供了以下好处:

  • 代码复用
  • 易于重构
  • 易于测试

依赖注入基础

在讲解特定于Android的依赖注入之前,这个页面会提供一个更为通用的关于依赖注入如何工作的概览.

什么是依赖注入

类通常需要引用其它类,例如,Car 类可能需要一个指向 Engine 类的引用.这些被需要的类被称作依赖,并且在这个例子中,Car类依赖于一个Engine类的实例才能够运行.

对于类来说,有三种获取它需要类的方式:

  1. 类本身构造它所需的依赖.在以上的例子中,Car 会创建并初始化它自己的 Engine 实例.
  2. 从别处获取依赖.一些 Android 接口,例如 Context的 getter系列以及getSystemService(),这是这么做的.
  3. 让依赖作为参数提供.应用能够在类构造或是方法中传入所需的依赖.在上面的例子中,Car 构造器会接收Engine作为参数.

第三种选项就是依赖注入.通过这种方式你能够获取并提供一个类的依赖,而不是让类实例本身自己获取.

以下是一个例子,不使用依赖注入,意味着 Car是像以下这样在代码中创建它自己的Engine依赖的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Car{

private val engine = Engine();

fun start(){
engine.start();
}

}

fun main(args:Array){
val car = Car()
car.start()
}

这不是一个依赖注入的例子,因为Car类构造了它自己的Engine.这可能是有问题的,因为:

  • CarEngine是紧密耦合的-Car实例适用了Engine类型,子类和替代的实现不能够很容易实现.如果Car需要构造它自己的Engine,你会需要创建两种类型的Car,而不是让GasElectric两种引擎复用相同的Car.

  • 硬编码依赖是的测试变得更加困难. Car使用了真实的Engine实例,从而阻止你使用替身来修改Engine来应对不同的测试用例.

使用依赖注入的代码是怎么样的? Car实例不是在初始化的时候构建它自己的Engine,它接收了一个参数作为它构造器的参数.

1
2
3
4
5
6
7
8
9
10
11
class Car(private val engine:Engine){
fun start(){
engine.start();
}
}

fun main(args:Array){
val engine=Engine();
val car=Car(engine);
car.start();
}

main 函数使用了Car.因为Car依赖于Engine,应用能够创建一个Engine实例,并将它用于构建一个Car实例.这种基于依赖注入的方式的好处有:

  • Car的复用性.你可以传入Engine的不同实现到Car.例如,你可能定义了一个新的Engine的子类叫做 ElectricEngine来让Car来使用.如果你使用了依赖注入,你需要的是传入修改过的EletricEngine子类,Car仍旧能够工作而不需要进行进一步的修改.

  • 很容易对Car进行测试.你可以针对不同的场景,你可以传入不同的替身.例如,你能够创建一个Engine替身叫做FakeEngine并将它配置到不同的测试场景下.

在Android中主要有两种依赖注入的方式:

  • 构造器注入.这是以上所描述的.你可以类依赖作到它的构造器.
  • 字段注入(或Setter注入).一些Android框架类,例如activity和fragment是由系统初始化的,构造器注入就不再可能了,字段注入中,依赖是在类创建完成之后初始化的.代码看起来是像这样的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Car {
lateinit var engine: Engine

fun start(){
engine.start()
}
}


fun main(args:Array){
val car=Car()
car.engine=Engine()
car.start()
}

自动化的依赖注入

在之前的例子中,你创建、提供以及管理不同类的依赖都是由你自己来的,没有依赖于一个库.这叫做手动依赖注入.在Car的例子中,只有一个依赖,但是越来越多的类和依赖会使手动依赖注入变得越来越单调乏味.手动依赖注入同样也暴露出一些问题:

  • 对于大型应用来说,获取所有的依赖并将它们正确连接需要大量的模版代码.在多层次的架构中,为了给高层创建一个对象,你需要提供所有这个层之下的依赖.举个具体的例子,为了建造一辆车你可能需要引擎,变速器,地盘以及其它的部分;并且引擎转而需要气缸和火花塞.

  • 当你不能够在传入依赖之前构造函数的时候,例如使用懒初始化或是限定对象来构成你的应用的时候,你需要写和维护一个自定义的容器(或是依赖图)来在内存中管理你的依赖的生命周期.

通过自动化创建和提供依赖这个过程,存在很多解决这个问题的库.它们可以分为两类:

  • 基于反射的解决方案,在运行时对依赖进行连接.
  • 静态解决方案,在编译时生成代码并连接依赖.

Dagger 是针对Java、Kotlin以及Android的一个流行的由Google维护的依赖注入库.Dagger通过为你创建和管理依赖图,使在你的应用中使用DI变得便利.它提供了全静态的编译时的依赖,解决了基于反射的解决方案(Guice)的开发和性能的问题.

依赖注入的替代方案

依赖注入的替代方案是使用服务定位器.服务定位器设计模式同样促进了类和具体依赖的解耦.你创建了一个服务定位类,这个类是用来创建和存储依赖用的,并按需提供这个依赖.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
object ServiceLocator{
fun getEngine():Engine=Engine()
}

class Car{
private val engine=ServiceLocator.getEngine()

fun start(){
engine.start()
}
}

fun main(args:Array){
val car=Car();
car.start()
}

服务定位模式和依赖注入的不同点在于元素的使用方式不同.服务定位模式中,类控制并请求对象注入;依赖注入中,应用控制并主动地注入需要的对象.

与依赖注入相比:

  • 依赖集合需要一个服务定位器是的代码测试变得困难,因为所有的测试都要与同一个全局服务定位器交互.
  • 依赖在类实现中编码,而不是在API层面.结果是,从外部很难知道类需要什么,对服务定位中对Car或依赖的修改可能导致在运行时或是测试中引用不到.
  • 当你想要限定对象的生命周期不只是整个应用的生命周期的时候,管理对象的生命周期显得更为困难.

为你的应用选择正确的技术

如上所述,有多种不同管理你应用依赖的技术.

手动依赖注入只在相对较小的应用上有效,因为它扩展性很差.当项目变大,传递对象需要大量的模版代码.

服务定位器起初需要较少的模版的代码,但是扩展性很差,进一步的来说,测试会变得更为困难因为它们依赖于单例对象.

Dagger为扩展而生.它与构建复杂的应用相适应.

如果你的小应用会变得复杂,你应该考虑尽早迁移到Dagger,因为不需要很大的代码修改量.

为你的库选择正确的技术

如果你在开发一个外部的SDK或是库,你应该基于SDK和库的大小在手动依赖注入和Dagger之间选择.注意如果你使用第三方库来做依赖注入,你的库的大小会有一定增长.

结论

依赖注入为你的应用提供了以下的好处:

  • 类的复用以及依赖的解耦,使得依赖实现的置换变得简单.由于控制反转,代码的复用性提高了,类不再控制它们的依赖是如何创建的,而是由配置控制.
  • 易于重构:依赖变成了API层面的一个可变部分,所以它们能够在对象创建或编译时进行检查,而不是隐藏在具体实现后面.
  • 易于测试,类不再管理它的依赖,所以当你测试它的时候,你可以传递进不同实现来测试你的不同用例.