在 Android 应用中使用 Hilt

1. 简介

在本 Codelab 中,您将了解 依赖项注入 (DI) 对于创建可靠且可扩展应用(扩展到大型项目)的重要性。我们将使用 Hilt 作为 DI 工具来管理依赖项。

依赖项注入是一种广泛用于编程的技术,非常适合 Android 开发。您需要遵循 DI 的原则,为打造优秀的应用架构奠定坚实的基础。

实施依赖项注入可带来以下优势:

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

Hilt 是专为 Android 设计的依赖项注入库,可减少在项目中使用手动 DI 的样板。进行 手动依赖项注入需要手工构造每个类及其依赖项,并使用容器重用和管理依赖项。

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

如果您在使用本 Codelab 时发现任何问题(代码漏洞、语法错误、措辞不清等),请通过 Codelab 左下角的"*Report a mistake"(报告错误)*链接来报告问题。

先决条件

  • 有使用 Kotlin 语法的经验。
  • 了解为什么依赖项注入在应用中很重要。

您将学习的内容

  • 如何在 Android 应用中使用 Hilt。
  • 利用相关的 Hilt 概念创建可持续的应用。
  • 如何使用限定符将多个绑定添加到同一类型。
  • 如何使用 @EntryPoint 从 Hilt 不支持的类中访问容器。
  • 如何使用单元测试和仪器测试来测试使用 Hilt 的应用程序。

您需具备的条件

  • Android Studio 4.0 或更高版本。

2. 准备工作

获取代码

从 GitHub 获取 Codelab 代码:

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

或者,您可以将存储库下载为 Zip 文件:

打开 Android Studio

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

运行示例应用

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

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

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

4d20613a36545c21.png 1283c797277c3ef8.png

如您所见,每当您与其中一个按钮交互时,系统都会创建并存储日志。在 **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

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

常见问题解答

3. 向项目添加 Hilt

为什么添加 Hilt?

如果看一下起始代码,就可以看到存储在 LogApplication 类中的 ServiceLocator 类的实例。ServiceLocator 创建并存储依赖项,您可按照需要这些依赖项的类按需进行获取。您可以将其视为附加到应用生命周期的依赖项容器,因为当应用消亡时,它也将不复存在。

Android DI 指南所述,服务定位器起初的样板代码相对较少,但扩展性也很差。要大规模开发 Android 应用,您应该使用 Hilt。

Hilt 通过生成在过去需要手动创建的代码(例如 ServiceLocator 类中的代码),删除了在 Android 应用中使用手动 DI 或服务定位器模式所需的不必要的样板。

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

在项目中使用 Hilt

我们已在

master 分支中配置 Hilt(您下载的代码)。您无需将以下代码添加到项目中,因为此过程已完成。尽管如此,我们还是来看看在 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 文件中,通过将插件添加到文件顶部的 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!

4. 在应用中使用 Hilt

与使用和初始化 LogApplication 类中的 ServiceLocator 实例类似,要添加附加到应用生命周期的容器,我们需要用 @HiltAndroidApp 注释 Application 类。打开 LogApplication.kt 并将注释添加到类中:

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

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

现在,我们的应用可以使用 Hilt 了!

5. 使用 Hilt 进行字段注入

无需从类中的 ServiceLocator 按需获取依赖项,我们将使用 Hilt 为我们提供这些依赖项。让我们开始替换类中对 ServiceLocator 的调用。

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

要让 LogsFragment 使用 Hilt,我们必须用 @AndroidEntryPoint 对其作出注释:

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

@AndroidEntryPoint 注释 Android 类将创建一个遵循 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 还不知道如何提供这些实例。

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

打开 ServiceLocator.kt 文件以查看 ServiceLocator 的实现方式。您可以看到调用 provideDateFormatter() 如何始终返回 DateFormatter 的不同实例。

这与我们希望通过 Hilt 实现的行为完全一致。幸运的是,DateFormatter 不依赖于其他类,因此我们现在不必担心传递依赖。

要告诉 Hilt 如何提供类型的实例,请将 @Inject 注释添加到要注入的类的构造函数中。

打开 util/DateFormatter.kt 文件,并用 @Inject 注释 DateFormatter 的构造函数。 请记住,要用 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 中做到这一点呢?

6. 将实例范围限定为容器

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

将实例范围限定为容器的注释是 @Singleton。此注释将使应用容器始终提供相同的实例,而不管该类型是否用作其他类型的依赖项,或者是否需要进行字段注入。

同样的逻辑可以应用于所有附加到 Android 类的容器。您可以在 文档中找到所有作用域注释的列表。例如,如果希望活动容器始终提供某个类型的相同实例,您可以使用 @ActivityScoped 注释该类型。

如上所述,由于我们希望应用容器始终提供 LoggerLocalDataSource 的相同实例,因此我们会使用 @Singleton 注释其类:

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

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

但是,LogDao 是一个接口,因此我们不能使用 @Inject 注释其构造函数,因为接口没有该函数。我们如何告诉 Hilt 怎样提供这种类型的实例呢?

7. 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 的作用域是应用容器,因此 LogDao 绑定需要在应用容器中可用。我们需要通过传入与之相关联的 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 而生成,所以是我们项目并不拥有的另一个类,因此我们也可以使用 @Provides 函数来提供,方式与在 ServiceLocator 类中构建数据库实例的方式类似:

@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 提供相同的数据库实例,所以我们用 @Singleton 注释 @Provides provideDatabase 方法。

每个 Hilt 容器都有一组默认绑定,系统可将其作为依赖项注入到自定义绑定中。在本例中这组默认绑定是 applicationContext:如要进行访问,您需要用 @ApplicationContext 注释该字段。

运行应用

现在,Hilt 拥有在 LogsFragment 中注入实例所需的所有信息。但是,在运行应用之前,Hilt 需要知道承载 FragmentActivity 才能正常工作。我们需要用 @AndroidEntryPoint 注释 MainActivity

打开 ui/MainActivity.kt 文件,用 @AndroidEntryPoint 注释 MainActivity

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

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

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

8. 用 @Binds 提供接口

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

因为 AppNavigator 是一个接口,所以我们不能使用构造函数注入。要告诉 Hilt 对接口使用什么实现,可以在 Hilt 模块内的函数上使用

@Binds 注释。

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

我们可以将信息添加到之前创建的 DatabaseModule 类中,还是需要一个新模块?多种原因表明,我们应该创建一个新模块:

  • 为了更好地组织,模块的名称应该传达所提供信息的类型。例如,在名为 DatabaseModule 的模块中加入导航绑定没有意义。
  • 由于 DatabaseModule 模块已安装在 ApplicationComponent 中,因此您可在应用容器中使用绑定。我们的新导航信息(即 AppNavigator)需要特定于 Activity 的信息(因为 AppNavigatorImpl 拥有 Activity 作为依赖项)。因此,我们必须将其安装在 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。由于系统会在 Activity 容器中提供 AppNavigator 实例(亦可用于 Fragment 容器和 View 容器,因为 NavigationModule 会安装在 ActivityComponent 中),所以 FragmentActivity 目前可用,因为其可用作 预定义绑定

在活动中使用 Hilt

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

  1. @Inject 注释 navigator 以通过 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. @AndroidEntryPoint 注释 ButtonsFragment
  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 中使用的实例相同,因为该类型的作用域是应用容器。但是,AppNavigator 的实例将不同于 MainActivity 中的实例,因为我们还没有将其范围限定到其各自的 Activity 容器。

此时,ServiceLocator 类不再提供依赖项,因此我们可以将其从项目中完全删除。唯一的用法保留在 LogApplication 类中,我们在其中保留了它的一个实例。我们来清理该类,因为我们不再需要它。

打开 LogApplication 类,并删除 ServiceLocator 用法。Application 类的新代码是:

@HiltAndroidApp
class LogApplication : Application()

现在,放心地从项目中完全删除 ServiceLocator 类。由于 ServiceLocator 仍在测试中使用,因此也要从 AppTest 类中删除其用法。

涵盖的基本内容

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

从现在开始,我们将向应用添加新功能,学习如何在不同情况下使用更高级的 Hilt 功能。

9. 限定符

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

在本节中,您将学习:

  • 如何将范围限定为活动容器。
  • 什么是限定符,其会解决哪些问题,以及如何加以使用。

为此,我们需要在应用中使用不同的行为。我们将把日志存储从数据库交换到内存中列表,目的是仅在应用会话期间记录日志。

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()
}

LoggerLocalDataSource 可用于两个片段:ButtonsFragmentLogsFragment。我们需要重构这两个片段,以便通过它们来使用 LoggerDataSource 的实例。

打开 LogsFragment 并将记录器类型的变量设为 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() { ... }
}

现在,我们创建另一个名为 LoggerDataSourceLoggerInMemoryDataSource 实现,其会将日志保存在内存中。在 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()
    }
}

将范围限定为活动容器

为了能够使用 LoggerInMemoryDataSource 作为实现细节,我们需要告诉 Hilt 如何提供这种类型的实例。如前所述,我们用 @Inject 注释类构造函数:

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

由于我们的应用只包含一个活动(也称为单活动应用程序),因此我们应该在 Activity 容器中加入 LoggerInMemoryDataSource 的实例,并跨 Fragment 重用该实例。

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

根据 "将范围限定为组件"文档,如要将类型的范围限定为 Activity 容器,我们需要用 @ActivityScoped 注释该类型:

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

现在,Hilt 知道如何提供 LoggerInMemoryDataSourceLoggerLocalDataSource 的实例,但是 LoggerDataSource 呢?当请求 LoggerDataSource 时,Hilt 不知道要使用哪种实现。

如上所述,我们可以在模块中使用 @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,在记录器字段中使用 @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"(查看所有日志)屏幕上显示的相应日志来确认我们完成的工作。

请注意,系统不会再将日志保存到数据库中。系统不会在会话间期存留这些日志,每当您关闭并重新打开应用时,日志屏幕都是空白。

77f882d79b9baa79.gif

10. UI 测试

现在应用程序已经完全迁移到 Hilt,我们还可以迁移项目中的仪器测试。用于检查应用功能的测试位于 app/androidTest 文件夹的 AppTest.kt 文件中。打开此文件!

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

在模拟器上运行 androitTest 测试。happyPath 测试会确认系统是否已将对"按钮 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"
}

自定义测试运行器

使用 Hilt 的仪器化测试需要在支持 Hilt 的 Application 中执行。该库已附带 HiltTestApplication,可用于运行界面测试。通过在项目中创建新的测试运行器来指定要在测试中使用的 Application

AppTest.kt 文件所在 androidTest 文件夹下的同一层级中,创建名为 CustomTestRunner 的新文件。我们的 CustomTestRunner 扩展自 AndroidJUnitRunner,其实现方式如下:

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)

    ...
}

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

有关测试和功能(例如测试中的字段注入或绑定替换)的更多信息,请参阅 文档

11. @EntryPoint 注释

在 Codelab 的这一节中,我们将学习如何使用 @EntryPoint 注释在 Hilt 不支持的类中注入依赖项

如前所述,Hilt 支持最常见的 Android 组件。但是,您可能需要在 Hilt 不直接支持或不能使用 Hilt 的类中执行字段注入。

在这些情况下,您可以使用 @EntryPoint。入口点是边界位置,您可借此从不能使用 Hilt 注入其依赖项的代码中获取 Hilt 提供的对象。其是代码进入由 Hilt 管理的容器时最先通过的点。

用例

我们希望能够将日志导出到应用进程的外部。为此,我们需要使用 ContentProvider。我们只允许使用者查询一个特定日志(给定 id)或使用 ContentProvider 查询应用的所有日志。我们将使用 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 应用容器中获取 LogDao 依赖项来加以实现。但是,Hilt 不支持注入到现成的 ContentProvider 中,因为其与活动相关,例如与 @AndroidEntryPoint 相关。

我们需要创建一个用 @EntryPoint 注释的新接口来对其进行访问。

使用 @EntryPoint

**入口点是一种接口,其中带有对每个所需绑定类型(包括其限定符)的访问器方法。**此外, 我们必须用 @InstallIn 对界面作出注释,以指定要在其中安装入口点的组件。

最佳做法是在使用 @EntryPoint 的类中添加新的入口点接口。因此,我们需在 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 注释的接口类。

12. 恭喜!

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

  • 如何使用 @HiltAndroidApp 在应用类中设置 Hilt。
  • 如何使用 @AndroidEntryPoint 将依赖项容器添加到不同的 Android 生命周期 组件中。
  • 如何使用模块告诉 Hilt 如何提供某些类型。
  • 如何使用限定符为某些类型提供多个绑定。
  • 如何使用 Hilt 测试应用。
  • @EntryPoint 在哪些情况下有用以及如何使用。