スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

WorkManager for Everyone

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 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.

Some smart developers know that this happens when you are overloading MainThread with work. This causes glitches in the app and ultimately slows the interaction of 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 run method and the 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 was the best way to manage work, especially when the app is not running in the foreground. Still, for a Service, we 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.

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. Unless using as foreground service (with notification) there is no good reason to use Service. 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 was simple and had 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 handle 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

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 stores results with Room database. Arguments, work details. 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 specified.

  • 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.

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(“delay”, 0)

   override fun doWork(): Result {
      
       try {
           // Do intensive task here
           Thread.sleep(delayInSeconds * 1000L)
           // Return with success
           return Result.success()
          
       } catch (ie: InterruptedException) {
           return Result.failure()
       }
   }
}

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

val inputData: Data = Data.Builder().putInt("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(“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 {
      
       try {
           // Do intensive task here
           Thread.sleep(delayInSeconds * 1000L)
           // Return with success
           return Result.success()
          
       } catch (ie: InterruptedException) {
           return Result.failure()
       }
   }

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("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.

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")
           }
       }
   })

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(
              Data.Builder()
                  .putString(KEY_FAILURE_REASON, "Parameter secretKey was not found in input data.")
                  .build()
          )
       }


       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 the network.
           Result.retry()

       } catch (otherError: Exception) {

           Result.failure(
               Data.Builder()
                   .putString(KEY_FAILURE_REASON, otherError.message)
                   .build()
           )
       }
   }

   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"
       private const val KEY_FAILURE_REASON = "key_failure_reason"

       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()
       }
      
       fun getFailureReason(data: Data): String {
           return data.getString(KEY_FAILURE_REASON) ?: "No reason found in data."
       }
   }
}


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 user’s battery when running our heavy task.


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

Setting input data and constraints is simple enough. The setBackoffCriteria methods 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.


       // 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(
              Data.Builder()
                  .putString(KEY_FAILURE_REASON, "Parameter secretKey was not found in input data.")
                  .build()
          )
       }


       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 the network.
           Result.retry()

       } catch (otherError: Exception) {

           Result.failure(
               Data.Builder()
                   .putString(KEY_FAILURE_REASON, otherError.message)
                   .build()
           )
       }

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.

  • For tasks that are to be scheduled on exact time, such as alarms or reminders, you should use AlarmManager.

  • For continuously running tasks, such as Music Player or live location tracking, ForegroundService 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.

References :