Following coroutine’s best practices, you might need to inject an application-scoped CoroutineScope in some classes to launch new coroutines that follow the app lifecycle or to make certain work outlive the caller’s scope.

In this article, you’ll learn how to create an application-scoped CoroutineScope using Hilt, and how to inject it as a dependency. To further improve the way we work with Coroutines, we’ll see how to inject the different CoroutineDispatchers and replace their implementations in tests.

Manual dependency injection

To create an application-scoped CoroutineScope following dependency injection (DI) best practices manually without any library, you’d typically add a new variable to your application class with an instance of a CoroutineScope. The same instance would be manually passed around when creating other objects.

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

class MyApplication : Application() {

    // Application-scoped types that any class in the app could access
    // using the applicationContext.
    val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    val myRepository = MyRepository(applicationScope)

}

Since there isn’t a reliable way to know when the Application is destroyed in Android, you don’t need to call applicationScope.cancel() manually as the scope and all ongoing work will be destroyed when the application process finishes.

A better option for doing this manually is to create an ApplicationContainer class that holds the application-scoped types. This helps with separation of concerns since these Container classes are responsible for:

  • handling the logic of how to build certain types,
  • holding container-scoped types instances, and
  • returning instances of scoped and unscoped types.
class ApplicationDiContainer {
    val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    val myRepository = MyRepository(applicationScope)
}

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

In the ApplicationDiContainer example above, all types were scoped. If MyRepository didn’t need to be scoped to the application, we’d have:

class ApplicationDiContainer {
    // Scoped type. Same instance is always returned
    val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

    // Unscoped type. Always returns a different instance
    fun getMyRepository(): MyRepository {
        return MyRepository(applicationScope)
    }
}

Using Hilt in your app

Hilt generates what you can see in ApplicationDiContainer (and more!) at compile time using annotations. Moreover, Hilt provides containers for most Android framework classes not only for the Application class.

To set up Hilt in your app and create the container for the Application class, annotate your Application class with @HiltAndroidApp.

@HiltAndroidApp
class MyApplication : Application()

With this, the application DI container is ready to be used. We just need to let Hilt know how to provide instances of different types.

Construction injection

Construction injection is the easiest way to let Hilt know how to provide instances of a type if we have access to the constructor of a class as we only need to annotate the constructor with @Inject:

@Singleton // Scopes this type to the SingletonComponent
class MyRepository @Inject constructor(
   private val externalScope: CoroutineScope
) { 
    /* ... */ 
}

This lets Hilt know that in order to provide an instance of the MyRepository class, an instance of CoroutineScope needs to be passed as a dependency. Hilt generates code at compile time to make sure dependencies are satisfied and passed in when creating an instance of a type or give errors in case it doesn’t have enough information. @Singleton is used to scope this class to the SingletonContainer.

At this point, Hilt doesn’t know how to satisfy the CoroutineScope dependency because we haven’t told Hilt how to do that. The following sections will explain how we can let Hilt know what to pass as a dependency.

Bindings

binding is a commonly-used term in Hilt that denotes the information Hilt knows about how to provide instances of a type as a dependency. We could say that we added a binding to Hilt with the @Inject annotation of the code snippet above.

Bindings flow through Hilt’s components hierarchy. Bindings that are available in the SingletonComponent are also available in the ActivityComponent.

Bindings for unscoped types (an example of this could’ve been the MyRepository code above if it wasn’t annotated with @Singleton), are available in all Hilt components. Bindings that are scoped to a component, such as MyRepository that is annotated with @Singleton, are available to the scoped component and the components below it in the hierarchy.

Providing types with modules

As mentioned above, we need to let Hilt know how to satisfy the CoroutineScope dependency. However, CoroutineScope is an interface type that comes from an external library, so we cannot use constructor injection as we did before with the MyRepository class. The alternative is letting Hilt know what code to run when providing the instance of a type using Modules:

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

    @Singleton // Provide always the same instance 
    @Provides
    fun providesCoroutineScope(): CoroutineScope {
        // Run this code when providing an instance of CoroutineScope
        return CoroutineScope(SupervisorJob() + Dispatchers.Default)
    }
}

The @Provides method is annotated with @Singleton to make Hilt always return the same instance of that CoroutineScope. This is because any work that needs to follow the application lifetime should be created using the same instance of a CoroutineScope that follows the Application’s lifecycle.

Hilt modules are annotated with @InstallIn that indicates in which Hilt component (and components below in the hierarchy) the binding is installed. In our case, as the application CoroutineScope is needed by MyRepository which is scoped to the SingletonComponent, this binding needs to be installed in the SingletonComponent as well.

In Hilt jargon, we could say that we added a CoroutineScope binding, as now, Hilt knows how to provide instances of CoroutineScope.

However, the code snippet above could be improved. Hardcoding dispatchers is a bad practice in coroutines, we should inject them to make them configurable and make testing easier. Following the previous code, we can create a new Hilt module to let it know which Dispatcher to inject for each case: main, default, and IO.

Providing implementations for CoroutineDispatcher

We have to provide different implementations for the same type: CoroutineDispatcher. In other words, we need different bindings for the same type.

We use qualifiers to let Hilt know which binding, or implementation, to use each time. Qualifiers are just annotations that you and Hilt use to identify specific bindings. Let’s create one qualifier per CoroutineDispatcher implementation:

// CoroutinesQualifiers.kt file

@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

Then, these qualifiers annotate the different @Provides methods to identify a specific binding in Hilt modules. The @DefaultDispatcher qualifier annotates the method that returns the default dispatcher, and so on.

@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
}

Note that these CoroutineDispatchers don’t need to be scoped to the SingletonComponent. Every time these dependencies are needed, Hilt calls the @Provides method and returns the corresponding CoroutineDispatcher.

Providing an application-scoped CoroutineScope

To get rid of the hardcoded CoroutineDispatcher from our previous application-scoped CoroutineScope code, we need to inject the Hilt-provided default dispatcher. For that, we can pass in the type we want to inject, CoroutineDispatcher, using the corresponding qualifier, @DefaultDispatcher, as a dependency in the method that provides the application CoroutineScope.

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

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

As Hilt has multiple bindings for the CoroutineDispatcher type, we disambiguate it using the @DefaultDispatcher annotation when CoroutineDispatcher is used as a dependency.

A qualifier for ApplicationScope

Even though we don’t need multiple bindings for CoroutineScope at the moment (this could change in the future if we ever need something like a UserCoroutineScope), adding a qualifier to the application CoroutineScope helps with readability when injecting it as a dependency.

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

As MyRepository depends on this scope, it’s very clear which external scope uses as implementation:

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

Replacing Dispatchers for instrumentation tests

We said before that we should inject dispatchers to make testing easier and have full control over what’s happening. For instrumentation tests, we’d want to make Espresso wait for coroutines to finish.

Instead of creating a custom CoroutineDispatcher with some Espresso Idling resource to make it wait for the coroutines to finish, we can take advantage of the AsyncTask API. Even though AsyncTask was deprecated in Android API 30, Espresso hooks into its thread pool to check for idleness. Therefore, any coroutine that should be executed in a background thread could be executed in the AsyncTask’s thread pool.

Use Hilt’s TestInstallIn API to make Hilt provide a different implementation of a type in tests. Similar to how we provided the different Dispatchers above, we can create a new file under the androidTest package to provide different implementations for those Dispatchers.

// androidTest/projectPath/TestCoroutinesDispatchersModule.kt file

@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
}

With the code above, we’re making Hilt “forget” the CoroutinesDispatchersModule used in production code in tests. That module will be replaced with TestCoroutinesDispatchersModule which uses the Async Task’s thread pool for work that needs to happen in the background, and Dispatchers.Main for work that needs to happen on the main thread which Espresso also waits for.

For more information about testing, check out Hilt’s testing guide.

By Manuel Vivo
Source Medium

Previous A New Protocol Buffers Generator For Go
Next IBM Survey: Pandemic-Induced Digital Reliance Creates Lingering Security Side Effects