Photo by EJ Yao on Unsplash

This post was originally published on quipper.hatenablog.com on February 6, 2019. I updated it for the stable version 1.0.0.


Whether you are a newbie in Android Developer or a Jedi Developer, you have undoubtedly heard about this new API in Jetpack library. WorkManager is the latest way of handling background processing and at the same time respecting the device resources. I am Rajanikant Deshmukh aka aruke from Quipper’s Mobile team, and we will go through Android’s WorkManager API in this article.

First, we need to know why do we need background processing? One may say “Why don’t we do everything in Main Thread, not worry about all the threading junk and keep our life simple?”. Well, the Main thread in Android does the most critical jobs of rendering UI to the user and responding to the UI touch events. If we give Main Thread too much to do, it skips the rendering work, and then we see this in our Logcat.

I/Choreographer(691): Skipped 647 frames! The application may be doing too much work on its main thread.

Smart developers know that this happens when you are overloading MainThread with work. This causes glitches in the app and ultimately slows the interaction between the user and the app. To avoid that we need to process some of the tasks in the background.

Now the background processes are there since the birth of Android. Knowing them makes us more respectful towards the new Almighty WorkManager.

History of Background Processing in Android

Threads: API 1

Threads are the Java Way of doing background processing. You create threads by overriding the run method and then start it. You can use this with ThreadPool and Handler for better performance and handling communication.

The only good thing about Thread is that if you know Java then you are good to go without knowing anything else. The problem is that there is no support for configuration changes. We have to handle states of the thread when rotating the device or the activity transitions between UI lifecycle.

Services: API 1

Service is the best way to manage work, especially when the app is not running in the foreground, in the past. For a Service, you have to handle threading by yourself, as they run on the main thread. Nowadays, Android wants to restrict such processing, to save battery life and for security purpose.

According to a blog by Google Developers

Also, if we want to pass any data to the UI, we have to use either broadcasts or some Event Bus mechanism or the Binders (Yes, they exist). Again, we have to handle subscribing when UI comes to life and unsubscribe when it goes away, leading to some unexpected crashes. Service is no good for unless we are not using it as a foreground service (with notification). Moreover, you don’t want to show the user a notification every time you want to do little jobs.

Intent Services: API 3

This class is just a child of the Service class but comes with pre-packed thread handling. You can run UI independent code in a different thread. The problem is that being a subclass of the Service class; it has to be declared in the AndroidManifest.xml file and creating it is as heavy as Service. Also, there is no direct way to communicate with UI thread, similar to Services.

AsyncTask: API 3

AsyncTask was a favorite way of doing light works like network calls among many Android Developers for a long time. The working of AsyncTask is simple, and it has a great way of delivering results to UI threads. The problems with AsyncTask is, you have to handle the device config changes yourself. Also, there is much boilerplate code, and you can use an AsyncTask object only once. If you don’t treat the AsyncTask properly, it can create multiple instances, and that is deadly for performance.

CursorLoader: API 11

In API 11, Android introduced Activity and Fragment supporting data loading mechanism. Using CursorLoader with ContentProviders and IntentServices can make your app smooth sailing ship, but it has a lot (really a lot) boilerplate code and if the data size increases, that smooth sailing ship may sink like Titanic!

JobScheduler: API 21

It is a cool API which handles jobs condition-based and not time-based. With the new UI introduced in Lollipop and apps being bigger, JobScheduler is a better way to handle background processing. It uses JobService, a subclass of Service again and has an overload of declaring JobService in the Manifest file. The bad news is, it supported only above 21 and on lower devices, you have to use AlarmManager or similar thing.

WorkManager: Jetpack/AndroidX

WorkManager is introduced in a separate support library. Android has moved all support libraries under a deluxe package of androidx and work manager is one of the modules as part of Jetpack. Under the hood, WorkManager uses JobDispatcher, FirebaseJobDipatcher, and combination of AlarmManager and BroadcastReceiver. It has backward support up to API 14.

It stores results, arguments and work details with Room database, so even if your process is killed in the most horrible way, the WorkManager has the record of all Works it has been doing. The most exciting part, you can observe it using LiveData, so no need to worry about managing subscriptions.

Introduction to WorkManager Concepts

To start using WorkManager, you need to know some concepts on which WorkManager works. The ideas are straightforward and easy to understand.

  • WorkManager: This is the main class which handles the requests for work and schedules it according to the constraints we specify.
  • Worker: A worker let you specify the work to be done. Worker is an abstract class in androidx.work package and you should extend it and override doWork method. The method comes with its own thread handling mechanism, so you don’t have to worry about it.
  • WorkRequest: As the name specifies, it is just a request you send to WorkManager. You have to specify at least the worker class you wrote your code. You can also specify the constraints, input data, and many other parameters using the Builder pattern.
  • WorkInfo: This class helps you to keep track of your work by providing status and out data if/when the work succeed.

A normal workflow

  1. Write a Worker class. Extend doWork method and put all the heavy code you need in it.
  2. Create a Request object specifying the previously written class. Specify the constraints and data you need for this work.
  3. Get WorkManager instance, and enqueue the work, by providing the request object to it.
  4. Get updates for the work from WorkManager.

Though these are simple steps, you can add more complex steps only if you want. Now, enough history lessons and theory. Let’s move to a code with a simple example.

Add WorkManager Dependency

To add WorkManager as a dependency, add Google repository in project level build.gradle file. If you are using the latest version of Android Studio, then it should be already added.

allprojects {
    repositories {
        google()
        jcenter()
        
    }
}

Then, add the dependency in app/build.gradle file.

implementation "android.arch.work:work-runtime-ktx:1.0.0"
androidTestImplementation "android.arch.work:work-testing:1.0.0"

This assumes that your code only in Kotlin. Also, I will explain what difference it makes by suffixing -ktx later.

Basic example with Code

The code below is a simple, minimal example of a typical Worker class. You have to override at least doWork method and specify the work in it. For now, our old friend Thread.sleep can replace intensive task here.

class SimpleWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {

    private val delayInSeconds: Int = workerParams.inputData.getInt(KEY_DELAY, 0)

    override fun doWork(): Result {
        // Do intensive task here
        // For now, our old friend Thread.sleep can replace intensive task here.
        Thread.sleep(delayInSeconds * 1000L)

        // Return with success
        return Result.success()
    }

    companion object {
        const val KEY_DELAY = "com.quipper.wmdemo.DELAY"
    }
}

To run this worker, we have to enqueue it to WorkManager.

val inputData: Data = Data.Builder().putInt(SimpleWorker.KEY_DELAY, 5).build()

val simpleWorkRequest = OneTimeWorkRequest.Builder(SimpleWorker::class.java)
        .setInputData(inputData)
        .build()

WorkManager.getInstance().enqueue(simpleWorkRequest)

Let see line by line how the code works.

class SimpleWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams)

Since the Worker class doesn’t have any default empty constructor, we have to specify the arguments Context and WorkerParameters in the constructor and pass them for super construction.

private val delayInSeconds: Int = workerParams.inputData.getInt(KEY_DELAY, 0)

We can use the WorkerParameters to get input data which returns the Data object we specified in the following code as inputData. The Data class facilitates key-value paired data, very similar to that of Intent an or a Bundle.

override fun doWork(): Result {
    // Do intensive task here
    // For now, our old friend Thread.sleep can replace intensive task here.
    Thread.sleep(delayInSeconds * 1000L)
    // Return with success
    return Result.success()
}

The doWork method runs on a WorkerThread and returns a Result object. There are few static methods available in Result class to make development easier. Result.success() Returning this object tells WorkManager that the work was executed successfully. Result.failure() means the Worker has failed to do its work. We will see in details about Results in the next example.

val inputData: Data = Data.Builder().putInt(SimpleWorker.KEY_DELAY, 5).build()

Data class uses a Builder pattern and we can specify the key and values as data input to our Worker class.

val simpleWorkRequest = OneTimeWorkRequest.Builder(SimpleWorker::class.java)
        .setInputData(inputData)
        .build()

We can run our Worker class as either a single task which is executed once or as a repetitive task. For now, we will use OneTimeRequestBuilder to run our task once. So we specify the worker we want to run, set the input data and build the request. Requesting WorkManager to execute our request is as easy as a one-liner.

WorkManager.getInstance().enqueue(simpleWorkRequest)

To observe the state of the worker, we need the ID. Every WorkRequest object has an auto-generated unique ID. You should save it if you need to get information about the work.

val workerId = simpleWorkRequest.id

Then we can observe the state by observing LiveData provided by WorkManager.

val workerId = simpleWorkRequest.id
        
WorkManager.getInstance().getWorkInfoByIdLiveData(workerId)
        .observe(this, Observer<WorkInfo> {
            it?.let { workInfo ->
                when (workInfo.state) {
                    WorkInfo.State.ENQUEUED ->
                        Log.d(TAG, "Worker ENQUEUED")
                    WorkInfo.State.RUNNING ->
                        Log.d(TAG, "Worker RUNNING")
                    WorkInfo.State.SUCCEEDED ->
                        Log.d(TAG, "Worker SUCCEEDED")
                    WorkInfo.State.FAILED ->
                        Log.d(TAG, "Worker FAILED")
                    WorkInfo.State.BLOCKED ->
                        Log.d(TAG, "Worker BLOCKED")
                    WorkInfo.State.CANCELLED ->
                        Log.d(TAG, "Worker CANCELLED")
                }
            }
        })

Or, in more simple terms,

WorkManager.getInstance().getWorkInfoByIdLiveData(workerId)
        .observe(this, Observer<WorkInfo> {
            it?.let { workInfo ->
                Log.d(TAG, "WorkStatus: $workInfo")
            }
        })

As opposed to other Background execution mechanisms, WorkManager provides LiveData. The observer on LiveData manages itself according to the UI lifecycle, so we don’t have to worry about managing its subscriptions.

Moving on to an Advanced Mode

Now you know how to run a simple Worker with WorkManager. However, simple is never enough for real-time situations. Let’s see how will you implement a real-time Worker to do a heavy network task, which takes some input and returns output on success.

class NetworkWorker(private val context: Context, private val params: WorkerParameters) : Worker(context, params) {

    override fun doWork(): Result {

        // Get the input data
        val userData = params.inputData.getStringArray(KEY_USER_DATA)
        val secretKey = params.inputData.getString(KEY_SECRET_KEY)

        secretKey ?: run {
            Log.e(TAG, "doWork: SecretKey not found in WorkParams")
            return Result.failure()
        }

        MockNetworkCall.initializeWithParameters(context, secretKey)

        return try {
            val result = MockNetworkCall.doLongRunningNetworkCall(userData)

            Result.success(result.toOutputData())

        } catch (ioError: IOException) {
            // This means there was a problem with network.
            Result.retry()
        } catch (otherError: Exception) {
            Result.failure()
        }
    }

    companion object {
        private const val TAG = "NetworkWorker"
        private const val KEY_USER_DATA = "key_user_data"
        private const val KEY_SECRET_KEY = "key_secret_key"

        const val NAME = "com.quipper.wmdemo.NetworkWorker"

        fun buildRequest(secretKey: String, userData: Array<String>): OneTimeWorkRequest {

            val inputData = Data.Builder()
                    .putString(KEY_SECRET_KEY, secretKey)
                    .putStringArray(KEY_USER_DATA, userData)
                    .build()

            val constraints = Constraints.Builder()
                    .setRequiredNetworkType(NetworkType.CONNECTED)
                    .setRequiresCharging(true)
                    .build()

            return OneTimeWorkRequest.Builder(NetworkWorker::class.java)
                    .setInputData(inputData)
                    .setConstraints(constraints)
                    .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.MINUTES)
                    .build()
        }
    }
}

Again let’s go through the code, understanding how it works.

fun buildRequest(secretKey: String, userData: Array<String>): OneTimeWorkRequest

It’s good practice to encapsulate the building logic for a Worker in its own companion object. The method buildRequest takes input as simple arguments, builds a OneTimeWorkRequest object and returns it.

val inputData = Data.Builder()
        .putString(KEY_SECRET_KEY, secretKey)
        .putStringArray(KEY_USER_DATA, userData)
        .build()

The Data class in androidx.work package uses a Map<String, Object> as a key-value store. It should be a lightweight container for passing data and thus enforces a max value of 10240 bytes for a serialized state. You can use all primitive data types along with String and Arrays to pass data to Worker through the Data class.

val constraints = Constraints.Builder()
        .setRequiredNetworkType(NetworkType.CONNECTED)
        .setRequiresCharging(true)
        .build()

You can specify the constraints and WorkManager runs the worker class until the constraints are satisfied by the system. For this Worker, we need a connected state since it is a network task and we also add setRequiresCharging to true because we don’t want to use the user’s battery when running our heavy task.

return OneTimeWorkRequest.Builder(NetworkWorker::class.java)
        .setInputData(inputData)
        .setConstraints(constraints)
        .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.MINUTES)
        .build()

As mentioned previously, the -ktx library, let us use a different method for building request which does not use ::class.java but a generic way.

return OneTimeWorkRequestBuilder<NetworkWorker>()
        .setInputData(inputData)
        .setConstraints(constraints)
        .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.MINUTES)
        .build()

Setting input data and constraints is simple enough. The setBackoffCriteriamethods let us set the way WorkManager will handle our worker when we ask to retry the execution.

Le’s see the doWork method in details.

val userData = params.inputData.getStringArray(KEY_USER_DATA)
val secretKey = params.inputData.getString(KEY_SECRET_KEY)
secretKey ?: run {
    Log.e(TAG, "doWork: SecretKey not found in WorkParams")
    return Result.failure()
}
MockNetworkCall.initializeWithParameters(context, secretKey)

This code block satisfies the prerequisites required for out network call. If there is something wrong with the input params, we return Result.failure() with an error message.

return try {
    val result = MockNetworkCall.doLongRunningNetworkCall(userData)
    Result.success(result.toOutputData())
} catch (ioError: IOException) {
    // This means there was a problem with network.
    Result.retry()
} catch (otherError: Exception) {
    Result.failure()
}

In the next code block, we try to execute our task. If successful we return Result.success() with the output data. If there is any situational error and we know that if we retry, the task may be executed, we return Result.retry(). This tells the WorkManager that the task is not successful yet and it should be executed again. Here, the backoff criteria are used which we set while building the request. If there is any non-recoverable error, we return Result.failure() with the error message.

Similarly, if we want to run this task periodically, say every 6 hours, we have to build a PeriodicWorkRequest and enqueue it.

fun buildPeriodicRequest(secretKey: String, userData: Array<String>): PeriodicWorkRequest {
    
    val inputData = Data.Builder()
            .putString(KEY_SECRET_KEY, secretKey)
            .putStringArray(KEY_USER_DATA, userData)
            .build()
    val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .setRequiresCharging(true).build()

    return PeriodicWorkRequest.Builder(NetworkWorker::class.java, 6, TimeUnit.HOURS)
            .setInputData(inputData)
            .setConstraints(constraints)
            .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.MINUTES)
            .build()
}

In UI, we can use it as

workManager.enqueueUniquePeriodicWork(
        NetworkWorker.NAME,
        ExistingPeriodicWorkPolicy.REPLACE,
        NetworkWorker.buildPeriodicRequest(secretKey, userData))

This runs this task every 6 hours if the network is available and the device is charging. Since we add ExstingPeriodicWorkPolicy to REPLACE, WorkManager replaces the existing task, if running or enqueued with the new task scheduled.

Where can we use WorkManager

After seeing WorkManager in action, we know it’s easy to use, supports UI lifecycle and results can be retrieved quickly. So why not use WorkManager everywhere? Not exactly. WorkManager is designed for guaranteed execution, but not necessary to be on perfect timing. Let’s see when to use WorkManager and when to not with few real-time use cases.

  • For network requests which are UI related, we can use Threadpool, RxJava or Kotlin coroutines. Here’s one of our previous posts about it.
  • https://quipper.hatenablog.com/entry/2018/10/29/introduction-to-kotlin-coroutines
  • For tasks that are to be scheduled at the exact time, such as alarms or reminders, you should use AlarmManager.
  • For continuously running tasks, such as Music Player or live location tracking, Foreground Service is a better option.

That’s it, folks!

Now that you have seen WorkManager in action, you can use it right away or experiment on it to learn more.


If you like this article, don’t forget to clap and share it! If you have suggestions/improvements let me know!

In the above article, I have used gists for embedding code, but you can see the complete code here:

aruke/WorkManagerDemo
Code for simple WorkManager implementation. Contribute to aruke/WorkManagerDemo development by creating an account on GitHub.

References