Эволюция планировщиков задач

bvmrpspwu-kyhojb5tiu4lqbdi8.jpeg

Приложение iFunny, над которым мы работаем, доступно в сторах уже более пяти лет. За это время мобильной команде пришлось пережить множество разных подходов и миграций между инструментами, а год назад появилось время перейти с самописного решения и посмотреть в сторону чего-то более «модного» и распространённого. Эта статья — небольшая выжимка о том, что было изучено, на какие решения смотрели и к чему в итоге пришли.

Зачем нам это всё?

Давайте сразу определимся, в честь чего эта статья и почему эта тема оказалась важной для команды Android-разработки:

  1. есть множество сценариев, когда необходимо запускать задачи вне рамок активного пользовательского интерфейса;
  2. система накладывает большое количество ограничений на запуск подобных задач;
  3. выбрать между существующими решениями оказалось довольно сложно, так как каждый инструмент имеет свои плюсы и минусы.


Хронология развития событий


Android 0

AlarmManager, Handler, Service


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

e-46znleyxvynpm35pbebgrkdhk.png

В конце 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. Мы даже поискали, но в итоге нашли следующее:

3n39uanajermu--vb5qrzgfl8jq.png

zmrqi3pnt_0hx8lcspdy8k6xio8.png

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.

createBestAvailableBackgroundScheduler
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 — статус задачи.

n_tsxiw0wsxv6a5abotzdzokcba.png

Условия для запуска наследовались от JobScheduler.
Можно отметить, что триггер на изменение URI появился только с API 23. К тому же можно подписаться на изменение не только определённого URI, но и всех вложенных в него с помощью флага в методе.

Если говорить о нас, то ещё на этапе альфы было решено перейти на WorkManager.
Причин для этого несколько. В Evernote есть пара критичных багов, которые разработчики библиотеки обещают поправить с переходом на версию с интегрированным WorkManager. Да и сами они соглашаются, что решение от Google сводит на нет плюсы Evernote. К тому же это решение хорошо вписывается в нашу архитектуру, так как мы используем Architecture Components.

Далее хотелось бы на простом примере показать, в каком виде мы стараемся использовать этот подход. При этом не сильно критично, WorkManager у вас или JobScheduler.

oxyd0pizr6h8tx4egaz_kub5wqy.png6mechdqawzamahgaij7df-isocg.png

Посмотрим на пример с очень простым кейсом: клик по republish или like.

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

В таких случаях сначала изменяются локальные данные — пользователь сразу видит результат своего действия. Затем в фоне идёт запрос на сервер, при неудаче которого данные сбрасываются в начальное состояние.

Далее покажу пример того, как это выглядит у нас.

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

JobRunner.java
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.

WorkerInjectorImpl.java
@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 со статусом лайка.

IFunnyContentViewController.java
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 (хочется в это верить).

© Habrahabr.ru