Saltar al contenido
Todas las entradas

WorkManager en 2026: trabajo único, encadenamiento y prueba de tareas en segundo plano en Android

Una guía práctica de WorkManager en Android — políticas de trabajo único, encadenamiento, trabajo acelerado y cómo probar de verdad una tarea en segundo plano antes de publicarla.

MFKAPPS 7 min de lectura

La mayoría del código de WorkManager que veo en la práctica es un único OneTimeWorkRequest puesto en cola y olvidado. Eso funciona hasta que el usuario toca un botón dos veces, el proceso de la app muere a mitad de la tarea, o un revisor pregunta cómo sabes que la tarea realmente se ejecutó. El verdadero valor de WorkManager no es «programar esto para más tarde» — son las garantías en torno a la unicidad, el encadenamiento y los reintentos las que mantienen correcta una tarea en segundo plano cuando el mundo que la rodea no lo es. Gran parte de ese valor queda sin usar porque es fácil pasar por alto la superficie de la API que lo ofrece.

Uso WorkManager en tres apps para tres tareas distintas — una exportación nocturna en Subly, el recálculo de datos de despensa en Stocky, y escrituras de copia de seguridad CSV en Granyn — y todos los bugs que he publicado se remontan a saltarme una de estas tres piezas.

Trabajo único: la protección contra tareas duplicadas

El bug más común de WorkManager no es un fallo, es un duplicado. Un usuario toca «exportar» dos veces antes de que aparezca el spinner del primer toque, y ahora hay dos tareas de exportación idénticas en cola. enqueue() por sí solo no hace nada para evitarlo — encola felizmente ambas.

enqueueUniqueWork es la solución, y el argumento de política es la parte que la gente entiende mal:

fun scheduleExport(context: Context) {
    val request = OneTimeWorkRequestBuilder<ExportWorker>()
        .setConstraints(
            Constraints.Builder()
                .setRequiredNetworkType(NetworkType.NOT_REQUIRED)
                .build(),
        )
        .build()

    WorkManager.getInstance(context).enqueueUniqueWork(
        "monthly_export",
        ExistingWorkPolicy.KEEP,
        request,
    )
}

Los tres valores de ExistingWorkPolicy corresponden a tres intenciones distintas, y elegir el equivocado es el verdadero bug:

  • KEEP — si ya hay trabajo pendiente o en ejecución con este nombre, descarta la nueva solicitud. Correcto para «exportar» — un segundo toque no debería reiniciar ni duplicar el primero.
  • REPLACE — cancela el trabajo existente y empieza de nuevo. Correcto cuando la nueva solicitud trae datos de entrada actualizados que dejan obsoleta a la anterior — el usuario cambió el rango de fechas de la exportación a mitad de camino.
  • APPEND_OR_REPLACE — se encadena al trabajo existente si aún no ha empezado, o inicia una nueva cadena en caso contrario. Poco frecuente; sobre todo para tareas secuenciales que deben ejecutarse en el orden en que se solicitaron.

enqueueUniquePeriodicWork recibe el mismo argumento de política para tareas recurrentes, y aplica el mismo razonamiento: una tarea de recálculo nocturno casi siempre debería ser KEEP o UPDATE, no REPLACE — reemplazar reinicia el momento de anclaje del calendario periódico, lo que desplaza silenciosamente cuándo se ejecuta la tarea.

Encadenamiento: secuenciar sin un callback hecho a mano

Un flujo de copia de seguridad rara vez es un solo paso — serializar los datos, comprimirlos, escribirlos en disco, verificar la escritura. Encadenar WorkRequest expresa esto como datos, no como callbacks anidados:

val serialize = OneTimeWorkRequestBuilder<SerializeWorker>().build()
val compress = OneTimeWorkRequestBuilder<CompressWorker>().build()
val write = OneTimeWorkRequestBuilder<WriteToDiskWorker>().build()
val verify = OneTimeWorkRequestBuilder<VerifyBackupWorker>().build()

WorkManager.getInstance(context)
    .beginUniqueWork("full_backup", ExistingWorkPolicy.REPLACE, serialize)
    .then(compress)
    .then(write)
    .then(verify)
    .enqueue()

La salida de cada worker se convierte automáticamente en la entrada del siguiente worker, a través de Data:

class SerializeWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        val path = serializeToTempFile()
        return Result.success(workDataOf("serialized_path" to path))
    }
}

class CompressWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        val path = inputData.getString("serialized_path") ?: return Result.failure()
        val compressedPath = compress(path)
        return Result.success(workDataOf("compressed_path" to compressedPath))
    }
}

Data es deliberadamente pequeño — está respaldado por una base de datos interna de tamaño limitado, no un canal de carga útil de propósito general. Pasa rutas de archivos e IDs, no el contenido de los archivos en sí. Si un paso de la cadena falla, Result.failure() detiene todo lo que viene después; la cadena no continúa silenciosamente con una entrada faltante.

Reintentos y backoff: no dejes que el valor por defecto te sorprenda

Result.retry() le dice a WorkManager que reintente el worker, y por defecto espera 30 segundos, luego retrocede exponencialmente, con un tope de 5 horas. Para una tarea con una dependencia de red real, el valor por defecto suele estar bien. Para una tarea que reintenta por un fallo local y transitorio — un bloqueo de archivo, una condición momentánea de poco almacenamiento — 30 segundos suele ser demasiado tiempo para algo que el usuario está esperando activamente.

Configura la política explícitamente en lugar de confiar silenciosamente en el valor por defecto:

OneTimeWorkRequestBuilder<WriteToDiskWorker>()
    .setBackoffCriteria(
        BackoffPolicy.LINEAR,
        10, TimeUnit.SECONDS,
    )
    .build()

Y distingue deliberadamente Result.retry() de Result.failure() en el propio worker — esta es la parte que la gente hace al revés:

override suspend fun doWork(): Result {
    return try {
        writeBackupFile()
        Result.success()
    } catch (e: IOException) {
        if (runAttemptCount < 3) Result.retry() else Result.failure()
    } catch (e: SecurityException) {
        // Permission won't fix itself by retrying.
        Result.failure()
    }
}

Un error de permisos reintentado cinco veces a lo largo de cinco horas no es resiliencia, es una tarea que falla silenciosamente cinco veces antes de que el usuario se entere. Reintenta solo los modos de fallo que el tiempo realmente puede solucionar.

Trabajo acelerado: para la tarea que el usuario está observando

No toda tarea en segundo plano puede esperar a que el planificador de WorkManager decida cuándo ejecutarse. Si un usuario toca «exportar ahora» y espera que empiece en segundos — no cuando las heurísticas de Doze del sistema lo permitan — márcala como acelerada:

OneTimeWorkRequestBuilder<ExportWorker>()
    .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
    .build()

El trabajo acelerado se ejecuta de inmediato (sujeto a la cuota diaria de ejecución de la app) y obtiene un breve periodo de gracia incluso si la app pasa a segundo plano en pleno funcionamiento. El argumento OutOfQuotaPolicy decide qué ocurre una vez que la app ha agotado su cuota del día: RUN_AS_NON_EXPEDITED_WORK_REQUEST recurre a la programación normal en lugar de lanzar una excepción. Recurre a esto solo para trabajo genuinamente iniciado por el usuario y visible para él — usarlo para sincronización rutinaria en segundo plano anula la programación amigable con la batería que es la razón de ser de WorkManager.

Pruebas: el paso que casi todos se saltan

WorkManager incluye un artefacto de prueba específicamente para que un worker no necesite una app en ejecución para verificarse. Casi nadie lo usa, y es la diferencia entre encontrar un bug de datos de entrada en la revisión de código y encontrarlo en el reporte de un usuario.

@RunWith(AndroidJUnit4::class)
class ExportWorkerTest {

    @Before
    fun setup() {
        val config = Configuration.Builder()
            .setExecutor(SynchronousExecutor())
            .build()
        WorkManagerTestInitHelper.initializeTestWorkManager(
            ApplicationProvider.getApplicationContext(),
            config,
        )
    }

    @Test
    fun exportWorker_writesFile_onSuccess() {
        val request = OneTimeWorkRequestBuilder<ExportWorker>().build()
        val workManager = WorkManager.getInstance(
            ApplicationProvider.getApplicationContext(),
        )

        workManager.enqueue(request).result.get()
        val info = workManager.getWorkInfoById(request.id).get()

        assertThat(info.state).isEqualTo(WorkInfo.State.SUCCEEDED)
    }
}

SynchronousExecutor hace que la tarea se ejecute en línea en lugar de en un hilo en segundo plano, de modo que la prueba no necesita un sleep ni un latch para esperar a que termine. Esto detecta los dos fallos que el encadenamiento y la lógica de reintento ocultan especialmente bien en las pruebas manuales: un worker que traga silenciosamente una excepción y aun así devuelve success(), y un paso de la cadena que lee la clave equivocada de inputData.

Lo que realmente importó

En las tres tareas que ejecuto sobre WorkManager, el patrón que se sostuvo no fue ingenioso — fue coherente: nombrar explícitamente cada trabajo único y elegir su ExistingWorkPolicy a propósito, encadenar tareas de varios pasos en lugar de anidar callbacks, reintentar solo los fallos que el tiempo puede solucionar, y escribir la prueba de WorkManagerTestInitHelper antes de confiar en una cadena en producción. Ninguna de estas es una API exótica. Son las partes de WorkManager que es fácil dejar en sus valores por defecto, y esos valores por defecto son incorrectos con la frecuencia suficiente como para importar.