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.
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:
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
)
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?
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?
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:
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 :)