Dagger2 / Muselee / WorkManager

Muselee 14: Work Manager – Part 2

Muselee is a demo app which allows the user to browse popular music artists. It is not intended to be a fully-featured user app, but a vehicle to explore good app architecture, how to implement current best-practice, and explore how the two often go hand in hand. Moreover it will be used to explore how implementing some specific patterns can help to keep our app both maintainable, and easy to extend.

Previously we created a worker that would refresh the data stored in the repository periodically to keep it up-to-date, but we don’t yet have it hooked up so that WorkManager is able to construct instances of this worker when it needs them. But this is complicated a little because our worker implementation requires dependency injection in order to work correctly. As-is WorkManager does not know how to create and inject our worker class, but there is a mechanism which enables us to do this, although it requires a little work.

WorkManager has a way of providing a custom WorkerFactory which is responsible for creating worker instances. We can define our own WorkerFactory which is hooked in to Dagger to create the injected worker instances that we require. We can do this by using the same technique as we used for creating an injected ViewModel. We first define an annotation in our Core module that will identify a Dagger map:

@MapKey
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class WorkerKey(val value: KClass)

We can now create our DaggerWorkerFactory which takes this Dagger map of class types to a factory capable of instantiating them:

class DaggerWorkerFactory @Inject constructor(
    private val workerFactories: Map, @JvmSuppressWildcards Provider>
) : WorkerFactory() {

    override fun createWorker(
        appContext: Context,
        workerClassName: String,
        workerParameters: WorkerParameters
    ): ListenableWorker? {
        val foundEntry =
            workerFactories.entries.find { Class.forName(workerClassName).isAssignableFrom(it.key) }
        return foundEntry?.value?.get()?.create(appContext, workerParameters)
    }

    interface ChildWorkerFactory {
        fun create(appContext: Context, params: WorkerParameters): ListenableWorker
    }
}

For this to work, we need to first implement a ChildWorkerFactory for our worker:

class TopArtistsUpdateWorker(
    private val provider: DataProvider,
    private val persister: DataPersister>,
    private val scheduler: UpdateScheduler,
    context: Context,
    workerParams: WorkerParameters
) : Worker(context, workerParams) {

    override fun doWork(): Result =
        when(val state = provider.requestData()) {
            is TopArtistsState.Success -> {
                persister.persistData(state.artists)
                scheduler.scheduleUpdate(state.artists)
                Result.success()
            }
            is TopArtistsState.Error -> Result.retry()
            is TopArtistsState.Loading -> throw IllegalStateException("Unexpected Loading State")
        }

    class Factory @Inject constructor(
        @Named(TopArtistsModule.NETWORK) private val provider: DataProvider,
        private val persister: DataPersister>,
        private val scheduler: UpdateScheduler
    ) : DaggerWorkerFactory.ChildWorkerFactory {

        override fun create(appContext: Context, params: WorkerParameters): ListenableWorker =
            TopArtistsUpdateWorker(provider, persister, scheduler, appContext, params)
    }
}

So here we have an implementation of ChildWorkerFactory that is capable of creating an instance of TopArtistsUpdateWorker but itself requires injection of the dependencies that TopArtistsUpdateWorker requires. To create instances of this factory, we need to declare it in our TopArtistsModule:

@Module(
    includes = [
        EntitiesModule::class,
        DatabaseModule::class,
        NetworkModule::class,
        BaseViewModule::class,
        SchedulerModule::class,
        LastFmTopArtistsModule::class
    ]
)
@Suppress("unused")
abstract class TopArtistsModule {

    companion object {
        const val ENTITIES = "ENTITIES"
        const val NETWORK = "NETWORK"
    }

    @ContributesAndroidInjector
    abstract fun bindTopArtistsFragment(): TopArtistsFragment

    @Binds
    @IntoMap
    @ViewModelKey(TopArtistsViewModel::class)
    abstract fun bindChartsViewModel(viewModel: TopArtistsViewModel): ViewModel

    @Binds
    @IntoMap
    @WorkerKey(TopArtistsUpdateWorker::class)
    abstract fun bindTopArtistsUpdateWorker(factory: TopArtistsUpdateWorker.Factory):
            DaggerWorkerFactory.ChildWorkerFactory
}

This will add an entry to the Dagger map which is keyed using WorkerKey and creates an entry which maps the class TopArtistsUpdateWorker to an instance of TopArtistsUpdateWorker.Factory and Dagger will create and inject the instance of that factory as it is added to the map.

This is very similar to what we did with ViewModelKey and ViewModelFactory previously, and we can see how this is similar to the @Binds declaration for ViewModel which precedes it. It is this map that gets injected in to DaggerWorkerFactory and therefore enables it to obtain injected instances of TopArtistsUpdateWorker when needed.

What remains is how we can pass this WorkerFactory in to WorkManager. Registering a custom WorkerFactory requires us to first disable the default one, and this is done via the Manifest in the App module:



  

    
      
        

        
      
    

    

  

The package name in the authorities attribute needs to match your declared package.

We can now register the custom WorkerFactory in the Application instance:

class MuseleeApplication : DaggerApplication() {

    override fun applicationInjector(): AndroidInjector =
        DaggerApplicationComponent.builder().create(this)

    @Inject
    lateinit var workerFactory: DaggerWorkerFactory

    override fun onCreate() {
        super.onCreate()

        WorkManager.initialize(
            this,
            Configuration.Builder()
                .setWorkerFactory(workerFactory)
                .build()
        )

    }
}

We initialise WorkManager with a configuration containing the injected DaggerWorkerFactory instance.

WorkManager is now able to obtain injected instances of TopArtistsUpdateWorker whenever it needs to execute one to perform the periodic work.

It is worth mentioning that there is a mechanism we could use to further simplify things by reducing the amount of boilerplate that we need in creating the TopArtistsUpdateWorker.Factory instance. A Square library named AssistedInject can be used to achieve this. While I would certainly use this in production code to keep it simple, I have omitted using it here because I feel that the code we have makes it easier to explain what is happening, and using @AssistedInject would actually make the code a little more difficult to understand for those that are unfamiliar with how to integrate a customer WorkerFactory using Dagger.

There are plans to integrate custom WorkerFactories with @AndroidInjector, which would certainly help to reduce the amount of boilerplate that it is currently required.

It is also worth mentioning this blog post by Tuan Kiet which goes in to more detail of what is happening, and also shows how to implement @AssistedInject. It should be fairly obvious from comparing the code in that article to the code in this that Tuan’s article was a major source of inspiration for how I solved this problem in Muselee.

So now that we have everything registered with WorkManager all that remains is to schedule the initial update:

class TopArtistsRepository(
    private val persister: DataPersister>,
    private val provider: DataProvider,
    private val scheduler: UpdateScheduler
) : DataProvider {

    override fun requestData(callback: (item: TopArtistsState) -> Unit) =
        persister.requestData { artists ->
            if (artists.isEmpty()) {
                provider.requestData { state ->
                    if (state is TopArtistsState.Success) {
                        GlobalScope.launch(Dispatchers.IO) {
                            persister.persistData(state.artists)
                        }
                        scheduler.scheduleUpdate(state.artists)
                    }
                    callback(state)
                }
            } else {
                callback(TopArtistsState.Success(artists))
            }
        }
}

If retrieval from the database is unsuccessful either because it is the first run, or that the there is no data within the validity period, then it will be retrieved from the network data provider. If that is successful then we persist as normal, and schedule an update. This now begins the periodic update cycle. But, thanks to the fact that we register this with WorkManager as unique work, we will only ever have a single task scheduled at any one time.

Our periodic updates to keep the data in the repository fresh is now fully working, and most of the time the user will get data displayed even if there is no network connection.

Many thanks to Sumir Kataria for proof-reading this post and providing some valuable feedback.

The source code for this article is available here.

© 2019, Mark Allison. All rights reserved.

Copyright © 2019 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.