在 Android 应用中使用 Hilt

1. 简介

在本代码实验室中,您将了解 依赖注入 (DI) 对创建稳定且可扩展的应用程序以扩展到大型项目的重要性。我们将使用 Hilt 作为 DI 工具来管理依赖项。

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

实现依赖注入为您提供以下优势

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

Hilt 是一个面向 Android 的依赖注入库,它减少了在项目中使用手动 DI 的样板代码。执行 手动依赖注入 需要手动构建每个类及其依赖项,并使用容器来重用和管理依赖项。

Hilt 通过为项目中的每个 Android 组件提供容器并自动管理容器的生命周期,为您的应用程序提供了一种标准的 DI 注入方式。这是通过利用流行的 DI 库实现的:Dagger.

如果您在完成本代码实验室时遇到任何问题(代码错误、语法错误、措辞不清等),请通过代码实验室左下角的“报告错误”链接报告问题。

先决条件

  • 您有 Kotlin 语法方面的经验。
  • 您了解依赖注入在应用程序中的重要性。

您将学到什么

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

您需要什么

  • Android Studio Arctic Fox 或更高版本。

2. 设置

获取代码

从 GitHub 获取代码实验室代码

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

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

打开 Android Studio

本代码实验室需要 Android Studio Arctic Fox 或更高版本。如果您需要下载 Android Studio,您可以从 这里 下载。

运行示例应用程序

在本代码实验室中,您将向一个记录用户交互并使用 Room 将数据存储到本地数据库的应用程序添加 Hilt。

按照以下说明在 Android Studio 中打开示例应用程序

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

4d20613a36545c21.png 1283c797277c3ef8.png

如您所见,每次与编号按钮交互时都会创建并存储日志。在“查看所有日志”屏幕中,您将看到所有先前交互的列表。要删除日志,请点击“删除日志”按钮。

项目设置

该项目是在多个 GitHub 分支中构建的

  • main 是您签出或下载的分支。这是代码实验室的起点。
  • solution 包含本代码实验室的解决方案。

我们建议您从 main 分支中的代码开始,并按照您自己的节奏逐步完成代码实验室。

在代码实验室期间,您将看到需要添加到项目中的代码片段。在某些地方,您还需要删除代码片段注释中明确提到的代码。

要使用 git 获取 solution 分支,请使用以下命令

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

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

常见问题

3. 将 Hilt 添加到项目

为什么使用 Hilt?

如果您查看起始代码,您会看到 ServiceLocator 类的实例存储在 LogApplication 类中。 ServiceLocator 创建并存储由需要它们的类按需获取的依赖项。您可以将其视为附加到应用程序生命周期的依赖项容器,这意味着它将在应用程序进程被销毁时被销毁。

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

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

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

项目中的 Hilt

Hilt 已经配置main 分支中(您下载的代码)。您不必在项目中包含以下代码,因为该代码已经为您完成。不过,让我们看看在 Android 应用程序中使用 Hilt 需要什么。

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

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

然后,要在 app 模块中使用 gradle 插件,我们需要在 app/build.gradle 文件中指定它,方法是在文件的顶部添加插件,位于 kotlin-kapt 插件下方

plugins {
    ...
    id 'kotlin-kapt'
    id '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 注解添加到 Application

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

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

现在,我们的应用程序已准备好使用 Hilt!

5. 使用 Hilt 进行字段注入

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

打开 ui/LogsFragment.kt 文件。 LogsFragmentonAttach() 中填充其字段。我们不是使用 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 将在 onAttach() 生命周期方法中填充这些字段,这些字段是在 Hilt 为 LogsFragment 自动生成的依赖项容器中构建的实例。

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

使用 @Inject 告诉 Hilt 如何提供依赖项

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

这正是我们希望使用 Hilt 实现的行为。幸运的是,DateFormatter 不依赖于其他类,因此我们现在不必担心传递依赖项。

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

打开 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 容器与 SingletonComponent 相关联,而 Fragment 容器与 FragmentComponent 相关联。

创建模块

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

由于 LoggerLocalDataSource 限定到应用程序容器,因此 LogDao 绑定需要在应用程序容器中可用。我们使用 @InstallIn 注解指定此要求,方法是传入与其关联的 Hilt 组件的类(即 SingletonComponent:class

package com.example.android.hilt.di

import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent

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

}

ServiceLocator 类的实现中,LogDao 的实例是通过调用 logsDatabase.logDao() 获取的。因此,要提供 LogDao 的实例,我们需要对 AppDatabase 类具有传递依赖项。

使用 @Provides 提供实例

我们可以使用 @Provides 注解 Hilt 模块中的函数,以告诉 Hilt 如何提供无法进行构造函数注入的类型。

使用 @Provides 注解的函数的函数体将在每次 Hilt 需要提供该类型的实例时执行。 @Provides 注解的函数的返回类型告诉 Hilt 绑定类型,即该函数提供的实例的类型。函数参数是该类型的依赖项。

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

package com.example.android.hilt.di

import com.example.android.hilt.data.AppDatabase
import com.example.android.hilt.data.LogDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent

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

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

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

我们的项目也不拥有 AppDatabase 类,因为它是由 Room 生成的。我们无法进行构造函数注入 AppDatabase,但我们可以使用 @Provides 函数来提供它。这类似于我们在 ServiceLocator 类中构建数据库实例的方式

import android.content.Context
import androidx.room.Room
import com.example.android.hilt.data.AppDatabase
import com.example.android.hilt.data.LogDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

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

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

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

由于我们始终希望 Hilt 提供相同的数据库实例,因此我们使用 @Singleton 注解 @Provides provideDatabase() 方法。

每个 Hilt 容器都附带一组默认绑定,这些绑定可以作为依赖项注入到您的自定义绑定中。对于 applicationContext 而言也是如此。要访问它,您需要使用 @ApplicationContext 注解该字段。

运行应用程序

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

打开 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 模块安装在 SingletonComponent 中,所以绑定在应用程序容器中可用。我们的新的导航信息(例如 AppNavigator)需要特定于活动的活动信息,因为 AppNavigatorImpl 依赖于一个 Activity。因此,它必须安装在 Activity 容器中,而不是 Application 容器中,因为活动信息只能在那里找到。
  • 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 容器中提供,因此 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 字段中删除 private 修饰符,并用 @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 功能。

在本节中,您将了解:

  • 如何将作用域设置为 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()
}

LoggerLocalDataSource 在两个 Fragment 中使用:ButtonsFragmentLogsFragment。我们需要重构它们,以便使用 LoggerDataSource 的实例,而不是直接使用 LoggerLocalDataSource

打开 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 呢?当请求 LoggerDataSource 时,Hilt 不知道该使用哪个实现。

正如我们之前章节所学,我们可以使用 @Binds 注释在模块中告诉 Hilt 使用哪个实现。但是,**如果我们需要在同一个项目中提供两个实现呢?** 例如,在应用程序运行时使用 LoggerInMemoryDataSource,在 Service 中使用 LoggerLocalDataSource

同一个接口的两个实现

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

幸运的是,我们可以在刚创建的文件中定义这两个模块的绑定。

package com.example.android.hilt.di

import com.example.android.hilt.data.LoggerDataSource
import com.example.android.hilt.data.LoggerInMemoryDataSource
import com.example.android.hilt.data.LoggerLocalDataSource
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.scopes.ActivityScoped
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@InstallIn(SingletonComponent::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(SingletonComponent::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
}

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

打开 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 进行注释即可。

运行应用程序

我们可以运行应用程序,并通过与按钮交互并在“查看所有日志”屏幕上观察相应的日志出现来确认我们所做的操作。

请注意,日志不再保存到数据库中。它们不会在会话之间持久化,只要您关闭并重新打开应用程序,日志屏幕就会为空。

47632a478b4ffb0e.gif

10. UI 测试

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

您会看到它无法编译,因为我们从项目中删除了 ServiceLocator 类。通过从类中删除 @After tearDown 方法,删除不再使用的 ServiceLocator 的引用。

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

使用 Hilt 进行 UI 测试

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

使用 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,我们可以用它来运行 UI 测试。通过在项目中创建一个新的测试运行器来指定测试中使用的 Application

androidTest 文件夹下,与 AppTest.kt 文件处于同一级别,创建一个名为 CustomTestRunner 的新文件。我们的 CustomTestRunner 扩展自 AndroidJUnitRunner,其实现如下

package com.example.android.hilt

import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication

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"
    }
    ...
}
...

现在,我们就可以在 UI 测试中使用 Hilt 了!

运行使用 Hilt 的测试

接下来,对于要使用 Hilt 的模拟器测试类,它需要

  1. 使用 @HiltAndroidTest 进行注释,该注释负责为每个测试生成 Hilt 组件
  2. 使用 HiltAndroidRule 管理组件的状态,并用于在测试中执行注入。

让我们将它们包含在 AppTest

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

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    ...
}

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

要详细了解测试以及字段注入或在测试中替换绑定等功能,请查看 文档

11. @EntryPoint 注释

在本节代码实验室中,我们将学习如何使用 @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.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
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 不支持像 Activity 一样开箱即用地注入 ContentProvider,例如,使用 @AndroidEntryPoint

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

@EntryPoint 实践

入口点是一个接口,它具有我们想要访问的每个绑定类型的访问器方法(包括其限定符)。此外,该接口必须使用 @InstallIn 进行注释以指定要安装入口点的组件。

最佳实践是在使用它的类中添加新的入口点接口。因此,请将该接口包含在 LogsContentProvider.kt 文件中

class LogsContentProvider: ContentProvider() {

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

    ...
}

请注意,该接口使用 @EntryPoint 进行注释,并且它安装在 SingletonComponent 中,因为我们希望从 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 传递给静态 EntryPointAccessors.fromApplication 方法以及使用 @EntryPoint 进行注释的接口的类。

12. 恭喜!

您现在熟悉了 Hilt,并且应该能够将其添加到您的 Android 应用程序中。在本代码实验室中,您学习了

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