作者:郭霖
终于要写这样一篇我自己都比较怕的文章了。
虽然今年的 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 隐藏得很深。
接下来就到了最有意思的地方,我们现在都知道 Dagger 的实现方式存在问题,那么 Dagger2 自然是要去解决这些问题的。但是 Dagger2 并不是由 Square 开发的,而是由 Google 开发的。
这就很奇怪了,正常情况下一个库的 1 版和 2 版应该都是由同一个公司或者同一批开发者维护的,怎么 Dagger1 到 Dagger2 会变化这么大呢?我也不知道为什么,但是我注意到,Google 现在维护的 Dagger 项目是从 Square 的 Dagger 项目 Fork 过来的。
所以我猜测,大概是 Google Fork 了一份 Dagger 的源码,然后在此基础上进行修改,并发布了 Dagger2 版本。Square 看到了之后,认为 Google 的这个版本做得非常好,自己没有必要再重做一遍,也没有必要继续维护 Dagger1 了,所以就发布了这样一条声明:
那么 Dagger2 和 Dagger1 不同的地方在哪里呢?最重要的不同点在于,实现方式完全发生了变化。刚才我们已经知道,Dagger1 是基于 Java 反射实现的,并且列举了它的一些弊端。而 Google 开发的 Dagger2 是基于 Java 注解实现的,这样就把反射的那些弊端全部解决了。
通过注解,Dagger2 会在编译时期自动生成用于依赖注入的代码,所以不会增加任何运行耗时。另外,Dagger2 会在编译时期检查开发者的依赖注入用法是否正确,如果不正确的话则会直接编译失败,这样就能将问题尽可能早地抛出。也就是说,只要你的项目正常编译通过,基本也就说明你的依赖注入用法没什么问题了。
那么 Google 的这个 Dagger2 有没有取得成功呢?简直可以说是大获成功。
根据 Google 官方给出的数据,在 Google Play 排名前 1000 的 App 当中,有 74% 的 App 都使用了 Dagger2。
这里我要提一句,海外和国内的 Android 开发者喜欢研究的技术栈不太一样。在海外,没有人去研究像热修复或插件化这种国内特有的 Android 技术。那么你可能想问了,海外开发者们都是学什么进阶的呢?
答案就是 Dagger2。
是的,Dagger2 在海外是非常受到欢迎和广泛认可的技术栈,如果你能用得一手好 Dagger2,基本也就说明你是水平比较高的开发者了。
不过有趣的是,在国内反倒没有多少人愿意去使用 Dagger2,我在公众号之前也推送过几篇关于 Dagger2 的文章,但是从反馈上来看感觉这项技术在国内始终比较小众。
虽然 Dagger2 在海外很受欢迎,但是其复杂程度也是众所周知的,如果你不能很好地使用它的话,反而可能会拖累你的项目。所以一直也有声音说,使用 Dagger2 会将一些简单的项目过度设计。
根据 Android 团队发布的调查,49% 的 Android 开发者希望 Jetpack 中能够提供一个更加简单的依赖注入解决方案。
于是,Google 在今年发布了 Hilt。
你是不是觉得我讲了这么多的长篇大论,现在才终于讲到主题?不要这么想,我认为了解以上这些综合的内容,比仅仅只是掌握了 Hilt 的用法要更加重要。
我们都知道,Dagger 是匕首的意思,依赖注入就好像是把匕首直接插入了需要注入的地方,直击要害。
而 Hilt 是刀把的意思,它把匕首最锋利的地方隐藏了起来,因为如果你用不好匕首的话反而可能会误伤自己。Hilt 给你提供了一个安稳的把手,确保你可以安全简单地使用。
事实上,Hilt 和 Dagger2 有着千丝万缕的关系。Hilt 就是 Android 团队联系了 Dagger2 团队,一起开发出来的一个专门面向 Android 的依赖注入框架。相比于 Dagger2,Hilt 最明显的特征就是:1. 简单。2. 提供了 Android 专属的 API。
那么接下来,就让我们开始学习一下 Hilt 的具体用法。
引入Hilt
在开始使用 Hilt 之前,我们需要先将 Hilt 引入到你当前的项目当中。这个过程稍微有点繁琐,所以请大家一步步按照文章中的步骤操作。
第一步,我们需要在项目根目录的 build.gradle 文件中配置 Hilt 的插件路径:
buildscript {
...
dependencies {
...
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.28-alpha'
}
}
可以看到,目前 Hilt 最新的插件版本还在 alpha 阶段,但是没有关系,我自己用下来感觉已经是相当稳定了,等正式版本发布之后升级一下就可以了,用法上不会有什么太大变化。
接下来,在 app/build.gradle 文件中,引入 Hilt 的插件并添加 Hilt 的依赖库:
...
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'
dependencies {
implementation "com.google.dagger:hilt-android:2.28-alpha"
kapt "com.google.dagger:hilt-android-compiler:2.28-alpha"
}
这里同时还引入了 kotlin-kapt 插件,是因为 Hilt 是基于编译时注解来实现的,而启用编译时注解功能一定要先添加 kotlin-kapt 插件。如果你还在用 Java 开发项目,则可以不引入这个插件,同时将添加注解依赖库时使用的 kapt 关键字改成 annotationProcessor 即可。
最后,由于 Hilt 还会用到 Java 8 的特性,所以我们还得在当前项目中启用 Java 8 的功能,编辑 app/build.gradle 文件,并添加如下内容即可:
android {
...
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
好了,要配置的内容总共就这么多。现在你已经成功将 Hilt 引入到了你的项目当中,下面我们就来学习一下如何使用它吧。
Hilt的简单用法
我们先从最简单的功能学起。
相信大家都知道,每个 Android 程序中都会有一个 Application,这个 Application 可以自定义,也可以不定义,如果你不定义的话,系统会使用一个默认的 Application。
而到了 Hilt 当中,你必须要自定义一个 Application 才行,否则 Hilt 将无法正常工作。
这里我们自定义一个 MyApplication 类,代码如下所示:
@HiltAndroidApp
class MyApplication : Application() {
}
你的自定义 Application 中可以不写任何代码,但是必须要加上一个 @HiltAndroidApp 注解,这是使用 Hilt 的一个必备前提。
接下来将 MyApplication 注册到你的 AndroidManifest.xml 文件当中:
<application
android:
...>
</application>
这样准备工作就算是完成了,接下来的工作就是根据你具体的业务逻辑使用 Hilt 去进行依赖注入。
Hilt 大幅简化了 Dagger2 的用法,使得我们不用通过 @Component 注解去编写桥接层的逻辑,但是也因此限定了注入功能只能从几个 Android 固定的入口点开始。
Hilt 一共支持 6 个入口点,分别是:
- Application
- Activity
- Fragment
- View
- Service
- BroadcastReceiver
其中,只有 Application 这个入口点是使用 @HiltAndroidApp 注解来声明的,这个我们刚才已经看过了。其他的所有入口点,都是用 @AndroidEntryPoint 注解来声明的。
以最常见的 Activity 来举例吧,如果我希望在 Activity 中进行依赖注入,那么只需要这样声明 Activity 即可:
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}
接下来我们尝试向 Activity 中注入点东西吧。注入什么呢?还记得刚才的那辆卡车吗,我们试着看把它注入到 Activity 当中吧。
定义一个 Truck 类,代码如下所示:
class Truck {
fun deliver() {
println("Truck is delivering cargo.")
}
}
可以看到,目前这辆卡车有一个 deliver() 方法,说明它具备送货功能。
然后修改 Activity 中的代码,如下所示:
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var truck: Truck
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
truck.deliver()
}
}
这里的代码可能乍一看上去稍微有点奇怪,我来解释一下。
首先 lateinit 是 Kotlin 中的关键字,和 Hilt 无关。这个关键字用于对变量延迟初始化,因为 Kotlin 默认在声明一个变量时就要对其进行初始化,而这里我们并不想手动初始化,所以要加上 lateinit。如果你是用 Java 开发的话,那么可以无视这个关键字。
接下来我们在 truck 字段的上方声明了一个 @Inject 注解,表示我希望通过 Hilt 来注入 truck 这个字段。如果让我类比的话,这大概就相当于电脑公司打电话让卡车配送公司安排卡车的过程。我们可以把 MainActivity 看作电脑公司,它是依赖于卡车的,但是至于这个卡车是怎么来的,电脑公司并不关心。而 Hilt 在这里承担的职责就类似于卡车配送公司,它负责想办法安排车辆,甚至有义务造一辆出来。
另外提一句,Hilt 注入的字段是不可以声明成 private 的,这里大家一定要注意。
不过代码写到这里还是不可以正常工作的,因为 Hilt 并不知道该如何提供一辆卡车。因此,我们还需要对 Truck 类进行如下修改:
class Truck @Inject constructor() {
fun deliver() {
println("Truck is delivering cargo.")
}
}
这里我们在 Truck 类的构造函数上声明了一个 @Inject 注解,其实就是在告诉 Hilt,你是可以通过这个构造函数来安排一辆卡车的。
好了,就是这么简单。现在可以运行一下程序了,你将会在 Logcat 中看到如下内容:
说明卡车真的已经在好好送货了。
有没有觉得很神奇?我们在 MainActivity 中并没有去创建 Truck 的实例,只是用 @Inject 声明了一下,结果真的可以调用它的 deliver() 方法。
这就是 Hilt 给我们提供的依赖注入功能。
带参数的依赖注入
必须承认,刚才我们所举的例子确实太简单了,在真实的编程场景中用处应该非常有限,因为真实场景中不可能永远是这样的理想情况。
那么下面我们就开始逐步学习如何在各种更加复杂的场景下使用 Hilt 进行依赖注入。
首先一个很容易想到的场景,如果我的构造函数中带有参数,Hilt 要如何进行依赖注入呢?
我们对 Truck 类进行如下改造:
class Truck @Inject constructor(val driver: Driver) {
fun deliver() {
println("Truck is delivering cargo. Driven by $driver")
}
}
可以看到,现在 Truck 类的构造函数中增加了一个 Driver 参数,说明卡车是依赖一位司机的,毕竟没有司机的话卡车自己是不会开的。
那么问题来了,既然卡车是依赖司机的,Hilt 现在要如何对卡车进行依赖注入呢?毕竟 Hilt 不知道这位司机来自何处。
这个问题其实没有想象中的困难,因为既然卡车是依赖司机的,那么如果我们想要对卡车进行依赖注入,自然首先要能对司机进行依赖注入才行。
所以可以这样去声明 Driver 类:
class Driver @Inject constructor() {
}
非常简单,我们在 Driver 类的构造函数上声明了一个 @Inject 注解,如此一来,Driver 类就变成了无参构造函数的依赖注入方式。
然后就不需要再修改任何代码了,因为 Hilt 既然知道了要如何依赖注入 Driver,也就知道要如何依赖注入 Truck 了。
总结一下,就是 Truck 的构造函数中所依赖的所有其他对象都支持依赖注入了,那么 Truck 才可以被依赖注入。
现在重新运行一下程序,打印日志如下所示:
可以看到,现在卡车正在被一位司机驾驶,这位司机的身份证号是 de5edf5。
接口的依赖注入
解决了带参构造函数的依赖注入,接下来我们继续看更加复杂的场景:如何对接口进行依赖注入。
毫无疑问,我们目前所掌握的技术是无法对接口进行依赖注入的,原因也很简单,接口没有构造函数。
不过不用担心,Hilt 对接口的依赖注入提供了相当完善的支持,所以你很快就能掌握这项技能。
我们继续通过具体的示例来学习。
任何一辆卡车都需要有引擎才可以正常行驶,那么这里我定义一个 Engine 接口,如下所示:
interface Engine {
fun start()
fun shutdown()
}
非常简单,接口中有两个待实现方法,分别用于启用引擎和关闭引擎。
既然有接口,那就还要有实现类才行。这里我再定义一个 GasEngine 类,并实现 Engine 接口,代码如下所示:
class GasEngine() : Engine {
override fun start() {
println("Gas engine start.")
}
override fun shutdown() {
println("Gas engine shutdown.")
}
}
可以看到,我们在 GasEngine 中实现了启动引擎和关闭引擎的功能。
另外,现在新能源汽车非常火,特斯拉已经快要遍地都是了。所以汽车引擎除了传统的燃油引擎之外,现在还有了电动引擎。于是这里我们再定义一个 ElectricEngine 类,并实现 Engine 接口,代码如下所示:
class ElectricEngine() : Engine {
override fun start() {
println("Electric engine start.")
}
override fun shutdown() {
println("Electric engine shutdown.")
}
}
类似地,ElectricEngine 中也实现了启动引擎和关闭引擎的功能。
刚才已经说了,任何一辆卡车都需要有引擎才可以正常行驶,也就是说,卡车是依赖于引擎的。现在我想要通过依赖注入的方式,将引擎注入到卡车当中,那么需要怎么写呢?
根据刚才已学到的知识,最直观的写法就是这样:
class Truck @Inject constructor(val driver: Driver) {
@Inject
lateinit var engine: Engine
...
}
我们在 Truck 中声明一个 engine 字段,这就说明 Truck 是依赖于 Engine 的了。然后在 engine 字段的上方使用 @Inject 注解对该字段进行注入。或者你也可以将 engine 字段声明到构造函数当中,这样就不需要加入 @Inject 注解了,效果是一样的。
假如 Engine 字段是一个普通的类,使用这种写法当然是没问题的。但问题是 Engine 是一个接口,Hilt 肯定是无法知道要如何创建这个接口的实例,因此这样写一定会报错。
下面我们就来看看该如何一步步解决这个问题。
首先,刚才编写的 GasEngine 和 ElectricEngine 这两个实现类,它们是可以依赖注入的,因为它们都有构造函数。
因此分别修改 GasEngine 和 ElectricEngine 中的代码,如下所示:
class GasEngine @Inject constructor() : Engine {
...
}
class ElectricEngine @Inject constructor() : Engine {
...
}
这又是我们刚才学过的技术了,在这两个类的构造函数上分别声明 @Inject 注解。
接下来我们需要新建一个抽象类,类名叫什么都可以,但是最好要和业务逻辑有相关性,因此我建议起名 EngineModule.kt,如下所示:
@Module
@InstallIn(ActivityComponent::class)
abstract class EngineModule {
}
这里注意,我们需要在 EngineModule 的上方声明一个 @Module 注解,表示这一个用于提供依赖注入实例的模块。
如果你之前学习过 Dagger2,那么对于这部分理解起来一定会相当轻松,这完全就是和 Dagger2 是一模一样的嘛。
而如果你之前没有学习过 Dagger2,也没有关系,跟着接下来的步骤一步步实现,你自然就能明白它的作用了。
另外可能你会注意到,除了 @Module 注解之外,这里还声明了一个 @InstallIn 注解,这个就是 Dagger2 中没有的东西了。关于 @InstallIn 注解的作用,待会我会使用一块单独的主题进行讲解,暂时你只要知道必须这么写就可以了。
定义好了 EngineModule 之后,接下来我们需要在这个模块当中提供 Engine 接口所需要的实例。怎么提供呢?非常简单,代码如下所示:
@Module
@InstallIn(ActivityComponent::class)
abstract class EngineModule {
@Binds
abstract fun bindEngine(gasEngine: GasEngine): Engine
}
这里有几个关键的点我逐个说明一下。
首先我们要定义一个抽象函数,为什么是抽象函数呢?因为我们并不需实现具体的函数体。
其次,这个抽象函数的函数名叫什么都无所谓,你也不会调用它,不过起个好点的名字可以有助于你的阅读和理解。
第三,抽象函数的返回值必须是 Engine,表示用于给 Engine 类型的接口提供实例。那么提供什么实例给它呢?抽象函数接收了什么参数,就提供什么实例给它。由于我们的卡车还比较传统,使用的仍然是燃油引擎,所以 bindEngine() 函数接收了 GasEngine 参数,也就是说,会将 GasEngine 的实例提供给 Engine 接口。
最后,在抽象函数上方加上 @Bind 注解,这样 Hilt 才能识别它。
经过一系列的代码编写之后,我们再回到 Truck 类当中。你会发现,这个时候我们再向 engine 字段去进行依赖注入就变得有道理了,因为借助刚才定义的 EngineModule,很明显将会注入一个 GasEngine 的实例到 engine 字段当中。
实际是不是这样呢?我们来操作一下就知道了,修改 Truck 类中的代码,如下所示:
class Truck @Inject constructor(val driver: Driver) {
@Inject
lateinit var engine: Engine
fun deliver() {
engine.start()
println("Truck is delivering cargo. Driven by $driver")
engine.shutdown()
}
}
我们在开始送货之前先启动车辆引擎,然后在送货完成之后完毕车辆引擎,非常合理的逻辑。
现在重新运行一下程序,控制台打印信息如图所示:
正如我们所预期的那样,在送货的前后分别打印了燃油引擎启动和燃油引擎关闭的日志,说明 Hilt 确实向 engine 字段注入了一个 GasEngine 的实例。
这样也就解决了给接口进行依赖注入的问题。
给相同类型注入不同的实例
友情提醒,别忘了刚才我们定义的 ElectricEngine 还没用上呢。
现在卡车配送公司通过送货赚到了很多钱,解决了温饱问题,就该考虑环保问题了。用燃油引擎来送货实在是不够环保,为了拯救地球,我们决定对卡车进行升级改造。
但是目前电动车还不够成熟,存在续航里程短,充电时间长等问题。怎么办呢?于是我们准备采取一个折中的方案,暂时使用混动引擎来进行过渡。
也就是说,一辆卡车中将会同时包含燃油引擎和电动引擎。
那么问题来了,我们通过 EngineModule 中的 bindEngine() 函数为 Engine 接口提供实例,这个实例要么是 GasEngine,要么是 ElectricEngine,怎么能同时为一个接口提供两种不同的实例呢?
可能你会想到,那我定义两个不同的函数,分别接收 GasEngine 和 ElectricEngine 参数不就行了,代码如下所示:
@Module
@InstallIn(ActivityComponent::class)
abstract class EngineModule {
@Binds
abstract fun bindGasEngine(gasEngine: GasEngine): Engine
@Binds
abstract fun bindElectricEngine(electricEngine: ElectricEngine): Engine
}
这种写法看上去好像挺有道理,但是如果你编译一下就会发现报错了:
注意红框中的文字即可,这个错误在提醒我们,Engine 被绑定了多次。
其实想想也有道理,我们在 EngineModule 中提供了两个不同的函数,它们的返回值都是 Engine。那么当在 Truck 中给 engine 字段进行依赖注入时,到底是使用 bindGasEngine() 函数提供的实例呢?还是使用 bindElectricEngine() 函数提供的实例呢?Hilt 也搞不清楚了。
因此这个问题需要借助额外的技术手段才能解决:Qualifier 注解。
Qualifier 注解的作用就是专门用于解决我们目前碰到的问题,给相同类型的类或接口注入不同的实例。
这里我们分别定义两个注解,如下所示:
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class BindGasEngine
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class BindElectricEngine
一个注解叫 BindGasEngine,一个注解叫 BindElectricEngine,这样两个注解的作用就明显区分开了。
另外,注解的上方必须使用 @Qualifier 进行声明,这个是毫无疑问的。至于另外一个 @Retention,是用于声明注解的作用范围,选择 AnnotationRetention.BINARY 表示该注解在编译之后会得到保留,但是无法通过反射去访问这个注解。这应该是最合理的一个注解作用范围。
定义好了上述两个注解之后,我们再回到 EngineModule 当中。现在就可以将刚才定义的两个注解分别添加到 bindGasEngine() 和 bindElectricEngine() 函数的上方,如下所示:
@Module
@InstallIn(ActivityComponent::class)
abstract class EngineModule {
@BindGasEngine
@Binds
abstract fun bindGasEngine(gasEngine: GasEngine): Engine
@BindElectricEngine
@Binds
abstract fun bindElectricEngine(electricEngine: ElectricEngine): Engine
}
如此一来,我们就将两个为 Engine 接口提供实例的函数进行了分类,一个分到了 @BindGasEngine 注解上,一个分到了 @BindElectricEngine 注解上。
不过现在还没结束,因为增加了 Qualifier 注解之后,所有为 Engine 类型进行依赖注入的地方也需要去声明注解,明确指定自己希望注入哪种类型的实例。
因此我们还需要修改 Truck 类中的代码,如下所示:
class Truck @Inject constructor(val driver: Driver) {
@BindGasEngine
@Inject
lateinit var gasEngine: Engine
@BindElectricEngine
@Inject
lateinit var electricEngine: Engine
fun deliver() {
gasEngine.start()
electricEngine.start()
println("Truck is delivering cargo. Driven by $driver")
gasEngine.shutdown()
electricEngine.shutdown()
}
}
这段代码现在看起来是不是很容易理解了呢?
我们定义了 gasEngine 和 electricEngine 这两个字段,它们的类型都是 Engine。但是在 gasEngine 的上方,使用了 @BindGasEngine 注解,这样 Hilt 就会给它注入 GasEngine 的实例。在 electricEngine 的上方,使用了 @BindElectricEngine 注解,这样 Hilt 就会给它注入 ElectricEngine 的实例。
最后在 deliver() 当中,我们先启动燃油引擎,再启动电动引擎,送货结束后,先关闭燃油引擎,再关闭电动引擎。
最终的结果会是什么样呢?运行一下看看吧,如下图所示。
非常棒,一切正如我们所预期地那样运行了。
这样也就解决了给相同类型注入不同实例的问题。
第三方类的依赖注入
卡车这个例子暂时先告一段落,接下来我们看一些更加实际的例子。
刚才有说过,如果我们想要在 MainActivity 中使用 OkHttp 发起网络请求,通常会创建一个 OkHttpClient 的实例。不过原则上 OkHttpClient 的实例又不应该由 Activity 去创建,那么很明显,这个时候使用依赖注入是一个非常不错的解决方案。即,让 MainActivity 去依赖 OkHttpClient 即可。
但是这又会引出一个新的问题,OkHttpClient 这个类是由 OkHttp 库提供的啊,我们并没有这个类的编写权限,因此自然也不可能在 OkHttpClient 的构造函数中加上 @Inject 注解,那么要如何对它进行依赖注入呢?
这个时候又要借助 @Module 注解了,它的解决方案有点类似于刚才给接口类型提供依赖注入,但是并不完全一样。
首先定义一个叫 NetworkModule 的类,代码如下所示:
@Module
@InstallIn(ActivityComponent::class)
class NetworkModule {
}
它的初始声明和刚才的 EngineModule 非常相似,只不过这里没有将它声明成抽象类,因为我们不会在这里定义抽象函数。
很明显,在 NetworkModule 当中,我们希望给 OkHttpClient 类型提供实例,因此可以编写如下代码:
@Module
@InstallIn(ActivityComponent::class)
class NetworkModule {
@Provides
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.build()
}
}
同样,provideOkHttpClient() 这个函数名是随便定义的,Hilt 不做任何要求,但是返回值必须是 OkHttpClient,因为我们就是要给 OkHttpClient 类型提供实例嘛。
注意,不同的地方在于,这次我们写的不是抽象函数了,而是一个常规的函数。在这个函数中,按正常的写法去创建 OkHttpClient 的实例,并进行返回即可。
最后,记得要在 provideOkHttpClient() 函数的上方加上 @Provides 注解,这样 Hilt 才能识别它。
好了,现在如果你想要在 MainActivity 中去依赖注入 OkHttpClient,只需要这样写即可:
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var okHttpClient: OkHttpClient
...
}
然后你可以在 MainActivity 的任何地方去使用 okHttpClient 对象,代码一定会正常运行的。
这样我们就解决了给第三方库的类进行依赖注入的问题,不过这个问题其实还可以再进一步拓展一下。
现在直接使用 OkHttp 的人已经越来越少了,更多的开发者选择使用 Retrofit 来作为他们的网络请求解决方案,而 Retrofit 实际上也是基于 OkHttp 的。
为了方便开发者的使用,我们希望在 NetworkModule 中给 Retrofit 类型提供实例,而在创建 Retrofit 实例的时候,我们又可以选择让其依赖 OkHttpClient,具体要怎么写呢?特别简单:
@Module
@InstallIn(ActivityComponent::class)
class NetworkModule {
...
@Provides
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.addConverterFactory(GsonConverterFactory.create())
.baseUrl("http://example.com/")
.client(okHttpClient)
.build()
}
}
这里定义了一个 provideRetrofit() 函数,然后在函数中按常规的方式去创建 Retrofit 的实例,并将其返回即可。
但是我们注意到,provideRetrofit() 函数还接收了一个 OkHttpClient 参数,并且我们在创建 Retrofit 实例的时候还依赖了这个参数。那么你可能会问了,我们要如何向 provideRetrofit() 函数去传递 OkHttpClient 这个参数呢?
答案是,完全不需要传递,因为这个过程是由 Hilt 自动完成的。我们所需要做的,就是保证 Hilt 能知道如何得到一个 OkHttpClient 的实例,而这个工作我们早在前面一步就已经完成了。
所以,假如现在你在 MainActivity 中去编写这样的代码:
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var retrofit: Retrofit
...
}
绝对是没有问题的。
Hilt内置组件和组件作用域
刚才我们在学习给接口和第三方类进行依赖注入时,跳过了 @InstallIn 这个注解,现在是时候该回头看一下了。
其实这个注解的名字起得还是相当准确的,InstallIn,就是安装到的意思。那么 @InstallIn(ActivityComponent::class),就是把这个模块安装到 Activity 组件当中。
既然是安装到了 Activity 组件当中,那么自然在 Activity 中是可以使用由这个模块提供的所有依赖注入实例。另外,Activity 中包含的 Fragment 和 View 也可以使用,但是除了 Activity、Fragment、View 之外的其他地方就无法使用了。
比如说,我们在 Service 中使用 @Inject 来对 Retrofit 类型的字段进行依赖注入,就一定会报错。
不过不用慌,这些都是有办法解决的。
Hilt 一共内置了 7 种组件类型,分别用于注入到不同的场景,如下表所示。
这张表中,每个组件的作用范围都不相同。其中,ApplicationComponent 提供的依赖注入实例可以在全项目中使用。因此,如果我们希望刚才在 NetworkModule 中提供的 Retrofit 实例也能在 Service 中进行依赖注入,只需要这样修改就可以了:
@Module
@InstallIn(ApplicationComponent::class)
class NetworkModule {
...
}
另外和 Hilt 内置组件相关的,还有一个叫组件作用域的概念,我们也要学习一下它的作用。
或许 Hilt 的这个行为和你预想的并不一致,但是这确实就是事实:Hilt 会为每次的依赖注入行为都创建不同的实例。
这种默认行为在很多时候确实是非常不合理的,比如我们提供的 Retrofit 和 OkHttpClient 的实例,理论上它们全局只需要一份就可以了,每次都创建不同的实例明显是一种不必要的浪费。
而更改这种默认行为其实也很简单,借助 @Singleton 注解即可,如下所示:
@Module
@InstallIn(ApplicationComponent::class)
class NetworkModule {
@Singleton
@Provides
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.build()
}
@Singleton
@Provides
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.addConverterFactory(GsonConverterFactory.create())
.baseUrl("http://example.com")
.client(okHttpClient)
.build()
}
}
这样就可以保证 OkHttpClient 和 Retrofit 在全局都只会存在一份实例了。
Hilt 一共提供了 7 种组件作用域注解,和刚才的 7 个内置组件分别是一一对应的,如下表所示。
也就是说,如果想要在全程序范围内共用某个对象的实例,那么就使用 @Singleton。如果想要在某个 Activity,以及它内部包含的 Fragment 和 View 中共用某个对象的实例,那么就使用 @ActivityScoped。以此类推。
另外,我们不必非得在某个 Module 中使用作用域注解,也可以直接将它声明到任何可注入类的上方。比如我们对 Driver 类进行如下声明:
@Singleton
class Driver @Inject constructor() {
}
这就表示,Driver 在整个项目的全局范围内都会共享同一个实例,并且全局都可以对 Driver 类进行依赖注入。
而如果我们将注解改成 @ActivityScoped,那么就表示 Driver 在同一个 Activity 内部将会共享同一个实例,并且 Activity、Fragment、View 都可以对 Driver 类进行依赖注入。
你可能会好奇,这个包含关系是如何确定的,为什么声明成 @ActivityScoped 的类在 Fragment 和 View 中也可以进行依赖注入?
关于包含关系的定义,我们来看下面这张图就一目了然了:
简单来讲,就是对某个类声明了某种作用域注解之后,这个注解的箭头所能指到的地方,都可以对该类进行依赖注入,同时在该范围内共享同一个实例。
比如 @Singleton 注解的箭头可以指向所有地方。而 @ServiceScoped 注解的箭头无处可指,所以只能限定在 Service 自身当中使用。@ActivityScoped 注解的箭头可以指向 Fragment、View 当中。
这样你应该就将 Hilt 的内置组件以及组件作用域的相关知识都掌握牢了。
预置Qualifier
Android 开发相比于传统的 Java 开发有其特有的特殊性,比如说 Android 中有个 Context 的概念。
刚入门 Android 开发的新手可能总会疑惑 Context 到底是什么,而做过多年 Android 开发的人估计根本就不关心这个问题了,我天天都在用,甚至到处都在用它,对 Context 是什么已经麻木了。
确实,Android 开发中有太多的地方要依赖于 Context,动不动调用的什么接口就会要求你传入 Context 参数。
那么,如果有个我们想要依赖注入的类,它又是依赖于 Context 的,这个情况要如何解决呢?
举个例子,现在 Driver 类的构造函数接收一个 Context 参数,如下所示:
@Singleton
class Driver @Inject constructor(val context: Context) {
}
现在你编译一下项目一定会报错,原因也很简单,Driver 类无法被依赖注入了,因为 Hilt 不知道要如何提供 Context 这个参数。
感觉似曾相识是不是?好像我们让 Truck 类去依赖 Driver 类的时候也遇到了这个问题,当时的解决方案是在 Driver 的构造函数上声明 @Inject 注解,让其也可以被依赖注入就可以了。
但是很明显,这里我们不能用同样的方法解决问题,因为我们根本就没有 Context 类的编写权限,所以肯定无法在其构造函数上声明 @Inject 注解。
那么你可能又会想到了,没有 Context 类的编写权限,那么我们再使用刚才学到的 @Module 的方式,以第三方类的形式给 Context 提供依赖注入不就行了?
这种方案乍看之下好像确实可以,但是当你实际去编写的时候又会发现问题了,比如说:
@Module
@InstallIn(ApplicationComponent::class)
class ContextModule {
@Provides
fun provideContext(): Context {
???
}
}
这里我定义好了一个 ContextModule,定义好了一个 provideContext() 函数,它的返回值也确实是 Context,但是我接下来不知道该怎么写了,因为我不能 new 一个 Context 的实例去返回啊。
没错,像 Context 这样的系统组件,它的实例都是由 Android 系统去创建的,我们不可以随便去 new 它的实例,所以自然也就不能用前面所学的方案去解决。
那么要如何解决呢?非常简单,Android 提供了一些预置 Qualifier,专门就是用于给我们提供 Context 类型的依赖注入实例的。
比如刚才的 Truck 类,其实只需要在 Context 参数前加上一个 @ApplicationContext 注解,代码就能编译通过了,如下所示:
@Singleton
class Driver @Inject constructor(@ApplicationContext val context: Context) {
}
这种写法 Hilt 会自动提供一个 Application 类型的 Context 给到 Truck 类当中,然后 Truck 类就可以使用这个 Context 去编写具体的业务逻辑了。
但是如果你说,我需要的并不是 Application 类型的 Context,而是 Activity 类型的 Context。也没有问题,Hilt 还预置了另外一种 Qualifier,我们使用 @ActivityContext 即可:
@Singleton
class Driver @Inject constructor(@ActivityContext val context: Context) {
}
不过这个时候如果你编译一下项目,会发现报错了。原因也很好理解,现在我们的 Driver 是 Singleton 的,也就是全局都可以使用,但是却依赖了一个 Activity 类型的 Context,这很明显是不可能的。
至于解决方案嘛,相信学了上一块主题的你一定已经知道了,我们将 Driver 上方的注解改成 @ActivityScoped、@FragmentScoped、@ViewScoped,或者直接删掉都可以,这样再次编译就不会报错了。
关于预置 Qualifier 其实还有一个隐藏的小技巧,就是对于 Application 和 Activity 这两个类型,Hilt 也是给它们预置好了注入功能。也就是说,如果你的某个类依赖于 Application 或者 Activity,不需要想办法为这两个类提供依赖注入的实例,Hilt 自动就能识别它们。如下所示:
class Driver @Inject constructor(val application: Application) {
}
class Driver @Inject constructor(val activity: Activity) {
}
这种写法编译将可以直接通过,无需添加任何注解声明。
注意必须是 Application 和 Activity 这两个类型,即使是声明它们的子类型,编译都无法通过。
那么你可能会说,我的项目会在自定义的 MyApplication 中提供一些全局通用的函数,导致很多地方都是要依赖于我自己编写的 MyApplication 的,而 MyApplication 又不能被 Hilt 识别,这种情况要怎么办呢?
这里我教大家一个小窍门,因为 Application 全局只会存在一份实例,因此 Hilt 注入的 Application 实例其实就是你自定义的 MyApplication 实例,所以想办法做一下向下类型转换就可以了。
比如说这里我定义了一个 ApplicationModule,代码如下所示:
@Module
@InstallIn(ApplicationComponent::class)
class ApplicationModule {
@Provides
fun provideMyApplication(application: Application): MyApplication {
return application as MyApplication
}
}
可以看到,provideMyApplication() 函数中接收一个 Application 参数,这个参数 Hilt 是自动识别的,然后我们将其向下转型成 MyApplication 即可。
接下来你在 Truck 类中就可以去这样声明依赖了:
class Driver @Inject constructor(val application: MyApplication) {
}
完美解决。
ViewModel的依赖注入
到目前为止,你已经将 Hilt 中几乎所有的重要知识点都学习完了。
做事情讲究有始有终,让我们回到开始时候的一个话题:在 MVVM 架构中,仓库层的实例到底应该由谁来创建?
这个问题现在你有更好的答案了吗?
我在学完 Hilt 之后,这个问题就已经释怀了。很明显,根据 MVVM 的架构示意图,ViewModel 层只是依赖于仓库层,它并不关心仓库的实例是从哪儿来的,因此由 Hilt 去管理仓库层的实例创建再合适不过了。
至于具体该如何实现,我总结下来大概有两种方式,这里分别跟大家演示一下。
注意,以下代码只是做了 MVVM 架构中与依赖注入相关部分的演示,如果你还没有了解过 MVVM 架构,或者没有了解过 Jetpack 组件,可能会看不懂下面的代码。这部分朋友建议先去参考 《第一行代码 Android 第 3 版》的第 13 和第 15 章。
第一种方式就是纯粹利用我们前面所学过的知识自己手写。
比如说我们有一个 Repository 类用于表示仓库层:
class Repository @Inject constructor() {
...
}
由于 Repository 要依赖注入到 ViewModel 当中,所以我们需要给 Repository 的构造函数加上 @Inject 注解。
然后有一个 MyViewModel 继承自 ViewModel,用于表示 ViewModel 层:
@ActivityRetainedScoped
class MyViewModel @Inject constructor(val repository: Repository) : ViewModel() {
...
}
这里注意以下三点。
第一,MyViewModel 的头部要为其声明 @ActivityRetainedScoped 注解,参照刚才组件作用域那张表,我们知道这个注解就是专门为 ViewModel 提供的,并且它的生命周期也和 ViewModel 一致。
第二,MyViewModel 的构造函数中要声明 @Inject 注解,因为我们在 Activity 中也要使用依赖注入的方式获得 MyViewModel 的实例。
第三,MyViewModel 的构造函数中要加上 Repository 参数,表示 MyViewModel 是依赖于 Repository 的。
接下来就很简单了,我们在 MainActivity 中通过依赖注入的方式得到 MyViewModel 的实例,然后像往常一样的方式去使用它就可以了:
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var viewModel: MyViewModel
...
}
这种方式虽然可以正常工作,但有个缺点是,我们改变了获取 ViewModel 实例的常规方式。本来我只是想对 Repository 进行依赖注入的,现在连 MyViewModel 也要跟着一起依赖注入了。
为此,对于 ViewModel 这种常用 Jetpack 组件,Hilt 专门为其提供了一种独立的依赖注入方式,也就是我们接下来要介绍的第二种方式了。
这种方式我们需要在 app/build.gradle 文件中添加两个额外的依赖:
dependencies {
...
implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha02'
kapt 'androidx.hilt:hilt-compiler:1.0.0-alpha02'
}
然后修改 MyViewModel 中的代码,如下所示:
class MyViewModel @ViewModelInject constructor(val repository: Repository) : ViewModel() {
...
}
注意这里的变化,首先 @ActivityRetainedScoped 这个注解不见了,因为我们不再需要它了。其次,@Inject 注解变成了 @ViewModelInject 注解,从名字上就可以看出,这个注解是专门给 ViewModel 使用的。
现在回到 MainActivity 当中,你就不再需要使用依赖注入的方式去获取 MyViewModel 的实例了,而是完全按照常规的写法去获取即可:
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
val viewModel: MyViewModel by lazy { ViewModelProvider(this).get(MyViewModel::class.java) }
...
}
看上去和我们平时使用 ViewModel 时的写法完全无二,这都是由 Hilt 在背后帮我们施了神奇的魔法。
需要注意的是,这种写法下,虽然我们在 MainActivity 里没有使用依赖注入功能,但是 @AndroidEntryPoint 这个注解仍然是不能少的。不然的话,在编译时期 Hilt 确实检测不出来语法上的异常,一旦到了运行时期,Hilt 找不到入口点就无法执行依赖注入了。
不支持的入口点怎么办?
在最开始学习 Hilt 的时候,我就提到了,Hilt 一共支持 6 个入口点,分别是:
- Application
- Activity
- Fragment
- View
- Service
- BroadcastReceiver
之所以做这样的设定,是因为我们的程序基本都是由这些入口点出发的。
比如一个 Android 程序肯定不可能凭空从 Truck 类开始执行代码,而一定要从上述的某个入口点开始执行,然后才能辗转执行到 Truck 类中的代码。
但是不知道你有没有发现,Hilt 支持的入口点中少了一个关键的 Android 组件:ContentProvider。
我们都知道,ContentProvider 是四大组件之一,并且它也是可以称之为一个入口点的,因为代码可以从这里开始直接运行,而并不需要经过其他类的调用才能到达它。
那么为什么 Hilt 支持的入口点中不包括 ContentProvider 呢?这个问题我也很疑惑,所以在上次的上海 GDG 圆桌会议上,我将这个问题直接提给了 Yigit Boyar,毕竟他在 Google 是专门负责 Jetpack 项目的。
当然我也算得到了一个比较满意的回答,主要原因就是 ContentProvider 的生命周期问题。如果你比较了解 ContentProvider 的话,应该知道它的生命周期是比较特殊的,它在 Application 的 onCreate() 方法之前就能得到执行,因此很多人会利用这个特性去进行提前初始化,详见 Jetpack 新成员,App Startup 一篇就懂 这篇文章。
而 Hilt 的工作原理是从 Application 的 onCreate() 方法中开始的,也就是说在这个方法执行之前,Hilt 的所有功能都还无法正常工作。
也正是因为这个原因,Hilt 才没有将 ContentProvider 纳入到支持的入口点当中。
不过,即使 ContentProvider 并不是入口点,我们仍然还有其他办法在其内部使用依赖注入功能,只是要稍微麻烦一点。
首先可以在 ContentProvider 中自定义一个自己的入口点,并在其中定义好要依赖注入的类型,如下所示:
class MyContentProvider : ContentProvider() {
@EntryPoint
@InstallIn(ApplicationComponent::class)
interface MyEntryPoint {
fun getRetrofit(): Retrofit
}
...
}
可以看到,这里我们定义了一个 MyEntryPoint 接口,然后在其上方使用 @EntryPoint 来声明这是一个自定义入口点,并用 @InstallIn 来声明其作用范围。
接着我们在 MyEntryPoint 中定义了一个 getRetrofit() 函数,并且函数的返回类型就是 Retrofit。
而 Retrofit 是我们已支持依赖注入的类型,这个功能早在 NetworkModule 当中就已经完成了。
现在,如果我们想要在 MyContentProvider 的某个函数中获取 Retrofit 的实例(事实上,ContentProvider 中不太可能会用到网络功能,这里只是举例),只需要这样写就可以了:
class MyContentProvider : ContentProvider() {
...
override fun query(...): Cursor {
context?.let {
val appContext = it.applicationContext
val entryPoint = EntryPointAccessors.fromApplication(appContext, MyEntryPoint::class.java)
val retrofit = entryPoint.getRetrofit()
}
...
}
}
借助 EntryPointAccessors 类,我们调用其 fromApplication() 函数来获得自定义入口点的实例,然后再调用入口点中定义的 getRetrofit() 函数就能得到 Retrofit 的实例了。
不过我认为,自定义入口点这个功能在实际开发当中并不常用,这里只是考虑知识完整性的原因,所以将这块内容也加入了进来。
结尾
到这里,这篇文章总算是结束了。
不愧称它是一篇我自己都怕的文章,这篇文章大概花了我半个月左右的时间,可能是我写过的最长的一篇文章。
由于 Hilt 涉及的知识点繁多,即使它将 Dagger2 的用法进行了大幅的简化,但如果你之前对于依赖注入完全没有了解,直接上手 Hilt 相信还是会有不少的困难。
我在本文当中尽可能地将 “什么是依赖注入,为什么要使用依赖注入,如何使用依赖注入” 这几个问题描述清楚了,但介于依赖注入这个话题本身复杂度的客观原因,我也不知道本文的难易程度到底在什么等级。希望阅读过的读者朋友们都能达到掌握 Hilt,并用好 Hilt 的水平吧。
另外,由于 Hilt 和 Dagger2 的关系过于紧密,我们在本文中所学的知识,有些是 Hilt 提供的,有些是 Dagger2 本身就自带。但是我对此在文中并没有进行严格的区分,统一都是以 Hilt 的视角去讲的。所以,熟悉 Dagger2 的朋友请不要觉得文中的说法不够严谨,因为太过严谨的话可能会增加没有学过 Dagger2 这部分读者朋友的理解成本。
最后,我将本文中用到的一些代码示例,写成了一个 Demo 程序上传到了 码云 上,有需要的朋友直接去下载源码即可。
网友评论