Hilt 实战 | 创建应用级别 CoroutineScope

在遵循 协程最佳实践 时,您可能需要在某些类中注入应用级别作用域的 CoroutineScope,以便可以创建与应用生命周期相同的新协程,或创建在调用者作用域之外仍可以工作的新协程。

通过本文,您将学习如何通过 Hilt 创建应用级别作用域的 CoroutineScope,以及如何将其作为依赖项进行注入。我们将在示例中展示如何注入不同的 CoroutineDispatcher 以及在测试中替换其实现,进一步优化协程的使用。

手动依赖项注入

在不使用任何库的情况下,遵循依赖项注入 (DI) 的最佳实践方案来 手动 创建一个应用级别作用域CoroutineScope,通常会在 Application 类中添加一个 CoroutineScope 实例变量。当创建其他对象时,手动将相同的 CoroutineScope 实例分发到这些对象中。

class MyRepository(private val externalScope: CoroutineScope) { /* ... */ }

class MyApplication : Application() {

    // 应用中任何类都可以通过 applicationContext 访问应用级别作用域的类型
    val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    val myRepository = MyRepository(applicationScope)

}

由于在 Android 中没有可靠的方法来获取 Application 销毁的时机,并且应用级别的作用域以及任何正在执行的任务都将同应用进程的结束一同销毁,也意味着您无需手动调用 applicationScope.cancel()

手动注入更优雅的做法是创建一个 ApplicationContainer 容器类来持有应用级别作用域的类型。这有助于关注点分离,因为容器类具有如下职责:

  • 处理如何构造确切类型的逻辑;
  • 持有容器级别作用域的类型实例;
  • 返回限定作用域或未限定作用域的类型实例。
class ApplicationDiContainer {
    val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    val myRepository = MyRepository(applicationScope)
}

class MyApplication : Application() {
    val applicationDiContainer = ApplicationDiContainer()
}

说明: 容器类永远返回被限定作用域的类型的相同实例,并且永远返回未被限定作用域的类型的不同实例。将类型的作用域限定到容器类中 成本很高,这是因为在组件销毁之前,被限定作用域的对象将一直存在于内存中,所以仅在真正需要限定作用域的场景使用。

在上述 ApplicationDiContainer 示例中,所有的类型都被限定了作用域。如果 MyRepository 无需将作用域限定为 Application,我们可以这样做:

class ApplicationDiContainer {
    // 限定作用域类型。永远返回相同的实例
    val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

    // 未限定作用域类型。永远返回不同实例
    fun getMyRepository(): MyRepository {
        return MyRepository(applicationScope)
    }
}

在应用中使用 Hilt

在 Hilt 中,可以通过使用注解在编译期生成 ApplicationDiContainer 的内容 (甚至更多)!并且 Hilt 除 Application 类外,还为大部分 Android Framework 类提供了容器。

在您的应用中配置 Hilt 并且创建 Application 类的容器,可以在 Application 类中使用 @HiltAndroidApp 注解。

@HiltAndroidApp
class MyApplication : Application()

此时,应用 DI 容器已经可以使用了。我们只需要让 Hilt 知道如何提供不同类型的实例。

说明 : 在 Hilt 中,容器类被引用为组件。与 Application 关联的组件被称为 SingletonComponent。请参阅 —— Hilt 提供的组件列表

构造方法注入

对于我们可以访问构造方法的类,构造方法注入是一个简单的方案来让 Hilt 知道如何提供类型的实例,因为我们只需要在构造器上增加 @Inject 注解:

@Singleton // 限定作用域为 SingletonComponent
class MyRepository @Inject constructor(
   private val externalScope: CoroutineScope
) { 
    /* ... */ 
}

这让 Hilt 知道,为了提供一个 MyRepository 类的实例,需要传递一个 CoroutineScope 的实例作为依赖项。Hilt 在编译期生成代码,以确保构造类型的实例时可以正确创建并传入所需依赖项,或者在条件不足时报错。使用 @Singleton 注解,将该类的作用域限定为 SingletonContainer

此时,Hilt 还不知道如何提供满足要求的 CoroutineScope 依赖项,因为我们还没有告诉 Hilt 该如何处理。 接下来的部分将展示如何让 Hilt 知道应该传递哪些依赖项。

说明 : Hilt 提供了多种注解,来实现将类型的作用域限定到各种 Hilt 的现有组件中。请参阅 —— Hilt 提供的组件列表

绑定

绑定 是 Hilt 中的一个常见术语,它表明了 Hilt 所知的如何提供类型的实例作为依赖项的信息。我们可以说,上文的代码片段就是使用 @Inject 在 Hilt 中添加了绑定。

绑定遵循 组件层次结构。在 SingletonComponent 中可用的绑定,在 ActivityComponent 中同样可用。

未限定作用域的类型的绑定 (假如上文的 MyRepository 代码去掉 @Singleton 就是一个例子),在任何 Hilt 组件中都可用。将绑定的作用域限定到一个组件,例如被 @Singleton 注解的 MyRepository,可以在当前作用域的组件以及该层级以下的组件中使用。

通过模块提供类型

通过上述内容,我们需要让 Hilt 知道如何提供合适的 CoroutineScope 的依赖项。然而 CoroutineScope 是一个外部依赖库提供的接口类型,所以我们不能像之前处理 MyRepository 类一样使用构造方法注入。取而代之的方案是通过 使用模块,让 Hilt 知道执行哪些代码来提供类型实例。

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

    @Singleton  // 永远提供相同实例
    @Provides
    fun providesCoroutineScope(): CoroutineScope {
        // 当提供 CoroutineScope 实例时,执行如下代码
        return CoroutineScope(SupervisorJob() + Dispatchers.Default)
    }
}

@Provides 注解的方法同时被 @Singleton 注解,让 Hilt 总是返回相同的 CoroutineScope 实例。这是因为任何需要遵循应用生命周期的任务都应该使用遵循应用生命周期的 CoroutineScope 的同一实例创建。

@InstallIn 注解的 Hilt 模块,表明该绑定被装载到哪个 Hilt 组件中 (包含该组件层级以下的组件)。在我们的案例中,被限定作用域到 SingletonComponent 上的 MyRepository,需要应用级别的 CoroutineScope,该绑定同样需要被装载到 SingletonComponent 中。

如果使用 Hilt 的行话,可以说成我们添加了一个 CoroutineScope 绑定,至此,Hilt 就知道如何提供 CoroutineScope 实例了。

然而,上述代码片段仍可以优化。协程中硬编码 Dispatcher 不是良好的实现,我们需要注入它们使得这些 Dispatcher 可配置并且易于测试。基于之前的代码,我们可以创建一个新的 Hilt 模块,让它知道为每种情况需要注入哪个 Dispatcher: main、default 还是 IO。

提供 CoroutineDispatcher 的实现

我们需要提供相同类型 CoroutineDispatcher 的不同实现。换句话说就是,我们需要相同类型的不同绑定。

我们可以使用 限定符 来让 Hilt 知道每种情况需要使用哪种绑定或者实现。限定符只是您和 Hilt 之间用来标识特定绑定的注解。让我们为每一种 CoroutineDispatcher 的实现创建一个限定符:

// CoroutinesQualifiers.kt 文件

@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class DefaultDispatcher

@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class IoDispatcher

@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class MainDispatcher

@Retention(AnnotationRetention.BINARY)
@Qualifier
annotation class MainImmediateDispatcher

接下来,在 Hilt 模块中使用这些限定符注解不同的 @Provides 方法来表示特定的绑定。@DefaultDispatcher 限定符注解的方法返回默认的 Dispatcher,其余限定符不再赘述。

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

    @DefaultDispatcher
    @Provides
    fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default

    @IoDispatcher
    @Provides
    fun providesIoDispatcher(): CoroutineDispatcher = Dispatchers.IO

    @MainDispatcher
    @Provides
    fun providesMainDispatcher(): CoroutineDispatcher = Dispatchers.Main

    @MainImmediateDispatcher
    @Provides
    fun providesMainImmediateDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate
}

需要注意,这些 CoroutineDispatchers 无需限定作用域到 SingletonComponent。每次需要这些依赖项时,Hilt 调用被 @Provides 注解的方法返回对应的 CoroutineDispatcher

提供应用级别作用域的 CoroutineScope

为了从我们之前的应用级别作用域的 CoroutineScope 代码中摆脱硬编码 CoroutineDispatcher,我们需要注入 Hilt 提供的默认 Dispatcher。为此,我们可以传入我们想要注入的类型: CoroutineDispatcher,在提供应用级别 CoroutineScope 的方法中使用对应的限定符 @DefaultDispatcher 作为依赖项。

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

    @Singleton
    @Provides
    fun providesCoroutineScope(
        @DefaultDispatcher defaultDispatcher: CoroutineDispatcher
    ): CoroutineScope = CoroutineScope(SupervisorJob() + defaultDispatcher)
}

由于 Hilt 对 CoroutineDispatcher 类型具有多个绑定,因此当 CoroutineDispatcher 用作依赖项时,我们使用 @DefaultDispatcher 注解消除它的歧义。

应用级别作用域限定符

虽然我们目前不需要 CoroutineScope 的多个绑定 (未来我们可能需要像 UserCoroutineScope这样的协程作用域),但是向应用级别 CoroutineScope 添加限定符可以提高其作为依赖项注入时的可读性。

@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class ApplicationScope

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

    @Singleton
    @ApplicationScope
    @Provides
    fun providesCoroutineScope(
        @DefaultDispatcher defaultDispatcher: CoroutineDispatcher
    ): CoroutineScope = CoroutineScope(SupervisorJob() + defaultDispatcher)
}

由于 MyRepository 依赖该 CoroutineScope,因而可以非常清晰地知道 externalScope 使用哪种实现:

@Singleton
class MyRepository @Inject constructor(
    @ApplicationScope private val externalScope: CoroutineScope
) { /* ... */ }

在插桩测试中替换 Dispatcher

如上所述,我们应该注入 Dispatcher 使测试更容易并可以完全控制发生的事情。对于插桩测试,我们希望 Espresso 等待协程结束。

我们可以利用 AsyncTask API 来替代使用 Espresso 空闲资源 创建自定义 CoroutineDispatcher,来等待协程的结束。即使 AsyncTask 已经在 Android API 30 中被弃用,但 Espresso 会 hook 到其线程池中来检查空闲情况。因此,任何应该在后台执行的协程都可以在 AsyncTask 的线程池中执行。

在测试中可以使用 Hilt TestInstallIn API 让 Hilt 提供一个类型的不同实现。这与上文提供不同 Dispatcher 类似,我们可以在 androidTest 包下创建一个新文件,来提供不同的 Dispatcher 实现。

// androidTest/projectPath/TestCoroutinesDispatchersMouule.kt 文件

@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [CoroutinesDispatchersModule::class]
)
@Module
object TestCoroutinesDispatchersModule {

    @DefaultDispatcher
    @Provides
    fun providesDefaultDispatcher(): CoroutineDispatcher =
        AsyncTask.THREAD_POOL_EXECUTOR.asCoroutineDispatcher()

    @IoDispatcher
    @Provides
    fun providesIoDispatcher(): CoroutineDispatcher =
        AsyncTask.THREAD_POOL_EXECUTOR.asCoroutineDispatcher()

    @MainDispatcher
    @Provides
    fun providesMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
}

通过上述代码,我们让 Hilt 在测试中 “忘记” 了在生产代码中使用的 CoroutinesDispatchersModule。该模块将会被替换为 TestCoroutinesDispatchersModule,它使用 AsyncTask 的线程池来处理后台工作,而 Dispatchers.Main 则作用于主线程,这也是 Espresso 等待的目标线程。

警告 : 这其实是通过 hack 的方式实现的,虽然不值得炫耀,然而由于 Espresso 目前没有办法知道 CoroutineDispatcher 是否处于空闲状态 (issue 链接),所以协程并不能与其完美的集成。因为 Espresso 不是使用空闲资源来检查该 executor 是否空闲,而是通过消息队列中是否有内容的方式,所以 AsyncTask.THREAD_POOL_EXECUTOR 是目前最佳的替代方案。也正是这些原因,使得它相对于诸如 IdlingThreadPoolExecutor 之类来说是一个更优解,并且非常不幸的是,当由于协程被编译成状态机而被挂起时,IdlingThreadPoolExecutor 会认为线程池是空闲的。

更多关于测试的信息,请参阅 Hilt 测试指南

通过本文,您已经了解到如何使用 Hilt 创建一个应用级别的 CoroutineScope 作为依赖项注入,如何注入不同的 CoroutineDispatcher 实例,以及如何在测试中替换它们的实现。

欢迎您 点击这里 向我们提交反馈,或分享您喜欢的内容、发现的问题。您的反馈对我们非常重要,感谢您的支持!