WorkManager en 2026 : travail unique, chaînage et test des tâches d'arrière-plan sur Android
Un guide pratique de WorkManager sur Android — politiques de travail unique, chaînage, travail accéléré, et comment tester réellement une tâche d'arrière-plan avant sa mise en production.
La plupart du code WorkManager que je vois sur le terrain se résume à une seule OneTimeWorkRequest mise en file d’attente puis oubliée. Cela fonctionne jusqu’au moment où l’utilisateur appuie deux fois sur un bouton, où le processus de l’application meurt en pleine tâche, ou où un relecteur demande comment vous savez que la tâche s’est réellement exécutée. La vraie valeur de WorkManager n’est pas « planifier ceci pour plus tard » — ce sont les garanties d’unicité, de chaînage et de nouvelle tentative qui maintiennent une tâche d’arrière-plan correcte quand le monde qui l’entoure ne l’est pas. La majeure partie de cette valeur reste inexploitée parce qu’il est facile de passer à côté de la surface d’API qui la porte.
J’utilise WorkManager dans trois applications pour trois tâches différentes — un export nocturne dans Subly, un recalcul des données de garde-manger dans Stocky, et des écritures de sauvegarde CSV dans Granyn — et tous les bugs que j’ai livrés remontent au fait d’avoir sauté l’une de ces trois pièces.
Travail unique : le garde-fou contre les tâches en double
Le bug WorkManager le plus courant n’est pas un crash, c’est un doublon. Un utilisateur appuie deux fois sur « exporter » avant que le spinner du premier appui n’apparaisse, et voilà deux tâches d’export identiques mises en file. enqueue() seul ne fait rien pour empêcher cela — il met joyeusement les deux en file.
enqueueUniqueWork est la solution, et l’argument de politique est la partie que les gens se trompent :
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,
)
}
Les trois valeurs ExistingWorkPolicy correspondent à trois intentions différentes, et choisir la mauvaise est le vrai bug :
KEEP— si un travail portant ce nom est déjà en attente ou en cours, abandonner la nouvelle requête. C’est le bon choix pour « exporter » — un deuxième appui ne doit ni redémarrer ni dupliquer le premier.REPLACE— annuler le travail existant et repartir à zéro. C’est le bon choix quand la nouvelle requête porte des données d’entrée mises à jour qui rendent l’ancienne obsolète — l’utilisateur a changé la plage de dates de l’export en plein vol.APPEND_OR_REPLACE— s’enchaîner au travail existant s’il n’a pas encore commencé, ou démarrer une nouvelle chaîne sinon. Rare ; surtout pour des tâches séquentielles qui doivent s’exécuter dans l’ordre où elles ont été demandées.
enqueueUniquePeriodicWork prend le même argument de politique pour les tâches récurrentes, et le même raisonnement s’applique : une tâche de recalcul nocturne devrait presque toujours être KEEP ou UPDATE, pas REPLACE — remplacer réinitialise l’heure d’ancrage du planning périodique, ce qui décale silencieusement le moment où la tâche s’exécute.
Chaînage : séquencer sans callback fait main
Un flux de sauvegarde est rarement une seule étape — sérialiser les données, les compresser, les écrire sur disque, vérifier l’écriture. Chaîner des WorkRequest exprime cela comme des données, pas comme des callbacks imbriqués :
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 sortie de chaque worker devient automatiquement l’entrée du worker suivant, via 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 est volontairement petit — il est soutenu par une base de données interne à taille limitée, pas un canal de charge utile généraliste. Passez des chemins de fichiers et des identifiants, pas le contenu des fichiers lui-même. Si une étape de la chaîne échoue, Result.failure() arrête tout ce qui suit en aval ; la chaîne ne continue pas silencieusement avec une entrée manquante.
Nouvelle tentative et backoff : ne laissez pas le défaut vous surprendre
Result.retry() indique à WorkManager de retenter le worker, et par défaut il attend 30 secondes, puis recule de façon exponentielle, plafonné à 5 heures. Pour une tâche avec une vraie dépendance réseau, le défaut convient généralement. Pour une tâche qui retente à cause d’un échec local et transitoire — un verrou de fichier, une condition de stockage faible momentanée — 30 secondes est souvent trop long pour quelque chose que l’utilisateur attend activement.
Définissez la politique explicitement plutôt que de faire confiance silencieusement au défaut :
OneTimeWorkRequestBuilder<WriteToDiskWorker>()
.setBackoffCriteria(
BackoffPolicy.LINEAR,
10, TimeUnit.SECONDS,
)
.build()
Et distinguez délibérément Result.retry() de Result.failure() dans le worker lui-même — c’est la partie que les gens font à l’envers :
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()
}
}
Une erreur de permission retentée cinq fois sur cinq heures n’est pas de la résilience, c’est une tâche qui échoue silencieusement cinq fois avant que l’utilisateur ne s’en aperçoive. Ne retentez que les modes d’échec que le temps peut réellement corriger.
Travail accéléré : pour la tâche que l’utilisateur regarde
Toutes les tâches d’arrière-plan ne peuvent pas attendre que le planificateur de WorkManager décide quand s’exécuter. Si un utilisateur appuie sur « exporter maintenant » et s’attend à ce que ça démarre en quelques secondes — pas dès que les heuristiques Doze du système le permettent — marquez-la comme accélérée :
OneTimeWorkRequestBuilder<ExportWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
Le travail accéléré s’exécute immédiatement (sous réserve du quota d’exécution quotidien de l’application) et bénéficie d’une courte période de grâce même si l’application passe en arrière-plan en cours d’exécution. L’argument OutOfQuotaPolicy décide de ce qui se passe une fois que l’application a épuisé son quota du jour : RUN_AS_NON_EXPEDITED_WORK_REQUEST retombe sur une planification normale au lieu de lever une exception. Réservez cela aux tâches réellement initiées par l’utilisateur et visibles par lui — l’utiliser pour une synchronisation d’arrière-plan de routine annule la planification économe en batterie qui est tout l’intérêt de WorkManager.
Tester : l’étape que presque tout le monde saute
WorkManager fournit un artefact de test spécifiquement pour qu’un worker n’ait pas besoin d’une application en cours d’exécution pour être vérifié. Presque personne ne l’utilise, et c’est la différence entre trouver un bug de données d’entrée en revue de code et le découvrir dans un rapport de bug utilisateur.
@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 fait exécuter la tâche en ligne plutôt que sur un thread d’arrière-plan, de sorte que le test n’a pas besoin d’un sleep ou d’un verrou pour attendre la fin. Cela capture les deux bugs que le chaînage et la logique de nouvelle tentative dissimulent particulièrement bien lors des tests manuels : un worker qui avale silencieusement une exception et retourne quand même success(), et une étape de chaîne qui lit la mauvaise clé dans inputData.
Ce qui a vraiment compté
Sur les trois tâches que je fais tourner sur WorkManager, le schéma qui a tenu n’était pas ingénieux — il était cohérent : nommer explicitement chaque travail unique et choisir sa ExistingWorkPolicy délibérément, chaîner les tâches à plusieurs étapes plutôt que d’imbriquer des callbacks, ne retenter que les échecs que le temps peut corriger, et écrire le test WorkManagerTestInitHelper avant de faire confiance à une chaîne en production. Rien de tout cela n’est une API exotique. Ce sont les parties de WorkManager qu’il est facile de laisser à leurs valeurs par défaut, et ces valeurs par défaut sont assez souvent fausses pour que ça compte.
// À lire aussi
D’autres notes du journal
Android App Links en 2026 : pourquoi vos liens https:// s'ouvrent encore dans le navigateur
La vérification Digital Asset Links, les pièges d'assetlinks.json et les commandes adb qui expliquent pourquoi Android ne confie pas le lien à votre app.
L'API In-App Review d'Android : demander une note sans être pénible
Un guide pratique de l'API In-App Review de Google — comment elle fonctionne réellement, quand la déclencher, et pourquoi la popup classique « notez-nous » nuit discrètement à votre note sur le Play Store.
Raccourcis d'application et tuiles de Paramètres rapides sur Android : enregistrer de l'eau sans ouvrir l'application
Un guide pratique de l'API dynamique ShortcutManager et de TileService sur Android — comment permettre à une action en un tap de contourner complètement l'application, avec les pièges qui font trébucher la plupart des implémentations.