
Приложение 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 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<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 со статусом лайка.
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 (хочется в это верить).
 
Комментарии (9)

Virus1908ua
29.12.2018 05:45Как вы реализуете обработку ошибок? Ее же нужно как-то показать пользователю.

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

serarhi
29.12.2018 05:45Не так давно модернизировал свое приложение в связи с введением Background Execution Limits, тоже столкнулся с нехваткой универсальных решений для создания планировщиков. В моем случае нужен был планировщик для автоматической отправки sms сообщений в заданное время.
В результате пришел к следующему решению. Для запуска процедуры отправки и перепланирования в заданное время использую AlarmManager c setExactAndAllowWhileIdle или setExact в зависимости от версии Android. А для самого исполнения задачи использую JobIntentService.
Таким образом, получаем простое решение, не завязанное на версию Android или внешние библиотеки и сервисы, которое просто работает. Может кому-то пригодится.
Spider55
29.12.2018 06:45Пришли к тому же решению. Пока работает, но есть и исключения: Xiaomi OneNote — на них есть настройка ограничения фонового режима и там хоть ты тресни, пока пользователь не снимет ограничение, нукуда ты не пойдёшь…

Spider55
29.12.2018 06:43Есть у нас странная задача — переодически общаться с BLE устройствами. Сделали на WorkManager с запуском раз в час (это отдельная тема) и получили практически БОЛТ. Спустя сутки или даже ранее (в зависимости от производителя и видимо настройки ОС) похоже что процесс вносится в некий blacklist и пока не откроешь UI — тебя больше не запустят.
Как быть пока ХЗ. Вешать ForegroundService с уведомлением в шторку…
metrolog_ma Автор
29.12.2018 14:25Задач с такой частой периодичностью у нас нет, поэтому с такой проблемой пока не столкнулись. Вообще, странно, что через AlarmManager он работает как надо, а через WorkManager нет. Возможно, были какие-то баги (alpha все-таки) и в beta поправили? Багов связанных с периодическими задачами у них в issuetracker было достаточно)
          
 
LAG_LAGbI4
Почему управление фоновыми задачами нельзя получить пользователю? Чтобы он сам прозрачно мог устанавливать, может ли приложение работать в фоне, использовать автозагрузку, фоновое обновление и прочее?
Fedcomp
Потому что в популярных ОС пользователь принимается за дурачка, и не без причин.
HUMAN1TY
Статья не об этом. Она о том, чтобы эти фоновые задачи в принципе работали, если даже пользователя спросили и он согласился.