Иногда при разработке под OS Android возникает необходимость выполнять ресурсоемкие операции периодически, регулярно или по запросу, и для этих операций важно, например, наличие интернета или чтобы устройство "не спало". Чаще всего при решении подобных задач применяются AlarmManager, WakefulBroadcastReceiver, либо же вообще WakeLock контролируется вручную. Все это не рекомендуется в документации для разработчиков под Android, а WakefulBroadcastReceiver уже отмечен как deprecated с API level 26.0.0.


Что же мы можем сделать, чтобы следовать рекомендациям Google и создавать приложения с более гибким поведением на версиях Android 5.0+, в которых энергосбережению уделяется все больше внимания? Если вы готовы выставить минимальный API level 21.0.0 для своего приложения, предлагаю под катом пример использования JobScheduler в связке с IntentService для последовательного выполнения трудоемких задач.


Суть вопроса и поднятых проблем


Достаточно открыть документацию по классу WakefulBroadcastReceiver, чтобы увидеть интересную ситуацию: класс добавлен в 22.0.0 версии API, а отмечен как deprecated в 26.0.0 версии. Можно предположить, что сначала разработчики Android решили добавить удобный класс для выполнения задач с удержанием WakeLock, но потом оказалось, что никто не гарантирует, что приложение будет работать на переднем плане, да и вообще когда каждое "умное" приложение пытается удержать WakeLock, когда же устройству экономить энергию? Да и принцип работы WakefulBroadcastReceiver стал идти в разрез со стремлением продлить время жизни заряда батареи, ведь кроме всего прочего незакрытый правильным образом ресивер мог привести к утечкам WakeLock'ов.


С другой стороны у рядовых разработчиков может возникнуть вопрос, как при всех новых ограничениях безопасно выполнять периодические задачи, когда, например, активен Doze mode? Чтобы уравновесить баланс ограничений и возможностей был создан JobScheduler, который берет на себя решение вопросов о том, когда задаче можно выполниться, каким приемлемым образом предоставить ей возможность выполнения, если это действительно очень нужно, и как при этом не нарушить политику энергосбережения и не потерять где-нибудь не отпущенный WakeLock.


Пока готовилась данная статья, на Хабре появилась статья другого автора, в которой раскрыто чуть больше теории и уделено чуть меньше внимания практике. Она будет полезной для быстрого старта и более глубоко понимания существующих альтернатив JobScheduler'у.


Пример создания проекта с JobScheduler и IntentService


Для простоты примера представим, что у нас есть задача по записи слова "Exercise" в файл, при этом нам нужен интернет, причем желательно не мобильный, потому что наша задача не стоит траты мобильного трафика пользователя. При этом мы хотим, чтобы каждая новая задача по записи слова выполнялась друг за другом. Так как нам нельзя блокировать главный поток (тут и работа с файлами и, возможно, какие-то сетевые запросы), и у нас задачи могут выстраиваться в очередь, на помощь нам приходит IntentService.


Для полного воспроизведения шагов, описанных в данной статье, необходимо создать новый проект без активити, с минимальной версией SDK 21. Также все действия можно производить в уже существующем проекте с минимальной версией SDK не ниже 21.


Добавление IntentService для обработки задач в фоне


Добавляем в проект новый IntentService, называем его ExerciseIntentService, используем стандартный подход, основанный на автоматически сгенерированных методах, почистив лишнее и переименовав методы и константы под наши условия.


В результате несложных манипуляций получаем следующее тело для ExerciseIntentService:


private static final String ACTION_WRITE_EXERCISE = 
    "com.github.ihandy.jobschedulerdemo.action.ACTION_WRITE_EXERCISE";

public ExerciseIntentService() {
    super("ExerciseIntentService");
}

public static void startActionWriteExercise(Context context) {
    Intent intent = new Intent(context, ExerciseIntentService.class);
    intent.setAction(ACTION_WRITE_EXERCISE);
    context.startService(intent);
}

@Override
protected void onHandleIntent(Intent intent) {
    if (intent != null) {
        final String action = intent.getAction();
        if (ACTION_WRITE_EXERCISE.equals(action)) {
            handleActionWriteExercise();
        }
    }
}

private void handleActionWriteExercise() {
    try {
        FileWriter fileWriter = 
            new FileWriter(getFilesDir().getPath() + "exercise.txt");
        fileWriter.write("Exercise");
        fileWriter.close();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

И так, сервис, который будет непосредственно выполнять наши однотипные условно-трудоемкие задачи по порядку, готов. Для того, чтобы создавать и планировать задачи, необходима точка входа, в качестве которой можно использовать BroadcastReceiver.


Добавление BroadcastReceiver для инициализации задач


Добавим в проект стандартными средствами BroadcastReciever и назовем его ExerciseRequestsReceiver. В дальнейшем мы сможем откуда угодно отправлять бродкасты нашему приложению, чтобы оно планировало выполнение задач (например, это можно сделать с помощью инструментальных тестов, что будет показано ближе к концу статьи).


Минимально необходимый код ExerciseRequestsReceiver выглядит так:


public static final String ACTION_PERFORM_EXERCISE = "ACTION_PERFORM_EXERCISE";

private static int sJobId = 1;

@Override
public void onReceive(Context context, Intent intent) {
    switch (intent.getAction()) {
        case ACTION_PERFORM_EXERCISE:
            scheduleJob(context);
            break;
        default:
            throw new IllegalArgumentException("Unknown action.");
    }
}

String ACTION_PERFORM_EXERCISE — действие для идентификации необходимости запуска процесса планирования задачи.
int sJobId — переменная для идентификатора задачи, который будет использован при планировании задач.
scheduleJob(context) — вызов метода, который будет содержать всю необходимую логику для планирования задачи.


Теперь при получении бродкаста мы могли бы отправить интент нашему ExerciseIntentService, и все бы было хорошо, только в нашем случае необходим WakeLock, а так как мы договорились больше не использовать WakefulBroadcastReceiver, то необходимо создать и запланировать новую задачу для JobScheduler, а дальше он все сделает за нас (почти).


Кстати, при использовании JobScheduler не нужно разрешение на WakeLock, в отличие от других способов решения подобной задачи.


Реализация наследника JobService для обработки событий от JobScheduler


JobScheduler требует отдельный сервис, унаследованный от JobService. Назовем его ExerciseJobService и добавим как обычный сервис, заменив родительский класс и добавив разрешение в манифест модуля:


<service
      android:name=".ExerciseJobService"
      android:enabled="true"
      android:exported="true"
      android:permission="android.permission.BIND_JOB_SERVICE">
</service>

Разрешение android.permission.BIND_JOB_SERVICE необходимо, чтобы данный сервис смог взаимодействовать с JobScheduler.


Кроме этого обязательными для реализации являются два метода onStartJob() и onStopJob().


  • onStartJob() вызывается когда настает время (условия) для выполнения запланированной задачи. Этот метод вызывается в главном потоке и любые тяжелые операции разработчик должен самостоятельно выносить в отдельные потоки (в нашем случае это уже предусмотрено — мы используем IntentService). При делегации выполнения задачи в другие потоки из onStartJob() необходимо вернуть true, а если все необходимые действия уже выполнены в теле этого метода, то вернуть нужно false.
  • onStopJob() вызывается тогда, когда требуемые условия для задачи перестали выполняться либо отведенное для задачи время исчерпано. Вызов этого метода информирует сервис, что все фоновые задачи немедленно должны перестать выполняться. Лучше всего предусмотреть безопасную логику остановки выполнения для обеспечения целостности данных.

Для правильной обработки ситуации с onStopJob() можно реализовать статические флаги, какие-либо дополнительные бродкасты, а также можно использовать другие средства взаимодействия сервисов. В текущей статье это будет опущено и введено допущение, что если наш IntentService не смог выполнить задачу, то ничего страшного для логики приложения и целостности данных не произойдет.


onStopJob также имеет возвращаемое значение, если это true — то JobScheduler поставит прерванную задачу в очередь выполнения снова, false — задача будет считаться выполненной и будет удалена из очереди, если она не была периодической.


Так как обработка внештатных ситуаций опускается, вернем из этого метода true, чтобы задача перезапланировалась, заодно это нам позволит рассмотреть использование критерия повтора.


Таким образом, установив возвращаемые значения из двух основных методов и добавив запуск нашего ExerciseIntentService в onStartJob(), получаем следующий достаточно емкий сервис:


public class ExerciseJobService extends JobService {
    public ExerciseJobService() {
    }

    @Override
    public boolean onStartJob(JobParameters params) {
        ExerciseIntentService.startActionWriteExercise(getApplicationContext());
        return true;
    }

    @Override
    public boolean onStopJob(JobParameters params) {
        return true;
    }
}

На данный момент уже подготовлен минимальный набор классов для реализации выполнения задачи через JobScheduler в IntentService, а именно: ExerciseIntentService — выполняет непосредственно необходимые для задачи операции в отдельных потоках, ExerciseJobService — ловит события от JobScheduler'a и запускает ExerciseIntentService, а ExerciseRequestsReceiver — входная точка для работы нашего комплекса, где мы ловим бродкасты извне и должны инициализировать задачу для JobScheduler, чем далее и займемся.


Создание новой задачи для JobScheduler


Для создания задачи для JobScheduler понадобится JobInfo.Builder. Его конструктор принимает два параметра: идентификатор задачи и ComponentName нашего ExerciseJobService.


Идентификатор задачи и ComponentName


С идентификатором все просто (но не без нюансов) — любое целочисленное значение:


  • постоянное, если мы хотим обновлять уже запланированную задачу либо же контролировать единственность периодической задачи;
  • уникальное значение, если мы хотим создать очередь из отдельных задач.

Замечание про использование UID

Если вдруг ваше приложение системное, либо же у вас есть несколько приложений с одним sharedUserId, то нужно учитывать дополнительное условие: id не должен пересекаться среди всех приложений с одним uid. Таким образом, если приложение использует android.uid.system, то нужно учитывать, что некоторые системные задачи также используют JobScheduler, и уникальность id нужно поддерживать самостоятельно.


Кстати, при использовании таких методов у JobScheduler как removeAll() мы можем удалить и чужие задачи с тем же uid.


Статья на английском о том, как можно контролировать подобную ситуацию.


В рассматриваемом примере не нужно забоится об UID и в качестве идентификатора используется инкрементируемое значение sJobId.


sJobId определен следующим образом:


private static int sJobId = 1;

С ComponentName все гораздо проще, это объект в конструктор которого передается ExerciseJobService.class.


ComponentName jobService = new ComponentName(context, ExerciseJobService.class);

JobInfo.Builder exerciseJobBuilder = new JobInfo.Builder(sJobId++, jobService);

Инициализация параметров с помощью JobInfo.Builder


Ниже рассмотрим основной набор методов JobInfo.Builder.


  • exerciseJobBuilder.setMinimumLatency(TimeUnit.SECONDS.toMillis(1)); 

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


  • exerciseJobBuilder.setOverrideDeadline(TimeUnit.SECONDS.toMillis(5));

    максимальное время, в течение которого задача может находиться в очереди / запланированном состоянии. Если по истечению 5 секунд (в нашем случае) благоприятные условия так и не наступили, задача начнет выполняться ни смотря ни на что (если это не противоречит другим политикам JobScheduler). Если не использовать, тогда задача будет выполнена только при наступлении необходимых условий.


  • exerciseJobBuilder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED); 

    задается тип подключения, например, нам нужен интернет, но чтобы это был свободный WIFi (не hotspot) или Ethernet, тогда мы выбираем NETWORK_TYPE_UNMETERED.


  • exerciseJobBuilder.setRequiresDeviceIdle(false);

    Определяет состояние, когда пользователь не взаимодействует с устройством, в нашем случае это не важно.


  • exerciseJobBuilder.setRequiresCharging(false); 

    В нашем примере предположим, что нам без разницы, заряжается устройство или нет.



Можно установить для задачи критерий повтора (подробнее об этом чуть ниже):


  • exerciseJobBuilder.setBackoffCriteria(TimeUnit.SECONDS.toMillis(10),    
        JobInfo.BACKOFF_POLICY_LINEAR);


Кроме этого есть возможность сделать задачу периодической:


  • exerciseJobBuilder.setPeriodic(интервал);


Периодические задачи


Когда мы устанавливаем задаче периодичность, мы сталкиваемся с логичными ограничениями:


  1. setMinimumLatency() и setOverrideDeadline() использовать нельзя, так как не имеет смысла — задача так или иначе должна выполниться один раз в течение заданного интервала, и никакие дополнительные ограничения сверху или снизу недопустимы. С другой стороны иногда нам нужно чего-то подождать, а потом начинать периодическую задачу — здесь добавить такое условие нельзя, если нужно ждать, значит ждать нужно до того, как добавлять задачу на выполнение.
  2. в JobService в onStopJob() нам можно не возвращать true — прерванная периодическая задача не будет удалена из очереди, в следующий раз она выполнится по расписанию.
  3. никто не гарантирует, что задача будет выполнена ровно через заданный интревал, она просто будет выполнена не более чем 1 раз за этот интервал.

Это основные отличия периодической задачи от обычной. В текущем примере мы не будем делать задачу периодической.


Критерий повтора выполнения задачи


setBackoffCriteria() позволяет задать правило, по которому будет произведена повторная попытка выполнения задачи в случае необходимости (например в onStopJob() мы вернули true).


JobScheduler предлагает нам две политики: линейная и экспоненциальная.


Формула линейной политики следующая:


retry_time(current_time, num_failures) = 
    current_time + initial_backoff_millis * num_failures, num_failures >= 1

т.е. от текущего момента времени следующая попытка будет предпринята через заданное количество времени умноженное на количество неудач.


Формула экспоненциальной политики:


retry_time(current_time, num_failures) = 
  current_time + initial_backoff_millis * 2 ^ (num_failures - 1), num_failures >= 1

Здесь же время следующей попытки растет гораздо большими шагами.


Все достаточно просто и прозрачно, но что будет, если наша задача не может выполниться успешно множество раз, 10, 20...? При заданном начальном времени повтора в 1 минуту к 10 попытке пройдет практически час. Отлавливать такие ситуации достаточно не просто, потому что мы не можем предсказать что будет через час. JobScheduler ограничивает такие повтороения пятью часами.


Таким образом с параметром setBackoffCriteria() нужно обращаться очень осторожно, тщательно продумывать начальное время и тип политики в соотвествии с поставленными задачами. Возможно также придется осуществлять дополнительную обработку, например, количества повторов и удалять задачу из JobScheduler.


Отправка задачи на выполнение


Таким образом у нас готов JobBuilder со всеми необходимыми нам параметрами. Для добавления задачи в очередь выполнения необходимо получить у системы инстанс JobScheduler:


JobScheduler jobScheduler = 
    (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);

И вызвать метод для добавления JobInfo из билдера:


jobScheduler.schedule(exerciseJobBuilder.build());

Для надежности можно проверить возвращаемое значение последнего метода, который нам скажет об успешном или не успешном добавлении задачи в очередь.


Далее когда JobScheduler находит, что условия для выполнения задачи оптимальные, вызывается метод onStartJob() в ExerciseJobService, который уже разобран выше.


Проверка и отладка


Если подытожить, то в результате у нас получилось тестовое приложение, которое позволяет планировать одноразовые задачи, которые выполняют условно-тяжелую операцию в IntentService.


Для проверки работоспособности предлагаю добавить небольшой инструментальный тест, который выглядит примерно так:


    @Test
    public void sendDemoBroadcast() throws Exception {
        Context appContext = InstrumentationRegistry.getTargetContext();

        Intent demoIntentForBroadcast = 
            new Intent(appContext, ExerciseRequestsReceiver.class);

        demoIntentForBroadcast
            .setAction(ExerciseRequestsReceiver.ACTION_PERFORM_EXERCISE);

        appContext.sendBroadcast(demoIntentForBroadcast);
    }

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


Если мы запустим тест, то мы увидим, что наша задача с "id: 1" стартует и завершается, стартует и завершается… Точнее её принудительно завершает JobScheduler.


Выполнение задачи в отдельных потоках и оповещение сервиса о завершении


В данном примере из метода onStartJob() мы вернули true, а это значит, что мы сообщили JobScheduler'у что выполнение задачи продолжается где-то в побочном потоке. Так как мы не уведомляем о завершении задачи, JobScheduler завершает её принудительно, а так как из onStopJob() мы тоже возвращаем true срабатывает политика повтора, и задача перепланируется и запускается заново.


Чтобы такого не происходило, нужно вызывать метод jobFinished() в классе сервиса ExerciseJobService, о его использовании и различных вариантах передачи информации о завершении задачи из IntentService я постараюсь рассказать в следующих статьях.


На этом создание тестового примера завершается, он готов к использованию и применению в рабочих проектах для планирования задач. Для выполнения задач в фоне здесь был использован IntentService, но допустимы и другие способы, например, использование ThreadPoolExecutor или HandlerThread. А в случае разработки исключительно под Android O и выше, рекомендую также обратить внимание на JobIntentService.


Полный код рассматриваемого примера приведен на GitHub.


Также можно ознакомиться с официальным пример реализации JobScheduler с Activity на developer.android.com.




Иллюстрация: Anni ART (копирование и воспроизведение только с согласия автора).

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


  1. simtm
    10.10.2017 12:52

    jobFinished() непосредственно связан с задачами в отдельном потоке. Мне кажется, если в примере в статье выполнение выносится в отдельный поток, то в этой же статье надо рассказать и про jobFinished().
    Если не сигнализировать о завершании, то повторные попытки выполнения задачи как раз будут идти с учетом BackoffCriteria


    1. Handy Автор
      10.10.2017 13:01

      Все верно, я об этом и упомянул в конце статьи. С jobFinished я хочу показать возможности передачи информации между сервисами, а также варианты обработки ситуаций, когда нужно прекратить выполнение задачи, или когда нужно перезапланировать её выполнение и так далее.
      Добавив всё это в текущую статью, я бы увеличил её размер в 2 раза.
      На данный же момент, чтобы избежать повторов по BackoffCriteria можно вернуть false из метода onStopJob, и следующее выполнение будет обусловлено либо новой задачей либо запланированной повторяющейся.


      1. simtm
        10.10.2017 13:19

        Так onStopJob может и не вызваться никогда. Это же не признак завершения работы, а сигнал, что система хочет прервать работу.
        Если в onStartJob вернуть true, и нигде не вызвать jobFinished, то система будет думать, что задача еще "крутиться", хотя по факту все уже давно могло закончиться.


        1. Handy Автор
          10.10.2017 14:03

          В теории onStopJob действительно может не вызваться тогда, когда нечего останавливать, но в этом случае он и не несет никакого смысла.
          На практике JobScheduler выделяет определенное время («окно») для каждой задачи, и если за это время задача все еще выполняется, то onStopJob для неё вызовется и будет иметь смысл.

          Я полностью согласен с тем, что при выполнении задач в других потоках избегать вызова jobFinished — плохая практика, именно поэтому я об этом упомянул, но пример реализации не поместился в данную статью, так как для конкретного примера это не является чем-то значительным, что может сказаться на работе комплекса. Ведь все что делает задача — отправляет интент в IntentService на базе applicationContext, в котором и находится этот IntentService, таким образом у нас нет бесконечных циклов или утечек памяти. Единственный побочный эффект это то, что перестает держаться WakeLock, а тестовый IntentService вполне это переживет.

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


          1. simtm
            10.10.2017 14:40

            Ну вот, кусок следующей статьи уже готов =))

            Единственный вопрос про WakeLock.

            Единственный побочный эффект это то, что перестает держаться WakeLock

            Мне казалось, что как раз до тех пор пока мы не вызвем jobFinished WakeLock будет удерживаться, что в теории приведет к повышенному выжиранию батарейки. Или я не правильно понял мысль?


            1. Handy Автор
              10.10.2017 15:08

              Да, действительно, это неоспоримая польза комментариев:)

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

              С одной стороны да, вызвав jobFinished мы информируем о том, что нам WakeLock больше не нужен (но не факт что JobScheduler его сразу снимет, ведь он может быть нужен другой задаче), с другой стороны, если удержание WakeLock не уместно (например запланирован уход в Doze или еще что-нибудь подобное), то немедленно будет вызван onStopJob для всех задач и WakeLock будет отпущен.


  1. simtm
    10.10.2017 14:38

    /del