Перейти к содержимому
Все записи

WorkManager в 2026 году: уникальная работа, цепочки задач и тестирование фоновых заданий на Android

Практическое руководство по WorkManager на Android — политики уникальной работы, цепочки задач, ускоренное выполнение и как на самом деле протестировать фоновое задание перед релизом.

MFKAPPS 6 мин чтения

Большая часть кода WorkManager, который я вижу в реальных проектах, — это одиночный OneTimeWorkRequest, поставленный в очередь и забытый. Это работает ровно до того момента, пока пользователь не нажмёт кнопку дважды, процесс приложения не умрёт посреди задания или рецензент не спросит, откуда вы знаете, что задание действительно выполнилось. Настоящая ценность WorkManager не в «запланировать это на потом» — это гарантии уникальности, цепочек и повторных попыток, которые сохраняют фоновое задание корректным, когда окружающий мир ненадёжен. Большая часть этой ценности остаётся неиспользованной, потому что поверхность API, которая её обеспечивает, легко упустить из виду.

Я использую WorkManager в трёх приложениях для трёх разных задач — ночной экспорт в Subly, пересчёт данных кладовой в Stocky и запись CSV-резервных копий в Granyn — и все баги, которые я выпускал, в итоге сводились к пропуску одной из этих трёх составляющих.

Уникальная работа: защита от дублирующихся заданий

Самый распространённый баг WorkManager — это не сбой, а дубликат. Пользователь нажимает «экспорт» дважды до того, как появится спиннер первого нажатия, и теперь в очереди оказываются два одинаковых задания на экспорт. Один только enqueue() никак этому не препятствует — он с радостью ставит в очередь оба.

enqueueUniqueWork — это решение, и именно с аргументом политики люди чаще всего ошибаются:

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

Три значения ExistingWorkPolicy соответствуют трём разным намерениям, и выбор неверного — это и есть настоящий баг:

  • KEEP — если работа с этим именем уже ожидает выполнения или выполняется, отбросить новый запрос. Правильно для «экспорта» — второе нажатие не должно ни перезапускать, ни дублировать первое.
  • REPLACE — отменить существующую работу и начать заново. Правильно, когда новый запрос несёт обновлённые входные данные, из-за которых старый становится неактуальным — пользователь изменил диапазон дат экспорта прямо на лету.
  • APPEND_OR_REPLACE — присоединиться к существующей работе, если она ещё не началась, иначе начать новую цепочку. Редкий случай; в основном для последовательных заданий, которые должны выполняться в том порядке, в котором были запрошены.

enqueueUniquePeriodicWork принимает тот же аргумент политики для повторяющихся заданий, и та же логика применима: ночное задание пересчёта почти всегда должно быть KEEP или UPDATE, а не REPLACE — замена сбрасывает опорное время периодического расписания, что незаметно сдвигает момент выполнения задания.

Цепочки задач: упорядочивание без самодельного колбэка

Процесс резервного копирования редко состоит из одного шага — сериализовать данные, сжать их, записать на диск, проверить запись. Связывание WorkRequest в цепочку выражает это как данные, а не как вложенные колбэки:

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()

Вывод каждого worker автоматически становится входом следующего worker через 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 намеренно небольшой — он опирается на внутреннюю базу данных с ограничением по размеру, а не на канал для передачи произвольных полезных нагрузок. Передавайте пути к файлам и идентификаторы, а не само содержимое файлов. Если какой-то шаг в цепочке завершается неудачей, Result.failure() останавливает выполнение всего, что идёт дальше по цепочке; цепочка не продолжается молча с отсутствующими входными данными.

Повторные попытки и backoff: не давайте значению по умолчанию застать вас врасплох

Result.retry() сообщает WorkManager, что нужно повторить попытку выполнения worker, и по умолчанию он ждёт 30 секунд, а затем откладывает выполнение по экспоненте, с ограничением в 5 часов. Для задания с реальной сетевой зависимостью значение по умолчанию обычно подходит. Для задания, которое повторяется из-за локального, временного сбоя — блокировки файла, кратковременной нехватки места — 30 секунд часто оказываются слишком долгим ожиданием для того, чего пользователь активно ждёт.

Задавайте политику явно, а не полагайтесь молча на значение по умолчанию:

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

И сознательно разграничивайте Result.retry() и Result.failure() внутри самого worker — именно в этом месте люди чаще всего делают наоборот:

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

Ошибка доступа, повторяемая пять раз в течение пяти часов, — это не отказоустойчивость, это задание, которое молча проваливается пять раз, прежде чем пользователь вообще об этом узнает. Повторяйте только те режимы сбоя, которые время действительно способно исправить.

Ускоренная работа: для задания, за которым следит пользователь

Не каждое фоновое задание может дожидаться, пока планировщик WorkManager решит, когда его выполнить. Если пользователь нажимает «экспортировать сейчас» и ожидает, что задание начнётся через секунды — а не когда это позволят эвристики Doze системы, — пометьте его как ускоренное:

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

Ускоренная работа выполняется немедленно (в пределах дневной квоты выполнения приложения) и получает короткий льготный период, даже если приложение уходит в фон в процессе выполнения. Аргумент OutOfQuotaPolicy определяет, что происходит после того, как приложение исчерпало квоту на день: RUN_AS_NON_EXPEDITED_WORK_REQUEST откатывается к обычному планированию вместо выброса исключения. Прибегайте к этому только для по-настоящему инициированной пользователем и видимой ему работы — использование этого для рутинной фоновой синхронизации сводит на нет бережное к батарее планирование, ради которого WorkManager вообще существует.

Тестирование: шаг, который пропускают почти все

WorkManager поставляется со специальным тестовым артефактом именно для того, чтобы worker не требовал запущенного приложения для проверки. Почти никто им не пользуется, а ведь это разница между тем, чтобы найти баг во входных данных на code review, и тем, чтобы найти его из багрепорта пользователя.

@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 заставляет задание выполняться синхронно, а не в фоновом потоке, поэтому тесту не нужны sleep или latch, чтобы дождаться завершения. Это позволяет поймать два бага, которые цепочки и логика повторных попыток особенно хорошо скрывают при ручном тестировании: worker, который молча проглатывает исключение и всё равно возвращает success(), и шаг цепочки, который читает не тот ключ из inputData.

Что действительно имело значение

На примере трёх заданий, которые я запускаю через WorkManager, закономерность, которая себя оправдала, была не хитроумной — она была последовательной: явно называть каждую уникальную работу и осознанно выбирать её ExistingWorkPolicy, объединять многошаговые задания в цепочки вместо вложенных колбэков, повторять только те сбои, которые может исправить время, и писать тест с WorkManagerTestInitHelper прежде чем доверять цепочке в продакшене. Ни одно из этого не экзотический API. Это те части WorkManager, которые легко оставить в значениях по умолчанию, а эти значения по умолчанию ошибочны достаточно часто, чтобы это имело значение.

// По теме

Ещё из журнала

MFKAPPS 4 мин чтения

Android App Links в 2026 году: почему ваши https:// ссылки всё ещё открываются в браузере

Верификация Digital Asset Links, подводные камни assetlinks.json и adb-команды, которые объясняют, почему Android не передаёт ссылку вашему приложению.

#android #engineering #mobile
MFKAPPS 4 мин чтения

In-App Review API в Android: как просить оценку, не раздражая пользователя

Практическое руководство по In-App Review API от Google — как он на самом деле работает, когда его запускать и почему обычный попап «оцените нас» незаметно портит вашу оценку в Play Store.

#android #engineering #kotlin
MFKAPPS 5 мин чтения

Ярлыки приложений Android и плитка быстрых настроек: логирование воды без открытия приложения

Практическое руководство по динамическому ShortcutManager API и TileService в Android — как позволить действию в один тап полностью обойти приложение, и о подводных камнях, на которых спотыкается большинство реализаций.

#android #engineering #kotlin