Кирилл Розов

Руководитель группы Android разработки в Тинькофф

В мае 2023 г. команда ГК Юзтех организовала в Томске Usetech Meetup «Тренды мобильной разработки», где своим опытом поделились эксперты российского ИТ-рынка. По итогам мероприятия мы написали серию статей, каждая из которых транслирует выступление одного из спикеров. Начали с выступления Mobile Developer Алексея Гладкова на тему: «The State of Kotlin Multiplatform». Продолжим выступлением Кирилла Розова.

Коллеги, приветствую! Меня зовут Кирилл Розов, я руководитель группы Android разработки в Тинькофф, а также автор YouTube-канала «Android Broadcast».

В Android все больше ограничений на запуск и выполнение задач, когда приложения находятся в фоне. Сегодня я расскажу о разных рецептах и правилах, как уживаться (а не сражаться!) с системой и выполнять работу в фоне. Мы поговорим про WorkManager / JobScheduler, DownloadManager, Foreground Servise, Sync Adapter, AlarmManager, о вендорах, а также о том, как выбрать API для задачи. 

1. WorkManager

Если спросить, как запустить задачу в фоне, вам скажут, что WorkManager — ваше всё. WorkManager — это класс, который будет обрабатывать запуск работы:

class UploadWorker(
    appContext: Context,
    workerParams: WorkerParameters
) : Worker(appContext, workerParams) {
    
    override fun doWork(): Result {
        // Делаем долгую работу
        doLongWork()
        // Отправляем результат выполнения работы
        return Result.success()
    }
}

По своей сути это нечто похожее на сервисы, только мы описываем не сервисы, а Worker, добавляем туда контекст и какие-то параметры. Эта система работает под капотом JobScheduler. Из плюсов — поддержка Kotlin Coroutines:

class UploadWorker(
    appContext: Context,
    workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
    override suspend fun doWork(): Result {
        // Делаем долгую работу
        doLongWork()
        // Отправляем результат выполнения работы
        return Result.success()
    }
}

Что еще умеет WorkManager? Вы можете описать для него одноразовый запрос, к примеру: «Мне нужно выполнить работу Х», задать определенные условия и частоту повторов, если первая попытка оказалась неуспешной:

val uploadWorkRequest: WorkRequest = 
    OneTimeWorkRequest.Builder(UploadWorker::class.java)
        .addTag(UPLOAD_WORK_TAG)
.setConstraints(uploadConstraints())
        .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofMinutes(10))
        .build()
val operation: Operation = WorkManager.getInstance(context)
    .enqueue(uploadWorkRequest)

Наряду с запуском одноразовых запросов возможно запускать и периодические:

val uploadWorkRequest: WorkRequest =
    PeriodicWorkRequest.Builder(UploadWorker::class.java, Duration.ofHours(1))
        .addTag(UPLOAD_WORK_TAG)
 .setConstraints(uploadConstraints())
        .build()
val operation: Operation = WorkManager.getInstance(context)
    .enqueue(uploadWorkRequest)

Тут есть важный нюанс: периодическая работа не означает запуск каждую минуту. Минимальный интервал между выполнениями работ — 15 минут. 

Перейдем к условиям: условия можно задавать абсолютно разные. Их, на самом деле, очень много и они пополняются с каждым обновлением версии. Сейчас возможно задать условия, связанные с сетью, зарядкой устройства, выполнением работ мгновенно или когда устройство будет в режиме покоя:

Constraints(
    requiredNetworkType = NetworkType.CONNECTED, // Требования к сети
    requiresCharging = false, // зарядка
    requiresDeviceIdle = false, // устройство не используется
    requiresBatteryNotLow = false, // не низкий заряд батареи
    requiresStorageNotLow = true, // не мало свободной памяти
)

Ещё есть вариант триггера на контент-провайдеров: если у вас какие-то данные в определенном контент-провайдере поменяются, WorkManager тоже может триггернуться и запуститься, отправить синхронизацию:

Constraints(
    // Время в течение которого не должно быть изменений по заданному Uri
    contentTriggerUpdateDelayMillis = NO_DELAY,
    // Максимальная задержка запуска выполнения работы с первого изменения
    contentTriggerMaxDelayMillis = NO_DELAY,
    // Uri контента в ContentProvider
    contentUriTriggers = setOf(
Constraints.ContentUriTrigger(
contentUri, isTriggeredForDescendants = false
)
)
)

Приведу пример: допустим, вы храните какие-то данные в контент-провайдере. Как только вы что-то сохраните, контент-провайдер триггернет изменения и ваш Worker заработает. Но важно помнить, что у нас есть 15 минут, то есть этот процесс происходит раз в 15 минут.

Для WorkManager можно ставить приоритеты. Появилась фича Set Expedited Job, она означает, что задача должна быть выполнена прямо сейчас:

val uploadWorkRequest: WorkRequest =
    PeriodicWorkRequest.Builder(UploadWorker::class.java, Duration.ofHours(1))
        .addTag(UPLOAD_WORK_TAG)
 .setConstraints(uploadConstraints())
 .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
         .build()
val operation: Operation = WorkManager.getInstance(context)
    .enqueue(uploadWorkRequest)

Не надейтесь, что вы любой work сделаете Expedited и все процессы тут же заработают. Это подходит для особых случаев, когда вам необходим запуск здесь и сейчас. Есть ограничение — задача должна быть короткой (до минуты).

Также, в WorkManager можно разбить задачу на подзадачи, а их, соответственно, объединять в цепочки. Так, одни подзадачи могут выполняться параллельно, другие находиться в зависимости друг от друга:

Подведем итоги по WorkManager:

  • Подключается как отдельная библиотека и учитывает возможности всех версий Android;

  • Не все возможности появляются одновременно с JobScheduler (например, приоритеты Job не появились в WorkManager);

  • Минимальный интервал выполнения периодических задач — 15 минут (нужно понимать, что это системный механизм для синхронизации, и он подвержен влиянию всех операций, которые делались в Android для оптимизации энергопотребления. Поэтому он не дает выполнять всё подряд и мгновенно. 15-тиминутный интервал, в свою очередь, не дает перегружать систему);

  • Работа ограничивается механизмами системы (App Standby, Doze Mode, Battery Saver, системные квоты выполнения и др);

  • Дополнительные возможности по сравнению с JobScheduler (цепочки из Work, работа в нескольких процессах, поддержка Coroutine и RxJava).

2. DownloadManager

Так как у WorkManager есть ограничения, нужно подумать, какие ABI можно использовать, чтобы минимизировать сложности. Первый и самый простой вариант — DownloadManager. 

DownloadManager — это простой системный API, его задача в лоб задать какой-то запрос и, соответственно, отправить его скачивать файл. Вы можете задать данные для уведомления, а также различные параметры и место, куда заданный файл должен сохраниться:

DownloadManager.Request(remoteFileUri)
 // Указываем будет ли показываться уведомление или нет
    .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
    // Текст для уведомления
    .setTitle("Sample download")
    .setDescription("Download file")
 // Задаем требования для выполнения загрузки
    .setAllowedOverRoaming(false)
    .setAllowedOverMetered(true)
    .setRequiresCharging(false)
    .setRequiresDeviceIdle(false)
// Куда сохранять файл на устройстве
    .setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, "")

Потом вы можете задать request, взять системный сервис и положить туда вашу загрузку:

val request: DownloadManager.Request = …
val downloadManager: DownloadManager = context.getSystemService()
val downloadId: Long = downloadManager.enqueue(request)

Далее можете отслеживать через BroadcastReceiver, когда загрузка будет завершена:

class DownloadsBroadcastReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val downloadInfo = DownloadInfo.fromDownloadIntent(context, intent)
        // Обрабатываем информацию о загрузке
    }
}

Также можно получать информацию о загрузке из DownloadManager:

val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0)
if (downloadId == 0L) return
val downloadManager: DownloadManager = checkNotNull(context.getSystemService())
val query = DownloadManager.Query().setFilterById(downloadId)
val downloadInfo: Cursor = downloadManager.query(query)
if (downloadInfo.count == 0) return
downloadInfo.moveToFirst()
DownloadInfo(
    status = downloadInfo.getInt(downloadInfo.getColumnIndex(DownloadManager.COLUMN_STATUS)),
    reason = downloadInfo.getInt(downloadInfo.getColumnIndex(DownloadManager.COLUMN_REASON)),
    localFileUri =
downloadInfo.getString(
downloadInfo.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
).toUri()
)

* DownloadInfo — собственный класс, разработанный для удобства

Также вы можете регистрировать BroadcastReceiver из кода:

context.registerReceiver(
    DownloadsBroadcastReceiver(),
    IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
    Context.RECEIVER_EXPORTED
)

Что мы имеем по итогу с DownloadManager:

  • Может загружать только с простых HTTP/HTTPS ссылок;

  • Сохранение файла происходит только в общедоступные места;

  • Придётся работать с Cursor;

  • Невозможность получения обновлений о прогрессе через уведомления.

3. Foreground Service

Почему Foreground? У нас есть три типа сервисов: Background, Foreground и Bound. Background мертвы, будем честны. Bound нужен только, если вы работаете между процессами. А Foreground остался актуальным.

Что может Foreground Service?

  • Единственный из рабочих вариантов;

  • Нужно объявить разрешение;

  • Нужно объявить тип задач;

  • Может быть остановлен через Task Manager.

Как выглядит работа с Foreground Service? Вы его запускаете (теперь при запуске Foreground Service каждый раз всплывает флажок с вопросом, с какой целью вы его запускаете):

class MediaService : Service() {
    override fun onCreate() {
        super.onCreate()
        startForeground(
            NOTIFICATION_ID,
            newForegroundNotification(),
       ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
        )
    }
}

Потом эти флажки вам нужно будет указать при объявлении service в манифесте:

<manifest>
    <application>
        <service
            android:name=".MediaService"
            android:foregroundServiceType="mediaPlayback"
            android:exported="false"
            />
    </application>
</manifest>

 В Android 14 появилось изменение: все сервисы теперь обязаны объявлять тип Foreground-задач, для которых они могут запускаться. Это может быть одна задача или несколько, но без этого Foreground Service нельзя будет запустить. Как вы думаете, зачем это делается? На самом деле, всё ради контроля. Все типы сервисов чётко описывают, какие задачи можно выполнять в Foreground Service.

Типы Foreground Service в Android 14:

  • camera (видеозвонки);

  • connectedDevice (подключение с устройствами по Bluetooth, NFC, USB и тд.);

  • dataSync (передача/ получение данных по сети, локальный процессинг файлов, импорт/ экспорт файлов);

  • health (фитнес приложения);

  • Location (навигация, поделиться местоположением);

  • mediaPlayback (воспроизведение аудио и видео);

  • mediaProjection (шаринг контента на дополнительный или внешний дисплей. Не включает ChromeCast SDK);

  • microphone (для приложений с голосовыми звонками);

  • remoteMessaging (передача текстовых сообщений между устройствами пользователя для продолжения работы);

  • shortService (окончание короткой важной задачи для пользователя, которая не может быть прервана или отложена);

  • specialUse (все другие случаи, которые не покрыты другими типами);

  • systemExempted (системные приложения и интеграции, которым надо использовать Foreground Service).

4. Sync Adapter

Если вы пользуетесь Android-смартфоном, то скорее всего видели в настройках возможность подключить синхронизацию. Это и есть витрина того, как видит пользователь Sync Adapter. Для чего он используется?

В большей мере это нужно для того, чтобы синхронизировать данные между клиентом и сервером как можно быстрее. Например, таким образом работают контакты. Вы в контакте что-то поправили и информация сразу отправилась на сервер благодаря этому механизму. 

Так выглядит реализация механизма синхронизации:

Как видите, это не один класс. Далеко не один. Чтобы это реализовать, нам нужны:

  • Authenticator (аккаунт пользователя в сервисе, можно стабовый);

  • ContentProvider — источник данных, который уведомляет об изменении данных (можно стабовый);

  • SyncAdapter — механизм, который линкует эти все части;

  • Sync Service — который всё это запускает;

  • Sync Adapter XML;

  • Получить специальные разрешения на синхронизацию.

Если вам нужна real time синхронизация, то это хороший выход. Если вам нужно что-то выполнить в точное время, то вам нужен AlarmManager.

5. AlarmManager

Начну с основных фактов об AlarmManager:

  • позволяет задать будильники в примерное время, точное и интервалы;

  • отправляет Intent во время срабатывания;

  • требует получения разрешения на Android 12+ и одобрения Google Play.

Как это всё выглядит:

val alarmManager: AlarmManager = checkNotNull(context.getSystemService())
val intent = Intent(context, AlarmReceiver::class.java)
val alarmPendingIntent =
PendingIntent.getBroadcast(context, requestId, intent, FLAG_IMMUTABLE)
alarmManager.set(
    AlarmManager.ELAPSED_REALTIME_WAKEUP,
    SystemClock.elapsedRealtime() + 1.minutes().toMillis(),
    alarmPendingIntent
)

Мы можем указать время в разных вариантах: абсолютное время, время с загрузки устройства (как я указал в примере выше). В примере я задал будильник через минуту. Сработает ли он через минуту? Нет. У меня он сработал гораздо позже, и причем рандомно. 

alarmManager.set(
    type = AlarmManager.ELAPSED_REALTIME_WAKEUP,
    triggerAt = SystemClock.elapsedRealtime() + 1.minutes().toMillis(),
    operation = alarmPendingIntent
)

Почему так? Потому что метод .set как бы задает будильник, но в примерном диапазоне срабатывания.

Есть еще метод .setExact. С ним уже больше гарантий на срабатывание будильника вовремя, но тоже не факт:

alarmManager.setExact(
    type = AlarmManager.ELAPSED_REALTIME_WAKEUP,
    triggerAt = SystemClock.elapsedRealtime() + 1.minutes().toMillis(),
    operation = alarmPendingIntent
)

На этом не всё, ведь еще есть .setExactAndAllowWhileIdle :) Этот метод появился, когда в Android внедрили режим глубокого сна. Когда телефон в режиме глубокого сна, у него появляются окна активности. Set и setExact могут сработать только в этих окнах активности, а setExactAndAllowWhileIdle может сработать в любом режиме и срабатывает вовремя:

alarmManager.setExactAndAllowWhileIdle(
    type = AlarmManager.ELAPSED_REALTIME_WAKEUP,
    triggerAt = SystemClock.elapsedRealtime() + 1.minutes().toMillis(),
    operation = alarmPendingIntent
)

Зачем использовать первые два метода, я не знаю. Все используют только .setExactAndAllowWhileIdle. 

Но на этом методы не заканчиваются — есть еще .setAlarmClock. Принцип его работы тот же, что у .setExactAndAllowWhileIdle, но он еще предназначен на тот случай, когда у вас должен отключиться дисплей:

alarmManager.setAlarmClock(
    info = AlarmClockInfo(
        triggerTime = System.currentTimeMillis() + 1.minutes().toMillis(),
        showIntent = alarmIntent
    ),
    operation = alarmIntent
)

Есть еще метод .setWindow, тут вы задаете интервал, в рамках которого должен сработать будильник:

alarmManager.setWindow(
    type = AlarmManager.ELAPSED_REALTIME_WAKEUP,
    windowStart = SystemClock.elapsedRealtime() + 1.minutes().toMillis()
    windowDuration = 5.minutes().toMillis(),
    action = alarmPendingIntent
)

Важно помнить, что теперь для всех будильников обязательно нужно задать permission:

<manifest>
    <uses-permission
        android:name="android.permission.SCHEDULE_EXACT_ALARM"
        android:maxSdkVersion="32"
        />
    <!--  На Android 13+ (API Level 33+)  -->
    <uses-permission android:name="android.permission.USE_EXACT_ALARM" />
</manifest>

Почему их два? 

В Android 12 появился permission.SCHEDULE_EXACT_ALARM (спецдоступ), а в Android 13 permission.USE_EXACT_ALARM (спецдоступ, который выдается Google Play, его могут получить только приложения, которые соответствуют Политике и правилам Google Play). USE_EXACT_ALARM фактически дает право всегда задавать будильники без дополнительных запросов от Google Play.

SCHEDULE_EXACT_ALARM — это тот случай, когда вы не можете получить USE_EXACT_ALARM, но с Android 14 он выдаваться больше не будет. Далее всё будет привязано к Политикам и вашей способности объяснить пользователю, зачем ему выдается permission. 

6. Оптимизации системы

Помимо всего, у нас еще есть оптимизации системы для всей фоновой работы. Как Android уменьшает расход заряда батареи:

  • Doze Mode;

  • App Standby;

  • Battery Saver;

  • Ограничения на запуск Services;

  • Остановка фоновых процессов;

  • Оптимизации вендоров.

Существует рейтинг DontKillMyApp:

По сути, это антирейтинг того, как оптимизируется работа телефонов за счет агрессивного уничтожения различных фоновых процессов. Долго в этом рейтинге с большим отрывом шли Xiaomi, но компания Samsung отобрала пальму первенства.

Вендоры это делают, потому что хотят, чтобы у вас лучше всё работало как у пользователя. Они также создают свой white-лист приложений, которые должны работать хорошо: соцсети, к примеру (тк вам наверняка нужно, чтобы пуш-уведомления прилетали вовремя). Договориться с вендором по попаданию в этот white-лист можно, но очень тяжело.

На этом сайте вы можете посмотреть истории разработчиков:

Можете найти, какие оптимизации применяются на каких версиях Android у вендоров:

А также найти способы, как рассказать пользователям о том, как отключить оптимизацию батареи на их устройствах (тк помимо стандартного механизма есть еще проприетарно-вендорский):

Вы удивитесь, как много способов отключения оптимизации:

Если вы хотите отключить оптимизацию, есть стандартный способ, который появился в Android 12+. Он доступен в настройках приложения и позволяет вам выбрать одну из трех настроек: 

Ещё в Android есть Battery Optimization. Вы можете попросить пользователя, чтобы он выставил «Игнорировать все оптимизации»:

7. Как выбрать API для задачи

Вот мы и добрались до самого главного момента: как во всем этом многообразии выбрать API?

Во-первых, теперь вам нужно умолять пользователя дать вам исключение и отключить оптимизации, если вам важно выполнять действия мгновенно.

Что еще есть из способов? Вам важно определить, действительно ли вам нужно запустить задачу ровно в заданное время:

Следующий вопрос, который важно себе задать: «Есть ли у нас конечный результат?» 

Опять же, повторюсь: главный вопрос, который вы должны задать себе: «Ухудшится ли значительно опыт пользователя если задача выполнится позже?»

Попробуйте понять, что будет, если вы заданную работу выполните не мгновенно, а через минуту, через 10, через 15 минут. Повлияет ли это на пользовательский опыт?

Все оптимизации, изменения в API и поведении должны планироваться с мыслью, насколько важно выполнять действия мгновенно. Возможно, за счет того, что вы выполните работу чуть позже, вы сделаете пользовательский опыт лучше и это не повлияет критично на пользователя. Если вы отправите данные, которые нужно синхронизировать каждый день, в течение этого дня, а не мгновенно, это не повлияет на него. Можно, как вариант, показать уведомление о том, что мы ждём пока появится Wi-Fi.

Есть разные варианты, важно лишь понимание временного окна, которое вы можете себе позволить для выполнения задач.

Комментарии (0)