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 中打开项目。
- 单击 运行按钮,然后选择模拟器或连接您的 Android 设备。
如您所见,每次与编号按钮交互时都会创建并存储日志。在“查看所有日志”屏幕中,您将看到所有先前交互的列表。要删除日志,请点击“删除日志”按钮。
项目设置
该项目是在多个 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
文件。 LogsFragment
在 onAttach()
中填充其字段。我们不是使用 ServiceLocator
手动填充 LoggerLocalDataSource
和 DateFormatter
的实例,而是可以使用 Hilt 来创建和管理这些类型的实例。
要让 LogsFragment
使用 Hilt,我们必须使用 @AndroidEntryPoint
注解它
@AndroidEntryPoint
class LogsFragment : Fragment() {
...
}
使用 @AndroidEntryPoint
注解 Android 类将创建一个遵循 Android 类生命周期的依赖项容器。
使用 @AndroidEntryPoint
,Hilt 将创建一个附加到 LogsFragment
生命周期并能够将实例注入 LogsFragment
的依赖项容器。我们如何引用由 Hilt 注入的字段?
我们可以在要注入的字段(即 logger
和 dateFormatter
)上使用 @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 提供 LoggerLocalDataSource
和 DateFormatter
的实例。但是,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 需要了解承载 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
)需要特定于活动的活动信息,因为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
文件并执行以下操作。
- 用
@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
类中删除。
涵盖的基本内容
您刚学到的内容足以让您将 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 中使用:ButtonsFragment
和 LogsFragment
。我们需要重构它们,以便使用 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
文件,并执行以下操作:
- 使其实现
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
}
此外,这些限定符必须在注入点使用,并使用我们想要注入的实现。在本例中,我们将使用 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
进行注释即可。
运行应用程序
我们可以运行应用程序,并通过与按钮交互并在“查看所有日志”屏幕上观察相应的日志出现来确认我们所做的操作。
请注意,日志不再保存到数据库中。它们不会在会话之间持久化,只要您关闭并重新打开应用程序,日志屏幕就会为空。
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 的模拟器测试类,它需要
- 使用
@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
。我们只允许使用者使用 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
有用以及如何使用它。