Kotlin 中使用 Hilt 的开发实践

2,735 阅读8分钟

Hilt 是基于 Dagger 开发的全新的依赖项注入代码库,它简化了 Android 应用中 Dagger 的调用方式。本文通过简短的代码片段为您展示其核心功能以帮助开发者们快速入门 Hilt。

配置 Hilt

如需在应用中配置 Hilt,请先参考 Gradle Build Setup

完成安装全部的依赖和插件以后,仅需在您的 Application 类之前添加 @HiltAndroidApp 注解即可开始使用 Hilt,而无需其它操作。

@HiltAndroidApp
class App : Application()

定义并且注入依赖项

当您写代码用到依赖项注入的时候,有两个要点需要考虑:

  1. 您需要注入依赖项的类;
  2. 可以作为依赖项进行注入的类。

而上述这两点并不互斥,而且在很多情况下,您的类既可以注入依赖项同时也包含依赖。

使依赖项可注入

如果需要在 Hilt 中使某个类变得可注入,您需要告诉 Hilt 如何创建该类的实例。该过程叫做绑定 (bindings)。

在 Hilt 中定义绑定有三种方式:

  1. 在构造函数上添加 @Inject 注解;
  2. 在模块上使用 @Binds 注解;
  3. 在模块上使用 @Provides 注解。

⮕ 在构造函数上使用 @Inject 注解

任何类的构造函数都可以添加 @Inject 注解,这样该类在整个工程中都可以作为依赖进行注入。

class OatMilk @Inject constructor() {
  ...
  }

⮕ 使用模块

在 Hilt 中另外两种将类转为可注入的方法是使用模块。

Hilt 模块 就好像 "菜谱",它可以告诉 Hilt 如何创建那些不具备构造函数的类的实例,比如接口或者系统服务。

此外,在您的测试中,任何模块都可以被其它模块所替代。这有利于使用 mock 替换接口实现。

模块通过 @InstallIn 注解被安装在特定的 Hilt 组件 中。这一部分我会在后面详细介绍。

选项 1: 使用 @Binds 为接口创建绑定

如果您希望在需要 Milk 时候,使用 OatMilk 在代码中取而代之,那么可以在模块中创建一个抽象方法,然后为该方法添加 @Binds 注解。注意 OatMilk 本身必须是可注入的,仅需在 OatMilk 的构造函数上添加 @Inject 注解即可。

interface Milk { ... }

class OatMilk @Inject constructor(): Milk {
  ...
}

@Module
@InstallIn(ActivityComponent::class)
abstract class MilkModule {
  @Binds
  abstract fun bindMilk(oatMilk: OatMilk): Milk
}

选项 2: 使用 @Provides 来创建工厂函数

当实例无法被直接创建,您可以创建一个 provider。provider 就是可以返回对象实例的工厂函数。

一个典型的例子就是系统服务,比如 ConnectivityManager,它们的实例需要通过 Context 对象来返回。

@Module
@InstallIn(ApplicationComponent::class)
object ConnectivityManagerModule {
  @Provides
  fun provideConnectivityManager(
    @ApplicationContext context: Context
  ) = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
}

只要使用注解 @ApplicationContext 或者 @ActivityContextContext 对象就是默认可注入的。

注入依赖

当依赖可注入后,您可以使用 Hilt 通过两种方式:

  1. 作为构造函数的参数注入;
  2. 作为字段注入。

⮕ 作为构造函数参数注入

interface Milk { ... }
interface Coffee { ... }

class Latte @Inject constructor(
  private val Milk milk,
  private val Coffee coffee
) {
  ...
}

如果构造函数使用了注解 @Inject,Hilt 会根据您为类型所定义的绑定来注入所有的参数。

⮕ 作为字段注入

interface Milk { ... }
interface Coffee { ... }

@AndroidEntryPoint
class LatteActivity : AppCompatActivity() {
  @Inject lateinit var milk: Milk
  @Inject lateinit var coffee: Coffee

  ...
}

如果类是入口点,这里特指使用了 @AndroidEntryPoint 注解的类 (后面章节会详细介绍),那么该类中所有包含 @Inject 注解的字段均会被注入。

使用 @Inject 注解的字段必须是 public 类型的。也可以添加 lateinit 来避免字段空值,因为它们在注入之前的初始值就是 null

请注意作为字段注入依赖项的场景仅仅适合类必须包含无参构造函数的情况,比如 Activity。在大多数场景下,您更应通过构造函数的参数来注入依赖项。

其它重要的概念

入口点

还记得我在上文里提到,在很多情况下,您的类会在通过依赖注入创建的同时包含被注入的依赖项。有些情况下,您的类可能不是通过依赖项注入来创建,但是仍然会被注入依赖项。一个典型的例子就是 activity,它是由 Android 框架内部创建的,而不是由 Hilt 创建。

这些类属于 Hilt 依赖图谱的 入口点,而且 Hilt 需要知道这些类包含要注入的依赖。

⮕ Android 入口点

大部分入口点是所谓的 Android 入口点:

  • Activity

  • Fragment

  • View

  • Service

  • BroadcastReceiver

如果是 Android 入口点,请添加 @AndroidEntryPoint 注解。

@AndroidEntryPoint
class LatteActivity : AppCompatActivity() {
  ...
}

⮕ 其它入口点

Android 入口点对于大多数应用已经足够,但是如果您使用了不含有 Dagger 的库或者尚未在 Hilt 中支持的 Android 组件,那么您可能需要创建您自己的入口点来手动访问 Hilt 依赖图谱。详情请查看 将任意类转换为入口点

ViewModel

ViewModel 是一个特例: 因为框架会创建它们,它既不是被直接实例化的,也不是 Android 入口点。ViewModel 需要使用特殊的 @HiltViewModel 注解,当 ViewModel 通过 byViewModels() 创建的时候,该注解使 Hilt 能够向 ViewModel 注入依赖,和其它类的 @Inject 注解的原理相似。

interface Milk { ... }
interface Coffee { ... }

@HiltViewModel
class LatteViewModel @Inject constructor(
  private val milk: Milk,
  private val coffee: Coffee
) : ViewModel() {
  ...
}

@AndroidEntryPoint
class LatteActivity : AppCompatActivity() {
  private val viewModel: LatteViewModel by viewModels()
  ...
}

如果您需要访问 ViewModel 已缓存的状态,可以添加 @Assisted 注解,将 SavedStateHandle 作为构造函数参数进行注入。

@HiltViewModel
class LatteViewModel @Inject constructor(
  @Assisted private val savedState: SavedStateHandle,
  private val milk: Milk,
  private val coffee: Coffee
) : ViewModel() {
  ...
}

要使用 @ViewModelInject,您可能需要添加更多依赖。更多详细内容请详见 Hilt 和 Jetpack 集成指南

组件

各个模块都是安装在 Hilt 组件 中的,通过 @InstallIn(<组件名>) 指定。模块的组件主要用于防止意外将依赖注入到错误的位置。比如,@InstallIn(ServiceComponent.class) 可以防止注解所修饰的模块中的 binding 和 provider 被 activity 调用。

此外,binding 的作用域会被限制在组件所属的整个模块。也就是接下来我们要讲的...

作用域

默认情况下,绑定都未被限定作用域。正如上面的示例,意味着每次注入 Milk 的时候,您都可以获得一个新的 OatMilk 实例。如果添加了 @ActivityScoped 注解,那么您会将绑定的作用域限制到 ActivityComponent

@Module
@InstallIn(ActivityComponent::class)
abstract class MilkModule {
  @ActivityScoped
  @Binds
  abstract fun bindMilk(oatMilk: OatMilk): Milk
}

现在您的模块被限制作用域了,Hilt 在每个 activity 实例中仅创建一个 OatMilk 实例。此外,OatMilk 实例会绑定到 activity 的生命周期中——当 activity 的 onCreate() 被调用的时候,它会被创建,而当 activity 的 onDestroy() 被调用的时候,它会被销毁。

@AndroidEntryPoint
class LatteActivity : AppCompatActivity() {
  @Inject lateinit var milk: Milk
  @Inject lateinit var moreMilk: Milk //这里的实例和上面的相同

  ...
}

在本例中,milkmoreMilk 指向同一个 OatMilk 实例。然而,如果您有多个 LatteActivity 实例,它们会包含各自的 OatMilk 实例。

相应的,其它被注入到该 activity 的依赖,它们的作用域是一致的。因此它们也会引用到相同的 OatMilk 实例:

// Milk 实例的创建会在 Fridge 存在之前,因为它被绑定到了 activity 的生命周期中
class Fridge @Inject constructor(private val Milk milk) { ... }

@AndroidEntryPoint
class LatteActivity : AppCompatActivity() {
  // 下面四项共享了同一个 Milk 实例
  @Inject lateinit var milk: Milk
  @Inject lateinit var moreMilk: Milk
  @Inject lateinit var fridge: Fridge
  @Inject lateinit var backupFridge: Fridge

  ...
}

作用域依赖于您的模块所安装的组件,比如 @ActivityScoped 仅仅用于在 ActivityComponent 安装的模块内的绑定。

作用域同样决定了注入实例的生命周期: 在本例中,被 FridgeLatteActivity 使用的 Milk 的单独实例会在 LatteActivityonCreate() 被调用的时候被创建——而当 onDestroy() 被调用的时候被销毁。这也意味着当配置发生改变的时候,Milk 不会 "幸免",因为配置发生改变的时候会调用 activity 的 onDestroy()。您可以通过使用生命周期更长的作用域来避免该问题,比如使用 @ActivityRetainedScope

如果想要了解可用的作用域列表、相关的组件以及所遵循的生命周期,请参见 Hilt 组件

Provider 注入

有些时候您希望能够更加直接地控制注入实例的创建。比如,您可能希望基于业务逻辑,注入某个类型的一个实例或者几个实例。针对这样的场景,您可以使用 dagger.Provider:

class Spices @Inject constructor() { ... }

class Latte @Inject constructor(
  private val spiceProvider: Provider<Spices>
) {
  fun addSpices() {
    val spices = spiceProvider.get()// 创建 Spices 的新实例
    ...
  }
}

provider 注入可以忽略具体的依赖类型以及注入的方式。任何可被注入的内容均可以封装在 Provider<...> 中来使用 provider 注入的方式。

依赖注入框架 (像 Dagger 和 Guice) 通常被用于大型且复杂的项目。而 Hilt 既容易上手,配置起来又非常简单,同时作为独立的代码包,还兼顾了 Dagger 中可被各种类型应用,无论代码规模大小,均可兼容的强大特性。

如果您希望了解更多关于 Hilt 的内容、它的工作原理,以及其它对您来说有用的特性,请移步官方网站,了解更多详细的介绍和参考文档