Service представляет собой компонент приложения для работы без UI. Он работает в главном потоке и сам никаких потоков не создаёт. Started-сервис живёт, пока его не остановят, bound-сервис живёт, пока к нему привязаны клиенты, а foreground-сервис работает как started-сервис с видимым уведомлением.
Started: startService() → onCreate() → onStartCommand() при каждом запуске; живёт до stopSelf() или stopService(), затем onDestroy().
Bound: bindService() → onBind() возвращает IBinder, у которого клиент вызывает методы; сервис уничтожается, когда отвязывается последний клиент, если его не запускали ещё и через start.
Foreground: started-сервис, повышенный через startForeground() с постоянным уведомлением. Система считает процесс видимым пользователю и почти не убивает его; с Android 14 нужен объявленный тип.
Главный поток: onStartCommand выполняется в главном потоке, поэтому блокирующая работа там подвешивает UI или даёт ANR. Запускайте свою корутину или поток и вызывайте stopSelf(), когда работа закончена.
**Service против Thread: поток отвечает только за выполнение; сервис представляет собой компонент, который отслеживает система, поэтому он повышает приоритет процесса, и его может запустить система или другое приложение.
IntentService**: устарел в API 30 вместе с JobIntentService; отложенная работа теперь уходит в WorkManager.
AndroidMiddle
Фоновая работа и сервисы
WorkManager, foreground-сервисы, типы сервисов, AlarmManager, Doze, ограничения фона
Закрыто вопросов
0 / 22
- 0Повторить
- 5Часто спрашивают
Вопросы
WorkManager является Jetpack API для отложенной работы, которая обязана завершиться: он сохраняет каждый запрос, перезапускает его после смерти процесса и перезагрузки, следит за ограничениями и повторами. Он гарантирует, что работа когда-нибудь выполнится, но никогда не гарантирует, когда именно.
Что он даёт
Запрос записывается в базу WorkManager на его фоновом executor-е; возвращённый Operation завершается, когда запись выполнена (enqueue(request).await()). После этого он переживает закрытие приложения, смерть процесса и перезагрузку.
Ограничения (сеть, зарядка, батарея, память), повторы с backoff, уникальные имена и цепочки обеспечивает библиотека, а не ваш код.
Состояние можно наблюдать откуда угодно: getWorkInfoByIdFlow(id) отдаёт ENQUEUED → RUNNING → SUCCEEDED, FAILED или CANCELLED.
Чего он не обещает
Время. Момент выбирают Doze, App Standby bucket и пакетирование системы; периодическая работа не может запускаться чаще раза в 15 минут.
Работу после принудительной остановки. Приложение после force stop ничего не выполняет, пока пользователь снова его не откроет, а батарейные менеджеры некоторых производителей тоже блокируют фоновую работу.
Дедлайн. Работа, чьи ограничения никогда не выполнятся, вечно остаётся в ENQUEUED.
Работе, которая бесполезна после ухода с экрана, место во viewModelScope; работе, которая должна случиться в точное время, место в AlarmManager.
OneTimeWorkRequest выполняется один раз и приходит в финальное состояние; PeriodicWorkRequest повторяется, пока его не отменят. Период не может быть меньше 15 минут (это нижняя граница системного планировщика задач), и меньшее значение поднимается до 15 с одним лишь предупреждением в логе.
One-time: можно отложить через setInitialDelay, поставить в цепочку и пометить expedited; заканчивается как SUCCEEDED, FAILED или CANCELLED.
Periodic: в цепочку не ставится и expedited быть не может, а после каждого прогона возвращается в ENQUEUED, поэтому SUCCEEDED никогда не показывает.
Нижние границы: PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS равен 15 минутам, а MIN_PERIODIC_FLEX_MILLIS равен 5 минутам. Ограничение ради батареи: каждый запуск может будить процессор и радиомодуль, а Doze растягивает интервал ещё сильнее.
Flex: билдер с четырьмя аргументами (1, HOURS, 15, MINUTES) означает «раз в час, где-то в последние 15 минут». Широкое окно позволяет системе объединить ваш запуск с запусками других приложений; это не способ добиться точности.
Если продукту нужны пять минут: пока экран открыт, опрашивайте сервер с экрана корутиной; когда сервер знает, что данные изменились, пусть отправит FCM-сообщение, которое поставит one-time работу.
Вход передаётся через setInputData(workDataOf(...)), а результат возвращается через Result.success(workDataOf(...)). Data хранится в базе WorkManager и ограничена 10 240 байтами, поэтому картинка на 2 МБ даёт IllegalStateException; вместо неё передают путь к файлу, URI или id строки.
Типы: только примитивы, String и массивы из них; Parcelable и объекты не поддерживаются.
Выход: читается из WorkInfo.outputData, когда работа в SUCCEEDED; в цепочке он автоматически становится входом следующего Worker. Ограничение в 10 КБ то же.
Прогресс: setProgress(...) в CoroutineWorker (setProgressAsync в Worker) виден в WorkInfo.progress, только пока работа в RUNNING, поэтому результат через него не передать.
Зачем ограничение: Data сериализуется в строку запроса и читается заново при каждом повторе. Это указатель, а не полезная нагрузка.
Что передавать вместо: запишите байты в filesDir или cacheDir и передайте путь, вставьте строку в Room и передайте её id или передайте URI content://. Для URI из ACTION_OPEN_DOCUMENT или photo picker сначала вызовите takePersistableUriPermission; для URI из share-интента доступ умирает вместе с принявшим его компонентом, поэтому скопируйте содержимое в filesDir и передайте путь. Иначе Worker получит SecurityException, когда наконец запустится через несколько часов.
Задайте два вопроса: должна ли работа пережить уход из приложения и нужен ли ей точный момент или присутствие пользователя? Работу, привязанную к экрану, запускайте в корутине; отложенную, но обязательную ставьте в WorkManager; длительную и видимую выносите в foreground-сервис; для точного времени берите AlarmManager.
**Корутина во viewModelScope: результат нужен, только пока открыт экран: поиск, загрузка списка. Отменяется вместе с ViewModel.
Корутина в скоупе приложения: короткая работа, которая должна пережить экран, например дописать сохранение после нажатия Назад, и потерять её при смерти процесса допустимо.
WorkManager: синхронизация, загрузки, отправка логов, очистка: обязано завершиться, может подождать сети или зарядки, нужны повторы.
Expedited-работа: пользователь только что попросил и ждёт от секунд до пары минут; расходует квоту приложения.
Foreground-сервис**: пользователь знает, что работа идёт, и она длительная: музыка, навигация, звонок, запись тренировки. Нужны тип и уведомление. Для большой передачи данных, запущенной пользователем, на Android 14+ предназначен user-initiated data transfer job (JobInfo.Builder.setUserInitiated(true)).
**AlarmManager: точный момент: будильник или напоминание календаря. Он только будит приложение; сама работа затем уходит в один из инструментов выше.
FCM**: когда сервер знает, что что-то изменилось, он присылает push вместо опроса, а из сообщения ставится работа.
JobScheduler напрямую нужен редко: WorkManager построен поверх него.
Почти каждая строка: syncBlocking выполняется в главном потоке, startService() из фона на Android 8+ бросает IllegalStateException, а START_STICKY не гарантирует надёжности. Ночную синхронизацию делают периодической работой WorkManager.
Главный поток: onStartCommand выполняется в главном потоке, поэтому блокирующая синхронизация подвешивает UI или даёт ANR. К тому же сервис не вызывает stopSelf() и будет жить, пока его не убьёт система.
Запуск из фона: сервис, запущенный, когда у приложения нет видимого UI и оно не входит в короткий список разрешений для broadcast, получает IllegalStateException на Android 8+. Broadcast будильника даёт лишь несколько секунд, гораздо меньше, чем нужно синхронизации. С Android 12 из фона запрещён и startForegroundService(), кроме исключений.
Возвращаемые значения: START_NOT_STICKY: после смерти процесса сервис не пересоздаётся; START_STICKY: пересоздаётся с null-интентом; START_REDELIVER_INTENT: пересоздаётся с последним интентом. Ни один вариант не помнит прогресс и не обещает, когда случится перезапуск.
Привязка ради прогресса: binder служит механизмом межпроцессного взаимодействия, а bound-сервис работает как канал «клиент-сервер». Внутри одного процесса проще и надёжнее репозиторий со StateFlow или таблица Room, в которую пишет воркер: они переживают Activity.
Исправление: PeriodicWorkRequest на 24 часа с ограничениями UNMETERED и «батарея не разряжена», поставленный как уникальная работа с ExistingPeriodicWorkPolicy.UPDATE.
Bound-сервис по-прежнему уместен, когда клиентом выступает другой процесс (AIDL, Messenger), или когда платформа просит унаследоваться от сервиса: MediaSessionService, TileService.
Каждый запрос хранится строкой в собственной Room-базе WorkManager, а планировщики передают его системному JobScheduler или запускают прямо в процессе, пока приложение живо. При каждом старте и после загрузки устройства WorkManager сверяет свою базу с системой, поэтому перезапуск ничего не теряет.
Хранение
enqueue() передаёт WorkSpec (класс Worker, входные Data, ограничения, backoff, состояние, счётчик попыток) фоновому executor-у, который его записывает; возвращённый Operation сообщает, когда запись готова.
Завершённая работа хранится около суток, чтобы наблюдатели успели прочитать результат, затем удаляется.
Планирование и выполнение
SystemJobScheduler регистрирует каждый запрос в JobScheduler через SystemJobService; на старых версиях API без JobScheduler WorkManager использовал AlarmManager.
GreedyScheduler, пока приложение живо, запускает работу прямо в текущем процессе, как только выполнены её ограничения, без похода через систему.
Worker выполняется на Configuration.executor, а CoroutineWorker выполняется на Dispatchers.Default. Ещё WorkManager ограничивает число одновременно зарегистрированных задач (setMaxSchedulerLimit), чтобы не упереться в системный лимит задач на приложение.
Как переживается перезапуск
RescheduleReceiver слушает BOOT_COMPLETED и смену времени или часового пояса; при следующем старте ForceStopRunnable замечает, что приложение было принудительно остановлено, и регистрирует задачи заново.
Следствие: после force stop ничего не выполняется, пока пользователь не откроет приложение, потому что у приложения нет возможности запустить код и всё перепланировать.
Остановка кооперативная. WorkManager выставляет isStopped и вызывает onStopped(), но поток из пула, в котором выполняется блокирующий doWork(), никто не прерывает, поэтому цикл, который не проверяет isStopped, копирует до конца, а возвращённый им Result потом просто отбрасывается.
Разновидности
ListenableWorker служит базовым классом: startWork() возвращает ListenableFuture<Result>, а потоками вы управляете сами.
Worker: блокирующий doWork() в потоке из Configuration.executor; isStopped нужно проверять самому.
CoroutineWorker: suspend fun doWork() на Dispatchers.Default. Остановка отменяет корутину, каждая точка приостановки бросает CancellationException, а блоки finally выполняются. Это выбор по умолчанию в Kotlin; блокирующий ввод-вывод оборачивайте в withContext(Dispatchers.IO).
RxWorker возвращает Single<Result>; остановка делает dispose подписки.
Что означает остановка
Причины: пропало ограничение, работу отменили или она упёрлась в лимит выполнения примерно в десять минут. На API 31+ причину сообщает stopReason.
Если работу не отменили, она возвращается в ENQUEUED и перезапускается с начала, поэтому фиксируйте прогресс по каждому элементу.
onStopped() может вызываться из любого потока и должен быть лёгким: выставьте флаг или отмените задачу, но не блокируйте и не делайте ввод-вывод.
В CoroutineWorker широкий catch (e: Exception) проглатывает CancellationException и работа продолжается; ловите конкретные исключения.
doWork() запускает загрузки в собственном scope и сразу возвращает Result.success(), поэтому WorkManager помечает работу как SUCCEEDED и перестаёт за ней следить. После этого процесс могут убить посреди загрузки, и потерянные файлы уже никто не отправит повторно.
Почему так происходит: возвращённый Result и есть контракт: работа считается завершённой в момент возврата из doWork(). WorkManager отпускает свой wakelock, а корутина в CoroutineScope(Dispatchers.IO) никому не принадлежит: на неё не распространяются ни ограничения, ни остановка, ни повторы, а процесс без видимого экрана и без выполняющейся работы система убивает одним из первых.
Ошибки тоже теряются: IOException внутри этого launch никогда не превратится в Result.retry(): это необработанное исключение в корневой корутине, и оно роняет приложение.
Как работает исправление: у CoroutineWorker есть suspend fun doWork(), и withContext(Dispatchers.IO) приостанавливается, пока не отправлен каждый файл, поэтому результат отражает то, что произошло на самом деле, а остановка отменяет загрузки. Оберните цикл в try/catch (e: IOException) с возвратом Result.retry(), чтобы пережить сбой сети.
Колбэчные API: тот же баг возникает с call.enqueue { } или слушателем; оберните их в suspendCancellableCoroutine или унаследуйтесь от ListenableWorker и завершайте future через CallbackToFutureAdapter.
Как поймать в следующий раз: любой launch, GlobalScope или колбэк внутри doWork() считается тревожным знаком на ревью; в тесте запустите TestListenableWorkerBuilder<UploadWorker>(context).build().doWork() с фейковым API и проверьте, что к моменту возврата отправлен каждый файл.
Под ограничениями понимают условия вроде типа сети, зарядки, «батарея или память не на исходе», которые должны выполняться до старта и отслеживаются всё время выполнения. Если одно пропадает посреди работы, WorkManager останавливает Worker, возвращает работу в ENQUEUED и позже перезапускает её с начала.
Какие бывают
setRequiredNetworkType(...): CONNECTED, UNMETERED, NOT_ROAMING, METERED, TEMPORARILY_UNMETERED или NOT_REQUIRED.
setRequiresCharging, setRequiresBatteryNotLow, setRequiresStorageNotLow и setRequiresDeviceIdle на API 23+.
addContentUriTrigger(uri, triggerForDescendants) запускает работу, когда меняется дерево content://.
Подводные камни
Перезапуск, а не продолжение. Result, возвращённый после остановки, отбрасывается, поэтому наполовину выполненная загрузка начнётся заново, если Worker не фиксирует прогресс по каждому элементу.
**CONNECTED не значит «сервер доступен».** Captive portal тоже его удовлетворяет, так что ветка IOException → Result.retry() нужна всё равно.
Срока годности нет. Работа, требующая зарядки, на телефоне, который никто не заряжает, ждёт вечно; решайте внутри doWork(), когда задача устарела.
Причину остановки на API 31+ даёт stopReason, например STOP_REASON_CONSTRAINT_CONNECTIVITY.
Обычный enqueue() добавляет новый запрос при каждом старте, а ExistingPeriodicWorkPolicy.KEEP игнорирует новый запрос, пока старое расписание существует, поэтому побеждает интервал с первой установки. Нужны уникальная one-time работа с KEEP и уникальная периодическая с UPDATE.
ExistingWorkPolicy для one-time работы
KEEP: если работа с таким именем в очереди или выполняется, новый запрос отбрасывается. Подходит для «синхронизации при старте».
REPLACE: отменить существующую работу, включая выполняющуюся, и поставить новую.
APPEND: прицепить новый запрос после существующего; если предшественник упал или отменён, новый тоже завершится неудачей.
APPEND_OR_REPLACE: как APPEND, но мёртвый предшественник заменяется, а не отравляет очередь. Подходит для outbox.
ExistingPeriodicWorkPolicy
KEEP: оставить старое расписание; это и есть баг с устаревшим интервалом.
UPDATE: подменить спецификацию новой, сохранив исходное время постановки в очередь, так что следующий запуск пересчитывается от него с новым периодом (WorkManager 2.8+). Правильный вариант по умолчанию для расписания, которое объявляется при каждом старте.
CANCEL_AND_REENQUEUE (бывший REPLACE) отменяет выполняющийся экземпляр и перезапускает расписание, поэтому вызов при каждом старте убивает синхронизации на полпути.
Result.retry() откладывает работу на backoff-задержку, и лимита попыток нет: остановить цикл может только ваш код. Считайте попытки через runAttemptCount и возвращайте Result.failure(), когда бюджет исчерпан или ошибка постоянная.
Как ведёт себя backoff
По умолчанию BackoffPolicy.EXPONENTIAL от 30 секунд; LINEAR растёт на базовую задержку с каждой попыткой. Задаётся через setBackoffCriteria(...).
Задержка зажата между WorkRequest.MIN_BACKOFF_MILLIS (10 секунд) и MAX_BACKOFF_MILLIS (5 часов). Примерно после десяти попыток бесконечный retry будит устройство раз пять в сутки, и так вечно.
runAttemptCount равен 0 при первом запуске, так что runAttemptCount >= 5 означает шестую попытку.
Задержка задаёт минимум, а не расписание: ограничения и Doze действуют поверх неё.
Классифицируйте каждую ошибку
Временная: таймаут, IOException, HTTP 5xx или 429: Result.retry(), но ограниченно.
Постоянная: 400, 401, 404, ошибка валидации: сразу Result.failure(); повтор получит тот же ответ.
Непойманное исключение: это не повтор: работа заканчивается как FAILED, поэтому Worker без try теряет задачу при первом сбое сети.
Сдаваясь, пометьте элемент как неудачный в своей базе, чтобы UI мог это показать и предложить повторить вручную.
beginWith(listOf(a, b, c)).then(publish).enqueue(): список в beginWith выполняется параллельно, then ждёт их всех, и publish стартует, только когда все три успешны. Если одна вернёт Result.failure(), всё ниже по цепочке помечается FAILED и publish не запустится вовсе.
Строительные блоки
beginWith(...) возвращает WorkContinuation; then(...) добавляет следующий шаг; ничего не планируется до вызова enqueue().
beginUniqueWork(name, policy, work) даёт всей цепочке уникальное имя.
WorkContinuation.combine(listOf(chainA, chainB)) сводит независимые цепочки в один следующий шаг.
В цепочку ставится только one-time работа; периодическую поставить нельзя.
Где ошибаются
Ошибка и отмена распространяются вниз, и флага «продолжить при ошибке» нет. Если шаг необязательный, ловите исключение внутри и возвращайте Result.success() с пометкой в выходных данных.
**Result.retry() не считается ошибкой. Этот узел повторяется со своим backoff, а остальная цепочка ждёт.
Выходы конфликтуют.** При нескольких родителях OverwritingInputMerger по умолчанию оставляет одно значение на ключ; ArrayCreatingInputMerger собирает их в массивы, что обычно и нужно при слиянии.
Цепочка не работает как транзакция. Каждый шаг может перезапуститься отдельно, поэтому каждый должен быть идемпотентным.
Внутри Worker вызывайте setProgress(workDataOf(...)), а WorkInfo.progress наблюдайте через Flow по уникальному имени работы. Работа дольше десяти минут или видимая пользователю вызывает setForeground(), и WorkManager выполняет её в foreground-сервисе с уведомлением.
Прогресс
setProgress (или setProgressAsync в Worker) виден, только пока работа в RUNNING. После повтора или перезапуска после смерти процесса он снова пустой.
Наблюдайте getWorkInfosForUniqueWorkFlow(name) во ViewModel. К постоянному имени можно заново подключиться после смерти процесса, а к UUID в памяти нельзя.
Прогресс, который пользователь должен увидеть и потом («отправлено 12 из 40», «3 с ошибкой»), хранится в Room, Worker пишет его по каждому элементу. WorkInfo тогда отвечает только на вопрос «жива ли задача».
Длительная работа
setForeground(ForegroundInfo(id, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)) переносит работу в SystemForegroundService WorkManager и снимает десятиминутный лимит.
На Android 14+ объявите этот сервис в своём манифесте с подходящим foregroundServiceType (слияние через tools:node="merge") и добавьте разрешение типа, например FOREGROUND_SERVICE_DATA_SYNC.
Вызывайте его в начале doWork(). На Android 12+ вызов может не сработать, если приложение в фоне, а на Android 15 dataSync ограничен шестью часами в сутки.
setExpedited просит систему запустить one-time запрос как можно скорее за счёт ограниченной квоты приложения. OutOfQuotaPolicy решает, что делать, когда квота кончилась: выполнить запрос как обычную работу или выбросить.
Что это такое
Только one-time запросы: PeriodicWorkRequest нельзя сделать expedited, а expedited-запрос с setInitialDelay бросает исключение.
На API 31+ он становится expedited-задачей JobScheduler: обычно стартует за секунды, сохраняет доступ к сети и меньше страдает от Doze и Battery Saver.
Ниже API 31 WorkManager вместо этого выполняет его в foreground-сервисе, поэтому нужно переопределить getForegroundInfo() (реализация по умолчанию бросает исключение) и вернуть уведомление с подходящим типом.
Квота зависит от App Standby bucket: у приложения на переднем плане ограничения фактически нет, у приложения в RARE квоты почти нет.
Когда квота кончилась
RUN_AS_NON_EXPEDITED_WORK_REQUEST превращает запрос в обычную работу. Подходит почти всегда: работа всё равно выполнится, просто позже.
DROP_WORK_REQUEST выбрасывает его. Только для работы, которая без немедленного запуска ничего не стоит.
Ловушка в том, чтобы помечать expedited всё подряд: квота общая, и «торопливые» запросы съедают её у того, который был действительно важен. Expedited подходит для того, что пользователь только что попросил и ждёт; для работы, за которой он следит минутами, нужен setForeground или foreground-сервис.
Android 12 запретил запускать foreground-сервис из фона, кроме исключений. Android 14 сделал обязательным объявленный тип с подходящим разрешением и тем же типом в startForeground. Android 15 добавил суточные лимиты времени для dataSync и mediaProcessing.
Три места, которые должны совпадать (Android 14+)
Манифест: android:foregroundServiceType="dataSync" у <service>; без него startForeground бросает MissingForegroundServiceTypeException.
Разрешения: FOREGROUND_SERVICE плюс разрешение типа, например FOREGROUND_SERVICE_DATA_SYNC. Некоторым типам нужно ещё и runtime-разрешение, например на геолокацию или камеру.
Вызов: ServiceCompat.startForeground(service, id, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC); тип, не объявленный в манифесте, приводит к исключению.
Запуск и время
После startForegroundService() есть около пяти секунд на вызов startForeground(), иначе приложение падает с ForegroundServiceDidNotStartInTimeException.
Из фона (Android 12+) запуск бросает ForegroundServiceStartNotAllowedException, если не действует исключение: видимая Activity, высокоприоритетное FCM-сообщение, точный будильник, некоторые колбэки Bluetooth или геозон.
Android 15: dataSync и mediaProcessing получают шесть часов на 24; затем вызывается Service.onTimeout(), и сервис обязан остановиться. shortService получает около трёх минут. Ресивер BOOT_COMPLETED больше не может запускать foreground-сервисы типов dataSync, camera, mediaPlayback, phoneCall, mediaProjection и microphone.
Doze откладывает задачи, будильники и доступ к сети на простаивающем устройстве без зарядки до коротких окон обслуживания; App Standby buckets распределяют запуски задач и будильников между приложениями по тому, как давно пользователь ими пользовался.
Doze (Android 6, облегчённый вариант в 7)
Включается, когда экран выключен и устройство не на зарядке; окна обслуживания наступают всё реже.
Между окнами: сети нет, wake lock игнорируются, задачи и обычные будильники ждут.
Что проходит: setExactAndAllowWhileIdle и setAndAllowWhileIdle (с ограничением частоты), setAlarmClock, высокоприоритетные FCM-сообщения; expedited-задачи затрагиваются меньше.
App Standby buckets (Android 9)
ACTIVE, WORKING_SET, FREQUENT, RARE и RESTRICTED, назначаются по реальному использованию. Чем ниже bucket, тем реже запускаются задачи и будильники; RESTRICTED получает примерно одно окно в сутки.
UsageStatsManager.getAppStandbyBucket() показывает, где вы. Приложение, которое открывают дважды в месяц, не будет синхронизироваться каждый час, и это система работает правильно.
Ограничения по версиям
8.0: нельзя вызывать startService() из фона; большинство неявных broadcast-ов больше не доставляется ресиверам из манифеста; фоновая геолокация урезана.
10: нельзя запускать Activity из фона.
12: запуск foreground-сервисов из фона заблокирован; точным будильникам нужно разрешение.
14: типы foreground-сервисов обязательны; точные будильники больше не выдаются заранее.
15: лимиты времени для dataSync и mediaProcessing. Подробности есть в вопросе о foreground-сервисах.
startForeground() вызывается только после сетевого запроса, а сервис, запущенный через startForegroundService(), обязан перейти в foreground примерно за пять секунд. На медленной сети дедлайн истекает раньше, и система роняет приложение с ForegroundServiceDidNotStartInTimeException.
Почему так происходит: отсчёт идёт от startForegroundService(), а не от момента, когда сервис готов. scope.launch возвращается сразу, поэтому onStartCommand завершается без перехода в foreground, а вызов внутри корутины ждёт HTTP-ответа. В офисном Wi-Fi он успевает; на слабой сети или после медленного холодного старта не успевает.
Что видит пользователь: на Android 12+ краш с сообщением "Context.startForegroundService() did not then call Service.startForeground()"; более старые версии бросают RemoteServiceException.
Как работает исправление: переходите в foreground синхронно, первой строкой onStartCommand, с уведомлением-заглушкой, а когда данные придут, обновите это уведомление через NotificationManager.notify с тем же id.
Важен каждый путь: сервис, который упал в ошибку и вызвал stopSelf() до startForeground(), крашится так же, поэтому сначала переход в foreground, потом остановка.
Как поймать в следующий раз: lint этого не ловит: тестируйте с ограничением сети в эмуляторе или с фейковым репозиторием, добавляющим delay(10_000), и следите за этим исключением в Android Vitals в Play Console после релиза.
Поставить точный будильник через setAlarmClock, получить разрешение на точные будильники и в boot-ресивере заново построить все будильники из своей базы: AlarmManager забывает их при перезагрузке и обновлении приложения.
Разрешение
SCHEDULE_EXACT_ALARM (API 31+) относится к специальному доступу. На Android 14+ он больше не выдаётся заранее новым установкам приложений с target Android 13+: проверьте canScheduleExactAlarms() и отправьте пользователя в Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM. Отзыв разрешения отменяет ваши точные будильники, поэтому слушайте ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED.
USE_EXACT_ALARM (API 33+) выдаётся при установке, но Play разрешает его, только если будильники или события календаря составляют основную функцию приложения. Будильник подходит, а напоминание магазина не подходит.
Как пройти через Doze
setExact откладывается, пока устройство в Doze.
setExactAndAllowWhileIdle срабатывает в Doze, но не чаще примерно раза в девять минут на приложение.
setAlarmClock оказывается правильным выбором: он не подчиняется Doze, будит устройство чуть заранее и показывает значок будильника в статус-баре. Его второй PendingIntent открывает экран по нажатию на этот значок.
Как пережить перезагрузку
Храните расписание в своей базе, объявите RECEIVE_BOOT_COMPLETED и заново ставьте следующий будильник на ACTION_BOOT_COMPLETED, ACTION_MY_PACKAGE_REPLACED и при каждом срабатывании. AlarmManager даёт одиночный звонок-побудку, а расписание хранит ваша база.
Direct Boot: после ночной перезагрузки телефон всё ещё заблокирован, а BOOT_COMPLETED не доставляется до первой разблокировки. Обрабатывайте также ACTION_LOCKED_BOOT_COMPLETED, укажите у ресивера android:directBootAware="true" и храните расписание будильников в защищённом от шифрования хранилище устройства (createDeviceProtectedStorageContext()).
Поверх Doze и App Standby многие производители ставят свои батарейные менеджеры, которые замораживают или убивают приложения вне белого списка (вместе с задачами и будильниками), и никакой API или targetSdk от этого не избавляет. Фичу проектируют так, чтобы она не зависела от фоновых запусков.
Что происходит
Батарейные менеджеры производителей: Xiaomi HyperOS, «спящие приложения» Samsung, ColorOS и другие останавливают приложения вскоре после выключения экрана или после смахивания из недавних. Это не поведение AOSP, поэтому оно разное у каждого производителя и версии; настройки собраны на dontkillmyapp.com.
Ограничение пользователем: режим батареи «Ограничено» делает ActivityManager.isBackgroundRestricted() равным true, и приложение ничего не получает, пока его снова не откроют.
Низкий bucket: редко открываемое приложение сидит в RARE или RESTRICTED и получает примерно одно окно в сутки, на любом телефоне.
Что можно сделать
Сделайте фоновую работу оптимизацией, а не механизмом: синхронизируйтесь ещё и при выходе приложения на передний план, а синхронизацию держите идемпотентной и возобновляемой.
Пусть сервер присылает FCM, когда что-то должно случиться сейчас, вместо опроса по расписанию, которое не сработает.
Определяйте это состояние и в момент, когда фиче это явно нужно, показывайте один экран с объяснением и диплинком в настройку производителя. Play разрешает REQUEST_IGNORE_BATTERY_OPTIMIZATIONS только приложениям, чьей основной функции это необходимо, поэтому по умолчанию его не запрашивают.
Измеряйте: логируйте запланированное и фактическое время запуска вместе с производителем, standby bucket и isIgnoringBatteryOptimizations и спорьте на данных, а не на телефоне одного тестировщика.
Push служит сигналом «проснись», а не самой работой: onMessageReceived ставит уникальную задачу WorkManager и возвращает управление, а с высоким приоритетом отправляются только сообщения, которые действительно не могут ждать.
Механика
Сообщение с приоритетом high выводит устройство из Doze и ненадолго разрешает приложению сеть и запуск foreground-сервиса. Сообщение normal ждёт следующего окна обслуживания, и для большей части трафика это правильно.
Для синхронизации отправляйте data-only сообщения (см. вопрос о типах FCM-сообщений).
У onMessageReceived есть всего несколько секунд, после чего процесс могут убить. Поставьте работу в очередь и вернитесь.
enqueueUniqueWork с ExistingWorkPolicy.KEEP превращает пачку из десяти push-ей в одну синхронизацию; про collapse_key см. тот же вопрос.
Какой бюджет вы тратите
FCM понижает приоритет high-сообщений, которые не приводят к видимому пользователю уведомлению. Сравните RemoteMessage.priority с originalPriority, чтобы это увидеть.
Expedited-работа тратит ту же квоту приложения, что и все остальные expedited-запросы, а квота сокращается вместе со standby bucket.
Push несёт версию или курсор, а не сами данные; клиент запрашивает и сверяет состояние, поэтому потерянное сообщение ничего не стоит. Доставка не гарантирована, так что та же синхронизация запускается и при выходе на передний план, и по периодическому расписанию.
Спросить планировщик напрямую: adb shell dumpsys jobscheduler показывает, какие ограничения не выполнены и когда задача может запуститься, а диагностический broadcast WorkManager логирует его взгляд на работу. Затем воспроизвести Doze и standby и добавить телеметрию без устройства.
На устройстве, которое у вас есть
dumpsys jobscheduler с фильтром по пакету: обязательные и выполненные ограничения задачи, backoff, standby bucket и самое раннее время запуска. Обычно одного этого хватает для ответа.
am broadcast -a "androidx.work.diagnostics.REQUEST_DIAGNOSTICS" -p <package> заставляет WorkManager вывести в лог завершённую, запланированную и выполняющуюся работу за последние 24 часа со счётчиками попыток.
Background Task Inspector в Android Studio показывает то же вживую, включая цепочки и выходные данные.
cmd jobscheduler run -f <package> <jobId> запускает задачу сейчас и отделяет «никогда не планировалась» от «запланирована и отложена».
Воспроизведите условия
dumpsys battery unplug, затем dumpsys deviceidle force-idle, чтобы войти в Doze, и dumpsys deviceidle step, чтобы пройти по его состояниям.
am set-standby-bucket <package> restricted переводит приложение в худший bucket.
Верните всё через dumpsys deviceidle unforce, dumpsys battery reset и bucket active.
В продакшене
Задача, которая не запустилась, не даёт ни краша, ни строчки в логе, поэтому измерять приходится отсутствие. Логируйте постановку в очередь, первый запуск и runAttemptCount изнутри Worker, отправляйте разрыв вместе с производителем и standby bucket и поднимайте алерт на отсутствующее завершение, а не на ошибку.