Jetpack新成员,一篇文章带你玩转Hilt和依赖注入

本文同步发表于我的微信公众号,扫一扫文章底部的二维码或在微信搜索 郭霖 即可关注,每个工作日都有文章更新。

各位小伙伴们大家早上好。

终于要写这样一篇我自己都比较怕的文章了。

虽然今年的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:name=".MyApplication"
    ...>
    
</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程序上传到了GitHub上,有需要的朋友直接去下载源码即可。

https://github.com/guolindev/HiltSample



如果想要学习Kotlin和最新的Android知识,可以参考我的新书 《第一行代码 第3版》点击此处查看详情


关注我的技术公众号,每个工作日都有优质技术文章推送。

微信扫一扫下方二维码即可关注:

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页