How to make Google‘s Work Manager work for you

As an Android developer, you‘ve likely encountered scenarios where your app needs to perform background processing – such as downloading files, uploading data to a server, synchronizing content, or executing long-running tasks. While Android offers various options for background work, choosing the right solution and implementing it correctly can be challenging. This is where Google‘s WorkManager API shines.

WorkManager is part of Android Jetpack and aims to simplify background processing in a battery-efficient and user-friendly manner. According to the Android Developer docs, WorkManager is the recommended solution for background work that‘s deferrable and requires guaranteed execution:

"WorkManager is an API that makes it easy to schedule deferrable, asynchronous tasks that are expected to run even if the app exits or the device restarts."

The key benefits of using WorkManager include:

  1. Guaranteed execution of your background work, even if the user navigates away from your app or the device restarts. WorkManager takes care of persisting and rescheduling work as needed.

  2. Ability to define constraints and backoff criteria for work. For example, you can ensure certain tasks only run when the device is charging, idle, or has a network connection. You can also specify retry and backoff policies for more reliable execution.

  3. Support for both one-off and periodic tasks. WorkManager allows you to schedule work that runs once or on a repeating interval.

  4. Chaining of sequential and parallel work using a fluent APIs. WorkManager makes it easy to define complex workflows where multiple tasks are executed in a specific order or concurrently.

  5. Built-in threading and asynchronous processing without directly managing threads yourself. WorkManager abstracts away the low-level details of multithreading.

  6. Integration with other Jetpack components like LiveData for observable work statuses and results.

So how do you get started with WorkManager in your Android app? Let‘s walk through the key steps and best practices.

Adding the WorkManager Dependency

First, add the WorkManager dependency to your app-level build.gradle file:

dependencies {
  def work_version = "2.5.0"
  implementation "androidx.work:work-runtime-ktx:$work_version"
}

Defining Your Worker

Next, create a worker class that extends the Worker abstract class and implements the doWork() method. This is where you define the actual work to be performed in the background.

For example, let‘s say we want to download a file from a remote server:

class DownloadWorker(appContext: Context, workerParams: WorkerParameters) : 
     Worker(appContext, workerParams) {

    override fun doWork(): Result {
        val inputUrl = inputData.getString("url") ?: return Result.failure()
        val outputFile = File(applicationContext.filesDir, "downloaded.zip")

        try {
            val downloadedBytes = URL(inputUrl).openStream().use { input ->
                outputFile.outputStream().use { output ->
                    input.copyTo(output)
                }
            }

            val outputData = workDataOf("size" to downloadedBytes)
            return Result.success(outputData)
        } catch (e: Exception) {
            return Result.failure()
        }
    }
}

Here we take an input URL from the WorkRequest, download the file contents, save it to local storage, and return the downloaded byte size as output data. We handle any errors and return a failure Result if anything goes wrong.

Scheduling Work

To schedule the worker, create an instance of a OneTimeWorkRequest or PeriodicWorkRequest and provide your worker class:

val downloadRequest = OneTimeWorkRequestBuilder<DownloadWorker>()
    .setInputData(workDataOf("url" to "https://example.com/file.zip"))
    .build()

WorkManager.getInstance(applicationContext).enqueue(downloadRequest)

You can also specify constraints and backoff criteria when building the request:

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

val downloadRequest = OneTimeWorkRequestBuilder<DownloadWorker>()
    .setInputData(workDataOf("url" to "https://example.com/file.zip"))
    .setConstraints(constraints)
    .setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.SECONDS)
    .build()

This ensures the download only occurs when the device has an unmetered network connection (e.g. WiFi) and is charging. If the work fails, it will be retried with a 30 second linear backoff delay.

Observing Work Status

To observe the status of your background work and react to its completion, use WorkManager‘s LiveData support:

WorkManager.getInstance(applicationContext)
    .getWorkInfoByIdLiveData(downloadRequest.id)
    .observe(lifecycleOwner, Observer { workInfo ->
        if (workInfo?.state == WorkInfo.State.SUCCEEDED) {
            val size = workInfo.outputData.getInt("size", 0)
            Log.d("WorkManager", "Download completed, size: $size bytes")
        }
    })

This observes the WorkInfo for the enqueued downloadRequest and logs a message with the output byte size when the work successfully completes.

Chaining Work

For more complex workflows involving multiple sequential or parallel tasks, use WorkManager‘s chaining APIs:

val compressRequest = OneTimeWorkRequestBuilder<CompressWorker>()
    .setInputData(workDataOf("file" to "downloaded.zip"))
    .build()

val uploadRequest = OneTimeWorkRequestBuilder<UploadWorker>()
    .setInputData(workDataOf("archive" to "compressed.zip"))
    .build()

WorkManager.getInstance(applicationContext)
    .beginWith(downloadRequest)
    .then(compressRequest)
    .then(uploadRequest)
    .enqueue()

This defines a chain of work where the downloaded file is compressed, then the compressed archive is uploaded to a server. The work will be executed sequentially in the specified order.

For parallel execution, use combineWith() instead of then():

WorkManager.getInstance(applicationContext)
    .beginWith(listOf(downloadRequest1, downloadRequest2))
    .then(uploadRequest)
    .enqueue()

Here, downloadRequest1 and downloadRequest2 will run in parallel, and the upload will only occur after both downloads have completed.

Testing WorkManager

To test your WorkManager code, use the WorkManagerTestInitHelper and a SynchronousExecutor:

@RunWith(AndroidJUnit4::class)
class DownloadWorkerTest {
    @get:Rule
    val workManagerRule = WorkManagerTestInitHelper.getWorkManagerTestRule()

    @Before
    fun setUp() {
        WorkManagerTestInitHelper.initializeTestWorkManager(
            InstrumentationRegistry.getInstrumentation().targetContext,
            SynchronousExecutor()
        )
    }

    @Test
    fun testDownloadWorker() {
        val request = OneTimeWorkRequestBuilder<DownloadWorker>()
            .setInputData(workDataOf("url" to "https://example.com/file.zip"))
            .build()

        val workManager = WorkManager.getInstance(ApplicationProvider.getApplicationContext())
        workManager.enqueue(request).result.get()

        val workInfo = workManager.getWorkInfoById(request.id).get()
        assertThat(workInfo.state, `is`(WorkInfo.State.SUCCEEDED))
        assertThat(workInfo.outputData.getInt("size", 0), `is`(greaterThan(0)))
    }
}

This test enqueues a DownloadWorker, waits for it to complete synchronously, then verifies the final WorkInfo state is SUCCEEDED and the output byte size is greater than 0.

Best Practices

When using WorkManager, keep the following best practices in mind:

  1. Keep your workers focused and manageable. Each worker class should focus on a single task or a small set of closely related tasks. If a worker becomes too complex, consider breaking it into smaller, more focused workers.

  2. Avoid long-running tasks that exceed 10 minutes. Android may terminate your app‘s process if it exceeds memory limits or consumes excessive battery. For longer tasks, consider using a foreground service or breaking the work into smaller chunks.

  3. Use input/output data for small payloads only. For passing larger data between workers, write to a local file and pass the file path in the input/output Data instead.

  4. Be mindful of system resources and battery. Use constraints to defer non-urgent tasks until the device is charging or idle. Batch work together when possible to reduce the frequency of background processing.

  5. Handle errors and edge cases gracefully. Use try/catch blocks to handle exceptions and return an appropriate Result (success, failure, or retry) from doWork(). Consider exponential backoff for retrying network-related failures.

Conclusion

WorkManager is a powerful and flexible tool for simplifying background processing in Android. By encapsulating your background work in focused Worker classes, defining proper constraints, and chaining tasks as needed, you can create robust and efficient background workflows.

While WorkManager is suitable for most common use cases, it may not be ideal for every scenario. For long-running tasks that should remain active even if your app is terminated, consider using a foreground service instead. For precise, short-duration tasks that must execute at an exact time (e.g. calendar event notifications), AlarmManager may be more appropriate.

By understanding WorkManager‘s capabilities and best practices, you‘ll be able to make informed decisions about when and how to use it effectively in your Android app development. With WorkManager handling the heavy lifting of background processing, you can focus on building great app experiences for your users.

Similar Posts