Содержание статьи
Постановка задачи
Наш планировщик должен уметь:
- добавлять неограниченное количество заданий на произвольные моменты времени;
- выполнять фоновые задания (даже если устройство заснуло);
- благополучно переживать перезагрузку устройства.
Включить сигнализацию!
Для выполнения поставленной задачи в Android предусмотрен специальный Java-класс AlarmManager
(менеджер сигнализаций), позволяющий установить сигнализацию (Alarm
), срабатывающую в установленное время или с заданной периодичностью. В качестве формата даты и времени сигнализации выступает старое доброе UNIX-time, то есть время, выраженное в миллисекундах, прошедших с 1 января 1970 года.
Прежде чем двигаться дальше, необходимо прояснить ситуацию, когда у нас, например, запланированы сотни или даже тысячи заданий. Вполне очевидно, что установка стольких же сигнализаций не самая лучшая идея (хм, новый вектор в сторону DoS?). Вместо этого мы будем использовать только одну сигнализацию, включать которую будем динамически. Наш планировщик будет выполнять все задания последовательно.
Архитектура проекта
Наш проект будет разделен на четыре модуля (см. исходник):
- Главная активность (Main.java).
- Широковещательный приемник (AlarmReceiver.java).
- Фоновый сервис (AlarmService.java).
- База данных заданий (
AlarmDB.
).java
Главная активность (Main.
), по сути, является графическим интерфейсом нашего приложения, состоящим из стандартных компонентов — текстовых меток (TextView
), полей ввода (EditView
) и главной кнопки (Button
). Основная его цель — определить параметры задания: дату и время, частоту срабатывания, само задание.
Непосредственная установка сигнализаций, а также весь полезный функционал наших заданий будет выполняться в фоновом сервисе (AlarmService.
). Так как журнал у нас добрый, в качестве боевой нагрузки будем просто выводить уведомление со звуком. Фоновый сервис (класс Service
) в Android выполняется в главном потоке приложения и требует использования многопоточности при длительных операциях (в противном случае рискуешь увидеть раздражающее ANR
— «Приложение не отвечает»). Для наших целей прекрасно подойдет класс IntentService
(наследник Service
), который, во‑первых, самостоятельно создаст рабочий поток, во‑вторых, автоматически завершится после выполнения задачи.
База данных (AlarmDB.
) — вспомогательный класс для помещения заданий в базу SQLite и их извлечения. Структура таблицы представлена во врезке. Останавливаться на этом модуле смысла нет, так как в нем используются стандартные SQL-запросы вида INSERT/
.
У тебя наверняка возник вопрос: для чего нужен широковещательный приемник (AlarmReceiver.
)? Дело в том, что при перезагрузке устройства все сигнализации AlarmManager’а
пропадают, что, естественно, нас не устраивает (см. последний пункт постановки задачи). Единственное, что мы можем сделать, — попросить систему сразу после загрузки запустить наш код, восстанавливающий все задания (о соответствующем разрешении для приложения смотри врезку). Подобная процедура должна быть обернута в широковещательный приемник (BroadcastReceiver
) и определена в (public
). В нашем случае эта функция будет определена статической (static
), что позволит ее вызывать из любого места приложения. Любой широковещательный приемник должен отработать максимально быстро — Android отводит для этого максимум десять секунд, иначе работа приемника, вероятно (зависит от многих факторов — версии Android, особенностей прошивки производителя), будет прервана. Этого времени вполне достаточно, чтобы запустить наш фоновый сервис.
Резюмируя, составим алгоритм работы приложения. После определения задания в главной активности оно помещается в базу данных, после чего вызывается статическая функция scheduleAlarms
из класса AlarmReceiver
(она же вызывается и при загрузке устройства). Единственное, что делает эта функция, — запускает фоновый сервис со специальным ключом SET_ALARM
, уведомляющим о необходимости извлечь из базы данных ближайшее задание и поставить его на сигнализацию. Как только она сработает, снова запускается фоновый сервис, но уже с ключом RUN_ALARM
, который выполняет эксплойт (шучу, выводит уведомление) и снова вызывает scheduleAlarms
, но уже для установки следующей сигнализации, то есть следующего задания, и так далее. Таким образом, мы фактически используем только одну сигнализацию при неограниченном количестве заданий.
На этом теория заканчивается и начинается, как ни странно, кодинг.
Структура БД
Для простоты наша база данных будет состоять из одной таблицы:
CREATE TABLE tbSheduler ( _id INTEGER PRIMARY KEY AUTOINCREMENT, utime NUMERIC, msg TEXT ),
где _id
— первичный ключ, utime
— запланированное время задания, msg
— текст сообщения. В боевом проекте вместо поля типа TEXT
правильнее было бы указать уникальный ключ (FOREIGN
), описывающий задание в рамках других связанных таблиц.
Главная активность
Для интерфейса главной активности, расположенной в файле Main.
, мы выберем три поля ввода: edDate
— для определения даты и времени срабатывания задания, edRepeat
— для указания необходимого количества дней повтора, edMessage
— собственно для текста сообщения. В методе onCreate
с помощью findViewById
получаем ссылки на все компоненты GUI
, а для кнопки bGo
определим обработчик:
public void bGo_click(View v){ Calendar c = getCalendarFromDate(edDate.getText().toString()); int repeat = getIntFromString(edRepeat.getText().toString()); String message = edMessage.getText().toString(); // Проверки на корректность ввода пропущены AlarmDb db = new AlarmDb(this); for (int i = 0; i < repeat; i++){ db.insertAlarm(c.getTimeInMillis(), message); c.add(Calendar.DAY_OF_MONTH, 1); } AlarmReceiver.scheduleAlarms(this);}
Автозагрузка
Разрешение на автозагрузку приложения нужно запросить в файле‑манифесте (AndroidManifest.
):
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
Когда будешь публиковать свое приложение в Play Market, правила хорошего тона рекомендуют в описании приложения доходчиво объяснить пользователю, зачем, собственно, тебе нужна автозагрузка, а то ведь пользователь может и испугаться.
Здесь вспомогательная функция getCalendarFromDate
возвращает экземпляр класса Calendar
с установленной в качестве параметра датой. Обрати внимание на задание формата строки с датой: String
, разумеется, ты можешь определить свой. Функция getIntFromString
банально переводит строку в число для переменной repeat
.
Следующий за этим цикл помещает задачу в базу данных с помощью метода insertAlarm
(long
, String
), принимающего в качестве параметров UNIX-time и текст сообщения. Затем, используя метод (с.
), экземпляр календаря сдвигается на один день вперед. Весь цикл продолжается repeat раз. Мы используем константу Calendar.
для прибавления одного дня, но также можно воспользоваться константами Calendar.
, Calendar.
, Calendar.
, Calendar.
и подобными. Если второй параметр отрицательный — указанный параметр вычитается.
info
Чтобы посмотреть все установленные сигнализации на смартфоне, можно воспользоваться командой
adb shell dumpsys alarm > alarm.txt
Анализ полученного файла позволит выявить все приложения, злоупотребляющие пробуждением устройства (и следовательно, разряжающие батарею), а также узнать, например, как часто Gmail проверяет почту, а Hangout запрашивает сообщения.
В завершении функции вызывается статический метод scheduleAlarms(
, определенный в широковещательном приемнике AlarmReceiver
и инициализирующий установку сигнализации. В качестве параметра мы передаем ссылку на текущий контекст. У этого метода будет еще одна форма, но об этом позднее.
Широковещательный приемник
Код приемника (AlarmReceiver.
) представлен ниже:
public class AlarmReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { scheduleAlarms(context); } static void scheduleAlarms(Context ctxt) { long NOW = Calendar.getInstance().getTimeInMillis(); startAlarmService(ctxt, NOW); } static void scheduleAlarms(Context ctxt, long TIME) { startAlarmService(ctxt, TIME); } static void startAlarmService(Context ctxt, long UTIME) { Intent i = new Intent(ctxt, AlarmService.class); i.setAction(AlarmService.SET_ALARM); i.putExtra("utime", UTIME); ctxt.startService(i); }}
Данный код требует некоторых пояснений. Класс AlarmReceiver
наследуется от суперкласса BroadcastReceiver
, который требует реализации абстрактного метода public
(Context
, Intent
), срабатывающего при поступлении определенного намерения (в нашем случае — intent.
).
Любой приемник должен быть обязательно зарегистрирован в манифесте приложения (AndroidManifest.
):
<receiver android:name=".AlarmReceiver" > <intent-filter> <action android:name="android.intent.action.BOOT_COMPLETED" /> </intent-filter></receiver>
Метод scheduleAlarms
имеет две формы: с указанием времени (расширенная), назовем его базовым, относительно которого нужно ставить сигнализацию, и без. Если время не указывается, то в качестве базового используется текущее время, то есть мы будем ставить сигнализации на ближайшее время (опережающее текущее) и отбросим все сигнализации в прошлом. Расширенная версия пригодится нам позже.
Венцом работы широковещательного приемника будет вызов метода startAlarmService
, запускающего фоновый сервис. В качестве параметра в StartService
используется намерение. Намерение (Intent
) — основа основ механизма для вызова различных компонентов в Android. Таковыми являются активности, сервисы, приемники и прочее. В нашем случае в качестве намерения указывается экземпляр нашего сервиса (AlarmService
). Все намерения класс IntentService
обрабатывает по очереди. Для передачи строкового ключа SET_ALARM
воспользуемся методом SetAction
, делающим наше намерение уникальным в системе (кстати, для уникальности в константу SET_ALARM
включено имя пакета: SET_ALARM
). Также намерения могут содержать поля типа «ключ = значение», чем мы и воспользуемся, указав в качестве ключа utime базовое время (метод putExtra
). Наконец, мы подошли к самой главной части нашего планировщика — фоновому сервису.
Фоновый сервис
Для начала фоновый сервис необходимо зарегистрировать в манифесте приложения:
<service android:name=".AlarmService" ></service>
Код сервиса условно можно разделить на две части: установка сигнализации и собственно сама сигнализация. Начнем с установки (для экономии ценного места в журнале отладочная печать опущена):
public class AlarmService extends IntentService {@Overrideprotected void onHandleIntent(Intent intent) { Bundle extras = intent.getExtras(); long TIME = extras.getLong("utime"); if (intent.getAction().equalsIgnoreCase(SET_ALARM)) { AlarmDb db = new AlarmDb(this); db.open(); Cursor c = db.select_NEXT_ALARM(TIME); AlarmManager mgr = (AlarmManager)this.getSystemService(Context.ALARM_SERVICE); Intent i = new Intent(this, AlarmService.class); if (c.getCount() > 0) { c.moveToFirst(); int UTIMEi = c.getColumnIndex("utime"); long UTIME = c.getLong(UTIMEi); i.putExtra("utime", UTIME); i.setAction(AlarmService.RUN_ALARM); PendingIntent pi = PendingIntent.getService(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT); mgr.cancel(pi); mgr.set(AlarmManager.RTC_WAKEUP, UTIME, pi); } else { PendingIntent pi = PendingIntent.getService(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT); mgr.cancel(pi); } c.close(); db.close(); }}
Внимательный читатель наверняка заметит, что в классе AlarmManager
уже есть подходящая функция для задания периодических сигнализаций — setRepeating
, но, тем не менее, мы ее не используем. Эта функция, безусловно, хороша в том случае, если у тебя всего десяток сигнализаций, а если их в десять раз больше? Управлять всем этим зоопарком будет гораздо сложнее, чем в нашем случае. Кроме того, как ты сам видишь, ручной расчет периодичности с помощью Java-календаря (Calendar
) не представляет особой сложности.
Обработку очередного поступающего намерения сервис (IntentService
) выполняет в методе protected void onHandleIntent (Intent
). Получив намерение (Intent
), мы извлекаем данные, запакованные ранее в широковещательном приемнике, — базовое время (getLong
) и флаг (getAction
). Если флаг соответствует константе SET_ALARM
, подключаемся к базе данных и получаем запись по сформированному в db.
запросу:
SELECT utime FROM tbSheduler WHERE utime >= TIME ORDER BY utime LIMIT 1
То есть мы запрашиваем ближайшее задание со временем, опережающим базовое (которое мы извлекли в переменную TIME
с помощью getLong
). Разумеется, при определении первого задания в TIME
окажется текущее время системы.
Получив задание и определив его время в переменной UTIME
, мы приступаем непосредственно к установке сигнализации. Как и ранее, в широковещательном приемнике мы должны создать намерение, связанное с нашим фоновым сервисом: Intent
, а также указать флаг срабатывания сигнализации: i.
) и время срабатывания: i.
. Так как наше намерение отложено до времени срабатывания, оно должно быть обернуто в оболочку ожидающего намерения (PendingIntent
):
PendingIntent pi = PendingIntent.getService(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT);
Флаг FLAG_UPDATE_CURRENT
означает, что, если намерение уже создано, необходимо лишь обновить его данные: в нашем случае — ключ‑значение utime
(без этого флага намерение не изменится).
Итак, у нас все готово для установки сигнализации:
AlarmManager mgr = (AlarmManager)this.getSystemService(Context.ALARM_SERVICE);mgr.set(AlarmManager.RTC_WAKEUP, UTIME, pi);
Сначала мы получаем доступ к системной службе менеджера сигнализаций, указывая соответствующую константу — Context.
. Для установки сигнализации мы будем использовать метод set
(о методе setRepeating
см. врезку), передавая ему в качестве второго параметра время срабатывания сигнализации UTIME
, а в качестве третьего — уже подготовленное нами ожидающее намерение pi
. Применение в качестве первого параметра AlarmManager.
приводит к тому, что сигнализация будет пробуждать устройство (см. второй пункт постановки задачи). Если бы нам не нужно было будить устройство, то мы бы могли указать флаг AlarmManager.
для доставки намерения уже после пробуждения устройства.
Спящий Android
Несмотря на то что мы использовали флаг AlarmManager.
, существует ненулевая вероятность того, что разбуженный с его помощью фоновый сервис (IntentService
) фактически не успеет начать обрабатывать намерения. Все дело в блокировке пробуждения, которой обладают широковещательный приемник (BroadcastReceiver
) и менеджер сигнализаций (AlarmManager
) для пробуждения устройства, но которая освобождается после выполнения кода в onReceive
приемника. Фоновый сервис подобной блокировкой не обладает. На страницах «Хакера» уже поднималась эта тема в № 6 за 2013 год (см. «Задачи на собеседованиях»).
Если в базе данных больше нет подходящих заданий, сигнализация отменится с помощью метода cancel(
, принимающего в качестве параметра то же ожидающее намерение.
Нам осталось рассмотреть только код срабатывания сигнализации. Здесь, как ты сам увидишь, все тривиально:
long TIME = extras.getLong("utime");...if (intent.getAction().equalsIgnoreCase(RUN_ALARM)) { AlarmDb db = new AlarmDb(this); db.open(); Cursor c = db.select_ALARM_BY_UTIME(TIME); int MSGi = c.getColumnIndex("msg"); String title, text; c.moveToFirst(); if (!c.isAfterLast()) { title = "Тревога!"; text = c.getString(MSGi); String link = "Нажми меня... "; showNotification(title, text, link); } c.close(); db.close(); TIME += 500; AlarmReceiver.scheduleAlarms(this, TIME);}
При срабатывании сигнализации запрашиваем из базы данных все задания с временем TIME
. Запрос в db.
выглядит следующим образом:
SELECT msg FROM tbSheduler WHERE utime = TIME
www
Готовый класс WakefulIntentService
, сохраняющий семантику использования IntentService
, но с блокировкой пробуждения на GitHub
Функция showNotification выводит в статусной строке новое уведомление, состоящее из двух иконок (иконки приложения и уведомления), заголовка (title
), текста (text
) и дополнительной информации (info
):
private void showNotification(String title, String text, String info) { Intent intent = new Intent(this, Main.class); PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, 0); NotificationManager notificationManager = (NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE); Bitmap bm = BitmapFactory.decodeResource(getResources(), R.drawable.ic_launcher); NotificationCompat.Builder b = new NotificationCompat.Builder(this); b.setAutoCancel(true) .setDefaults(Notification.DEFAULT_SOUND) .setContentTitle(title) .setContentText(text) .setContentInfo(info) .setContentIntent(pendingIntent) .setSmallIcon(R.drawable.ic_notify) .setLargeIcon(bm) .setWhen(System.currentTimeMillis()) .setTicker(title); Notification n = b.getNotification(); notificationManager.notify(NOTIFY_GROUP, n);}
С помощью константы Context.
мы запрашиваем доступ к системной службе уведомлений. Далее заполняем все поля, определяющие надписи и иконки (отличия уведомлений в разных версиях Android представлены на скриншоте), а также время срабатывания — setWhen(
, то есть немедленно. Метод setContentIntent
задает уже знакомое тебе ожидающее намерение, вызываемое при нажатии на уведомление. В нашем случае мы просто запускаем единственную активность нашего планировщика (Main.
). Указанная в setDefaults
константа Notification.
определяет в качестве звукового сопровождения уведомления стандартный звуковой сигнал.
Как ты мог заметить, мы обработали только одно задание на конкретное время, обработка остальных станет твоим домашним заданием.
Иконки в уведомлениях
При выводе уведомлений в статусной строке неплохо бы вместе с текстом показывать соответствующую иконку. В Eclipse есть удобный инструмент, помогающий создать такую иконку сразу для всех размеров экрана и версии Android. Чтобы его вызвать в контекстном меню своего проекта, выбери New → Other → Android Icon Set → Notification Icons. В появившемся мастере открой графический файл или укажи необходимый текст (см. скриншот). Все возможные виды твоей иконки будут созданы автоматически.
Разобравшись с одной сигнализацией, мы увеличиваем время на полсекунды и вызываем расширенную версию scheduleAlarms
что два задания могут быть столь близки по времени, что второе просто не сработает, поскольку еще не завершилось первое. Передавая в качестве параметра время первого с небольшим сдвигом (хотя бы в 1 мс), мы гарантируем, что второе задание, пусть и с вынужденной задержкой, сработает. К слову, в нашем планировщике задания ставятся с точностью до минуты, но ничто не мешает увеличить точность до секунд или даже миллисекунд.
Выводы
Сегодня мы познакомились с механизмом работы широковещательного приемника в OS Android, запустили фоновый сервис, немного поработали с базой данных, научились выводить системные уведомления, создали пару ожидающих намерений. И конечно же, подготовили неплохой каркас для планировщика, наращивая который ты сможешь создать свою уникальную версию Cron’a.