在 Android 应用中使用 Hilt

在本 Codelab 中,您将了解在打造可靠且能够顺利发展为大型项目的应用时,依赖项注入 (DI) 的重要性。我们将使用 Hilt 作为依赖项注入工具来管理依赖项。

依赖项注入是一种在编程中运用广泛的技术,非常适用于 Android 开发。遵循依赖项注入的原则可以为良好的应用架构奠定基础。

实现依赖项注入可为您带来以下优势:

  • 重用代码
  • 易于重构
  • 易于测试

Hilt 是 Android 颇具特色的依赖项注入库,可减少在项目中使用手动依赖项注入时产生的样板代码。手动依赖项注入要求您手动构造每个类及其依赖项,并借助容器来重复使用和管理依赖项。

Hilt 通过为项目中的每个 Android 组件提供容器并自动为您管理容器生命周期,提供了一种在应用中执行依赖项注入的标准方法。这通过利用热门依赖项注入库 Dagger 实现。

如果在此 Codelab 的操作过程中遇到任何问题(代码错误、语法错误、措辞含义不明等),欢迎通过 Codelab 左下角的“报告错误”链接向我们报告相应问题。

前提条件

  • 您有 Kotlin 语法经验。
  • 您了解依赖项注入为什么对应用至关重要。

学习内容

  • 如何在 Android 应用中使用 Hilt。
  • 与打造可持续发展的应用相关的 Hilt 概念。
  • 如何使用限定符为同一类型添加多个绑定。
  • 如何使用 @EntryPoint 从 Hilt 不支持的类访问容器。
  • 如何使用单元测试和插桩测试来测试使用 Hilt 的应用。

所需条件

  • Android Studio 4.0 或更高版本。

获取代码

从 GitHub 获取 Codelab 代码:

$ git clone https://github.com/googlecodelabs/android-hilt

或者,您可以下载 Zip 文件形式的代码库:

下载 Zip 文件

打开 Android Studio

本 Codelab 需要 Android Studio 4.0 或更高版本。如果您需要下载 Android Studio,可以在此处下载。

运行示例应用

在本 Codelab 中,您将向记录用户互动并使用 Room 将数据存储到本地数据库的应用添加 Hilt。

按照下列说明在 Android Studio 中打开示例应用。

  • 如果您是下载的 zip 压缩档,请在本地解压缩文件。
  • 在 Android Studio 中打开项目。
  • 点击 execute.png Run 按钮,然后选择模拟器或连接 Android 设备。

如您所见,每当您与某个有编号的按钮互动时,系统都会创建并存储一条日志。在 See All Logs 屏幕中,您将看到之前所有互动的列表。如需移除日志,请点按 Delete Logs 按钮。

项目设置

本项目在多个 GitHub 分支中构建:

  • master 是您签出或下载的分支,也是本 Codelab 的起点。
  • solution 包含本 Codelab 的解决方案。

建议您从 master 分支中的代码着手,按照自己的节奏逐步完成 Codelab。

在本 Codelab 中,系统会为您显示需要添加到项目的代码段。在某些地方,您还需要移除代码,我们将在代码段的注释中明确标出这部分内容。

如需使用 Git 获取 solution 分支,请使用以下命令:

$ git clone -b solution https://github.com/googlecodelabs/android-hilt

或从此处下载解决方案代码:

下载最终代码

常见问题解答

为什么选择 Hilt?

如果您查看起始代码,会看到 ServiceLocator 类的一个实例存储在 LogApplication 类中。ServiceLocator 会创建并存储依赖项,供需要它的类按需获取。您可以将其视为依赖项的容器,这种容器附着于应用的生命周期,因为当应用不存在时它将随之销毁。

Android 依赖项注入指南中所述,若使用服务定位器,一开始样板代码较少,但随着代码规模逐渐增长,会出现一些问题。如需开发大型 Android 应用,您应使用 Hilt。

在 Android 应用中使用手动依赖项注入或服务定位器模式需要样板代码,而 Hilt 通过生成本应由您手动创建的代码(例如,ServiceLocator 类中的代码),消除了不必要的样板代码。

在接下来的步骤中,您将使用 Hilt 来替换 ServiceLocator 类。然后,我们会为该项目添加新功能,以探索 Hilt 的更多功能。

在项目中使用 Hilt

Hilt 已在 master 分支(您下载的代码)中进行配置。您无需将以下代码添加到项目中,因为我们已代您执行该操作。尽管如此,我们还是来看看在 Android 应用中使用 Hilt 需要做些什么。

除了库依赖项之外,Hilt 还会使用在项目中配置的 Gradle 插件。打开根 build.gradle 文件,并在类路径中找到以下 Hilt 依赖项:

buildscript {
    ...
    ext.hilt_version = '2.28-alpha'
    dependencies {
        ...
        classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
    }
}

然后,为在 app 模块中使用 gradle 插件,我们应在 app/build.gradle 文件中指定它,具体方法为:将 gradle 插件添加到此文件的顶部、kotlin-kapt 插件之下:

...
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'

android {
    ...
}

最后,Hilt 依赖项会包含在项目的同一个 app/build.gradle 文件中:

...
dependencies {
    ...
    implementation "com.google.dagger:hilt-android:$hilt_version"
    kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
}

在您构建和同步项目时会下载包括 Hilt 在内的所有库。让我们开始使用 Hilt!

LogApplication 类中 ServiceLocator 的实例的使用和初始化方式类似,要添加附着于应用的生命周期的容器,我们需要为 Application 类添加 @HiltAndroidApp 注解。打开 LogApplication.kt 并为该类添加注解:

@HiltAndroidApp
class LogApplication : Application() {
    ...
}

@HiltAndroidApp 会触发 Hilt 的代码生成操作,生成的代码包括应用的一个基类,该基类可使用依赖项注入。application 容器是应用的父级容器,这意味着其他容器可以访问它提供的依赖项。

现在,应用已经可以开始使用 Hilt 了!

我们将使用 Hilt 提供依赖项,而不是从类的 ServiceLocator 中按需抓取依赖项。接下来,我们开始替换对类中 ServiceLocator 的调用。

打开 ui/LogsFragment.kt 文件。LogsFragment 会在 onAttach 中填充其字段。我们可以使用 Hilt 来创建和管理 LoggerLocalDataSourceDateFormatter 的实例,而不是使用 ServiceLocator 手动填充这些类型的实例。

如要让 LogsFragment 使用 Hilt,我们需要为其添加 @AndroidEntryPoint 注解。

@AndroidEntryPoint
class LogsFragment : Fragment() {
    ...
}

为 Android 类添加 @AndroidEntryPoint 注解会创建一个沿袭 Android 类生命周期的依赖项容器。

利用 @AndroidEntryPoint,Hilt 可创建附着于 LogsFragment 生命周期的依赖项容器,并能够将实例注入 LogsFragment。如何让 Hilt 进行字段注入?

对于要进行注入的字段(例如 loggerdateFormatter),我们可以利用 @Inject 注解让 Hilt 注入不同类型的实例

@AndroidEntryPoint
class LogsFragment : Fragment() {

    @Inject lateinit var logger: LoggerLocalDataSource
    @Inject lateinit var dateFormatter: DateFormatter

    ...
}

这就是所谓的字段注入

由于 Hilt 将负责填充这些字段,因此我们不再需要 populateFields 方法。让我们从类中移除该方法:

@AndroidEntryPoint
class LogsFragment : Fragment() {

    // Remove following code from LogsFragment

    override fun onAttach(context: Context) {
        super.onAttach(context)

        populateFields(context)
    }

    private fun populateFields(context: Context) {
        logger = (context.applicationContext as LogApplication).serviceLocator.loggerLocalDataSource
        dateFormatter =
            (context.applicationContext as LogApplication).serviceLocator.provideDateFormatter()
    }

    ...
}

在后台,Hilt 将使用自动生成的 LogsFragment 依赖项容器中内置的实例在 onAttach() 生命周期方法中填充这些字段。

要执行字段注入,Hilt 需要知道如何提供这些依赖项的实例!在本例中,Hilt 需要知道如何提供 LoggerLocalDataSourceDateFormatter 的实例。但是,Hilt 还不知道如何提供这些实例。

告知 Hilt 如何通过 @Inject 提供依赖项

打开 ServiceLocator.kt 文件,以了解 ServiceLocator 是如何实现的。您可以看看为什么调用 provideDateFormatter() 总是返回 DateFormatter 的不同实例。

这与我们希望通过 Hilt 实现的行为完全相同。幸运的是,DateFormatter 不依赖其他类,因此目前我们无需考虑传递依赖项。

如要告知 Hilt 如何提供类型的实例,请向要注入的类的构造函数添加 @Inject 注解

打开 util/DateFormatter.kt 文件并为 DateFormatter 的构造函数添加 @Inject 注解。请注意,要在 Kotlin 中为构造函数添加注解,您还需要 constructor 关键字:

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

通过它,Hilt 便会知道如何提供 DateFormatter 的实例。需要针对 LoggerLocalDataSource 执行相同的操作。打开 data/LoggerLocalDataSource.kt 文件并为其构造函数添加 @Inject 注解:

class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
    ...
}

如果再次打开 ServiceLocator 类,可以看到有一个公开的 LoggerLocalDataSource 字段。这意味着,无论何时调用 ServiceLocator,它始终返回 LoggerLocalDataSource 的相同实例。这就是所谓的“将实例的作用域限定为容器”。在 Hilt 中如何实现这一点?

我们可以使用注解将实例的作用域限定为容器。由于 Hilt 可以生成具有不同生命周期的不同容器,因此有不同的注解可用于将作用域限定为这些容器。

将实例的作用域限定为 application 容器的注解是 @Singleton。该注解将使 application 容器始终提供相同的实例,无论相应类型是否用作其他类型的依赖项,也无论它是否需要字段注入。

可以将同一逻辑应用到附着于 Android 类的所有容器。您可以在该文档中找到所有限定作用域的注解列表。例如,如果您希望 activity 容器始终提供某类型的相同实例,则可以为该类型添加 @ActivityScoped 注解。

如上所述,由于我们希望 application 容器始终提供 LoggerLocalDataSource 的相同实例,因此为其类添加 @Singleton 注解:

@Singleton
class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
    ...
}

现在,Hilt 知道如何提供 LoggerLocalDataSource 实例了。但是,这一次,该类型具有传递依赖项!如要提供 LoggerLocalDataSource 实例,Hilt 还需要知道如何提供 LogDao 实例。

不过,由于 LogDao 是一个接口,而接口没有构造函数,因此我们无法为其构造函数添加 @Inject 注解。该怎么告知 Hilt 如何提供该类型的实例?

模块用于向 Hilt 添加绑定,换句话说,用于告知 Hilt 如何提供不同类型的实例。在 Hilt 模块中,您可以为无法通过构造函数注入的类型(例如接口或未包含在您项目中的类)添加绑定。这种类型的一个示例是 OkHttpClient,您需要使用其构建器来创建实例。

Hilt 模块是带有@Module@InstallIn 注解的类。@Module 会告知 Hilt 这是一个模块,而 @InstallIn 会通过指定 Hilt 组件告知 Hilt 绑定在哪些容器中可用。您可以将 Hilt 组件视为容器,如需查看组件的完整列表,请点击此处

对于每个可被 Hilt 注入的 Android 类,都有一个关联的 Hilt 组件。例如,Application 容器与 ApplicationComponent 相关联,而 Fragment 容器与 FragmentComponent 相关联。

创建模块

我们来创建一个可添加绑定的 Hilt 模块。在 hilt 文件包下创建一个名为 di 的新文件包,并在该文件包中创建一个名为 DatabaseModule.kt 的新文件。

由于 LoggerLocalDataSource 的作用域限定为 application 容器,因此 LogDao 绑定需要在 application 容器中可用。我们通过传递与其相关联的 Hilt 组件的类(即 ApplicationComponent:class),使用 @InstallIn 注解指定该要求:

package com.example.android.hilt.di

@InstallIn(ApplicationComponent::class)
@Module
object DatabaseModule {

}

ServiceLocator 类实现中,LogDao 的实例通过调用 logsDatabase.logDao() 来获取。因此,为了提供 LogDao 的实例,我们在 AppDatabase 类上创建了一个传递依赖项。

使用 @Provides 提供实例

我们可以在 Hilt 模块中为函数添加 @Provides 注解,告知 Hilt 如何提供无法通过构造函数注入的类型。

每当 Hilt 需要提供相应类型的实例时,都会执行带有 @Provides 注解的函数的函数主体。带有 @Provides 注解的函数的返回值类型会告知 Hilt 绑定的类型,即如何提供该类型的实例。函数参数是该类型的依赖项。

在本例中,我们将此函数包含在 DatabaseModule 类中:

@Module
object DatabaseModule {

    @Provides
    fun provideLogDao(database: AppDatabase): LogDao {
        return database.logDao()
    }
}

上述代码告知 Hilt 当提供 LogDao 的实例时需要执行 database.logDao()。由于我们将 AppDatabase 作为传递依赖项,因此还需要告知 Hilt 如何提供该类型的实例。

AppDatabase 类由 Room 生成,我们的项目也不拥有该类,因此我们也能够以类似于在 ServiceLocator 类中构建数据库实例的方式,使用 @Provides 函数提供它。

@Module
object DatabaseModule {

    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext appContext: Context): AppDatabase {
        return Room.databaseBuilder(
            appContext,
            AppDatabase::class.java,
            "logging.db"
        ).build()
    }

    @Provides
    fun provideLogDao(database: AppDatabase): LogDao {
        return database.logDao()
    }
}

由于我们希望 Hilt 始终提供相同的数据库实例,因此为 @Provides provideDatabase 方法添加了 @Singleton 注解。

每个 Hilt 容器都随附一组默认绑定,可作为依赖项注入到您的自定义绑定。applicationContext 便是这样:要访问它,您需要为字段添加 @ApplicationContext 注解。

运行应用

现在,Hilt 具有在 LogsFragment 中注入实例所需的全部信息。但是,在运行应用之前,Hilt 需要了解托管 FragmentActivity 才能正常运作。我们需要为 MainActivity 添加 @AndroidEntryPoint 注解。

打开 ui/MainActivity.kt 文件并为 MainActivity 添加 @AndroidEntryPoint 注解:

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

现在,您可以运行应用并检查一切是否可以像以前一样正常工作。

我们来继续重构应用,以从 MainActivity 中移除 ServiceLocator 调用。

MainActivity 从调用 provideNavigator(activity: FragmentActivity) 函数的 ServiceLocator 中获取 AppNavigator 的实例。

由于 AppNavigator 是一个接口,因此我们无法使用构造函数注入。要告知 Hilt 为接口使用哪种实现,您可以对 Hilt 模块内的函数使用 @Binds 注解

必须为抽象函数添加 @Binds 注解(它是抽象的,因此不包含任何代码,且该类也需要是抽象的)。抽象函数的返回值类型是我们要提供实现的接口(即 AppNavigator)。实现通过添加具有接口实现类型(即 AppNavigatorImpl)的唯一参数指定。

我们可以向之前创建的 DatabaseModule 类中添加信息吗,或者我们需要新模块吗?应创建新模块的原因有多种:

  • 为便于组织,模块的名称应体现它提供的信息类型。例如,在名为 DatabaseModule 的模块中添加导航绑定是没有意义的。
  • DatabaseModule 模块安装在 ApplicationComponent 中,因此绑定在 application 容器中可用。新导航信息(即 AppNavigator)需要特定于 Activity 的信息(因为 AppNavigatorImplActivity 作为依赖项)。因此,它必须安装在 Activity 容器中而不是 Application 容器中,因为关于 Activity 的信息位于前者之中。
  • Hilt 模块不能同时包含非静态和抽象绑定方法,因此您不能将 @Binds@Provides 注解放在同一个类中。

di 文件夹中创建一个名为 NavigationModule.kt 的新文件。在该文件中,我们来创建一个名为 NavigationModule 的新抽象类,并为其添加 @Module@InstallIn(ActivityComponent::class) 注解,如上所述:

@InstallIn(ActivityComponent::class)
@Module
abstract class NavigationModule {

    @Binds
    abstract fun bindNavigator(impl: AppNavigatorImpl): AppNavigator
}

在该模块内,我们可以为 AppNavigator 添加绑定。一个抽象函数会返回我们告知 Hilt 的接口(即 AppNavigator),而函数参数是该接口的实现(即 AppNavigatorImpl)。

现在,我们必须告知 Hilt 如何提供 AppNavigatorImpl 的实例。由于该类可以进行构造函数注入,因此我们只需为其构造函数添加 @Inject 注解。

打开 navigator/AppNavigatorImpl.kt 文件,然后执行该操作:

class AppNavigatorImpl @Inject constructor(
    private val activity: FragmentActivity
) : AppNavigator {
    ...
}

AppNavigatorImpl 依赖于 FragmentActivity。由于 AppNavigator 实例在 Activity 容器(因为 NavigationModule 安装在 ActivityComponent 中,所以该实例也在 Fragment 容器和 View 中可用)中提供,FragmentActivity预定义绑定的形式提供,因此它已经可用。

在 Activity 中使用 Hilt

现在,Hilt 具有注入 AppNavigator 实例所需的全部信息。打开 MainActivity.kt 文件并执行以下操作:

  1. navigator 字段添加 @Inject 注解,以供 Hilt 获取;
  2. 移除 private 可见性修饰符;并
  3. 移除 onCreate 函数中的 navigator 初始化代码。

新代码应如下所示:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject lateinit var navigator: AppNavigator

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        if (savedInstanceState == null) {
            navigator.navigateTo(Screens.BUTTONS)
        }
    }

    ...
}

运行应用

您可以运行该应用,看看它是否按预期工作。

完成重构

目前唯一仍在使用 ServiceLocator 提取依赖项的类是 ButtonsFragment。由于 Hilt 已经知道如何提供 ButtonsFragment 需要的所有类型,因此我们只需在该类中执行字段注入。

正如我们之前所了解的,要让 Hilt 对该类进行字段注入,我们需要执行以下操作:

  1. ButtonsFragment 添加 @AndroidEntryPoint 注解;
  2. loggernavigator 字段中移除私有修饰符,并为其添加 @Inject 注解;
  3. 移除字段初始化代码(即 onAttachpopulateFields 方法)。

ButtonsFragment 的代码如下:

@AndroidEntryPoint
class ButtonsFragment : Fragment() {

    @Inject lateinit var logger: LoggerLocalDataSource
    @Inject lateinit var navigator: AppNavigator

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_buttons, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        ...
    }
}

请注意,LoggerLocalDataSource 的实例与我们在 LogsFragment 中所用的实例相同,因为该类型的作用域限定为 application 容器。但是,AppNavigator 的实例与 MainActivity 中的实例不同,因为我们尚未将其作用域限定为相应的 Activity 容器。

现在,ServiceLocator 类不再提供依赖项,因此我们可以将其从项目中彻底移除。只有 LogApplication 类使用了它,我们在此类中保留了它的一个实例。由于不再需要该类,下面我们来将其清除。

打开 LogApplication 类并移除使用的 ServiceLocatorApplication 类的新代码如下:

@HiltAndroidApp
class LogApplication : Application()

现在,您可以随时从项目中彻底移除 ServiceLocator 类。由于 ServiceLocator 仍在测试中使用,请也从 AppTest 类中将其移除。

已介绍基本内容

您刚刚所学的知识应该足以让您在 Android 应用中将 Hilt 用作依赖项注入工具。

从现在开始,我们将向应用添加一些新功能,以便您了解如何在不同情形中使用更高级的 Hilt 功能。

现在,我们已从项目中移除 ServiceLocator 类,并且您学习了 Hilt 的基础知识,下面我们来向应用中添加新功能,以探索其他 Hilt 功能。

在本部分中,您将学习以下内容

  • 如何将作用域限定为 Activity 容器。
  • 什么是限定符、限定符解决什么问题以及如何使用。

为展示这一部分内容,我们需要应用有不同的行为。我们会将日志存储位置从数据库切换为内存中列表,以仅记录应用会话期间的日志。

LoggerDataSource 接口

让我们开始将数据源抽象化到一个接口中。在 data 文件夹下创建一个名为 LoggerDataSource.kt 的新文件,并在其中包含以下内容:

package com.example.android.hilt.data

// Common interface for Logger data sources.
interface LoggerDataSource {
    fun addLog(msg: String)
    fun getAllLogs(callback: (List<Log>) -> Unit)
    fun removeLogs()
}

ButtonsFragmentLogsFragment 这两个 Fragment 都使用 LoggerLocalDataSource。我们需要重构它们,使其改为使用 LoggerDataSource 的实例。

打开 LogsFragment 并将 logger 变量的类型设置为 LoggerDataSource

@AndroidEntryPoint
class LogsFragment : Fragment() {

    @Inject lateinit var logger: LoggerDataSource
    ...
}

ButtonsFragment 中执行相同的操作:

@AndroidEntryPoint
class ButtonsFragment : Fragment() {

    @Inject lateinit var logger: LoggerDataSource
    ...
}

接下来,让我们让 LoggerLocalDataSource 实现此接口。打开 data/LoggerLocalDataSource.kt 文件并:

  1. 使其实现 LoggerDataSource 接口,以及
  2. 使用 override 标记其方法
@Singleton
class LoggerLocalDataSource @Inject constructor(
    private val logDao: LogDao
) : LoggerDataSource {
    ...
    override fun addLog(msg: String) { ... }
    override fun getAllLogs(callback: (List<Log>) -> Unit) { ... }
    override fun removeLogs() { ... }
}

现在,我们创建 LoggerDataSource 的另一个实现,名为 LoggerInMemoryDataSource,它会将日志保存到内存中。在 data 文件夹下创建一个名为 LoggerInMemoryDataSource.kt 的新文件,并在其中包含以下内容:

package com.example.android.hilt.data

import java.util.LinkedList

class LoggerInMemoryDataSource : LoggerDataSource {

    private val logs = LinkedList<Log>()

    override fun addLog(msg: String) {
        logs.addFirst(Log(msg, System.currentTimeMillis()))
    }

    override fun getAllLogs(callback: (List<Log>) -> Unit) {
        callback(logs)
    }

    override fun removeLogs() {
        logs.clear()
    }
}

将作用域限定为 Activity 容器

为了能够将 LoggerInMemoryDataSource 用作实现细节,我们需要告知 Hilt 如何提供此类型的实例。和以前一样,我们为类构造函数添加 @Inject 注解:

class LoggerInMemoryDataSource @Inject constructor(
) : LoggerDataSource { ... }

由于我们的应用仅包含一个 Activity(也称为“单 Activity”应用),因此我们应该在 Activity 容器中有一个 LoggerInMemoryDataSource 的实例,并在 Fragment 中重复使用该实例。

我们可以通过将 LoggerInMemoryDataSource 的作用域限定为 Activity 容器来实现内存中记录行为:创建的每个 Activity 都有自己的容器及不同的实例。在每个容器中,当 logger 需要作为依赖项或用于字段注入时,系统将提供 LoggerInMemoryDataSource 的相同实例。此外,在组件层次结构下的容器中,也将提供相同的实例。

根据“将作用域限定为组件”文档,要将某个类型的作用域限定为 Activity 容器,我们需要为该类型添加 @ActivityScoped 注解:

@ActivityScoped
class LoggerInMemoryDataSource @Inject constructor(
) : LoggerDataSource { ... }

目前,Hilt 知道如何提供 LoggerInMemoryDataSourceLoggerLocalDataSource 的实例,但 LoggerDataSource 呢?Hilt 不知道请求 LoggerDataSource 时应使用哪种实现。

如前面部分所述,我们可以在模块中使用 @Binds 注解来告知 Hilt 应使用哪种实现。但是,如果我们需要在同一项目中提供两种实现,该怎么办?例如,在应用运行时使用 LoggerInMemoryDataSource并在 Service 中使用 LoggerLocalDataSource

同一接口的两种实现

让我们在 di 文件夹中创建一个名为 LoggingModule.kt 的新文件。由于 LoggerDataSource 的不同实现的作用域限定为不同的容器,因此我们不能使用同一个模块:LoggerInMemoryDataSource 的作用域限定为 Activity 容器,而 LoggerLocalDataSource 的作用域限定为 Application 容器。

幸运的是,我们可以在刚才创建的同一文件中为两个模块定义绑定:

package com.example.android.hilt.di

@InstallIn(ApplicationComponent::class)
@Module
abstract class LoggingDatabaseModule {

    @Singleton
    @Binds
    abstract fun bindDatabaseLogger(impl: LoggerLocalDataSource): LoggerDataSource
}

@InstallIn(ActivityComponent::class)
@Module
abstract class LoggingInMemoryModule {

    @ActivityScoped
    @Binds
    abstract fun bindInMemoryLogger(impl: LoggerInMemoryDataSource): LoggerDataSource
}

如果类型限定了作用域,@Binds 方法必须具有限定作用域的注解,因此,上面的函数带有 @Singleton@ActivityScoped 注解。如果 @Binds@Provides 用作某个类型的绑定,则该类型中限定作用域的注解将不会再使用,因此您可以开始将其从其他实现类中移除。

如果您现在尝试构建项目,将看到 DuplicateBindings 错误!

error: [Dagger/DuplicateBindings] com.example.android.hilt.data.LoggerDataSource is bound multiple times

这是因为 LoggerDataSource 类型注入到了 Fragment 中,但同一类型有两个绑定,Hilt 不知道应使用哪种实现!如何告知 Hilt 应使用哪种实现呢?

使用限定符

如要告知 Hilt 如何提供同一类型的不同实现(多个绑定),您可以使用限定符

由于每个限定符将用于标识一个绑定,我们需要为每种实现定义一个限定符。在 Android 类中注入该类型或将该类型作为其他类的依赖项时,需要使用限定符注解以避免歧义。

由于限定符只是注解,因此我们可以在添加模块的 LoggingModule.kt 文件中定义它们:

package com.example.android.hilt.di

@Qualifier
annotation class InMemoryLogger

@Qualifier
annotation class DatabaseLogger

现在,我们必须使用限定符为提供每种实现的 @Binds(或 @Provides,如果我们需要它)函数添加注解。请查看完整的代码,并注意 @Binds 方法中使用的限定符:

package com.example.android.hilt.di

@Qualifier
annotation class InMemoryLogger

@Qualifier
annotation class DatabaseLogger

@InstallIn(ApplicationComponent::class)
@Module
abstract class LoggingDatabaseModule {

    @DatabaseLogger
    @Singleton
    @Binds
    abstract fun bindDatabaseLogger(impl: LoggerLocalDataSource): LoggerDataSource
}

@InstallIn(ActivityComponent::class)
@Module
abstract class LoggingInMemoryModule {

    @InMemoryLogger
    @ActivityScoped
    @Binds
    abstract fun bindInMemoryLogger(impl: LoggerInMemoryDataSource): LoggerDataSource
}

此外,在注入时,必须将这些限定符用于要注入的实现。在本例中,我们将在 Fragment 中使用 LoggerInMemoryDataSource 实现。

打开 LogsFragment 并对 logger 字段使用 @InMemoryLogger 限定符,已告知 Hilt 注入 LoggerInMemoryDataSource 的实例:

@AndroidEntryPoint
class LogsFragment : Fragment() {

    @InMemoryLogger
    @Inject lateinit var logger: LoggerDataSource
    ...
}

ButtonsFragment 执行相同的操作:

@AndroidEntryPoint
class ButtonsFragment : Fragment() {

    @InMemoryLogger
    @Inject lateinit var logger: LoggerDataSource
    ...
}

如果您希望更改要使用的数据库实现,只需为注入的字段添加 @DatabaseLogger 而不是 @InMemoryLogger 注解。

运行应用

我们可以运行应用,通过与按钮互动并观察“See all logs”屏幕上是否显示相应的日志,确认所执行的操作是否有效。

请注意,系统不会再将日志保存到数据库。日志不会在不同会话之间保留,只要您关闭并再次打开应用,日志屏幕就是空的。

现在,该应用已完全迁移到 Hilt,我们还可以迁移项目中的插桩测试。该测试用于检查应用的功能,位于 app/androidTest 文件夹下的 AppTest.kt 文件中。打开它!

您会发现,该测试无法编译,这是因为我们从项目中移除了 ServiceLocator 类。通过从类中移除 @After tearDown 方法,移除对不再使用的 ServiceLocator 的引用。

androitTest 测试在模拟器上运行。happyPath 测试会确认对“Button 1”的点按已记录到数据库中。由于应用使用的是内存中数据库,因此测试结束后,所有日志都会消失。

使用 Hilt 进行界面测试

Hilt 会在您的界面测试中注入依赖项,就像在您的生产代码中一样。

使用 Hilt 进行测试不需要维护,因为 Hilt 会自动为每个测试生成一组新的组件

添加测试依赖项

Hilt 使用另外一个包含测试专用注解的库,该库名为 hilt-android-testing,可让您更轻松地测试代码。此外,由于 Hilt 需要在 androidTest 文件夹中为各个类生成代码,因此其注解处理器也必须能够在其中运行。要实现这一点,您需要在 app/build.gradle 文件中添加两个依赖项。

如要添加这些依赖项,请打开 app/build.gradle 并将以下配置添加到 dependencies 部分的底部:

...
dependencies {

    // Hilt testing dependency
    androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
    // Make Hilt generate code in the androidTest folder
    kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version"
}

自定义 TestRunner

使用 Hilt 的插桩测试需要在支持 Hilt 的 Application 中执行。该库中已随附 HiltTestApplication,可用于运行界面测试。通过在项目中创建新的测试运行程序,可指定要在测试中使用的 Application

在同一级别,AppTest.kt 文件位于 androidTest 文件夹下,创建一个名为 CustomTestRunner 的新文件。CustomTestRunnerAndroidJUnitRunner 扩展而来,并按如下方式实现:

class CustomTestRunner : AndroidJUnitRunner() {

    override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
        return super.newApplication(cl, HiltTestApplication::class.java.name, context)
    }
}

接下来,我们需要指示项目在插桩测试中使用该测试运行程序。这在 app/build.gradle 文件的 testInstrumentationRunner 属性中指定。打开该文件,并用以下内容替换默认的 testInstrumentationRunner 内容:

...
android {
    ...
    defaultConfig {
        ...
        testInstrumentationRunner "com.example.android.hilt.CustomTestRunner"
    }
    ...
}
...

现在,我们可以开始在界面测试中使用 Hilt 了!

运行使用 Hilt 的测试

接下来,模拟器测试类要使用 Hilt,它需要满足以下条件:

  1. 带有 @HiltAndroidTest 注解,该注解负责为每个测试生成 Hilt 组件
  2. 使用 HiltAndroidRule,该规则可用于管理组件的状态,并对测试执行注入。

我们来将其添加到 AppTest 中:

@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
class AppTest {

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    ...
}

现在,如果您使用类定义或测试方法定义旁边的播放按钮运行测试,模拟器将会启动。如果您已配置模拟器,测试将通过。

要详细了解测试和功能,例如字段注入或如何在测试中替换绑定,请参阅此文档

在本 Codelab 的这一部分,我们将了解如何使用 @EntryPoint 注解,该注解用于在 Hilt 不支持的类中注入依赖项

正如我们之前看到的那样,Hilt 随附对最常见的 Android 组件的支持。但是,您可能需要在 Hilt 不直接支持或无法使用 Hilt 的类中执行字段注入。

在此类情况下,您可以使用 @EntryPoint。入口点是一个边界位置,在该位置,您可以从无法使用 Hilt 注入依赖项的代码中获取由 Hilt 提供的对象。在入口点,代码首次进入到由 Hilt 管理的容器中。

用例

我们希望能够在应用进程之外导出日志。为此,需要使用 ContentProvider。我们仅允许使用方使用 ContentProvider 查询应用中的某个特定日志(提供一个 id)或所有日志。我们将使用 Room 数据库检索数据。因此,LogDao 类应提供使用数据库 Cursor 返回所需信息的方法。打开 LogDao.kt 文件并向接口添加以下方法。

@Dao
interface LogDao {
    ...

    @Query("SELECT * FROM logs ORDER BY id DESC")
    fun selectAllLogsCursor(): Cursor

    @Query("SELECT * FROM logs WHERE id = :id")
    fun selectLogById(id: Long): Cursor?
}

接下来,我们必须创建一个新的 ContentProvider 类,并替换 query 方法,以返回包含日志的 Cursor。在新的 contentprovider 目录下创建一个名为 LogsContentProvider.kt 的新文件,并在其中包含以下内容:

package com.example.android.hilt.contentprovider

import android.content.ContentProvider
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.UriMatcher
import android.database.Cursor
import android.net.Uri
import com.example.android.hilt.data.LogDao
import dagger.hilt.EntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ApplicationComponent
import java.lang.UnsupportedOperationException

/** The authority of this content provider.  */
private const val LOGS_TABLE = "logs"

/** The authority of this content provider.  */
private const val AUTHORITY = "com.example.android.hilt.provider"

/** The match code for some items in the Logs table.  */
private const val CODE_LOGS_DIR = 1

/** The match code for an item in the Logs table.  */
private const val CODE_LOGS_ITEM = 2

/**
 * A ContentProvider that exposes the logs outside the application process.
 */
class LogsContentProvider: ContentProvider() {

    private val matcher: UriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
        addURI(AUTHORITY, LOGS_TABLE, CODE_LOGS_DIR)
        addURI(AUTHORITY, "$LOGS_TABLE/*", CODE_LOGS_ITEM)
    }

    override fun onCreate(): Boolean {
        return true
    }

    /**
     * Queries all the logs or an individual log from the logs database.
     *
     * For the sake of this codelab, the logic has been simplified.
     */
    override fun query(
        uri: Uri,
        projection: Array<out String>?,
        selection: String?,
        selectionArgs: Array<out String>?,
        sortOrder: String?
    ): Cursor? {
        val code: Int = matcher.match(uri)
        return if (code == CODE_LOGS_DIR || code == CODE_LOGS_ITEM) {
            val appContext = context?.applicationContext ?: throw IllegalStateException()
            val logDao: LogDao = getLogDao(appContext)

            val cursor: Cursor? = if (code == CODE_LOGS_DIR) {
                logDao.selectAllLogsCursor()
            } else {
                logDao.selectLogById(ContentUris.parseId(uri))
            }
            cursor?.setNotificationUri(appContext.contentResolver, uri)
            cursor
        } else {
            throw IllegalArgumentException("Unknown URI: $uri")
        }
    }

    override fun insert(uri: Uri, values: ContentValues?): Uri? {
        throw UnsupportedOperationException("Only reading operations are allowed")
    }

    override fun update(
        uri: Uri,
        values: ContentValues?,
        selection: String?,
        selectionArgs: Array<out String>?
    ): Int {
        throw UnsupportedOperationException("Only reading operations are allowed")
    }

    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
        throw UnsupportedOperationException("Only reading operations are allowed")
    }

    override fun getType(uri: Uri): String? {
        throw UnsupportedOperationException("Only reading operations are allowed")
    }
}

您会发现,getLogDao(appContext) 调用无法编译!我们需要通过从 Hilt application 容器获取 LogDao 依赖项来实现它。但是,Hilt 不提供对注入到 ContentProvider 的开箱支持;对于 Activity,则提供该支持,例如使用 @AndroidEntryPoint

我们需要创建一个带有 @EntryPoint 注解的新接口才能访问它。

@EntryPoint 的实际应用

入口点是一个接口,对于我们所需的每个绑定(包括其限定符),都具有访问器方法。此外,该接口还必须带有 @InstallIn 注解,以指定要安装入口点的组件。

最佳做法是在使用入口点接口的类中添加新的接口。因此,我们将该接口添加到 LogsContentProvider.kt 文件中:

class LogsContentProvider: ContentProvider() {

    @InstallIn(ApplicationComponent::class)
    @EntryPoint
    interface LogsContentProviderEntryPoint {
        fun logDao(): LogDao
    }

    ...
}

请注意,该接口带有 @EntryPoint 注解,并且安装在 ApplicationComponent 中,因为我们需要来自 Application 容器的实例的依赖项。在该接口中,我们会提供要访问的绑定的方法,在本例中为 LogDao

如需访问入口点,请使用来自 EntryPointAccessors 的适当静态方法。参数应该是组件实例或充当组件持有者的 @AndroidEntryPoint 对象。确保您以参数形式传递的组件和 EntryPointAccessors 静态方法都与 @EntryPoint 接口上的 @InstallIn 注解中的 Android 类匹配:

现在,我们可以实现上面的代码中缺少的 getLogDao 方法。让我们在 LogsContentProviderEntryPoint 类中使用在上面定义的入口点接口:

class LogsContentProvider: ContentProvider() {
    ...

    private fun getLogDao(appContext: Context): LogDao {
        val hiltEntryPoint = EntryPointAccessors.fromApplication(
            appContext,
            LogsContentProviderEntryPoint::class.java
        )
        return hiltEntryPoint.logDao()
    }
}

请注意我们如何将 applicationContext 传递给静态 EntryPoints.get 方法和带有 @EntryPoint 注解的接口的类。

现在,您已经熟悉 Hilt,应该能够将其添加到您的 Android 应用。在本 Codelab 中,您学习了以下内容:

  • 如何使用 @HiltAndroidApp 在 Application 类中设置 Hilt。
  • 如何使用 @AndroidEntryPoint 向不同的 Android 生命周期组件添加依赖项容器。
  • 如何使用模块告知 Hilt 如何提供特定类型。
  • 如何使用限定符为某些类型提供多个绑定。
  • 如何使用 Hilt 测试应用。
  • @EntryPoint 何时实用以及如何使用它。