Так уж случилось, что на работе я с небольшой командой единомышленников занимаюсь написанием приложений для смартфонов, в частности iТелефон и Андроид.
Начинали мы с разработок под iPhone, где все работало гладко и как положено.
А что работало? Основная задача приложения была послать запрос «Где ты?» — ничего сложного. Но уж очень хотелось бы этот запрос доставлять адресату как можно быстрее, пока он еще актуален. Здесь, имеющий опыт в разработках под iPhone, читатель скажет, что есть APN Service, и будет абсолютно прав. Именно им мы и пользовались, и не знали горя, ибо доставлялись эти уведомления быстрее секунды.
Затем по некоторым внутренним причинам мы перешли на разработки под Android и быстренько все портировали. В частности без каких-либо задних мыслей модуль работы с APN был заменен на аналогичный с C2DM.
На всех телефонах разработчиков проблем с доставкой уведомлений не было. А вот у новых пользователей сразу вскрылась огромная проблема — время доставки уведомления никак не гарантировано, и некоторые из них доходили через несколько часов. Причем на соседнем же устройстве они доходили за секунды.
В ходе исследования этой проблемы я натолкнулся на ряд странных особенностей работы этих уведомлений от Google.
Интересующиеся реализованным низкоуровневым взаимодействием смартфона с сервером без разбора предпосылок могут эти предпосылки пропустить и перейти к разделу «4. Альтернатива Google C2DM, но не замена».
1. Схема использования уведомлений
Прежде всего схемка (обозначения выбраны просто для наглядности, а не по ГОСТу):
Для изучения проблемы нужно понять, как устроены соединения 1, 2 и 3.
- Это нами открытое соединение по HTTP, которое шлет запрос. Приложение ждет от сервера лишь 200 OK и остальное не важно. Здесь широкая часть бутылки — пользователей пока мало, и шлют запросов они немного (60-100 сообщений/с во время активной работы).
- Это соединение наш сервер открывает по протоколу HTTP к серверам Google. При этом приходится делать 2 последовательных соединения: сначала авторизация рекомендуемым способом — ClientLogin, затем запрос к android.clients.google.com/c2dm/send. Первым делом искать проблему начали именно здесь.
- Наконец, это соединение держит сам Google со смартфонами под управлением Android.
2. Ищем проблемы с запросом к C2DM
Раз уж я разрабатываю в основном серверную часть, то первый камень прилетел в меня за возможные проблемы в соединении №2. Что же было предпринято?
По моему скромному мнению лучшее объяснение, как подключить C2DM в приложении, есть в хабратопике Пишем приложение под Android с поддержкой Cloud to Device Messaging (C2DM)
Само это соединение реализовано по рекомендациям этой статьи.
Иногда при подключении к серверу Google приходил Connection timed out, что натолкнуло меня на мысль об ограничении количества наших одновременных подключений. Может мысль и ошибочная, но примененное решение оказалось полезным.
Серверная часть написана на Java и запускается как отдельный JAR с встроенным в него Jetty. За настройку и запуск отвечает Spring Framework, а значит, мне довольно безболезненно удалось перенастроить взаимодействие с C2DM сервером.
Шаг 1
Добавить асинхронности в выполнение запроса.
public class C2DMServer implements IPushNotificator, IPushChecker {
...
@Override
@Async // вот так добавить асинхронность
public void sendData(String deviceId, String c2dmID, String jsonObject) {
...
}
}
Этот шаг дал еще одно улучшение — ответ 200 OK запрашивающему клиенту теперь приходит гораздо быстрее, так как поток не ждет ответа от сервера уведомлений Google.
Шаг 2
Настроить количество параллельных запросов к Google, чтобы как раз уложиться в лимиты.
Здесь было множество тестов и подбора коэффициентов, а результат вылился в такую настройку Spring.
<task:annotation-driven executor="asyncExecutor" />
<task:executor id="asyncExecutor" pool-size="15" queue-capacity="300" rejection-policy="CALLER_RUNS" />
Если pool-size выставлять более 15, то такое количество одновременных подключений приводит к разного рода сетевым ошибкам.
Итог
Что порадовало: больше не появлялись ошибки подключения к серверу Google.
Что расстроило: проблема скорости доставки осталась, а значит, движемся дальше.
3. Исследуем работу Google с Android
Любое приложение после установки на Android может запросить у специального сервиса идентификатор, с которым нужно отправлять уведомления.
Это делается наследованием от базового класса C2DMBaseReceiver.
Пример такого наследования можно посмотреть все в том же хабратопике Пишем приложение под Android с поддержкой Cloud to Device Messaging (C2DM). Вот модифицированная реализация у меня:
import com.google.android.c2dm.C2DMBaseReceiver;
public class C2DMReceiver extends C2DMBaseReceiver {
private static final String DATA = "data";
public C2DMReceiver() {
super(Settings.C2DM_ACCOUNT);
}
@Override
public void onDestroy() {
super.onDestroy();
}
@Override
public void onError(Context context, String errorId) {
Settings.Init(context, false);
Settings.updateC2DM(null);
}
@Override
protected void onMessage(Context context, Intent receiveIntent) {
Settings.Init(context, false);
String data = receiveIntent.getStringExtra(DATA);
JSONUtils.processJSON(context, data);
}
@Override
public void onRegistered(Context context, String registrationId) {
Settings.Init(context, false);
if (!registrationId.equals(Settings.getC2dm_id()))
Settings.updateC2DM(registrationId);
}
@Override
public void onUnregistered(Context context) {
}
}
Здесь Settings — класс помощник с кучей статических полей и методов для хранения состояния приложения. JSONUtils — еще один класс помощник, разбирающий JSON и сохраняющий все данные в Settings.
Что важно понимать, так это то, что момент получения идентификатора не определен. Фактически, этим классом мы лишь вешаемся на событие получения C2DM идентификатора, и по идее при его срабатывании незамедлительно должны передать идентификатор на сервер.
Пример такого идентификатора: «APA91bF8hral5wCq_E7HPD1wq29aSIEYyY2g_P4BOue_CaBTJvTHKFPplmp2MHxFgn3c1ysNjTHyXmsp8OejRSc809ZiOYqNcXoJWiWfvarCayT6ar3RyZwRRV0CrgQNaPjLxTrYqXXcQfcxjB07xmjeNtUzc6UlGQ».
После этого любое сообщение к C2DM серверу с этим идентификатором должно быть доставлено на нужное устройство и нужному приложению.
Посмотрим как доставляются эти сообщения
В центре всего стоит сервис Cloud To Device Messaging.
Что интересно, на проблемных устройствах этот сервис иногда был выгружен из памяти. Это значит, что он не берет никаких блокировок ОС и вполне может выключиться, когда Android-у понадобятся ресурсы. Этот сервис в качестве ядра протокола обмена использует Google Messaging сервис, от которого также зависит GTalk. Это происходит, потому что C2DM протокол инкапсулирован в XMPP протокол, по которому обменивается GTalk. По этому каналу раз в 300 секунд C2DM сервис шлет Ping на сервера Google и ожидает Ack, подтверждающий, что соединение в порядке. Подробнее можно узнать у первоисточника в этом видео.
С сервисами все, конечно, не настолько печально. Сервис уведомлений умеет восстанавливаться при изменении условий сети и при включении экрана, хотя и не всегда.
Чтобы посмотреть состояние своего соединения, можно набрать *#*#8255#*#*
и в открывшемся GTalk Service Monitor посмотреть, какое приложение какой обмен через Google Messaging проводило.
Итак, часть проблемы была идентифицирована, но решения для нее не было.
Почему именно часть? Потому что уведомления все равно не доходили даже при работающих сервисах. Иногда замечались волны уведомлений, когда через некоторое время (20-40 минут) все устройства получали уведомления одновременно, хоть и отправленные в разное время.
В итоге после размышлений, чтений документации и множества форумов и Q&A все сошлись на одном — будем делать альтернативный канал уведомлений.
4. Альтернатива Google C2DM, но не замена
Основной вопрос: как устроить стабильный канал сервер-клиент?
Побочный вопрос: как не съедать этим каналом всю батарейку пользователя?
Источником вдохновения стали примеры с ресурса http://code.google.com/p/android-random/.
В частности пример KeepAliveService.
Первая идея — лобовое решение: раз в n секунд открывать подключение к серверу и проверять нет ли уведомлений.
Вместо этого лобового решения «часто опрашивать сервер» авторы предлагают более разумный вариант, хотя и похожий на своего рода хак.
Фишки предложенного решения:
- подключение к серверу нужно держать постоянно, а не переподключаться с интервалом;
- раз в n секунд, где n > 60, проверять состояние подключения отправкой в него чего-либо и переподключаться только при обрыве;
- использовать блокирующий read на подключении к серверу.
Я провел тестирование разных вариантов работы клиента с сервером уведомлений.
Было написано 2 клиента:
- Открывал раз в n секунд и считывал, не появилось ли чего. Именно n варьировалось в тестировании
- Открывал постоянное подключение и проверял его раз в 60 секунд. Варьировались устройства, чтобы узнать насколько различаются времена жизни.
Первый клиент реализовать не трудно самостоятельно. Все тонкости второго можно посмотреть в архиве. В него включены клиент Android второго типа и сервер, поддерживающий подключение, выводящий в лог все keepalive сообщения клиента, а также раз в минуту по своему случайному разумению отправляющий на клиент уведомление. Собирается все Maven-ом с подключенным android-maven-plugin.
Устройство | Desire | Desire | Desire | Desire | Desire | Wildfire S | Desire S |
Продолжительность теста (мин.) | 540 | 1273 | 845 | 962 | 1117 | 1180 | 1121 |
Расход единиц заряда | 82 | 87 | 31 | 9 | 39 | 80 | 49 |
KeepAlive в секундах | 10 | 30 | 60 | 60 | 60 | 60 | 60 |
Подключение к Интернет | 3G | 3G | 3G | WiFi | 3G | 3G | 3G |
Вычисленная скорость разряда (е.з./ч) | 10 | 4.28 | 2.22 | 0.56 | 2.22 | 4.28 | 3 |
Сразу бросается в глаза несколько результатов:
- Хоть переоткрывать подключение, хоть держать его открытым — это не имеет значения с точки зрения батарейки.
- 3G выжигает батарейку в разы быстрее, чем WiFi.
- Оптимальное время опроса 60 секунд.
Для нас самый важный — первый результат. Из него следует, что выбирать из двух клиентов нужно по функциональным возможностям. У переподключающегося клиента (№1) уведомления приходят лишь один раз в указанный интервал проверки. У клиента, поддерживающего подключение (№2), уведомления приходят в тот момент, когда в открытое подключение напишет сервер. Причем, забегая вперед, скажу, что даже уснувшее устройство просыпается, когда в открытое подключение приходит сообщение от сервера.
Чтобы выдержать наплыв TCP подключений я построил следующую архитектуру.
Сервер уведомлений состоит из двух компонент:
- Маршрутизатор — регистрирует подключенное устройство и отдает ему адрес и порт сервера, с которым держать подключение. Кроме того все запросы на отправку уведомлений он маршрутизирует к нужному серверу уведомлений.
- Сам сервер уведомлений — держит подключение, сообщает маршрутизатору об успешном получении keepalive от устройства и отправляет уведомление, если его вызвал маршрутизатор.
Все взаимодействие с клиентом идет на чистом TCP. Само уведомление может быть произвольного размера и содержания, но для уменьшения нагрузки в своем приложении я шлю ровно один байт «1».
Между компонентами сервера, используя Spring Remoting, поднимаются RMI соединения.
Теперь посмотрим логику клиентской стороны по шагам.
- Клиент при подключении к серверам уведомлений сообщает свой уникальный идентификатор, в моем случае это просто GUID.
- В ответ он получает адрес и порт, куда открывать и поддерживать подключение.
- После открытия socket-а клиент уходит в блокирующий read без установки timeout-а чтения.
- Используя AlarmManager, клиент раз в 60 секунд просыпается и отправляет в открытое подключение сообщение со своим GUID-ом. Так сервер узнает, что за клиент все еще жив.
- Если подключение упало, то клиент проверяет наличие какого-либо доступа в Интернет и при его наличии переподключается.
- Если read вернул данные, значит, пришло уведомление, о чем сообщается остальной логике приложения, а клиент опять уходит в блокирующий read.
Работа с AlarmManager-ом очень простая.
// создаем интент, которым нас известят о событии таймера
Intent i = new Intent(this, NotificationService.class).setAction(ACTION_KEEPALIVE);
PendingIntent pi = PendingIntent.getService(this, 0, i, 0);
// передаем временной интервал и интент AlarmManager-у
AlarmManager alarmMgr = (AlarmManager) getSystemService(ALARM_SERVICE);
alarmMgr.setRepeating(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + KEEP_ALIVE_INTERVAL, KEEP_ALIVE_INTERVAL, pi);
Подробнее в документации.
Результаты и проблемы моей реализации или почему нельзя полностью отказаться от C2DM
- Для максимальной легковесности все сервера уведомлений не работают ни с файлами, ни с базами данных. Отсюда первое следствие: если уведомление не дошло, оно никогда уже не дойдет.
- Сервера уведомлений ничего не знают о данных в маршрутизаторе. Второе следствие: клиент не обязан слушаться маршрутизатора и идти именно на указанный адрес и порт, а значит, клиенты могут устроить атаку на один сервер уведомлений, тогда как другие будут простаивать.
- Маршрутизатор запоминает, когда от клиента приходил keepalive. Полезное третье следствие: маршрутизатор может сообщать эту информацию внешним системам, а эта информация по сути представляет собой записи о том, кто сейчас online.
- Маршрутизатор запоминает, на какой сервер уведомлений клиент отправил keepalive. Четвертое следствие: даже если клиент подключается к неправильному серверу, маршрутизатор будет знать, через какой сервер уведомлений отправлять пакет, вместо рассылки этого пакета по всем серверам.
5. Заключение
«Любой уважающий себя программист для смартфонов должен написать свою реализацию сервиса уведомлений» — так в шутку охарактеризовали результат моей работы.
Но несмотря на шутку, описанный выше сервис уведомлений работает на той же скорости и почти с той же стабильностью, как у Apple, что не может не радовать, а время жизни устройства, о котором так много волнуются разработчики, сокращается совсем не на много.
6. Полезные ссылки
Размышления на тему, как реализовать хорошую доставку уведомлений
Документация Google по подключению C2DM
Хабратопик «Пишем приложение под Android с поддержкой Cloud to Device Messaging (C2DM)»
Жалобы на скорость работы C2DM и другие — надеюсь среди них однажды появится ответ «Ура! Все заработало!».
Автор: ionsphere