Эволюция планировщиков задач
Приложение iFunny, над которым мы работаем, доступно в сторах уже более пяти лет. За это время мобильной команде пришлось пережить множество разных подходов и миграций между инструментами, а год назад появилось время перейти с самописного решения и посмотреть в сторону чего-то более «модного» и распространённого. Эта статья — небольшая выжимка о том, что было изучено, на какие решения смотрели и к чему в итоге пришли.
Зачем нам это всё?
Давайте сразу определимся, в честь чего эта статья и почему эта тема оказалась важной для команды Android-разработки:
- есть множество сценариев, когда необходимо запускать задачи вне рамок активного пользовательского интерфейса;
- система накладывает большое количество ограничений на запуск подобных задач;
- выбрать между существующими решениями оказалось довольно сложно, так как каждый инструмент имеет свои плюсы и минусы.
Хронология развития событий
Android 0
AlarmManager, Handler, Service
Изначально были реализованы свои решения для запуска бэкграунд-задач на основе сервисов. Также был механизм, который привязывал задачи к жизненному циклу и умел отменять и восстанавливать их. Команду это долгое время устраивало, так как никаких ограничений платформа к таким задачам не предъявляла.
В Google же советовали это делать, опираясь на следующую диаграмму:
В конце 2018 года разбираться в этом уже нет смысла, достаточно оценить масштабы бедствия.
Фактически никого не заботило, как много работы происходит в фоне. Приложения делали что хотели и когда хотели.
Плюсы:
доступно везде;
доступно для всех.
Минусы:
система всячески ограничивает работу;
нет запусков по условию;
API минимальное и нужно писать много кода.
Android 5. Lollipop
JobScheduler
Спустя 5(!) лет, ближе к 2015 году в Google заметили, что задачи запускаются неэффективно. Пользователи стали регулярно жаловаться, что их телефоны разряжаются, просто лёжа на столе или в кармане.
С выходом Android 5 появился такой инструмент, как JobScheduler. Это механизм, с чьей помощью можно в фоне выполнять различную работу, начало выполнения которой оптимизировалось и упрощалось за счёт централизованной системы запуска этих задач и возможности задавать условия для этого самого запуска.
В коде всё это выглядит достаточно просто: объявляется сервис, в который приходят события старта и конца работы.
Из нюансов: если вы хотите выполнить работу асинхронно, то из onStartJob нужно запустить поток; главное не забыть вызвать метод jobFinished по окончанию работы, иначе система не отпустит WakeLock, ваша задача не будет считаться выполненной и утечёт.
public class JobSchedulerService extends JobService {
@Override
public boolean onStartJob(JobParameters params) {
doWork(params);
return false;
}
@Override
public boolean onStopJob(JobParameters params) {
return false;
}
}
Из любого места в приложении вы можете инициировать выполнение этой работы. Задачи выполняются в нашем процессе, но инициируются на уровне IPC. Есть централизованный механизм, который управляет их выполнением и будит приложение только в необходимые для этого моменты. Также можно задавать различные условия запуска и передавать данные через Bundle.
JobInfo task = new JobInfo.Builder(JOB_ID, serviceName)
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED)
.setRequiresDeviceIdle(true)
.setRequiresCharging(true)
.build();
JobScheduler scheduler =
(JobScheduler) context.getSystemService(JOB_SCHEDULER_SERVICE);
scheduler.schedule(task);
В общем, по сравнению с ничем это было уже кое-что. Но этот механизм доступен только с API 21, и на момент выхода Android 5.0 было бы странно перестать поддерживать все старые девайсы (прошло 3 года, а мы до сих пор поддерживаем четвёрки).
Плюсы:
API простое;
условия для запуска.
Минусы:
доступно начиная с API 21;
фактически только с API 23;
легко ошибиться.
Android 5. Lollipop
GCM Network Manager
Также был представлен аналог JobScheduler — GCM Network Manager. Это библиотека, которая предоставляла схожий функционал, но работала уже с API 9. Правда, взамен требовала наличие Google Play Services. Видимо, функционал, необходимый для работы JobScheduler, стали поставлять не только через версию Android, но и на уровне GPS. Надо отметить, что разработчики фреймворка очень быстро одумались и решили не связывать своё будущее с GPS. Спасибо им за это.
Выглядит всё абсолютно идентично. Такой же сервис:
public class GcmNetworkManagerService extends GcmTaskService {
@Override
public int onRunTask(TaskParams taskParams) {
doWork(taskParams);
return 0;
}
}
Такой же запуск задач:
OneoffTask task = new OneoffTask.Builder()
.setService(GcmNetworkManagerService.class)
.setTag(TAG)
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED)
.setRequiresCharging(true)
.build();
GcmNetworkManager mGcmNetworkManager =
GcmNetworkManager.getInstance(this);
mGcmNetworkManager.schedule(task);
Такая похожесть архитектуры диктовалась унаследованным функционалом и желанием получить простую миграцию между инструментами.
Плюсы:
API, аналогичное JobScheduler;
доступно начиная с API 9.
Минусы:
необходимо иметь Google Play Services;
легко ошибиться.
Android 5. Lollipop
WakefulBroadcastReceiver
Далее напишу пару слов об одном из базовых механизмов, который используется в JobScheduler и доступен разработчикам напрямую. Это WakeLock и основанный на нём WakefulBroadcastReceiver.
С помощью WakeLock можно запретить системе уходить в suspend, то есть держать девайс в активном состоянии. Это необходимо, если мы хотим выполнить какую-то важную работу.
При создании WakeLock можно указать его настройки: держать CPU, экран или клавиатуру.
PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE)
PowerManager.WakeLock wl = pm.newWakeLock(PARTIAL_WAKE_LOCK, "name")
wl.acquire(timeout);
На основе этого механизма работает WakefulBroadcastReceiver. Мы запускаем сервис и удерживаем WakeLock.
public class SimpleWakefulReceiver extends WakefulBroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
Intent service = new Intent(context, SimpleWakefulService.class);
startWakefulService(context, service);
}
}
После того как сервис выполнил необходимую работу, мы отпускаем его через аналогичные методы.
Через 4 версии этот BroadcastReceiver станет deprecated, и на developer.android.com будут описаны следующие альтернативы:
- JobScheduler;
- SyncAdapter;
- DownloadManager;
- FLAG_KEEP_SCREEN_ON для Window.
Android 6. Marshmallow
DozeMode: сон на ходу
Далее в Google начали применять различные оптимизации для приложений, запущенных на устройстве. Но что для пользователя оптимизация, то для разработчика ограничение.
Первым делом появился DozeMode, который переводит устройство в спящий режим, если оно лежало без действий определённое время. В первых версиях это длилось час, в последующих длительность сна уменьшили до 30 минут. Периодически телефон просыпается, выполняет все отложенные задачи и снова засыпает. Окно DozeMode увеличивается экспоненциально. Все переходы между режимами можно отследить через adb.
При наступлении DozeMode на приложение накладываются следующие ограничения:
- система игнорирует все WakeLock;
- откладывается AlarmManager;
- JobScheduler не работает;
- SyncAdapter не работает;
- доступ в сеть ограничен.
Также вы можете добавить ваше приложение в whitelist, чтобы оно не попадало под ограничения DozeMode, но как минимум Samsung полностью игнорировал этот список.
Android 6. Marshmallow
AppStandby: неактивные приложения
Система определяет приложения, которые являются неактивными, и накладывает на них все те же ограничения, что и в рамках DozeMode.
Приложение отправляется в изоляцию, если:
- не имеет процесса на переднем плане;
- не имеет активной нотификации;
- не добавлено в список исключений.
Android 7. Nougat
Background Optimizations. Svelte
Svelte — это проект, в рамках которого Google пытается оптимизировать потребление оперативной памяти приложениями и самой системой.
В Android 7 в рамках этого проекта было решено, что неявные бродкасты не очень эффективны, так как их слушает огромное количество приложений и система тратит большое количество ресурсов при наступлении этих событий. Поэтому следующие типы событий были запрещены для объявления в манифесте:
- CONNECTIVITY_ACTION;
- ACTION_NEW_PICTURE;
- ACTION_NEW_VIDEO.
Android 7. Nougat
FirebaseJobDispatcher
В это же время была опубликована новая версия фреймворка для запуска задач — FirebaseJobDispatcher. На самом деле это был дописанный GCM NetworkManager, который немного привели в порядок и сделали чуть более гибким.
Визуально всё выглядело точно так же. Такой же сервис:
public class JobSchedulerService extends JobService {
@Override
public boolean onStartJob(JobParameters params) {
doWork(params);
return false;
}
@Override
public boolean onStopJob(JobParameters params) {
return false;
}
}
Единственное чем он отличался, так это возможностью установки своего драйвера. Драйвер — это класс, который отвечал за стратегию запуска задач.
Сам же запуск задач с течением времени не изменился.
FirebaseJobDispatcher dispatcher =
new FirebaseJobDispatcher(new GooglePlayDriver(context));
Job task = dispatcher.newJobBuilder()
.setService(FirebaseJobDispatcherService.class)
.setTag(TAG)
.setConstraints(Constraint.ON_UNMETERED_NETWORK,
Constraint.DEVICE_IDLE)
.build();
dispatcher.mustSchedule(task);
Плюсы:
API, аналогичное JobScheduler;
доступно начиная с API 9.
Минусы:
необходимо иметь Google Play Services;
легко ошибиться.
Вселяла надежду возможность установки своего драйвера, чтобы отвязаться от GPS. Мы даже поискали, но в итоге нашли следующее:
Google знает об этом, но эти задачи несколько лет остаются открытыми.
Android 7. Nougat
Android Job by Evernote
В итоге сообщество не выдержало, и появилось самописное решение в виде библиотеки от Evernote. Оно было не единственное, но именно решение от Evernote смогло зарекомендовать себя и «выбилось в люди».
В архитектурном плане эта библиотека была удобнее своих предшественников.
Появилась сущность, отвечающая за создание задач. В случае с JobScheduler они создавались через reflection.
class SendLogsJobCreator : JobCreator {
override fun create(tag: String): Job? {
when (tag) {
SendLogsJob.TAG -> return SendLogsJob()
}
return null
}
}
Имеется отдельный класс, который является самой задачей. В JobScheduler это всё было свалено в switch внутри onStartJob.
class SendLogsJob : Job() {
override fun onRunJob(params: Params): Result {
return doWork(params)
}
}
Запуск задач идентичен, но кроме унаследованных событий Evernote ещё добавил и свои, такие как запуск ежедневных задач, уникальные задачи, запуск в рамках окна.
new JobRequest.Builder(JOB_ID)
.setRequiresDeviceIdle(true)
.setRequiresCharging(true)
.setRequiredNetworkType(JobRequest.NetworkType.UNMETERED)
.build()
.scheduleAsync();
Плюсы:
удобное API;
поддерживается на всех версиях;
не нужны Google Play Services.
Минусы:
стороннее решение.
Ребята активно поддерживали свою библиотеку. Хотя было довольно много критичных проблем, она работала на всех версиях и на всех девайсах. В итоге в прошлом году наша Android-команда выбрала решение именно от Evernote, так как библиотеки от Google срезают большой пласт девайсов, которые они не могут поддержать.
Внутри себя же она работала на решениях от Google, в крайних случаях — с AlarmManager.
Android 8. Oreo
Background Execution Limits
Вернёмся к нашим ограничениям. С приходом нового Android пришли и новые оптимизации. Ребята из Google нашли другую проблему. В этот раз всё дело оказалось в сервисах и бродкастах (да, ничего нового).
- startService если приложения в фоне
- implicit broadcast в манифесте
Во-первых, было запрещено запускать сервисы из фона. В «рамках закона» остались только foreground services. Сервисы теперь, можно сказать, deprecated.
Второе ограничение — всё те же бродкасты. В этот раз стала запрещена регистрация ВСЕХ неявных бродкастов в манифесте. Неявный бродкаст — это бродкаст, который предназначается не только нашему приложению. Например, есть Action ACTION_PACKAGE_REPLACED, а есть ACTION_MY_PACKAGE_REPLACED. Так вот, первый — это неявный.
Но любой бродкаст по-прежнему можно зарегистрировать через Context.registerBroadcast.
Android 9. Pie
WorkManager
На этом оптимизации пока прекратились. Возможно, устройства стали работать быстро и бережно в плане энергопотребления; возможно, пользователи стали меньше жаловаться на это.
В Android 9 разработчики фреймворка основательно подошли к инструменту для запуска задач. В попытке решить все насущные проблемы, на Google I/O была представлена библиотека для запуска бэкграунд-задач WorkManager.
Google последнее время пытается сформировать своё видение архитектуры Android-приложения и даёт разработчикам инструменты, необходимые для этого. Так появились архитектурные компоненты с LiveData, ViewModel и Room. WorkManager выглядит как разумное дополнение их подхода и парадигмы.
Если же говорить про то, как устроен WorkManager внутри, то никакого технологического прорыва в нём нет. По сути это обёртка уже существующих решений: JobScheduler, FirebaseJobDispatcher и AlarmManager.
static Scheduler createBestAvailableBackgroundScheduler(Context, WorkManager) {
if (Build.VERSION.SDK_INT >= MIN_JOB_SCHEDULER_API_LEVEL) {
return new SystemJobScheduler(context, workManager);
}
try {
return tryCreateFirebaseJobScheduler(context);
} catch (Exception e) {
return new SystemAlarmScheduler(context);
}
}
Код выбора довольно прост. Но надо заметить, что JobScheduler доступен начиная с API 21, но используют его только с API 23, так как первые версии были довольно нестабильные.
Если версия ниже 23, то через reflection пробуем найти FirebaseJobDispatcher, в противном случае используем AlarmManager.
Стоит отметить, обёртка вышла достаточно гибкой. В этот раз разработчики всё разбили на отдельные сущности, и архитектурно это выглядит удобно:
- Worker — логика работы;
- WorkRequest — логика запуска задачи;
- WorkRequest.Builder — параметры;
- Constrains — условия;
- WorkManager — менеджер, который управляет задачами;
- WorkStatus — статус задачи.
Условия для запуска наследовались от JobScheduler.
Можно отметить, что триггер на изменение URI появился только с API 23. К тому же можно подписаться на изменение не только определённого URI, но и всех вложенных в него с помощью флага в методе.
Если говорить о нас, то ещё на этапе альфы было решено перейти на WorkManager.
Причин для этого несколько. В Evernote есть пара критичных багов, которые разработчики библиотеки обещают поправить с переходом на версию с интегрированным WorkManager. Да и сами они соглашаются, что решение от Google сводит на нет плюсы Evernote. К тому же это решение хорошо вписывается в нашу архитектуру, так как мы используем Architecture Components.
Далее хотелось бы на простом примере показать, в каком виде мы стараемся использовать этот подход. При этом не сильно критично, WorkManager у вас или JobScheduler.
Посмотрим на пример с очень простым кейсом: клик по republish или like.
Сейчас все приложения стараются уйти от блокирующих запросов в сеть, так как это нервирует пользователя и заставляет его ждать, хотя в это время он может делать покупки внутри приложения или смотреть рекламу.
В таких случаях сначала изменяются локальные данные — пользователь сразу видит результат своего действия. Затем в фоне идёт запрос на сервер, при неудаче которого данные сбрасываются в начальное состояние.
Далее покажу пример того, как это выглядит у нас.
JobRunner содержит логику запуска задач. В его методах описывается конфигурация задач и передаются параметры.
fun likePost(content: IFunnyContent) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val input = Data.Builder()
.putString(LikeContentJob.ID, content.id)
.build()
val request = OneTimeWorkRequest.Builder(LikeContentJob::class.java)
.setInputData(input)
.setConstraints(constraints)
.build()
WorkManager.getInstance().enqueue(request)
}
Сама задача в рамках WorkManager выглядит следующим образом: берём id из параметров и вызываем метод на сервере, чтобы поставить лайк на этот контент.
У нас есть базовый класс, который содержит следующую логику:
abstract class BaseJob : Worker() {
final override fun doWork(): Result {
val workerInjector = WorkerInjectorProvider.injector()
workerInjector.inject(this)
return performJob(inputData)
}
abstract fun performJob(params: Data): Result
}
Во-первых, он позволяет немного уйти от явного знания о Worker. Также он содержит логику внедрения зависимостей через WorkerInjector.
@Singleton
public class WorkerInjectorImpl implements WorkerInjector {
@Inject
public WorkerInjectorImpl() {}
@Ovierride
public void inject(Worker job) {
if (worker instanceof AppCrashedEventSendJob) {
Injector.getAppComponent().inject((AppCrashedEventSendJob) job);
} else if (worker instanceof CheckNativeCrashesJob) {
Injector.getAppComponent().inject((CheckNativeCrashesJob) job);
}
}
}
Он просто проксирует вызовы в Dagger, но это нам помогает при тестировании: мы подменяем реализации инжектора и внедряем в задачи необходимое окружение.
fun void testRegisterPushProvider() {
WorkManagerTestInitHelper.initializeTestWorkManager(context)
val testDriver = WorkManagerTestInitHelper.getTestDriver()
WorkerInjectorProvider.setInjector(TestInjector()) // mock dependencies
val id = jobRunner.runPushRegisterJob()
testDriver.setAllConstraintsMet(id)
Assert.assertTrue(…)
}
class LikePostInteractor @Inject constructor(
val iFunnyContentDao: IFunnyContentDao,
val jobRunner: JobRunner) : Interactor {
fun execute() {
iFunnyContentDao.like(getContent().id)
jobRunner.likePost(getContent())
}
}
Interactor — это сущность, которую дёргает ViewController, чтобы инициировать прохождение сценария (в данном случае —поставить лайк). Мы отмечаем локально контент как «залайканный» и отправляем задачу на выполнение. Если задача происходит неуспешно, то лайк снимается.
class IFunnyContentViewModel(val iFunnyContentDao: IFunnyContentDao) : ViewModel() {
val likeState = MediatorLiveData()
var iFunnyContentId = MutableLiveData()
private var iFunnyContentState: LiveData = attachLiveDataToContentId();
init {
likeState.addSource(iFunnyContentState) { likeState.postValue(it!!.hasLike) }
}
}
Мы используем Architecture Components от Google: ViewModel и LiveData. Так выглядит наша ViewModel. Здесь мы связываем обновление объекта в DAO со статусом лайка.
class IFunnyContentViewController @Inject constructor(
private val likePostInteractor: LikePostInteractor,
val viewModel: IFunnyContentViewModel) : ViewController {
override fun attach(view: View) {
viewModel.likeState.observe(lifecycleOwner, { updateLikeView(it!!) })
}
fun onLikePost() {
likePostInteractor.setContent(getContent())
likePostInteractor.execute()
}
}
ViewController, с одной стороны, подписывается на изменение статуса лайка, с другой — инициирует прохождение нужного нам сценария.
И это практически весь код, необходимый нам. Осталось дописать поведение самой View с лайком и реализацию вашего DAO; если вы используете Room, то просто прописать поля в объекте. Выглядит довольно просто и эффективно.
Если подводить итоги
JobScheduler, GCM Network Manager, FirebaseJobDispatcher:
- не используйте их
- больше не читайте статьи про них
- не смотрите доклады
- не думайте, что из них выбрать.
Android Job by Evernote:
- внутри будут использовать WorkManager;
- критичные баги размываются между решениями.
WorkManager:
- API LEVEL 9+;
- не зависит от Google Play Services;
- Chaining/InputMergers;
- реактивный подход;
- поддержка от Google (хочется в это верить).