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.