Muselee / WorkManager

Muselee 13: Work Manager – Part 1

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 looked at how we can improve the user experience by adding a repository to improve loading times, and make the app a little more resilient to times when the device does not have data connectivity by using cached data. However this would only work if the data had been refreshed within the expiry period which is one day. One technique that we can use on top of the repository is periodic re-fetching of the data so that the repository data gets refreshed before it expires and therefore ensures that the repository is always able to deliver content. This isn’t foolproof because it does not cover the cases where the device has no connectivity for extended periods, but it still reduces significantly the occasions where the repository contains no valid data.

Before we continue I think that it is worth mentioning that I do not really see this technique as being relevant for an app like Muselee which will be an app that a user might use occasionally rather than on a daily basis. So keeping the data up-to-date for such an app is a little wasteful. However, there are certainly apps for which this technique is appropriate so we’ll use Muselee as a vehicle to demonstrate how to achieve it.

Essentially what we need to to is periodically re-fetch the data from last.fm, and replace the data in the repository with the new data. Then whenever the user enters the app, there will be valid data in the repository so the loading time will be really fast (as no network round trip is required), and the data will be available even if there is no data connectivity.

For scheduling these periodic updates, I have decided to use WorkManager which has (at the time of writing), just hit version 1.0.0. WorkManager allows us to schedule pieces of work to run at some time in the future, and that is how we’ll achieve these periodic updates.

The first thing that we need to do is define a new interface in our core module which will be the contract that we’ll use to schedule work:

interface UpdateScheduler {
    fun scheduleUpdate(items: List)
}

Then we can provide an implementation of this in the TopArtists module:

class TopArtistsScheduler : UpdateScheduler {

    override fun scheduleUpdate(items: List) {
        WorkManager.getInstance()
            .enqueueUniqueWork(
                UNIQUE_WORK_ID,
                ExistingWorkPolicy.REPLACE,
                OneTimeWorkRequestBuilder()
                    .setInitialDelay(items.earliestUpdate(), TimeUnit.MILLISECONDS)
                    .setConstraints(
                        Constraints.Builder()
                            .setRequiredNetworkType(NetworkType.UNMETERED)
                            .setRequiresBatteryNotLow(true)
                            .build()
                    )
                    .build()
            )
    }

    private fun List.earliestUpdate() =
        (minBy { it.expiry }?.expiry?.let { it - System.currentTimeMillis() }
            ?: TimeUnit.DAYS.toMillis(1)) / 2

    companion object {
        private const val UNIQUE_WORK_ID: String = "TopArtistsScheduler"
    }
}

This component is responsible for scheduling the next background update of the data. It is invoked through the scheduleUpdate() method which is what gets exposed through the UpdateScheduler interface.

This first gets a WorkManager instance, which is used to enqueue a piece of unique work, which is identified by a specific ID, in this case "TopArtistsScheduler". We then specify an existing work policy of REPLACE which ensures that if there is already work queued with the same ID, then it will be cancelled and replaced by this new piece of work. It is important to do this otherwise we could queue up multiple instances of the work which would result in far more updates that we need. Using unique work in this way ensures that we only ever have one job queued at any time.

The next thing that we do is define a OneTimeWorkRequest which will run a n instance of TopArtistsUpdateWorker once in the future. It may seem like we should actually use a PeriodicWorkRequest which would repeat the work periodically. However, I prefer to schedule a single update, and then reschedule then next when that piece of work is executed because it respects changing validity periods of the data. If last.fm were to change the Access-Control-Max-Age duration, then the data validity would change and we would need to alter the update frequency accordingly. Determining the next update based on the validity of the most recent data responds to any changes.

The initial delay that we set means that the work will not be executed until some time after that delay period has elapsed, but it does not guarantee that it will run at that specific time. For this reason, I have elected to set the initial delay to half of the current validity period as this gives us a good window to be able to perform the update before the existing data expires.

Next we set some constraints for when the work is permitted to be run. In this case we specify that the device must be connected to an unmetered network, so we won’t consume any of the user’s cellular data allowance; and that we won’t run if the user has a low battery. Both of these are to behave nicely for the user, but these criteria will certainly vary depending on the nature of our app. For Muselee I felt that these were appropriate.

So this has scheduled some work be be run after the initial delay, but only when the device is connected to an unmetered network, and does not have a low battery. Once these conditions are all met then the work will be run.

The TopArtistsUpdateWorker is the class that will actually perform the work, and we need to change things slightly before we can implement it. It will use LastFmTopArtistsProvider through the DataProvider<TopArtistsState> interface to perform the network call, and then store it to the DatabaseTopArtistsPersister through the DataPersister<List<Artist>> interface. Up to now we have always used DataProvider asynchronously to avoid blocking the main thread, but we don’t need to do that in our worker because it is already running on a background thread. It actually makes life easier if we call it synchronously, so we need to update DataProvider to also have an optional synchronous mode:

interface DataProvider {

    fun requestData(callback: (item: T) -> Unit)

    fun requestData(): T = throw NotImplementedError()
}

We use a default implementation which throws an exception so that any existing implementations of this interface will still compile, but will throw an exception if we attempt to call them synchronously. But we can now update LastFmTopArtistsProvider to work synchronously as well:

class LastFmTopArtistsProvider(
    private val topArtistsApi: LastFmTopArtistsApi,
    private val connectivityChecker: ConnectivityChecker,
    private val mapper: DataMapper, List>
) : DataProvider {

    override fun requestData(): TopArtistsState {
        return if (!connectivityChecker.isConnected) {
            TopArtistsState.Error("No network connectivity")
        } else {
            val response = topArtistsApi.getTopArtists().execute()
            response.takeIf { it.isSuccessful }?.body()?.let { artists ->
                TopArtistsState.Success(mapper.encode(artists to response.expiry))
            } ?: TopArtistsState.Error(response.errorBody()?.string() ?: "Network Error")
        }
    }
    .
    .
    .
}

With this in place we can implement the 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")
        }
}

This is pretty straightforward, but there is quite a nice little trick buried in here. We perform the synchronous call to provider.requestData() and if it is successful then we persist the data, and call the scheduler to schedule the next update, and return Result.success() to inform WorkManager that the work completed successfully. However, if the network call fails we return Result.retry()instead. This informs WorkManager that there was a problem and the work needs to be re-run. The most likely reason here is that we lost connectivity, or the network call timed out. Additionally, if connectivity was lost, WorkManager would make a callback to the onStopped() method of our Worker implementation, and schedule a retry. So we could override that as well. That said, the approach that we have here is still necessary to ensure that we can also cope with error responses coming from the back-end when the transaction itself is successful.

WorkManager will not have triggered the worker unless there was connectivity in place because of the constraints we set. However one edge-case is that connectivity is lost after the worker has been called. By returning Result.retry() we signal the problem to WorkManager and it will wait until the conditions are met once again before triggering the worker again. With this fairly simple mechanism it becomes the responsibility of WorkManager to handle retries, so we do not have to implement any of the retry logic ourselves.

So the functionality is almost there, all that is lacking is how WorkManager creates the instance of TopArtistsUpdateWorker when it needs to run it. If our worker had a no arguments constructor WorkManager would be able to create instances with no further effort on our part. However our worker requires dependency injection which complicates things somewhat. The next article will cover how we can achieve that.

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

Although this isn’t yet hooked up and working, 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.