前言:
已经很久没有在CSDN写过文章了,现在打算回到最开始的地方,整理这些年的工作所得,并勇敢的迈出学习成长最勇敢的一步:在分享自我中勇于暴露自己的错误,被指出然后在讨论中获得进步。所有接下来写的博客,都是一些较大的话题,浅显的、深入的方面都会涉及。
概念篇:什么是DI,什么是IoC
先来谈一谈什么是IoC:
控制反转(Inversion of Control,缩写为IoC),是面向对象编程中的一种设计原则,可以用来减低计算机代码之间的耦合度
1996年,Michael Mattson在一篇有关探讨面向对象框架的文章中,首先提出了IoC 这个概念。 这个概念看起来苍白,还有更加形象一点的、相关的概念名词:“依赖倒置原则”、“依赖抽象原则”等。注:依赖抽象是对于”依赖倒置“的实际操作方式;依赖倒置原则(Dependence Inversion Principle)
我们说,控制反转是一种设计原则,可以用来降低系统中存在的耦合,那么我们用一个很直白的反例来理解它:
注意:如无必要,后续代码均为kotlin
//手抓饼
class ShouZhuaBing
class Milk
class Hamburger
class Noodles
//各种食物类
class Tom {
val shouZhuaBing: ShouZhuaBing = ShouZhuaBing()
val milk: Milk = Milk()
val hamburger: Hamburger = Hamburger()
val noodles: Noodles = Noodles()
//各种食物
fun onBreakfast() {
eatShouZhuaBing()
//eatMilk
}
fun onLunch() {
//eatHamburger
}
fun onSupper() {
//eatNoodles
}
private fun eatShouZhuaBing() {
print("eatShouZhuaBing")
//shouZhuaBing.foo()
}
// "下面的各种eatXXX不写了,简直想打人"
}
这是我写出来的耦合度最高的代码了,简直想打人,Tom也想打人:他必须定义好他这辈子可能吃到的所有的食物、他这辈子每天的食谱!那么是什么使我们如此暴躁,是Tom和各种食物之间的很强的耦合,Tom一直具有着控制权。那么要改进设计就是将Tom的控制权移交出去,这就是所谓的控制反转。
那么如何去做呢?最关键的就是先实现”依赖抽象“。Tom只要知道那是食物,到点吃就完事了,至于这些食物是Tom之前想好的还是他妈妈决定的,这都不是Tom张嘴吃东西所关心的事情。
我们看一下改进后的例子:
注:限于篇幅,我不展示每一步重构,直接展示一个相对完善的阶段性成果
abstract class Food {
abstract fun beEaten(eater: Eater)
}
class FoodBundle(val foods:List<Food>) : Food() {
override fun beEaten(eater: Eater) {
//while(eater还想吃&&没有吃完) {
// 弄点东西继续给他执行吃的动作
//
// }
}
}
abstract class Eater {
fun eat(food: Food) {
food.beEaten(this)
}
//咀嚼
fun chew(food: Food) {
//咀嚼有更多的细节,用哪些牙,咀嚼到什么程度,这里不继续完善,每进一步完善,都
//包含着定义相关抽象类和反转控制的部分
}
//吞咽
fun gulp(food: Food) {
//
}
}
//手抓饼
class ShouZhuaBing : Food() {
override fun beEaten(eater: Eater) {
eater.chew(this)
eater.gulp(this)
}
}
class Milk : Food() {
override fun beEaten(eater: Eater) {
eater.gulp(this)
}
}
class Hamburger : Food() {
override fun beEaten(eater: Eater) {
eater.chew(this)
eater.gulp(this)
}
}
class Noodles : Food() {
override fun beEaten(eater: Eater) {
eater.chew(this)
eater.gulp(this)
}
}
enum class Type {
Breakfast,Lunch,Supper,SnackTime
}
class Tom :Eater(){
fun onBreakfast() {
getFood(Type.Breakfast)?.beEaten(this)
}
fun onLunch() {
getFood(Type.Lunch)?.beEaten(this)
}
fun onSupper() {
getFood(Type.Supper)?.beEaten(this)
}
private fun getFood(type: Type): Food? {
//例如从包里拿出汉堡、去超时买牛奶、从厨房端出面。当然你也有搞不到食物的时候
return null
}
}
我们发现,Tom和具体食物之间没有直接依赖了(耦合降低),想要在系统中添加更多的食物,添加更多的eater就会变得更加简单(系统的扩展性和可维护性提升)
到这里,我们应该可以体会,IoC在面向对象编程中,是一种怎么的思想。
什么是DI?
相比于IoC,一种编程思想或者说指导性原则,DI讲的是一个更加具体的东西:依赖注入。
这里话题扯开一下,在oop(面向对象编程,后续将直接使用缩写)中,我们为了让程序的扩展性更好,有单一职能、依赖优于继承等原则,带来的结果就是:多个相关职能的类的实例结合起来才能完成复杂任务。而这些类实例之间就存在着依赖关系(我要完成一件事情时,需要另外一个类的功能),而如何维护这些依赖(使用别的类功能)就是DI要处理的事情。
IOC或者DIP原则指导我们:通过面向抽象去编程,将控制权移交给依赖者、即抽象类的具体子实现类。这也意味着:获得依赖对象的过程被反转了!
在上面的例子中,最开始Tom需要控制自己吃什么,具体吃什么的时候怎么吃,这样编码是很复杂的,修改过后,Tom作为Eater对外的可观测方法是咀嚼、吞咽等动作,选择食物的控制权(这部分我们Demo中没有写,比进食过程要复杂的多,限于篇幅自动略去)移交了出去、如何吃一个具体食物的控制权移交到具体食物中。
那么此时还有一个问题需要处理:将抽象类的具体子实现类的实例注入目标中,即如何获得依赖对象。
DI的具体方式
我们会了解到这样一种说法,包括我之前的一些文章中也提到过:
- 构造器注入
- API注入、接口注入
- 注解注入
这是从表象观察上得出的结论,因为比较简单,不做示例代码。
而从本质上看,所有的注入形式本质上只有两种:
- 分析了具体业务,注入的规则(选择具体的依赖对象)直接侵入,在编码中进行描述,包含在类的某一段业务逻辑的编写中。
- 注入过程抽象给第三方,使用者维护依赖规则。
我们重点讨论下这一块儿。
从事Android的朋友们应该都接触过google出品的MVP分层架构设计的Demo,为了演示的更加清楚一些,我按照记忆中google sample的设计,移除掉jetpack的内容,现写了一个简单的例子:
基类
interface IBaseView {
//ignore 一些常用的API,例如toast,loading等
}
interface IBasePresenter<T : IBaseView>
abstract class BasePresenter<T : IBaseView>(val view: T) {
//生命周期相关的控制,略去
}
abstract class BaseFragment<V : IBaseView, P : IBasePresenter<V>> : Fragment(), IBaseView {
protected lateinit var presenter: P
open fun bindPresenter(presenter: P) {
this.presenter = presenter
}
//生命周期相关控制略去
}
数据类和model部分:
data class Author(val id: Int, val name: String)
data class Essay(val id: Int, val title: String, val content: String, val author: Author)
interface EssayRepo {
fun getEssayListByAuthorId(id: Int, from: Int, limit: Int): List<Essay>
fun getEssayDetail(essayId: Int): Essay
//...
}
一个文章详情展示页面:
注:我们仅用来做Demo说明问题,实际生产中绑定presenter的时机一般会更复杂一些。
interface EssayDetailContract {
interface View : IBaseView {
fun displayEssay(essay: Essay)
}
interface Presenter : IBasePresenter<View> {
fun loadEssayDetail(essayId: Int)
}
}
class EssayDetailFragment : BaseFragment<EssayDetailContract.View, IBasePresenter<EssayDetailContract.View>>(), EssayDetailContract.View {
override fun displayEssay(essay: Essay) {
//ignore
}
}
class EssayPresenterImpl(val view: EssayDetailContract.View, val repo: EssayRepo) : EssayDetailContract.Presenter {
override fun loadEssayDetail(essayId: Int) {
//ignore detail
//1.工作线程处理获取数据 repo.getEssayDetail(essayId)
//2.主线程回调view#dislpayEssay(essay)
}
}
class EssayRepoDao : EssayRepo {
override fun getEssayListByAuthorId(id: Int, from: Int, limit: Int): List<Essay> {
TODO("curd") //To change body of created functions use File | Settings | File Templates.
}
override fun getEssayDetail(essayId: Int): Essay {
TODO("curd") //To change body of created functions use File | Settings | File Templates.
}
}
class EssayRepoMemoryCache : EssayRepo {
override fun getEssayListByAuthorId(id: Int, from: Int, limit: Int): List<Essay> {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
}
override fun getEssayDetail(essayId: Int): Essay {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
}
}
class EssayDetailActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
EssayDetailFragment().apply {
bindPresenter(EssayPresenterImpl(this, EssayRepoDao()))
}.run {
//display the Fragment via FragmentManager
}
}
}
我们可以看到,用Fragment来实现了View,定义了类EssayPresenterImpl实现了Presenter接口。这两者是如何实现依赖的注入的呢?
就是最终在Activity中的这一段业务代码:
EssayDetailFragment().apply { bindPresenter(EssayPresenterImpl(this, EssayRepoDao())) }
回到前面:
分析了具体业务,注入的规则(选择具体的依赖对象)直接侵入,在编码中进行描述,包含在类的某一段业务逻辑的编写中
我们是分析了Activity呈现一个视图的流程、以及潜在的更加复杂的业务,在Activity的onCreate阶段,选择了具体的类进行实例化,并完成了相关的依赖注入。
当然,具有实际android开发经验的朋友们可能注意到:实际生产中,一个Presenter使用到不同Fragment子类的场景还可能存在(小概率)例如Essay的阅读模式和编辑模式;但是这种场景确实很少。即使出现了一些需求变化,我们只需要在Activity相应的代码片段做出调整即可,改动量是有限的、可接受的。
ok,继续从刚才的例子看,本身这个设计已经足够优秀了,根据日常实际得出了一个结论:这样的方案已经满足日常开发。让我们把注意力放到EssayRepo的注入。
细心的朋友们一定注意到了,我上面定义了两个EssayRepo的实现类。假定我们的故事是这样的:请原谅我黑了一波产品经理和差劲的沟通氛围
- 一开始产品经理拍板,这个文档编辑应用就是单机的(不要问为什么,就是没多过脑子)
于是我们哼哧哼哧的完成了一个版本,数据都是存在本地数据库的。我们为各种XXXPresenter注入了各种XXXXDao的实例,当然,有些可能是单例。
- 项目上线后,用户对于创作交互好评如潮,但是提出反馈:没法分享和阅读他人开放的文章。产品经理一拍脑门,我怎么没想到,2.0:存储全部做服务端(不要问为什么之前做单机,就是没过脑子)
于是服务端的开始哼哧哼哧的搞。客户端开始哼哧哼哧的把之前所有注入XXXXDao的地方改成XXXXWebService,并且把之前自动存储的功能转嫁到WebService,不再存储本地DB。
- 项目上线后,IT和服务端主程开始天天盯着服务器监测,随后开始喷产品经理:”说了不信,所有功能都依靠服务器那是作死,QPS太高了“(不要问为什么产品经理之前不听,听了他也不懂,不见棺材不掉泪)
于是客户端的兄弟们又开始哼哧哼哧的修改,将注入的XXXXWebService修改成一种RepoImpl,它在内部封装了自动存储到DB,完成后提交到服务端,按照特定算法决定差异版本中实际有效的版本,并做本地和服务端同步。
- 终于项目又上线了,IT和后端主程可以睡安稳觉了。细心的用户又提出反馈了,我对文档编辑了,提交了之后、之前打开的详情页啊列表啊啥的信息都不同步啊,写文章的时候都挺费脑子的,一不注意就以为自己漏编辑了,又去操作一遍。产品经理又拍板了:应用内数据一致性维护
于是客户端程序员赶鸭子上架,EventBus满天飞,最终赶上了工期,但几个版本后,项目已经快无法维护了,尤其是大量直接操作DB使得体验变的越来越糟糕。
- 于是公司高薪聘请客户端架构师,试图最后锤死挣扎。
架构师放大招了,提出了本地中央仓储层,在内存对象层面实现多页面观测数据的一致性+基于数据变化的相应式设计,取代之前难以管理的EventBus事件体系、以及从DB读取数据同步页面。然后又带着大家做了一遍依赖的注入。并在总结会上提出了一个问题:”这几波依赖注入修改的爽吗?“
ok,我们虚构的故事结束了,请原谅我将故事中众多人的智商设计的很低,毕竟,设计高了就没这些事了?。
从这个故事中,我们得出结论:我们把自己忽悠了,这些侵入的代码是没有经过抽象的,他们面临修改时,会给我们制造一些麻烦,这些麻烦和这些代码的场景数成正比,正是我们的一些经验让我们没有正视这一问题,当问题没有爆发时(几率太小)他一点问题都没有,一旦爆发,对于项目的维护和稳定迭代都是巨大的挑战。
那么如何解决这些问题呢(如果有必要的话)?
对于注入过程做抽象,用一个第三方,它抽象并实现了依赖的注入、接受使用者对依赖规则的维护,将整个系统中做依赖注入的内容剥离。
两种方式的区别
这里有必要直接给出结论了,假定这个系统是个10万行代码的项目,有100行代码是这样处理依赖注入的。
按照第一种侵入式做法:我们对100行夹杂在业务代码中的内容进行了修改,第一步先从10万行代码中找到并修改,至少需要对这100处代码做单测,甚至需要对10万行代码的项目做完整的集成测试。
按照第二中方式做,可能需要编写300-500行代码实现这100行的内容,但是很集中,只需要在这500行代码中做修改,只需要对这500行代码做单测即可判断依赖注入正确与否。
聊一聊IoC和DI的发展史
- 1996年,Michael Mattson在一篇有关探讨面向对象框架的文章中,首先提出了IOC 的概念
- 2004年,Martin Fowler探讨了同一个问题,提出了IoC的实现细节,Martin Fowler的经典文章:Inversion of Control Containers and the Dependency Injection pattern》
- spring在实践中推广,2004年3月,Spring 1.0使用外部配置文件(xml)描述对象之间的依赖关系。
- 2004年10月,JDK1.5支持注解(Annotation)语法。2007年3月, Google发布 Guice 1.0,使用annotation描述依赖关系。
- 2007年11月,Spring 2.5支持使用annotation描述依赖关系
- 为了规范和统一,JCP于2009年10月发布了JSR330
- 2012年square发布了开源的dagger1,用于Android平台后来google接盘,搞了dagger2.
- 等等
深入了解一个东西,你才能用好它
区别于网上直接讲一个DI框架(例如dagger2)如何用,我们还是先来了解了解JSR-330,这样能够帮助我们更好的使用工具,毕竟这些工具是面向JSR-330写的。
其中定义了一些注解:
- @Inject
- @Qualifer, @Named
- @Scope, @Singleton
Inject :Identifies injectable constructors, methods, and fields. 需要注入的标识,可用于构造器(constructors), 方法(methods)或字段(fields)
Qualifier:Identifies qualifier annotations. 限定器,多子类情况
Named:String-based qualifier. 使用字符串的限定符,
Scope:Identifies scope annotations. 标记作用域
Singleton:Identifies a type that the injector only instantiates once.
定义了一个接口:
public interface Provider<T> {
/**
* Provides a fully-constructed and injected instance of {@code T}.
*
* @throws RuntimeException if the injector encounters an error while
* providing an instance. For example, if an injectable member on
* {@code T} throws an exception, the injector may wrap the exception
* and throw it to the caller of {@code get()}. Callers should not try
* to handle such exceptions as the behavior may vary across injector
* implementations and even different configurations of the same injector.
*/
T get();
}
我们前面提到过,jsr-330是jcp为了规范各种提供给java生态中使用的ioc容器而定义的协议标准,在其他生态中,是不完全一致的,但是思想是一致的.
前面说到,获取依赖对象的方式交给第三方管理,这个第三方就是IOC容器,它可以是dagger,可以是spring,可以是kodein或者其他,他的作用就是管理依赖规则以及按照依赖规则提供依赖注入。
前面提到:@Inject向IOC容器表达了此处有依赖需要注入,而Provider接口在IOC容器中被实现(或者被注册)以提供可能需要的依赖’对象实例‘;
而限定器(@Qualifer, @Named)在存在必要时,配合@Inject用于向容器说明需要”特定“的依赖、配合Provider用于向容器说明提供”特定“的实例。
而@Scope(用于自定义注解)、@Singleton(通过Scope注解的一个特定注解)是用来标记依赖对象的”生命周期“的控制的,它用于指导ioc容器如何管理这些依赖实例,是每次都新建实例(默认的)还是按照规则复用实例。
后记
这篇文章写得内容很长,但精髓的内容并不是很多,主旨在于:(1)把复杂的概念通过最直白的语言讲透彻;(2)举出例子引发思考以达到和”被动接受的概念、结论“相互印证;
本来还打算解析一下dagger2以及在kotlin中挺好用的kodein(本身是支持多平台的,不是面向JSR-330),限于篇幅决定拆分出去,我们下篇文章见。