依赖注入框架之 「Hilt」

什么是依赖注入

有同学可能会说,"我不知道依赖注入是什么,也不知道它有什么用,但是我的代码似乎也没什么问题,耦合也不高,为什么要用依赖注入呢?"如果有这样的疑问,其实可能是对依赖注入 的概念不理解。下面我会通过一个简单的场景来解释什么是依赖注入

假设有一个商店,这个店规模比较小,就只卖苹果和香蕉两种商品,我们用代码实现下这个商店的功能。

kotlin 复制代码
class CustomStore {  
    val apple: Fruit = Apple()  
    val banana: Fruit = Banana()  

    fun sell() {  
        val totalPrice = apple.price() + banana.price()  
        println("totalPrice = $totalPrice")  
    }  
}

代码比较简单,有一个 CustomStore 类,通过 sell()方法,每次可以卖出一个苹果和一个香蕉。这样写我们程序能正常运行吗?毫无疑问,可以。但前提是需求不能改动,如果新增需求代码逻辑就要调整。通常,CustomStore应该只负责售卖,而此处还负责了商品的生产,这便是我们说的「耦合 」。耦合会有什么问题?如果需求不怎么变,耦合便耦合了吧。但实际开发中,需求不变基本不可能,所以高耦合一定会增加代码的维护成本,需求越多越难维护

如果此处的商店要增加一百种商品,并且还基于此结构膨胀代码,那么CustomStore类将会变成一个上帝类 ,它包含生产商品、计算价格、售卖等等一系列逻辑,于是一座屎山便诞生了

好,那如果不这样写,应该怎么改呢?让抽象出来的类只做自己职责内的事,不负责任何额外工作。CustomStore是一个商店,就只负责售卖,其他事不做。那么商品在哪里生产呢?不管它,它可以从任何地方生产,那不是CustomStore需要关心的。CustomStore只需要关注两点:卖什么和怎么卖。根据这个思路便可以将代码改成这样:

kotlin 复制代码
class CustomStore {  
    var fruits: List<Fruit>? = null  

    fun sell() {  
        val totalPrice = fruits?.sumOf { it.price() }  
        println("totalPrice = $totalPrice")  
    }  
}

这样便把商品与商店类解耦了是吧?CustomStore不需要关注fruits来自哪里,生产fruits的逻辑可以在外部任何地方,不关心。

而这种代码结构便是依赖注入。哈?这么简单么?对,就这么简单。

再进一步解释下,依赖 它其实就是类中的属性,如上面代码的fruits就是依赖注入 是指类的依赖 是通过构造或方法传入的方式,而不是在本类中new出来。就像是用注射器把注射液「需要传入的对象」用针头扎到身上「构造或方法」注射到身体「类」里,把这个过程称为依赖注入,还是很形象的吧!

那为什么要写这样的代码呢?简单概括就是:降低代码耦合,就是解耦

这样的代码相信大部分同学一定写过,所以大家都用过依赖注入,没用过的应该是依赖注入框架

依赖注入框架做了什么

假设商店东西卖出去之后需要将商品寄送给买家,那么便需要给商品安排一家快递公司,帮忙寄送商品,该如何实现?CustomStore不应该有寄送能力,所以需要将快递能力注入进来,来看实现:

kotlin 复制代码
class CustomStore {  
    var fruits: List<Fruit>? = null  
    var express: Express? = null  

    fun sell() {  
        val totalPrice = fruits?.sumOf { it.price() }  
        println("totalPrice = $totalPrice")  
        express?.delivery()  
        println("sell finished.")  
    }  
}

这样的代码比较清爽,CustomStore只负责自身职责,与其他能力没有耦合,所以代码会方便扩展、维护。不过代码还没完,还有另一半:生产fruits、与express并注入到CustomStore,可能会是这样:

kotlin 复制代码
store.fruits = listOf(Apple(), Banana())  
val aaa = A()  
val bbb = B(a)  
store.express = Express(bbb)
store.sell()

如果生产Express还需要整理其他依赖关系,如果只有一个地方需要注入,还行,否则便可能会出现不少模版代码 。模版代码其实基本上也不影响我们代码的逻辑、性能,但如果有一种方式可以让你的代码减少模版代码会不会更好呢?依赖注入框架就是做这个工作,不同的框架有不同的实现原理,它们有的用 xml 配置,有的用反射、有的用注解等等,但他们都有一样的目的:自动生产依赖所需要的实例,再自动注入需要的类中。可以用一张图描述下:

总结:依赖注入框架让你只需要关注一次某个类型如何生产,不需要关注它怎么注入,需要注入到哪几个类。

Android 常见的依赖注入框架

在早期的时候 Android 是没有依赖注入框架的,那要怎么利用依赖注入实现解耦呢?没办法,手写!

目前 Android 开发指南还有手动依赖注入的介绍。手动依赖项注入 | Android 开发者 | Android Developers (google.cn)如果你不想用依赖注入框架,这也是一种选择,但是写着写着你就会发现你需要写很多重复的模版代码,而这些代码正是依赖注入框架可以代劳的部分。

2012年我们熟悉的 Square 公司发布了大名鼎鼎的依赖注入框架 Dagger ,不过国内估计多数人都没用过这个框架,我们了解更多的是 Dagger2 。但它并不是由 Square 开发,是由 Googele 公司基于 Dagger 的二次开发。

为什么一个框架的后续版本是另一个公司开发的啊?因为 Dagger 虽然非常优秀,但是却存在一个问题。它是基于反射实现的,我们都知道反射的效率比较低,所以会降低运行效率。不过这不是最主要的,比较麻烦的是 Dagger 由于基于反射,所以必须在代码运行之后才知道代码有没有问题,如果测试有遗漏,问题会不容易被发现。所以 Google 基于 Dagger 做二次开发,通过 APT 在编译时生成需要依赖注入的代码,如果使用有问题,在编译阶段就会报错,很容易被发现,这样就能及时将问题暴露出来了。

不过 Dagger2 在国内使用率是不高的,它的上手难度相对 Google 其他的库还是有点高的。所以为了更简化 Android 开发的依赖注入,Google 基于 Dagger2 推出了 Hilt ,下面我们便来聊一聊 Hilt的用法。

添加 Hilt 依赖

首先,将 hilt-android-gradle-plugin 插件添加到项目的根级 build.gradle 文件中:

kotlin 复制代码
plugins {
  ...
  id("com.google.dagger.hilt.android") version "2.44" apply false
}

然后,应用 Gradle 插件并在 app/build.gradle 文件中添加以下依赖项:

kotlin 复制代码
plugins {
  id 'kotlin-kapt'
  id("com.google.dagger.hilt.android")
}

android {
  ...
}

dependencies {
  implementation("com.google.dagger:hilt-android:2.44")
  kapt("com.google.dagger:hilt-android-compiler:2.44")
}

// Allow references to generated code
kapt {
  correctErrorTypes = true
}

Hilt 使用 Java 8 功能。如需在项目中启用 Java 8,请将以下代码添加到 app/build.gradle 文件中:

ini 复制代码
android {
  ...
  compileOptions {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
  }
}

Hilt 简单应用

首先,如果要使用 Hilt ,必须要有一个自定义的 Application 类,并为其添加注解@HiltAndroidApp

kotlin 复制代码
@HiltAndroidApp
class ExampleApplication : Application() { ... }

@HiltAndroidApp 会触发 Hilt 的代码生成操作,生成的代码包括应用的一个基类,该基类充当应用级依赖项容器。如果不添加此注解,Hilt 将不会工作。

Hilt 大幅减幅了 Dagger2的用法,但是也限制了注入功能只能从几个 Anroid 固定的入口点开始。

Hilt 目前支持以下 Android 类:

  • Application(通过使用 @HiltAndroidApp)
  • ViewModel(通过使用 @HiltViewModel)
  • Activity
  • Fragment
  • View
  • Service
  • BroadcastReceiver

在 Application 类中设置了 Hilt 且有了应用级组件后,Hilt 可以为带有 @AndroidEntryPoint 注解的其他 Android 类提供依赖项,比如:

kotlin 复制代码
@AndroidEntryPoint
class ExampleActivity : ComponentActivity() { ... }

此时,我们便可以通过 HiltExampleActivity 类中注入想要的对象了,比如我们需要注入 CustomStore 这个类,那便可以这样写:

kotlin 复制代码
@AndroidEntryPoint
class ExampleActivity : ComponentActivity() {

  @Inject lateinit var customStore: CustomStore

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {}
    customStore.sell()
  }
}

@Inject lateinit var customStore: CustomStore 这行代码是告诉 Hilt 这里需要一个 CustomStore,它怎么来我不关心,我只管跟你 Hilt 要。

需要说明下,需要 Hilt 注入的字段可见性不能声明为 private

现在 Hilt 知道了 ExampleActivity 需要注入 CustomStore类型的对象,但是还不知道要怎么新建它,所以我们还要告诉 Hilt 怎么创建CustomStore类型的对象:

kotlin 复制代码
class CustomStore @Inject constructor() {
  fun sell() {
    println("sell all goods")
  }
}

这样就是告诉 Hilt CustomStore可以通过构造函数来创建。好了,现在便可以运行了,就这么简单。

简单是简单,但如果需要注入类型的构造是有参数的,应该怎么做呢?

比如CustomStore有一个 Express参数:

kotlin 复制代码
class CustomStore @Inject constructor(express: Express) {
  fun sell() {
    println("sell all goods")
  }
}

其实这个问题并不复杂,道理跟之前还是一样。ExampleActivity 需要注入 CustomStore,所以告诉 Hilt 应该怎么创建 CustomStore。而创建 CustomStore 需要 Express,所以通知 Hilt 应该怎么创建 Express 就行:

kotlin 复制代码
class Express @Inject constructor(){
  fun deliver() {
    println("deliver all goods")
  }
}

这样我们的代码就能正常运行了。

总结一下:Hilt 生成依赖代码时会去分析整条链路上的依赖关系,只有所有依赖都提供了声明方式才能完成这次注入。这个依赖关系其实就是 Dagger(directed acyclic graph 有向无环图) 名字的由来。

当不能通过构造注入依赖时

接口

我们知道类都有构造函数,我们可以通过构造函数去注入依赖,所以很好理解。而接口是不能实例化的,那我们应该怎么注入相应的依赖呢?别担心,这是非常常见的需求场景,所以 Hilt 也有非常好的支持,我们来看一个接口:

kotlin 复制代码
interface Express{
  fun deliver()
}

很简单,一个快递公司接口,有一个运送方法。

我们再定义两家快递公司,顺丰和京东,这两家应该是国内服务最好的快递公司了:

kotlin 复制代码
class SFExpress : Express {
  override fun deliver() {
    println("SFExpress deliver")
  }
}

class JDExpress : Express {
  override fun deliver() {
    println("JDExpress deliver")
  }
}

再看一眼之前的例子:

kotlin 复制代码
class CustomStore @Inject constructor(express: Express) {
  fun sell() {
    println("sell all goods")
  }
}

之前的 Express 是一个普通类,所以它可以通过构造被注入进来,但现在 Express 是一个接口,很显然没办法通过构造注入了。那应该怎么办?我们一步步来

第一步:虽然接口不能通过构造实例化,但是它的实现类可以啊,所以我们先将实现类的构造添加 @Inject 注解

kotlin 复制代码
class SFExpress @Inject constructor() : Express {
  override fun deliver() {
    println("SFExpress deliver")
  }
}

class JDExpress @Inject constructor(): Express {
  override fun deliver() {
    println("JDExpress deliver")
  }
}

第二步:需要使用 Hilt 模块向 Hilt 提供绑定信息。Hilt 模块是一个带有 @Module 注解的类。与 Dagger 模块一样,它会告知 Hilt 如何提供某些类型的实例。与 Dagger 模块不同的是,您必须使用 @InstallIn(在后文中会有讲解) 为 Hilt 模块添加注解,以告知 Hilt 每个模块将用在或安装在哪个 Android 类中。

另外,以 Express 为例。如果 Express 是一个接口,将无法通过构造函数注入它,所以需要向 Hilt 提供绑定信息,方法是在 Hilt 模块内创建一个带有 @Binds 注解的抽象函数,代码如下:

kotlin 复制代码
@Module
@InstallIn(ActivityComponent::class)
abstract class ExpressModule {

    @Binds
    abstract fun bindExpress(sfExpress: SFExpress): Express
}

这样我们就创建了一个模块,这个模块可以提供Express的实例。这里的 ExpressModule是一个抽象类,因为不需要具体的实现。我们订一个了一个抽象方法,这个方法名不关键,没有约束。我们需要关注两件事:

  1. 方法的返回类型必须是你需要注入的类型,此处是 Express
  2. 方法的参数是什么,最终就会返回什么实现类型,此处是 SFExpress

最后在方法上添加@Binds注解,此时 Hilt 框架便能知道如何注入 Express实例到CustomStore类中了。

为同一个类型提供多个实例

前面的示例 Express 有两个实现类,我们刚完成了SFExpress的注入,那如果现在两个子类型都需要注入应该怎么做呢?再加一个 bindXXX函数可以么

kotlin 复制代码
@Module
@InstallIn(ActivityComponent::class)
abstract class ExpressModule {

  @Binds
  abstract fun bindSFExpress(sfExpress: SFExpress): Express

  @Binds
  abstract fun bindJDExpress(sfExpress: JDExpress): Express
}

其实这样是不行的,这样相当于是Express的依赖被提供了两种方式,Hilt 就闷逼了,不知道该选哪个了。所以如果这样写,在编译的时候便会抛出异常。

那应该怎么解决呢?此时就需要新的知识点了,@Qualifier注解。

Qualifier的作用就是专门用来区分相同类型的不同实现,具体怎么做,我们看代码:

kotlin 复制代码
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class ShunFengExpress

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class JingDongExpress

我们定义了两个注解:ShunFengExpressJingDongExpress,这两个注解分别代表两个不同的实现,然后我们再将其加到之前的两个 bindXXX方法上:

kotlin 复制代码
@Module
@InstallIn(ActivityComponent::class)
abstract class ExpressModule {

  @ShunFengExpress
  @Binds
  abstract fun bindSFExpress(sfExpress: SFExpress): Express

  @JingDongExpress
  @Binds
  abstract fun bindJDExpress(sfExpress: JDExpress): Express
}

这样我们便为Express的两个实现进行了分类,然后我们就可以在需要依赖的地方指定需要注入的具体类型

kotlin 复制代码
class CustomStore @Inject constructor(@ShunFengExpress express: Express) {
  fun sell() {
    println("sell all goods")
  }
}

class OtherStore @Inject constructor(@JingDongExpress express: Express) {
  fun sell() {
    println("sell all goods")
  }
}

如此,我们便可以任意指定我们需要的Express了。CustomStore 需要依赖 SFExpress就需要加上@ShunFengExpress注解,OtherStore 需要 JDExpress 就需要加上 @JingDongExpress注解。 这样是不是就完美解决了我们的需求!

为第三方库添加依赖注入

前面我们的示例都是通过构造和 @Inject 注解来注入依赖,但是如果我们用第三库是没有办法给三方库加注解的,这也是很常见的场景,我们怎么做呢?

它的注入方式跟前面说的给接口类型添加依赖注入有点类似,不完全一样。区别是接口类型是有实现类的,所以还是通过实现类的构造来提供实例对象,第三方则没有,需要我们手动写出如何创建对象的代码。方法是在 Hilt 模块内创建一个函数,并使用 @Provides 为该函数添加注解。

举个例子,比如我们需要通过Retrofit获取到 ApiService处理网络请求:

kotlin 复制代码
@Module
@InstallIn(ActivityComponent::class)
class ApiModule {
  @Provides
  fun provideApiService(
      // 如果有参数,则需要提供参数相关的依赖方式
  ): AnalyticsService {
      return Retrofit.Builder()
               .baseUrl("https://example.com")
               .build()
               .create(ApiService::class.java)
  }
}

带有注解的函数会向 Hilt 提供以下信息:

  • 函数返回类型会告知 Hilt 函数提供哪个类型的实例。
  • 函数参数会告知 Hilt 相应类型的依赖项。
  • 函数主体会告知 Hilt 如何提供相应类型的实例。每当需要提供该类型的实例时,Hilt 都会执行函数主体。

这样便可以在需要依赖的的地方方便注入了,比如在 ExampleActivity 中使用,跟添加其他依赖一样:

less 复制代码
@AndroidEntryPoint
class ExampleActivity : ComponentActivity() {

  @Inject lateinit var customStore: CustomStore
  
  @Inject lateinit var apiService: ApiService
  
  ......
}

这样便完成了第三方库的依赖注入。

Hilt 内置组件

前面在介绍 Hilt 如何为接口、第三方库添加依赖时有提到 @InstallIn注解,当时没有解释,现在我们来说下。顾名思义,InstallIn就是指安装到哪里,比如@InstallIn(ActivityComponent::class),就表示将这个模块「xxxModule」安装到 Activity 中。就是说,在 Activity 组件中可以提供这个模块的所有依赖,其他组件则不行。比如上面提到的 ApiService,如果我想在 Service 组件中注入就不行。

Hilt提供了以下几种组件:

Hilt 组件 注入器面向的对象
SingletonComponent Application
ActivityRetainedComponent 不适用
ViewModelComponent ViewModel
ActivityComponent Activity
FragmentComponent Fragment
ViewComponent View
ViewWithFragmentComponent 带有 @WithFragmentBindings 注解的 View
ServiceComponent Service

组件的生命周期:

生成的组件 创建时机 销毁时机
SingletonComponent Application#onCreate() Application 已销毁
ActivityRetainedComponent Activity#onCreate() Activity#onDestroy()
ViewModelComponent ViewModel 已创建 ViewModel 已销毁
ActivityComponent Activity#onCreate() Activity#onDestroy()
FragmentComponent Fragment#onAttach() Fragment#onDestroy()
ViewComponent View#super() View 已销毁
ViewWithFragmentComponent View#super() View 已销毁
ServiceComponent Service#onCreate() Service#onDestroy()

组件作用域

默认情况下,Hilt 中的所有绑定都未限定作用域。**这意味着,每当应用请求绑定时,Hilt 都会创建所需类型的一个新实例。

比如每当 Hilt 提供 CustomStore 作为其他类型的依赖项或通过字段注入提供它(如在 ExampleActivity 中)时,Hilt 都会提供 CustomStore 的一个新实例。

不过,Hilt 也允许将绑定的作用域限定为特定组件。Hilt 只为绑定作用域限定到的组件的每个实例创建一次限定作用域的绑定,对该绑定的所有请求共享同一实例。

下表列出了生成的每个组件的作用域注解:

Android 类 生成的组件 作用域
Application SingletonComponent @Singleton
Activity ActivityRetainedComponent @ActivityRetainedScoped
ViewModel ViewModelComponent @ViewModelScoped
Activity ActivityComponent @ActivityScoped
Fragment FragmentComponent @FragmentScoped
View ViewComponent @ViewScoped
带有 @WithFragmentBindings 注解的 View ViewWithFragmentComponent @ViewScoped
Service ServiceComponent @ServiceScoped

如果使用 @ActivityScopedCustomStore 的作用域限定为 ActivityComponent,Hilt 会在相应 activity 的整个生命周期内提供 CustomStore 的同一实例:

kotlin 复制代码
@ActivityScoped
class CustomStore @Inject constructor(@ShunFengExpress express: Express) {
  fun sell() {
    println("sell all goods")
  }
}

如果某个依赖,在任何地方都应该只用同一个实例,那便可以使用 @Singleton 来限定,本文就不再举例了。

最后

只要你写代码,就一定需要依赖注入,我们能选择的是要不要用某个依赖注入框架。在 Google 的架构指南里面是强烈建议我们使用依赖注入框架,由于 Dagger 的学习曲线比较曲折,所以 Google 在 Dagger2 的基础上实现了一个针对 Android 极简版的依赖注入框架。主要使用方式就是如上文所述,相信还是不复杂的,希望本文能对大家学习 Hilt 有所帮助,如果有想法也欢迎评论或私信交流。

相关推荐
安冬的码畜日常8 分钟前
【D3.js in Action 3 精译_027】3.4 让 D3 数据适应屏幕(下)—— D3 分段比例尺的用法
前端·javascript·信息可视化·数据可视化·d3.js·d3比例尺·分段比例尺
l1x1n036 分钟前
No.3 笔记 | Web安全基础:Web1.0 - 3.0 发展史
前端·http·html
昨天;明天。今天。1 小时前
案例-任务清单
前端·javascript·css
夜流冰1 小时前
工具方法 - 面试中回答问题的技巧
面试·职场和发展
GEEKVIP2 小时前
手机使用技巧:8 个 Android 锁屏移除工具 [解锁 Android]
android·macos·ios·智能手机·电脑·手机·iphone
zqx_72 小时前
随记 前端框架React的初步认识
前端·react.js·前端框架
惜.己2 小时前
javaScript基础(8个案例+代码+效果图)
开发语言·前端·javascript·vscode·css3·html5
什么鬼昵称3 小时前
Pikachu-csrf-CSRF(get)
前端·csrf
长天一色3 小时前
【ECMAScript 从入门到进阶教程】第三部分:高级主题(高级函数与范式,元编程,正则表达式,性能优化)
服务器·开发语言·前端·javascript·性能优化·ecmascript
NiNg_1_2343 小时前
npm、yarn、pnpm之间的区别
前端·npm·node.js