How to Cook WorkManager with Dagger 2

Written by iliaku | Published 2021/10/06
Tech Story Tags: android | dependency-injection | software-architecture | workmanager | mobile-app-development | dagger-2 | programming | workmanager-with-dagger2

TLDR WorkManager allows us to run background work without being bound to the application process lifecycle. We have explored how to integrate it with a dependency injection framework like Dagger 2.via the TL;DR App

WorkManager is ubiquitous in modern android development. We use it to run any background work which should not be bound to the application process lifecycle. It was first introduced in May 2018 and replaced several scheduling frameworks. The library unifies it all with a concise and simple API.

You no longer have to orchestrate JobScheduler, GcmNetworkManager, Evernote AndroidJob, and so on. In fact, this new API is saving us quite a lot of maintenance work, but is it perfect? There are a few rough areas we’ll explore in this article.

Dependency injection

It’s hard to imagine a modern production-grade android application without dependency injection. In our example, we’ll use Dagger2, but the principle will remain the same for any DI framework. Consider that you have a job that runs every hour. Its purpose is to delete old telemetry records from an SQLite database. The naive approach would be to create a component inside the worker and call inject:

class CleanTelemetryWorker(context: Context, params: WorkerParameters) : Worker(context, params) {

    @Inject
    lateinit var telemetryRepository: TelemetryRepostory

    init {
        CleanTelemetryWorkerComponent.Factory.create(context).inject()
    }

    override fun doWork(): ListenableWorker.Result {
        telemetryRepository.clean()
    }
}

This solution is not ideal for a few reasons:

  • The dependency inversion principle breaks since the component should not know how to get its dependencies
  • This worker is hard to test because there’s no easy way to mock dependencies, but we’ll get to it later

What we want is to be able to declare any worker like this:

class CleanTelemetryWorker @Inject constructor(
        context: Context, 
        params: WorkerParams,
        telemetryRepository: TelemetryRepository,
        ...//any other external dependencies
)

Can we do better?

We can! As you may know, we can customize WorkManager using the Configuration class:

class MyApplication() : Application(), Configuration.Provider {
     override fun getWorkManagerConfiguration() =
           Configuration.Builder()
                .setMinimumLoggingLevel(android.util.Log.INFO)
                .setWorkerFactory(myCustomWorkerFactory)
                .build()
}

You override getWorkManagerConfiguration in your Application class and provide a config object. The most important part here is the setWorkerFactory method. It allows us to pass a component that will create jobs for us. It sounds like the ideal place to inject our dependencies into workers.

WorkerFactory has only one method we can override, and it’s called… createWorker :

class CustomWorkerProvider {
    override createWorker(appContext: Context, 
                          workerClassName: String, 
                          workerParameters: WorkerParameters): ListenableWorker? {
        //We need to provide workers here. 
    } 
}

Now we can provide arguments into our worker’s constructor. Fortunately, we don't have to do it ourselves. There exists this handy tool to generate dependency provision for us called Dagger 2.

Let’s try to create a simple and robust schema to inject dependencies into workers. Of course there’s plenty of ways to do this, but let’s start simple and then add some options to it as we go.

So what exactly do we need to create a worker? The ListenableWorker class has two required dependencies: Context and WorkerParameters. Let’s start with the WorkerComponent dagger component that we will init in the createWorker function.

@Component
interface WorkerComponent {
    @Component.Factory
    interface Factory {
        fun create(
          @BindsInstance context: Context,
          @BindsInstance workerParameters: WorkerParameters,
        ): WorkerComponent
    }
}

We use Component.Factory annotation to tell dagger to create an implementation of WorkerComponent.Factory which will return us the WorkerComponent implementation.

@BindsInstance annotation in the factory’s create method means that this component should be able to provision Context and WorkParameters using the objects we passed to the method.

Cool, we can now provide workers by adding a method that returns a concrete worker class, like CleanTelemetryWorker

@Component(dependencies=[ApplicationComponent::class])
interface WorkerComponent {

   @Component.Factory
   interface Factory {
        fun create(
          applicationComponent: ApplicationComponent,
          @BindsInstance context: Context,
          @BindsInstance workerParameters: WorkerParameters,
        ): WorkerComponent
   }

   fun cleanTelemetryWorker(): CleanTelemetryWorker
...
}

Notice that we need to add dependencies to the component’s annotation because WorkerComponent by itself can’t provide you with anything other than Context and WorkerParameters. We also have to pass the dependency component as a factory argument.

Let’s see how the CustomWorkerProvider will look like now:


class CustomWorkerProvider(private val applicationComponent: ApplicationComponent) {
    override createWorker(appContext: Context, 
                          workerClassName: String, 
                          workerParameters: WorkerParameters): ListenableWorker? {
        val component = DaggerWorkerComponent.factory().create(applicationComponent, appContext, workerParameters)
        when(workeClassName) {
          ClientTelemetryWorker::class.qualifiedName -> component.cleanTelemetryWorker()
          ...
        }
    } 
}

It looks much better, but every time we add a new worker, we have to provide it here and add a new function definition to the component. Can dagger2 help us with this?

Dagger Multibinding!

With dagger multi binding, we can declare a module for each worker which will provide a worker provider in a map, where keys are the java classes of the workers.

@Module
interface CleanTelemetryWorkerModule {
    @Binds
    @IntoMap
    @ClassKey(CleanTelemetryWorker::class.java)
    fun bindCleanTelemetryWorker(cleanTelemetryWorkerProvider: Provider<CleanTelemetryWorker>): Provider<out ListenableWorker>
}

Let’s add this module to the component:

@Component(dependencies=[ApplicationComponent::class], 
           modules=[CleanTelemetryWorkerModule::class],)
interface WorkerComponent {
    ...
}

And finally, our CustomWorkerProvider will look like this:

class CustomWorkerProvider(private val applicationComponent: ApplicationComponent) {
    override createWorker(appContext: Context, 
                          workerClassName: String, 
                          workerParameters: WorkerParameters): ListenableWorker? {
        val component = DaggerWorkerComponent.factory().create(applicationComponent, appContext, workerParameters)
        val workerProvidersMap = component.workerProvidersMap()
        return workerProvidersMap[Class.forName(workerClassName)]!!.get()
    } 
}

Every time you have to add a new job, you’ll only have to create a new module and attach it to the WorkerComponent. Neat, right?

Why does it matter, after all?

The jobs now look like any other class with externally declared dependencies. It allows us to enforce the dependency inversion principle of SOLID. With the relatively small overhead, we achieve the following benefits:

  • Testability
    • We can mock job dependencies or pass different implementations at any time.
  • Jobs are not aware of the dependency inversion framework.
    • If you choose to switch from Dagger 2 to any other DI framework, your jobs will remain the same. The only thing that will change is the external glue.
  • Ease of support Reducing mental stress on the developers is also a thing.
    • Previously every time you created a job, you had to think about how to get your dependencies there. It’s not always trivial, especially if your dependency graph has different scopes.

In the following article, we’ll reap the benefits of this setup and explore how to properly test your workers now that we have an easy way to mock the dependencies.


I hope this article was helpful! Let me know what you think :)


Written by iliaku | Software engineer working primarily with mobile and backend technologies NLP hacker
Published by HackerNoon on 2021/10/06