company_banner

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



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

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

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

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

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


    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.

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




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

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

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



    Посмотрим на пример с очень простым кейсом: клик по 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<Boolean>()
        var iFunnyContentId = MutableLiveData<String>()
        private var iFunnyContentState: LiveData<IFunnyContent> = 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 (хочется в это верить).
    FunCorp
    239,00
    Разработка развлекательных сервисов
    Поделиться публикацией

    Комментарии 11

      0

      Почему управление фоновыми задачами нельзя получить пользователю? Чтобы он сам прозрачно мог устанавливать, может ли приложение работать в фоне, использовать автозагрузку, фоновое обновление и прочее?

        0
        Потому что в популярных ОС пользователь принимается за дурачка, и не без причин.
          +1
          Статья не об этом. Она о том, чтобы эти фоновые задачи в принципе работали, если даже пользователя спросили и он согласился.
            0
            Потому что если вы пишете, скажем, мессенджер, то пользователи будут массово, по совету знакомого, запрещать вашему приложению фоновую работу «чтобы батарейку не ело», а потом говорить, что ваш мессенджер отстой, потому что не принимает сообщения пока не откроешь. И это будет не их проблема, а ваша.
              0
              Не все мессенджеры одинаково полезные. Сам не вникал, но краем уха слышал, что фэйсбук батарейку высаживает.
              Но меня больше не месенджеры волнуют, а всякие приложения для заказа пицц и похожие. Я не хочу, чтобы они в фоне работали. Вообще не хочу.
            +1
            Как вы реализуете обработку ошибок? Ее же нужно как-то показать пользователю.
              0

              Хороший вопрос. В каких-то случаях у нас есть своя LiveData, которая висит на Appplication, через которую пробрасываются статусы прямо из Worker; в каких-то случаях это менеджеры самописные; бывает мы слушаем результат выполнения Worker через WorkManager.getInstance().getWorkInfoByIdLiveData()

              0
              Не так давно модернизировал свое приложение в связи с введением Background Execution Limits, тоже столкнулся с нехваткой универсальных решений для создания планировщиков. В моем случае нужен был планировщик для автоматической отправки sms сообщений в заданное время.
              В результате пришел к следующему решению. Для запуска процедуры отправки и перепланирования в заданное время использую AlarmManager c setExactAndAllowWhileIdle или setExact в зависимости от версии Android. А для самого исполнения задачи использую JobIntentService.
              Таким образом, получаем простое решение, не завязанное на версию Android или внешние библиотеки и сервисы, которое просто работает. Может кому-то пригодится.
                0
                Пришли к тому же решению. Пока работает, но есть и исключения: Xiaomi OneNote — на них есть настройка ограничения фонового режима и там хоть ты тресни, пока пользователь не снимет ограничение, нукуда ты не пойдёшь…
                0
                Есть у нас странная задача — переодически общаться с BLE устройствами. Сделали на WorkManager с запуском раз в час (это отдельная тема) и получили практически БОЛТ. Спустя сутки или даже ранее (в зависимости от производителя и видимо настройки ОС) похоже что процесс вносится в некий blacklist и пока не откроешь UI — тебя больше не запустят.
                Как быть пока ХЗ. Вешать ForegroundService с уведомлением в шторку…
                  0

                  Задач с такой частой периодичностью у нас нет, поэтому с такой проблемой пока не столкнулись. Вообще, странно, что через AlarmManager он работает как надо, а через WorkManager нет. Возможно, были какие-то баги (alpha все-таки) и в beta поправили? Багов связанных с периодическими задачами у них в issuetracker было достаточно)

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

                Самое читаемое