1 前言
这里讨论的目的一直是 解耦 ,实现 高内聚,低耦合 。从最开始的例子一步步抽丝剥茧,逐渐解耦。直至符合 对新增开发,对修改封闭 这一软件设计原则。
这里有几个名词要先预热一下:
依赖倒置原则(DIP)
依赖倒置是面向对象开发领域中的软件设计原则,它倡导上层模块不依赖于底层模块,抽象不依赖细节。良好的面向对象代码设计都要遵从这一原则。
控制反转(Ioc)
依赖反转是遵守依赖倒置这个原则而提出来的一种设计模式,它引入了IoC容器的概念。早在2004年,Martin Fowler就提出了“哪些方面的控制被反转了?”这个问题。他总结出是依赖对象的获得被反转了,因为大多数应用程序都是由两个或是更多的类通过彼此的合作来实现企业逻辑,这使得每个对象都需要获取与其合作的对象(也就是它所依赖的对象)的引用。如果这个获取过程要靠自身实现,那么这将导致代码高度耦合并且难以维护和调试。
依赖注入(DI)
依赖注入是为了实现控制反转的一种手段之一。另外一种是依赖查找。
2 何为依赖
什么是依赖?依赖是一种关系,简单来讲就是需要。比如程序员依赖电脑,电脑被程序员依赖。在面向对象编程中,代码可以这样编写。很简单,就是Coder需要电脑,依赖电脑,有了电脑才能工作。
class Coder { Computer mComputer; public Coder () { mComputer = new Computer(); } public startWork() { mComputer.powerOn(); } }
3 依赖倒置原则
依赖倒置(Dependency inversion principle)是面向对象设计领域的一种软件设计原则。具体描述为 上层模块不应该依赖底层模块,他们都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象 。总结起来就是要面向接口编程。
回到之前Coder和Computer的例子,假如Coder的公司赚钱了,要给所有Coder的电脑都升级为MacComputer。最直接的方案是新增一个MacComputer类。然后在Coder中new MacComputer。
class Coder { // Computer mComputer; MacComputer mMacComputer; public Coder () { // mComputer = new Computer(); mMacComputer = new MacComputer(); } public startWork() { // mComputer.powerOn(); mMacComputer.powerOn(); } }
新增一个MacComputer,改动了三处。明显不符合 对新增开放,对修改关闭 。从划分模块的角度看,这种做法也不符合 依赖倒置原则 。因为Coder属于MacComputer的上层模块。MacComputer为底层模块。存在 上层模块依赖于底层模块 这种情况。
这次,我们对代码做一下改进。新增一个Computable借口,让MacComputer实现Computable接口。Coder中持有一个Computable的实例引用。
public interface Computable { public void powerOn(); } public class Coder { Computable mComputer; public Coder () { mComputer = new MacComputer(); } public startWork() { mComputer.powerOn(); } } public class MacComputer implements Computable { @Override public void powerOn() { System.out.println("Mac power on"); } }
再看看改版后的代码,比上一版有所改善。假如当公司又上了一个台阶,要给每个Coder配最新版的MacPro。我们只需要新增一个MacProComputer。并且修改实例化代码mComputer = new MacProComputer即可。改动次数由之前的三次,改为一次。虽然很少,仅有一次。并且这一次只体现为 对象实例化 。
public interface Computable { void powerOn(); } public class Coder { Computable mComputer; public Coder () { // 唯一改动的一处 mComputer = new MacProComputer(); } public startWork() { mComputer.powerOn(); } } public class MacComputer implements Computable { @Override public void powerOn() { System.out.println("Mac power on"); } } public class MacProComputer implements Computable { @Override public void powerOn() { System.out.println("MacPro power on"); } }
这次,我们消灭掉对象获取的这一处依赖。通过构造函数传参的方式,获取对象实例。不主动new生成实例。当我们要新增一种电脑类型的时候,并不需要改动Coder类中的任何代码。高层模块和底层模块都依赖于抽象接口Computable。所以,这一版设计符合 开闭原则,依赖倒置原则 。
public interface Computable { void powerOn(); } public class Coder { Computable mComputer; public Coder (Computable computable) { mComputer = computable; } public startWork() { mComputer.powerOn(); } } public class MacComputer implements Computable { @Override public void powerOn() { System.out.println("Mac power on"); } } public class MacProComputer implements Computable { @Override public void powerOn() { System.out.println("MacPro power on"); } } public class Company { public void init() { Coder coder = new Coder(new MacProComputer()); } }
上面的设计称之为 控制反转 。很难理解的一个名词。为什么是反转,对比上一版代码。Coder对于mComputer成员的初始化享有绝对的控制权。因为在Coder中直接new了一个MacProComputer对象,是主动获取。再看看上面的代码,mComputer的获取途径是由构造函数传递进来的参数。Coder是被动获取。所以称之为 控制反转 。丧失了主动控制权。外部负责实例化MacProComputer的类称之为控制反转容器。 事实上,上面的代码展示了基于构造函数的依赖注入达到控制反转的目的。
所以控制反转IoC(Inversion of Control)是说创建对象的控制权进行转移,以前创建对象的主动权和创建时机是由自己把控的,而现在这种权力转移到第三方,比如转移交给了IoC容器,它就是一个专门用来创建对象的工厂,你要什么对象,它就给你什么对象,有了IoC容器,依赖关系就变了,原先的依赖关系就没了,它们都依赖IoC容器了,通过IoC容器来建立它们之间的关系。
4 依赖注入
Dependency Injection,即“依赖注入”:组件之间依赖关系由容器在运行期决定,形象的说,即由容器动态的将某个依赖关系注入到组件之中。依赖注入的目的并非为软件系统带来更多功能,而是为了提升组件重用的频率,并为系统搭建一个灵活、可扩展的平台。通过依赖注入机制,我们只需要通过简单的配置,而无需任何代码就可指定目标需要的资源,完成自身的业务逻辑,而不需要关心具体的资源来自何处,由谁实现。
4.1 基于构造函数的依赖注入
上面的例子已经展示过基于构造函数注入依赖。
4.2 基于set方法的依赖注入
public interface Computable { void powerOn(); } public class Coder { Computable mComputer; public Coder () { } public void setComputer(Computable computable) { mComputer = computable; } public startWork() { mComputer.powerOn(); } } public class MacComputer implements Computable { @Override public void powerOn() { System.out.println("Mac power on"); } } public class MacProComputer implements Computable { @Override public void powerOn() { System.out.println("MacPro power on"); } } public class Company { public void init() { Coder coder = new Coder(); coder.setComputer(new MacProComputer()); } }
4.3 基于接口的依赖注入
public interface ComputerSetter { void set(Computable computable); } public interface Computable { void powerOn(); } public class Coder implements ComputerSetter{ Computable mComputer; public Coder () { } @Override public void set(Computable computable){ mComputer = computable; } public void setComputer(Computable computable) { mComputer = computable; } public startWork() { mComputer.powerOn(); } } public class MacComputer implements Computable { @Override public void powerOn() { System.out.println("Mac power on"); } } public class MacProComputer implements Computable { @Override public void powerOn() { System.out.println("MacPro power on"); } } public class Company { public void init() { Coder coder = new Coder(); coder.set(new MacProComputer()); } }
4.4 基于注解的依赖注入
参考Dagger2。
5 Dagger2
为什么会有Dagger2?当我们的代码遵循 开闭原则,依赖倒置等原则之后 。并把对象实例化的权力移交到外部之后,总归有一处地方(称之为注射器,参考以下代码)是要来实例化对象。更为复杂的是注射器中的实例初始化还要依照一定的先后顺序生成对象。Dagger2的出现进一步解放了我们的双手,让我们不必自己去编写注射器代码,只需要依照Dagger2的约定配置。Dagger2会自动生成相应的注射器。会在适当的时候注入。 所以Dagger2本质上是一个依赖注入框架,依赖注入的目的就是为了给需求方在合适的时候注入依赖。
B b = new B(5); C c = new C(110,"110"); D d = new D(110, c); A a = new A(); a.setB(b); a.setC(c); a.setD(d);
前门提到过Dagger2是 基于注解 来实现依赖注入。所以,在使用Dagger2之前,我们需要了解这些注解的含义,如果对注解是什么还不清楚的可以自行Google一下。Dagger2中主要有6种注解。前四种通俗易懂,后两种理解起来就有一定难度了。
5.1 @Inject
用来修饰构造函数的时候,表示提供依赖。用来修饰成员变量,表示需要依赖。该变量由注入框架负责实例化。
5.2 @Module
带有此注解的类,用来提供依赖,里面定义一些用@Provides注解的以provide开头的方法,这些方法就是所提供的依赖,Dagger2会在该类中寻找实例化某个类所需要的依赖。
5.3 @Component
它是一个桥梁,一端是目标类@Inject,另一端是目标类所依赖类的实例@Module,它也是注入器负责把目标类所依赖类的实例注入到目标类中,同时它也管理Module。
5.4 @Provides
修饰方法,表示该方法提供依赖。
5.5 @Qulifier
当某个对象需要注入依赖时,Dagger2就会根据Module中标记了@Provide的方法的返回值来确定由谁为这个变量提供实例。那问题来了,如果有两个一样的返回类型,该用谁呢。我们把这种场景叫做依赖迷失,见名知意,Dagger这时候就不知道用谁来提供依赖,自然就迷失了。所以我们引入了@Qulifier这个东西,通过自定义Qulifier,可以告诉Dagger2去需找具体的依赖提供者。
5.6 @Scope
Dagger2可以通过自定义Scope注解,来限定通过Module和Inject方式创建的类的实例的生命周期能够与目标类的生命周期相同。或者可以这样理解:通过自定义Scope注解可以更好的管理创建的类实例的生命周期。(需要自己手动控制,并不是加了@Scope就可以自动与目标类的生命周期一致。比如在Activity的OnCreate和OnDestroy中加入Component的创建和销毁代码,就能实现和Activity目标类生命周期一致。另外@Scope还可以用来实现Component中的局部单例。)
5.7 小结
@Inject和@Module(其中的@Provides)都是属于依赖提供方,含有@Inject标注的成员变量的类属于依赖需求方。@Component属于一个桥梁,把依赖需求方和依赖提供方联系起来。@Qulifier用来解决依赖提供方的依赖迷失,@Scope用来解决实例的创建和声明生命周期。
5.8 注入步骤
- 查找Module中是否存在创建该类的方法
- 若存在创建类方法,查看该方法是否存在参数。
- 若存在参数,则按从 步骤1 开始依次初始化每个参数。
- 若不存在参数,则直接初始化该类实例,一次依赖注入到此结束。
- 若不存在创建类方法,则查找Inject注解的构造函数,看构造函数是否存在参数。
- 若存在参数,则从 步骤1 开始依次初始化每个参数。
- 若不存在参数,则直接初始化该类实例,一次依赖注入到此结束。
5.9 Component组织方式
5.9.1 为何要划分
假如一个app(app指的是Android app)中只有一个Component,那这个Component是很难维护、并且变化率是很高,很庞大的,就是因为Component的职责太多了导致的。所以就有必要把这个庞大的Component进行划分,划分为粒度小的Component。
5.9.2 以何种粒度划分
- 要有一个全局的Component(可以叫ApplicationComponent),负责管理整个app的全局类实例(全局类实例整个app都要用到的类的实例,这些类基本都是单例的,后面会用此词代替)。
- 每个页面对应一个Component,比如一个Activity页面定义一个Component,一个Fragment定义一个Component。当然这不是必须的,有些页面之间的依赖的类是一样的,可以公用一个Component。
5.9.3 为什么需要页面粒度划分
- 一个app是由很多个页面组成的,从组成app的角度来看一个页面就是一个完整的最小粒度了。
- 一个页面的实现其实是要依赖各种类的,可以理解成一个页面把各种依赖的类组织起来共同实现一个大的功能,每个页面都组织着自己的需要依赖的类,一个页面就是一堆类的组织者。
- 划分粒度不能太小了。假如使用mvp架构搭建app,划分粒度是基于每个页面的m、v、p各自定义Component的,那Component的粒度就太小了,定义这么多的Component,管理、维护就很非常困难。
5.9.4 组织Component
- 依赖方式
一个Component是依赖于一个或多个Component,Component中的dependencies属性就是依赖方式的具体实现。视角在子Component,表明子Component依赖父Component。Component依赖方式只能访问通过Component接口暴露的对象。不能访问父Component的依赖图表。
- 包含方式
一个Component是包含一个或多个Component的,被包含的Component还可以继续包含其他的Component。这种方式特别像Activity与Fragment的关系。SubComponent就是包含方式的具体实现。视角在父Component,表明父Component包含子Component。SubComponent可以访问父Component的所有依赖图表。
- 继承方式
官网没有提到该方式,具体没有提到的原因我觉得应该是,该方式不是解决类实例共享的问题,而是从更好的管理、维护Component的角度,把一些Component共有的方法抽象到一个父类中,然后子Component继承。