1. 简介
在本 Codelab 中,您将了解依赖注入 (DI) 对创建可靠且可扩展的应用程序(能够扩展到大型项目)的重要性。我们将使用 Hilt 作为 DI 工具来管理依赖项。
依赖注入是一种广泛应用于编程的技术,非常适合 Android 开发。通过遵循 DI 的原则,您为良好的应用程序架构奠定了基础。
实现依赖注入为您提供了以下优势
- 代码可重用性
- 易于重构
- 易于测试
Hilt 是一个面向 Android 的依赖注入库,它减少了在项目中使用手动 DI 的样板代码。进行手动依赖注入 需要手动构造每个类及其依赖项,并使用容器来重用和管理依赖项。
Hilt 通过为项目中的每个 Android 组件提供容器并自动为您管理容器的生命周期,提供了一种在应用程序中进行 DI 注入的标准方法。这是通过利用流行的 DI 库实现的:Dagger。
如果您在完成此 Codelab 时遇到任何问题(代码错误、语法错误、措辞不清等),请通过 Codelab 左下角的“报告错误”链接报告问题。
先决条件
- 您具有 Kotlin 语法方面的经验。
- 您了解依赖注入在您的应用程序中为何重要。
您将学到什么
- 如何在您的 Android 应用中使用 Hilt。
- 创建可持续应用程序的相关 Hilt 概念。
- 如何使用限定符将多个绑定添加到同一类型。
- 如何使用
@EntryPoint
从 Hilt 不支持的类访问容器。 - 如何使用单元测试和 Instrumentation 测试来测试使用 Hilt 的应用程序。
您需要什么
- Android Studio Arctic Fox 或更高版本。
2. 设置
获取代码
从 GitHub 获取 Codelab 代码
$ git clone https://github.com/android/codelab-android-hilt
或者,您可以将存储库下载为 Zip 文件
打开 Android Studio
此 Codelab 需要 Android Studio Arctic Fox 或更高版本。如果您需要下载 Android Studio,可以在此处进行下载。
运行示例应用
在本 Codelab 中,您将向一个记录用户交互并使用Room 将数据存储到本地数据库的应用程序中添加 Hilt。
请按照以下说明在 Android Studio 中打开示例应用
- 如果您下载了 zip 归档文件,请在本地解压缩该文件。
- 在 Android Studio 中打开项目。
- 点击运行按钮,然后选择模拟器或连接您的 Android 设备。
如您所见,每次您与其中一个编号按钮交互时,都会创建一个日志并将其存储起来。在“查看所有日志”屏幕中,您将看到所有先前交互的列表。要删除日志,请点击“删除日志”按钮。
项目设置
该项目是在多个 GitHub 分支中构建的
main
是您检出或下载的分支。这是 Codelab 的起点。solution
包含此 Codelab 的解决方案。
我们建议您从main
分支中的代码开始,并按照 Codelab 的步骤逐步进行,并以您自己的速度进行。
在 Codelab 期间,您将看到需要添加到项目中的代码片段。在某些地方,您还需要删除代码片段注释中明确提到的代码。
要使用 git 获取solution
分支,请使用以下命令
$ git clone -b solution https://github.com/android/codelab-android-hilt
或从此处下载解决方案代码
常见问题
3. 将 Hilt 添加到项目中
为什么选择 Hilt?
如果您查看起始代码,您会看到 LogApplication
类中存储的 ServiceLocator
类的实例。 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
文件。LogsFragment
在 onAttach()
中填充其字段。我们可以使用 Hilt 创建和管理这些类型的实例,而不是使用 ServiceLocator
手动填充 LoggerLocalDataSource
和 DateFormatter
的实例。
要使 LogsFragment
使用 Hilt,我们必须使用 @AndroidEntryPoint
对其进行注解
@AndroidEntryPoint
class LogsFragment : Fragment() {
...
}
使用 @AndroidEntryPoint
注解 Android 类会创建一个遵循 Android 类生命周期的依赖项容器。
使用 @AndroidEntryPoint
,Hilt 将创建一个与 LogsFragment
的生命周期关联的依赖项容器,并能够将实例注入到 LogsFragment
中。我们如何引用由 Hilt 注入的字段呢?
我们可以使用 @Inject
注解在我们要注入的字段(例如 logger
和 dateFormatter
)上,让 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 提供 LoggerLocalDataSource
和 DateFormatter
的实例。但是,Hilt 还没有知道如何提供这些类的实例。
使用 @Inject 告诉 Hilt 如何提供依赖项
打开 ServiceLocator.kt
文件以查看 ServiceLocator
的实现方式。您可以看到调用 provideDateFormatter()
始终会返回 DateFormatter
的不同实例。
这正是我们希望使用 Hilt 实现的行为。幸运的是,DateFormatter
不依赖于其他类,因此我们暂时不必担心传递依赖项。
要告诉 Hilt 如何提供某种类型的实例,请在您希望 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 类的所有容器。您可以在文档的 组件作用域 部分找到所有作用域注解的列表。例如,如果您希望 Activity 容器始终提供某种类型的相同实例,则可以使用 @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 提供实例
我们可以在 Hilt 模块中使用 @Provides
注解函数,以告诉 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 需要知道承载 Fragment
的 Activity
才能工作。我们需要使用 @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
)需要特定于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
。因为AppNavigator
实例是在Activity
容器中提供的,所以FragmentActivity
已经作为预定义绑定可用。
在Activity中使用Hilt
现在,Hilt拥有所有信息,能够注入AppNavigator
实例。打开MainActivity.kt
文件并执行以下操作。
- 用
@Inject
注解navigator
字段以获取Hilt提供的实例。 - 移除
private
可见性修饰符。 - 移除
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进行字段注入,我们必须
- 用
@AndroidEntryPoint
注解ButtonsFragment
。 - 从
logger
和navigator
字段中移除private修饰符,并用@Inject
注解它们。 - 移除字段初始化代码(即
onAttach
和populateFields
方法)。
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
类中移除其用法。
涵盖的基本内容
你刚刚学到的内容应该足以在你的Android应用程序中使用Hilt作为依赖注入工具。
从现在开始,我们将向我们的应用程序添加新功能,以学习如何在不同情况下使用更高级的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中使用:ButtonsFragment
和LogsFragment
。我们需要重构它们以使用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
文件并
- 使其实现
LoggerDataSource
接口。 - 用
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知道如何提供LoggerInMemoryDataSource
和LoggerLocalDataSource
的实例,但是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
}
此外,这些限定符必须在注入点与我们想要注入的实现一起使用。在本例中,我们将在 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
来注释注入的字段。
运行应用程序
我们可以运行应用程序并通过与按钮交互并观察相应的日志出现在“查看所有日志”屏幕上确认我们所做的操作。
请注意,日志不再保存到数据库中。它们不会在会话之间持久保存,无论何时关闭并再次打开应用程序,日志屏幕都是空的。
10. UI 测试
现在应用程序已完全迁移到 Hilt,我们还可以迁移项目中现有的 Instrumentation 测试。检查应用程序功能的测试位于 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"
}
自定义 TestRunner
使用 Hilt 的 Instrumentation 测试需要在支持 Hilt 的 Application
中执行。该库已附带 HiltTestApplication
,我们可以使用它来运行我们的 UI 测试。通过在项目中创建一个新的测试运行器来指定要使用的 Application
。
在 AppTest.kt
文件位于 androidTest
文件夹下的同一级别,创建一个名为 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)
}
}
接下来,我们需要告诉项目对 Instrumentation 测试使用此测试运行器。这在 app/build.gradle
文件的 testInstrumentationRunner
属性中指定。打开文件,并将默认的 testInstrumentationRunner
内容替换为此内容。
...
android {
...
defaultConfig {
...
testInstrumentationRunner "com.example.android.hilt.CustomTestRunner"
}
...
}
...
现在我们已准备好在我们 UI 测试中使用 Hilt 了!
运行使用 Hilt 的测试
接下来,要使模拟器测试类使用 Hilt,它需要:
- 使用
@HiltAndroidTest
进行注释,它负责为每个测试生成 Hilt 组件。 - 使用
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
。我们只允许使用者查询一个特定的日志(给定一个 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.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 中使用 @AndroidEntryPoint
一样开箱即用地支持注入 ContentProvider
。
我们需要创建一个新的使用 @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
在您的 Application 类中设置 Hilt。 - 如何使用
@AndroidEntryPoint
将依赖项容器添加到不同的 Android 生命周期组件。 - 如何使用模块来告诉 Hilt 如何提供某些类型。
- 如何使用限定符为某些类型提供多个绑定。
- 如何使用 Hilt 测试您的应用程序。
- 何时
@EntryPoint
有用以及如何使用它。