各位小伙伴们大家早上好。
终于要写这样一篇我自己都比较怕的文章了。
虽然今年的Google I/O大会由于疫情的原因没能开成,但是Google每年要发布的各种新技术可一样都没少。
随着Android 11系统的发布,Jetpack家族又迎来了不少新成员,包括Hilt、App Startup、Paging3等等。
关于App Startup,我在之前已经写过一篇文章进行讲解了,感兴趣的朋友可以参考 Jetpack新成员,App Startup一篇就懂 这篇文章。
本篇文章的主题是Hilt。
Hilt是一个功能强大且用法简单的依赖注入框架,同时也可以说是今年Jetpack家族中最重要的一名新成员。
那么为什么说这是一篇我自己都比较怕的文章呢?因为关于依赖注入的文章太难写了。我觉得如果只是向大家讲解Hilt的用法倒还算是简单,但是如果想要让大家弄明白为什么要使用Hilt?或者再进一步,为什么要使用依赖注入?这就不是一个非常好写的话题了。
本篇文章我会尝试将以上几个问题全部讲清楚,希望我可以做得到。
另外请注意,依赖注入这个话题本身是不分语言的,但由于我还要在本文中讲解Hilt的知识,所以文中所有的代码都会使用Kotlin来演示。对Kotlin还不熟悉的朋友,可以去参考我的新书 《第一行代码 Android 第3版》 。
/ 为什么要使用依赖注入? /
依赖注入的英文名是Dependency Injection,简称DI。事实上这并不是什么新兴的名词,而是软件工程学当中比较古老的概念了。
如果要说对于依赖注入最知名的应用,大概就是Java中的Spring框架了。Spring在刚开始其实就是一个用于处理依赖注入的框架,后来才慢慢变成了一个功能更加广泛的综合型框架。
我在学生时代学习Spring时产生了和绝大多数开发者一样的疑惑,就是为什么我们要使用依赖注入呢?
现在的我或许可以给出更好的答案了,一言以蔽之:解耦。
耦合度过高可能会是你的项目中一个比较严重的隐患,它会让你的项目到了后期变得越来越难以维护。
为了让大家更容易理解,这里我准备通过一个具体的例子来讲述一下。
假设我们开了一家卡车配送公司,公司里目前有一辆卡车每天用来送货,并以此赚钱维持公司运营。
今天接到了一个配送订单,有客户委托我们公司去配送两台电脑。
为了完成这个任务,我们可以编写出如下代码:
class Truck {
val computer1 = Computer()
val computer2 = Computer()
fun deliver() {
loadToTruck(computer1)
loadToTruck(computer2)
beginToDeliver()
}
}
这里有一辆卡车Truck,卡车中有一个deliver()函数用于执行配送任务。我们在deliver()函数中先将两台电脑装上卡车,然后开始进行配送。
这种写法可以完成任务吗?当然可以,我们的任务是配送两台电脑,现在将两台电脑都配送出去了,任务当然也就完成了。
但是这种写法有没有问题呢?有,而且很严重。
具体问题在哪里呢?明眼的小伙伴应该已经看出来了,我们在Truck类当中创建了两台电脑的实例,然后才对它们进行的配送。也就是说,现在我们的卡车不光要会送货,还要会生产电脑才行。
这就是刚才所说的耦合度过高所造成的问题,卡车和电脑这两样原本不相干的东西耦合到一起去了。
如果你觉得目前这种写法问题还不算严重,第二天公司又接到了一个新的订单,要求我们去配送手机,因此这辆卡车还要会生产手机才行。第三天又接到了一个配送蔬果的订单,那么这辆卡车还要会种地。。。
最后你会发现,这已经不是一辆卡车了,而是一个全球商品制造中心。
现在我们都意识到了问题的严重性,那么回过头来反思一下,我们的项目到底是从哪里开始跑偏的呢?
这就是一个结构设计上的问题了。仔细思考一下,卡车其实并不需要关心配送的货物具体是什么,它的任务就只是负责送货而已。因此你可以理解成,卡车是依赖于货物的,给了卡车货物,它就去送货,不给卡车货物,它就待命。
那么根据这种说法,我们就可以将刚才的代码进行如下修改:
class Truck {
lateinit var cargos: List<Cargo>
fun deliver() {
for (cargo in cargos) {
loadToTruck(cargo)
}
beginToDeliver()
}
}
现在Truck类当中添加了cargos字段,这就意味着,卡车是依赖于货物的了。经过这样的修改之后,我们的卡车不再关心任何商品制造的事情,而是依赖了什么货物,就去配送什么货物,只做本职应该做的事情。
这种写法,我们就可以称之为:依赖注入。
/ 依赖注入框架的作用是什么? /
目前Truck类已经设计得比较合理了,但是紧接着又会产生一个新的问题。假如我们的身份现在发生了变化,变成了一家电脑公司的老板,我该如何让一辆卡车来帮我运送电脑呢?
这还不好办?很多人自然而然就能写出如下代码:
class ComputerCompany {
val computer1 = Computer()
val computer2 = Computer()
fun deliverByTruck() {
val truck = Truck()
truck.cargos = listOf(computer1, computer2)
truck.deliver()
}
}
这段代码同样是可以正常工作的,但是这段代码同样也存在比较严重的问题。
问题在哪儿呢?就是在deliverByTruck()函数中,为了让卡车帮我们送货,这里自己制造了一辆卡车。这很明显是不合理的,电脑公司应该只负责生产电脑,它不应该去生产卡车。
因此,更加合理的做法是,我们通过拨打卡车配送公司的电话,让他们派辆空闲的卡车过来,这样就不用自己去造车了。当卡车到达之后,我们再将电脑装上卡车,然后执行配送任务即可。
这个过程可以用如下示意图来表示:
使用这种结构设计出来的项目,将会拥有非常出色的扩展性。假如现在又有一家蔬果公司需要找一辆卡车来送菜,我们完全可以使用同样的结构来完成任务:
注意,重点的地方来了。呼叫卡车公司并让他们安排空闲车辆的这个部分,我们可以通过自己手写来实现,也可以借助一些依赖注入框架来简化这个过程。
因此,如果你想问依赖注入框架的作用是什么,那么实际上它就是为了替换下图所示的部分。
看到这里,希望你已经能明白为什么我们要使用依赖注入,以及依赖注入框架的作用是什么了。
/ Android也需要依赖注入框架? /
有不少人会存在这样的观点,他们认为依赖注入框架主要是应用在服务器这用复杂度比较高的程序上的,Android开发通常根本就用不到依赖注入框架。
这种观点在我看来可能并没有错,不过我更希望大家把依赖注入框架当成是一个帮助我们简化代码和优化项目的工具,而不是一个额外的负担。
所以,不管程序的复杂度是高是低,既然依赖注入框架可以帮助我们简化代码和优化项目,那么就完全可以使用它。
说到优化项目,大家可能觉得我刚才举的让卡车去生产电脑的例子太搞笑了。可是你信不信,在我们实际的开发过程中,这样的例子简直每天都在上演。
思考一下,你平时在Activity中编写的代码,有没有创建过其实并不应该由Activity去创建的实例呢?
比如说我们都会使用OkHttp来进行网络请求,你有没有在Activity中创建过OkHttpClient的实例呢?如果有的话,那么恭喜你,你相当于就是在让卡车去生产电脑了(Activity是卡车,OkHttpClient是电脑)。
当然,如果只是一个比较简单的项目,我们确实可以在Activity中去创建OkHttpClient的实例。不考虑代码耦合度的话,即使真的让卡车去生产电脑,也不会出现什么太大的问题,因为它的确可以正常工作。至少暂时可以。
我第一次清晰地意识到自己迫切需要一个依赖注入框架,是我在使用MVVM架构来搭建项目的时候。
在Android开发者官网有一张关于MVVM架构的示意图,如下图所示。
这就是现在Google最推荐我们使用的Android应用程序架构。
为防止有些同学还没接触过MVVM,我来对这张图做一下简单的解释。
这张架构图告诉我们,一个拥有良好架构的项目应该要分为若干层。
其中绿色部分表示的是UI控制层,这部分就是我们平时写的Activity和Fragment。
蓝色部分表示的是ViewModel层,ViewModel用于持有和UI元素相关的数据,以及负责和仓库之间进行通讯。
橙色部分表示的是仓库层,仓库层要做的工作是判断接口请求的数据应该是从数据库中读取还是从网络中获取,并将数据返回给调用方。简而言之,仓库的工作就是在本地和网络数据之间做一个分配和调度的工作。
另外,图中所有的箭头都是单向的,比方说Activity指向了ViewModel,表示Activity是依赖于ViewModel的,但是反过来ViewModel不能依赖于Activity。其他的几层也是一样的道理,一个箭头就表示一个依赖关系。
还有,依赖关系是不可以跨层的,比方说UI控制层不能和仓库层有依赖关系,每一层的组件都只能和它的相邻层交互。
使用这套架构设计出来的项目,结构清晰、分层明确,一定会是一个代码质量非常高的项目。
但是在按照这张架构示意图具体实现的过程中,我却发现了一个问题。
UI控制层当中,Activity是四大组件之一,它的实例创建是不用我们去操心的。
而ViewModel层当中,Google在Jetpack中提供了专门的API来获取ViewModel的实例,所以它的实例创建也是不用我们去操心的。
但是到了仓库层,一个尴尬的事情出现了,谁应该去负责创建仓库的实例呢?ViewModel吗?不对,ViewModel只是依赖了仓库而已,它不应该负责创建仓库的实例,并且其他不同的ViewModel也可能会依赖同一个仓库实例。Activity吗?这就更扯了,因为Activity和ViewModel通常都是一一对应的。
所以最后我发现,没人应该负责创建仓库的实例,最简单的方式就是将仓库设置成单例类,这样就不需要操心实例创建的问题了。
但是设置成单例类之后又会出现一个新的问题,就是依赖关系不可以跨层这个规则被打破了。因为仓库已经设置成了单例类,那么自然相当于谁都拥有它的依赖关系了,UI控制层可以绕过ViewModel层,直接和仓库层进行通讯。
从代码设计的层面来讲,这是一个非常不好解决的问题。但如果我们借助依赖注入框架,就可以很灵活地解决这个问题。
从刚才的示意图中已经可以看出,依赖注入框架就是帮助我们呼叫和安排空闲卡车的,我并不关心这个卡车是怎么来的,只要你能帮我送货就行。
因此,ViewModel层也不应该关心仓库的实例是怎么来的,我只需要声明ViewModel是需要依赖仓库的,剩下的让依赖注入框架帮我去解决就行了。
通过这样一个类比,你是不是对于依赖注入框架的理解又更加深刻了一点呢?
/ Android常用的依赖注入框架 /
接下来我们聊一聊Android有哪些常用的依赖注入框架。
在很早的时候,绝大部分的Android开发者都是没有使用依赖注入框架这种意识的。
大名鼎鼎的Square公司在2012年推出了至今仍然知名度极高的开源依赖注入框架:Dagger。
Square公司有许多非常成功的开源项目,OkHttp、Retrofit、LeakCanary等等大家都耳熟能详,而且几乎所有的Android项目都在使用。但是Dagger却空有知名度,现在应该没有任何项目还在使用它了,为什么呢?
这就是一个很有意思的故事了。
Dagger的依赖注入理念虽然非常先进,但是却存在一个问题,它是基于Java反射去实现的,这就导致了两个潜在的隐患。
第一,我们都知道反射是比较耗时的,所以用这种方式会降低程序的运行效率。当然这个问题并不大,因为现在的程序中到处都在用反射。
第二,依赖注入框架的用法总体来说是非常有难度的,除非你能相当熟练地使用它,否则很难一次性编写正确。而基于反射实现的依赖注入功能,使得在编译期我们无法得知依赖注入的用法到底对不对,只能在运行时通过程序有没有崩溃来判断。这样测试的效率就很低,而且容易将一些bug隐藏得很深。
接下来就到了最有意思的地方,我