
Привет! С вами снова Алексей, Android-разработчик из Облака Mail. В прошлой статье я подробно разобрал, как менялись правила фоновой работы в Android на протяжении всех версий ОС. А сегодня я расскажу, какие инструменты реально работают на последних версиях системы и как их правильно использовать. Везде также будут примеры кода и ссылки на документацию.
Немного контекста
Как вы помните, Android хочет беречь батарейку телефона и постоянно ужесточает правила фоновой работы. Из-за этого у разработчиков осталось не так много инструментов, с помощью которых можно организовать какую-либо работу в фоне:
WorkManager;
JobScheduler;
Foreground Service;
Специализированные API (AlarmManager, DownloadManager, Geofencing).
Какой из этих инструментов стоит выбрать?
Для начала давайте поймём, что считается работой в фоне. Это может быть не совсем очевидно. Приложение работает в фоне, когда:
никакие из его Activity не видны пользователю (не находятся в состоянии started или paused);
нет запущенных foreground-сервисов (!);
нет других приложений, использующих это приложение через Content Provider или Binding-сервис.
Несмотря на то, что мы можем воспринимать foreground-сервис как средство работы в фоне, для системы Android это работа приложения на переднем плане. И именно поэтому, начиная с 12 версии, запрещено запускать foreground-сервис, когда приложение работает в фоне, за исключением некоторых ситуаций: например, когда приложение получает High Priority пуш, некоторые системные интенты, или если пользователь отключает оптимизацию батареи для приложения (changelog, исключения).
Выбор инструмента
Чтобы правильно выбрать инструмент, важно понимать, как долго ваша фоновая работа может выполняться и насколько срочно её надо запустить. Можно задать себе следующие вопросы:
Должно ли выполнение задачи продолжаться после сворачивания приложения? Если нет, то используйте инструменты для асинхронной работы (корутины, RxJava, Handler или Java-треды).
Задача должна или может быть отложена до наступления определённых событий или выполняться периодически? Если да, то используйте инструменты планирования задач WorkManager или JobScheduler. Их API тесно интегрирован с системой и позволяет запускать отложенные задачи, а также настраивать условия запуска или периодичность. При этом запланированный запуск таких задач может переживать даже перезагрузку устройства.
Подходят ли альтернативные API для выполнения задачи? Если да — используйте их. Возможные варианты альтернативных API: AlarmManager для запуска будильников, DownloadManager для скачивания общедоступных файлов, или альтернативные варианты, представленные в документации Android для различных типов foreground-сервисов.
Задача должна начать выполняться моментально? Если да, то в таком случае либо используйте Expedited Work из WorkManager, либо запускайте foreground-сервис. При этом поддержка системой Expedited Work есть только начиная с версии Android 12, на более старых версиях работа под капотом реализована через тот же foreground-сервис для обратной совместимости (документация).
Задача длительная или не подходит под все вышерассмотренные ситуации? Если да, то нашим единственным выходом становится foreground-сервис. Длительные задачи можно запускать и через WorkManager, при этом под капотом он всё равно будет вызывать foreground-сервис. Тем не менее, использование WorkManager сильно упрощает работу с foreground-сервисами. (документация).
Ещё можно отметить, что в Android недавно появился очередной альтернативный способ выполнять передачу данных: User-initiated data transfer. Это специальный тип фоновых задач для JobScheduler для выполнения длительной передачи данных. По сути, это аналог foreground-сервиса. Его тоже можно запускать только из foreground, но при этом на него нет ограничений по квотам App Standby. Он работает только на Android 14 и выше, а для более старых версий нужно вручную реализовывать и запускать аналогичный foreground-сервис. На момент написания статьи этот тип фоновых задач работает только через JobScheduler; возможно, позже его добавят уже и в WorkManager (документация).
Давайте теперь изучим каждый из инструментов.
WorkManager
В современной Android-разработке WorkManager охватывает подавляющее большинство сценариев с фоновой работой. Это отдельно подключаемая библиотека, которая при этом работает в отдельном процессе в системе, в единственном экземпляре. Она из коробки предоставляет поддержку корутин и RxJava, реализует под капотом нужные взаимодействия с системой и имеет обратную совместимость со многими старыми версиями Android. Одним словом, этот инструмент экономит огромное количество времени и сил всем разработчикам. И практически всегда его стоит использовать как решение по умолчанию для фоновых задач.
Пример использования
Создаём класс BackgroundWorkManager:
class BackgroundWorkManager(context: Context, params: WorkerParameters) : Worker(context, params) {
override fun doWork(): Result {
var counter = 0
while (counter < 1_000_000) {
if (isStopped) { // если задача отменена, то возвращаем ошибку
return Result.failure()
}
Thread.sleep(1000)
counter++
Log.e("Worker", "Counter $counter")
}
return Result.success()
}
}
Теперь можем запланировать эту задачу, указав нужные условия запуска:
val constraints = Constraints.Builder()
.setRequiresCharging(false)
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.setRequiresStorageNotLow(true)
.build()
val worker = OneTimeWorkRequest.Builder(BackgroundWorkManager::class.java)
.setConstraints(constraints)
.build()
WorkManager.getInstance(applicationContext)
.enqueueUniqueWork("123", ExistingWorkPolicy.REPLACE, worker)
В этом примере мы ставим задачу c id “123”, которая выполнится, только если есть интернет, батарея не разряжена и достаточно места на устройстве. Если задача с таким id уже была запущена, то она заменится на новую.
Важный момент: все ограничения для foreground-сервисов действуют даже тогда, когда вы работаете с ними через WorkManager (при запуске expedited или long-running задач). Вам по-прежнему нужно указывать тип и разрешение для сервиса, и у вас точно так же будут действовать квоты App Standby на запуск сервисов. Все особенности foreground-сервисов могут внезапно проявиться в виде падений приложения в проде.
JobScheduler
Возможно, вы хотите использовать JobScheduler напрямую. Например, чтобы попробовать реализовать новоиспечённый User-initiated data transfer. Но, тем не менее, в качестве решения для production-кода я бы рекомендовал всё-таки использовать WorkManager. JobScheduler — это системный сервис, доступность и реализация его методов напрямую зависит от версии Android. В свою очередь, WorkManager предоставляет высокоуровневую абстракцию, которая автоматически выбирает подходящий механизм выполнения задач в зависимости от версии ОС (JobScheduler, Foreground Service или даже AlarmManager и Broadcast Receiver для версий ниже Android 6). И это значительно упрощает реализацию фоновых задач, а также защищает от ошибок, которые можно допустить при реализации механизмов обратной совместимости вручную.
Пример использования
Создаём класс BackgroundJob:
class BackgroundJob : JobService() {
private var counter: Int = 0
override fun onStartJob(params: JobParameters?): Boolean {
Thread {
while (counter < 1_000_000) {
try {
Thread.sleep(1000)
counter++
Log.e("BackgroundJob", "Counter $counter")
} catch (e: InterruptedException) {
e.printStackTrace()
}
}
}.start()
return true
}
override fun onStopJob(params: JobParameters?): Boolean {
Log.e("BackgroundJob", "Job stopped before completion");
return true; // Позволяет повторно запланировать задачу
}
}
Добавим этот класс в AndroidManifest:
<manifest …>
…
<application …>
…
<service
android:name=".BackgroundJob"
android:exported="true"
android:permission="android.permission.BIND_JOB_SERVICE" />
…
</application>
</manifest>
Теперь можем запланировать эту задачу, указав нужные условия запуска:
val componentName = ComponentName(applicationContext, BackgroundJob::class.java)
val jobInfo = JobInfo.Builder(1, componentName)
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
.setPersisted(true)
.setPeriodic((15 * 60 * 1000).toLong())
.build()
val jobScheduler = getSystemService(JOB_SCHEDULER_SERVICE) as? JobScheduler
jobScheduler?.let {
val result = it.schedule(jobInfo)
if (result == JobScheduler.RESULT_SUCCESS) {
Log.e("BackgroundJob", "Job scheduled successfully")
} else {
Log.e("BackgroundJob", "Job scheduling failed")
}
}
В этом примере мы ставим задачу, которая будет выполняться каждые 15 минут, если есть сеть. После перезагрузки устройства задача сохранится (persisted).
AlarmManager
AlarmManager на ранних версиях Android позволял точно планировать выполнение задачи в любой момент. Теперь же мы можем использовать его только в ситуации, когда нужно разбудить устройство в строго определённое время (например, будильник, напоминание) и такая задача должна выполняться даже в Doze-режиме.
Чтобы воспользоваться этим инструментом, начиная с Android 12, приложению нужно иметь специальное разрешение для запуска алармов SCHEDULE_EXACT_ALARM. А в Android 13 добавили ещё один вариант разрешения: USE_EXACT_ALARM. Разница между ними следующая:
SCHEDULE_EXACT_ALARM:
запрашивается у пользователя;
подходит для любых случаев применения.
USE_EXACT_ALARM:
выдаётся автоматически при установке приложения и не может быть отозвано пользователем;
подходит только для приложений, у которых основная функциональность связана с запуском точных алармов (т. е. приложение-будильник или календарь); это разрешение проходит строгую модерацию в магазинах приложений.
Таким образом, скорее всего, в большинстве случаев вам подойдёт SCHEDULE_EXACT_ALARM. Давайте рассмотрим пример использования.:
Первым делом создадим класс BackgroundAlarm:
class BackgroundAlarm : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
Log.e("BackgroundAlarm", "Alarm started")
}
}
Добавим в AndroidManifest этот класс и разрешение:
<manifest …>
…
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
…
<application …>
…
<receiver android:name=".BackgroundAlarm" />
…
</application>
</manifest>
И теперь запланируем запуск аларма:
val calendar = Calendar.getInstance()
calendar.add(Calendar.SECOND, 10)
val intent = Intent(applicationContext, BackgroundAlarm::class.java)
val pendingIntent = PendingIntent.getBroadcast(applicationContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
val alarmManager = getSystemService(ALARM_SERVICE) as? AlarmManager
alarmManager?.let {
// проверяем, что пользователь выдал пермишен специальным методом
if (it.canScheduleExactAlarms()) {
try {
it.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, calendar.timeInMillis, pendingIntent)
} catch (e: SecurityException) {
e.printStackTrace()
}
} else {
// если пермишена нет, то нужно в настройках выдать его
startActivity(Intent(ACTION_REQUEST_SCHEDULE_EXACT_ALARM))
}
}
В этом примере мы планируем запуск аларма через 10 секунд от текущего заданного времени на устройстве. Мы также могли бы вместо календарного времени использовать время с момента запуска устройства, получаемое через SystemClock.elapsedRealtime(). В таком случае нужно было бы сменить тип аларма с AlarmManager.RTC_WAKEUP на AlarmManager.ELAPSED_REALTIME_WAKEUP. Подробнее можете почитать здесь.
Также обратите внимание, что метод AlarmManager.setExactAndAllowWhileIdle не является единственным методом для запуска аларма, их на самом деле несколько:
setExact()планирует запуск аларма примерно в указанное время, до тех пор, пока не применяются меры экономии заряда батареи (Doze, AppStandby);setExactAndAllowWhileIdle()планирует запуск аларма примерно в указанное время, даже если применяются меры экономии заряда батареи;setAlarmClock()запускает аларм точно в указанное время. Система идентифицирует такие алармы как самые критически важные и никак не оптимизирует время их запуска. Но платой за это является повышенный расход батареи пользователя.
И ещё обратите внимание на способ, которым мы запрашиваем разрешение. SCHEDULE_EXACT_ALARM относится к типу «специальных» разрешений, которые нужно настраивать через Настройки → Приложения → Специальные разрешения приложений. Для таких разрешений нельзя просто вызвать requestPermissions(), а нужно проинструктировать пользователя, чтобы он сам нашёл ваше приложение в списке и дал ему доступ. В официальной документации по специальным разрешениям в качестве примера рассматривается как раз SCHEDULE_EXACT_ALARM.
DownloadManager
В ситуации, когда вам нужно скачать файл с минимальной головной болью, на помощь придёт DownloadManager — это системный сервис, полностью предназначенный для скачивания файлов, особенно больших. Он берёт на себя всю грязную работу: управление сетевыми подключениями, повторные попытки при сбое, отображение системного уведомления о прогрессе и управление файлом после загрузки. Однако он не подходит для загрузки файлов, требующих авторизации (например, с использованием токенов), так как не поддерживает кастомные HTTP-заголовки.
Здесь нам даже не потребуется писать никакой класс, мы можем сразу запустить скачивание файла:
val url = "https://example.com/file.zip"
val request = DownloadManager.Request(Uri.parse(url))
.setTitle("Загрузка файла")
.setDescription("Файл загружается…")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "file.zip")
.setAllowedOverMetered(true) // можно скачивать по мобильной сети
.setAllowedOverRoaming(false) // нельзя скачивать в роуминге
val downloadManager = getSystemService(DOWNLOAD_SERVICE) as? DownloadManager
downloadManager?.let {
val downloadId = it.enqueue(request)
Log.e("DownloadManager", "File download scheduled with id: $downloadId")
}
В этом примере мы создаём запрос на скачивание файла. Методы setTitle и setDescription — это то, что пользователь увидит в системном уведомлении. Значение VISIBILITY_VISIBLE_NOTIFY_COMPLETED означает, что уведомление будет видно во время загрузки и останется после её завершения. setDestinationInExternalPublicDir указывает, что файл нужно сохранить в общедоступную папку «Downloads».
После вызова метода enqueue мы получаем downloadId, по которому можно отслеживать прогресс скачивания или даже отменить его. Для этого есть специальный API:
// удаление
downloadManager?.remove(downloadId)
// получение статуса
val query = DownloadManager.Query().setFilterById(downloadId)
downloadManager?.query(query)?.use { cursor ->
if (it.moveToFirst()) {
val status = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_STATUS))
when (status) {
DownloadManager.STATUS_SUCCESSFUL -> {
Log.e("DownloadManager", "Download completed successfully")
}
DownloadManager.STATUS_FAILED -> {
Log.e("DownloadManager", "Download failed")
}
else -> {
Log.e("DownloadManager", "Download in progress")
}
}
}
}
Также приложение может подписаться на получение broadcast-интента ACTION_DOWNLOAD_COMPLETE, который сообщит об успешном скачивании файла. Вместе с этим интентом будут передаваться extra данные, и по ключу EXTRA_DOWNLOAD_ID можно будет получить идентификатор загрузки, чтобы сравнить его с downloadId, полученным из метода enqueue, и убедиться, что скачался именно тот файл, который нас интересует.
Foreground Service — когда ничего другого больше не остается
Если вы уже рассмотрели все остальные методы работы в фоне и никакие из них не подходят под ваш случай, то остаётся последний вариант: великий и ужасный ForegroundService, который дошёл до нас с самых первых версий Android, претерпел множество ограничений, но, тем не менее, продолжает работать и выполнять задачи Android-приложений.
В современных версиях Android у каждого foreground-сервиса должен быть определён тип работы, которую он будет выполнять. Это может влиять на то, какие доступы будет иметь сервис, а также на то, как система будет его оптимизировать. Рассмотрим типы сервисов:
Camera: использование камеры в фоне, например, для видеоконференций;
Connected device: взаимодействие с внешними устройствами, подключёнными по Bluetooth, USB, сети и т. д.;
Data sync: работа с данными в фоне (загрузка, скачивание и обработка);
Health: любые длительные задачи, связанные с категорией фитнес, например, использование фитнес-трекеров;
Location: любые длительные задачи, требующие доступ к локации, например, навигация и передача местоположения;
Media: проигрывание аудио и видео в фоне;
Media processing: длительные операции над медиафайлами, такие как конвертация в другой формат;
Media projection: проецирование контента на внешний экран или устройство с помощью API MediaProjection;
Microphone: использование микрофона в фоне, например, диктофон или видеоконференции;
Phone call: ведение звонка в фоне через ConnectionService;
Remote messaging: передача текстовых сообщений с одного устройства на другое;
Short service: выполнение важной короткой задачи, которая не может быть прервана или отложена (максимальное время работы около трёх минут);
Special use: выполнение любых типов задач, которые не покрываются другими типами foreground-сервисов (требует описания в свободной форме и строго модерируется в магазинах приложений);
System exempted: для системных приложений и специфических системных интеграций, например, VPN.
При выборе типа сервиса для своего приложения обратите внимание, что с Android 15 типы Data sync и Media processing не могут в сумме работать больше 6 часов в день.
Ещё один важный момент заключается в том, что мы не можем запускать foreground-сервис из фона, за некоторыми исключениями (например, получение пуша). Подробнее можно почитать здесь.
Рассмотрим теперь пример кода.
Создадим класс ForegroundService:
class ForegroundService : Service() {
private var counter = 0
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
createNotificationChannel()
val notification = getNotification()
startForeground(1, notification)
Thread {
while (counter < 1_000_000) {
try {
Thread.sleep(1000)
counter++
Log.e("ForegroundService", "Counter $counter")
} catch (e: InterruptedException) {
e.printStackTrace()
}
}
}.start()
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val serviceChannel = NotificationChannel(
"my_channel_id",
"My service channel",
NotificationManager.IMPORTANCE_DEFAULT
)
val manager = getSystemService(NOTIFICATION_SERVICE) as? NotificationManager
manager?.createNotificationChannel(serviceChannel)
}
}
private fun getNotification(): Notification {
return NotificationCompat.Builder(applicationContext, "my_channel_id")
.setContentTitle("My service title")
.setContentText("My service running")
.setSmallIcon(R.drawable.ic_launcher_foreground)
.build()
}
}
Добавим в AndroidManifest этот класс и разрешения для foreground-сервиса:
<manifest …>
…
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
…
<application …>
…
<service
android:name=".ForegroundService"
android:foregroundServiceType="dataSync" />
…
</application>
</manifest>
Настроим запуск сервиса:
val intent = Intent(applicationContext, ForegroundService::class.java)
val bundle = Bundle()
intent.putExtras(bundle)
startService(intent)
Таким образом мы запустим получившийся сервис.
Немного о работе с пушами в фоне
Пуши, которые мы отправляем через Firebase, имеют два типа:
normal: доставляются сразу, если экран устройства включён. А если устройство в режиме Doze, то такие пуши доставляются пачками во время maintenance window;.
high priority: доставляются сразу, пробуждая устройство при необходимости.
Таким образом, high priority пуши нужно использовать для действительно важных уведомлений. И они должны приводить к видимому показу уведомления на устройстве пользователя или открытию приложения в foreground. Если high priority пуш не приводит к какому-то визуальному его проявлению на устройстве (например, при отправке data message, или если пользователь запретил пуши от вашего приложения), то в таком случае Firebase может понизить приоритет пуша до normal.
При доставке пуша Android-приложению даётся всего несколько секунд на обработку полученной информации и на показ самого уведомления на устройстве. Обработка происходит внутри метода onMessageReceived, причём она должна происходить моментально, поэтому туда нельзя вставлять какие-либо асинхронные операции, сетевые запросы и прочие длительные операции. Иначе для пользователя это может привести к потерянным уведомлениям.
Если приложению всё-таки нужно выделить больше времени на обработку пуша (например, загрузить картинку по imageUrl), то в таком случае нужно воспользоваться WorkManager:
для high priority — запускаем Expedited job;
для normal — используем обычный
WorkRequest.
Хороший момент заключается в том, что для high priority пушей не расходуется квота на запуск Expedited job. Таким образом, можно грамотно и красиво реализовать показ пушей.
Финальные советы
Какую бы фоновую работу ни подразумевало ваше приложение, вот ещё несколько общих советов, на которые стоит обратить внимание:
Тестируйте работу приложения на реальных устройствах. Помните, что в реальности приложение может работать не так, как на эмуляторе (см. https://dontkillmyapp.com/).
Запрашивайте исключение вашего приложения из списка оптимизированных для работы батареи с помощью интента
ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, если это влияет на функциональность вашего приложения (документация).Можно принудительно включать работу режимов Doze и App Standby для вашего приложения с помощью adb-команд. Так можно проверить, как оно себя будет вести в этих режимах (doze, app standby).
Спасибо, что дочитали до конца, и надеюсь, что эта статья была вам полезна! Делитесь своими историями о том, как вы реализовывали фоновую работу в своих приложениях и с какими трудностями сталкивались.