Приложение 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 (хочется в это верить).

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


  1. LAG_LAGbI4
    28.12.2018 18:10

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


    1. Fedcomp
      28.12.2018 21:17

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


    1. HUMAN1TY
      29.12.2018 05:45

      Статья не об этом. Она о том, чтобы эти фоновые задачи в принципе работали, если даже пользователя спросили и он согласился.


  1. Virus1908ua
    29.12.2018 05:45

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


    1. metrolog_ma Автор
      29.12.2018 14:07

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


  1. serarhi
    29.12.2018 05:45

    Не так давно модернизировал свое приложение в связи с введением Background Execution Limits, тоже столкнулся с нехваткой универсальных решений для создания планировщиков. В моем случае нужен был планировщик для автоматической отправки sms сообщений в заданное время.
    В результате пришел к следующему решению. Для запуска процедуры отправки и перепланирования в заданное время использую AlarmManager c setExactAndAllowWhileIdle или setExact в зависимости от версии Android. А для самого исполнения задачи использую JobIntentService.
    Таким образом, получаем простое решение, не завязанное на версию Android или внешние библиотеки и сервисы, которое просто работает. Может кому-то пригодится.


    1. Spider55
      29.12.2018 06:45

      Пришли к тому же решению. Пока работает, но есть и исключения: Xiaomi OneNote — на них есть настройка ограничения фонового режима и там хоть ты тресни, пока пользователь не снимет ограничение, нукуда ты не пойдёшь…


  1. Spider55
    29.12.2018 06:43

    Есть у нас странная задача — переодически общаться с BLE устройствами. Сделали на WorkManager с запуском раз в час (это отдельная тема) и получили практически БОЛТ. Спустя сутки или даже ранее (в зависимости от производителя и видимо настройки ОС) похоже что процесс вносится в некий blacklist и пока не откроешь UI — тебя больше не запустят.
    Как быть пока ХЗ. Вешать ForegroundService с уведомлением в шторку…


    1. metrolog_ma Автор
      29.12.2018 14:25

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