На написание данной статьи меня подтолкнула задача, которая была поставлена передо мной в одном из рабочих проектов: реализовать Push-уведомления в приложении. Казалось, все просто: штудируешь документацию, примеры и вперед. К тому же, опыт работы с уведомлениями уже был. Но не тут то было…

Сервис, в рамках которого реализовано приложение под Android, предъявляет довольно жесткие требования к работе Push-уведомлений. Необходимо в пределах 30-60 секунд оповестить пользователя о некотором действии. При успешном оповещении с устройства пользователя отправляется запрос на сервер с соответствующим статусом. Из документации известно, что сервис GCM (Google Cloud Messaging) не гарантирует доставку PUSH-уведомлений на устройства, поэтому в качестве backdoor варианта, при нарушении этих временных рамок, наш сервис уведомляет пользователя с помощью SMS сообщения. Поскольку стоимость SMS сообщения существенно выше чем PUSH-уведомления, необходимо максимально сократить поток SMS сообщений на клиентские устройства.

Проштудировав документацию и прикрутив пуш-уведомления, мы разослали нескольким клиентам первую сборку приложения для теста и стали ждать. Результаты были примерно следующими:

  • при активном Wifi соединении все работает идеально: уведомления доставляются, клиенты рады.
  • при активном мобильном интернете началось самое веселье.

Некоторые клиенты писали, что испытывают задержки в доставке пушей, либо получали одновременно и PUSH и SMS, что достаточно не практично. Другие писали, что вовсе не получали уведомлений, а только SMS. У третьих, как и у нас на тестовых устройствах, все было ок. Собрав с недовольных клиентов максимально возможную информацию, стали разбираться в проблеме и вывели следующий список ограничений (этот список позже вылился в полноценный FAQ):

  • включенный режим Энергосбережения (например, Stamina на устройствах Sony) влияет на работу Push уведомлений;
  • у пользователя обязательно должен быть минимум 1 активный Google аккаунт на устройстве;
  • необходимо удостовериться в том, что на устройстве установлена актуальная версия приложения “Сервисы Google Play”;
  • проверить, не отключены ли уведомления для приложения (галочка на страничке приложения в настройках телефона);
  • проверить, не ограничена ли работа фонового режима для приложения (настройка расположена в меню «Использование данных»);
  • в документации к GCM указано, что уведомления рассылаются только по определенным портам, поэтому настройки роутера, файервола и антивируса так же стоит учитывать.

Разослав данную памятку по всем клиентам, мы снова стали ждать результатов. И они оказались снова «не очень». Стали копать дальше.

На данном этапе очень сильно помогла статья, написанная ребятами из Mail.ru. В ней очень подробно описаны тонкости реализации GCM на клиентской стороне, а так же моменты, в связи с которыми отказываются работать Push уведомления в мобильных сетях. В конечном счете было принято решение о том, чтобы держать свое соединение с сервером в связке с GCM.

Перед тем, как приступить к решению, стоить выделить несколько очень важных моментов, которые позволяют сузить круг потенциально «нерабочих» устройств:

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

И так, перейдем к реализации.

Бывалый разработчик под Android сходу скажет, что решений задачи как минимум 2: использовать Service или AlarmManager. Мы попробовали оба варианта. Рассмотрим первый из них.

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

startForeground(int notificationID, Notification notification);

где

  • notificationId — некоторый уникальный идентификатор уведомления, который будет выведен в статус баре и в выезжающей шторке;
  • notification — само уведомление.

В данном случае обязательным условием является отображение уведомления в статус баре. Такой подход гарантирует то, что сервису будет дан больший приоритет (поскольку он взаимодействует с UI частью системы) в момент нехватки памяти на устройстве и система будет выгружать его одним из последних. Нам это уведомление не нужно, поэтому мы воспользовались следующим велосипедом: достаточно запустить одновременно с первым сервисом второй и для обоих сервисов в качестве notificationID использовать одно и тоже значение. Затем убить второй сервис. При этом уведомление пропадет из статус бара, но функциональные и приоритетные возможности первого сервиса останутся.

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

AlarmManager — это класс, который предоставляет работу с, грубо говоря, «будильником». Он позволяет указать время, по достижении которого система отправит широковещательное уведомление, которое позволит пробудить наше приложение и даст ему возможность выполнить необходимые действия. В работе этого метода есть некоторые ограничения, и их необходимо обработать:

  • данные о «будильниках» будут стерты после перезагрузки устройства;
  • данные о «будильниках» будут стерты после обновления приложения.

Первыми граблями, на которые мы наступили, был метод

setRepeating()

который позволяет установить повторяющийся с некоторым интервалом «будильник». Прикрутив данный способ, стали тестировать, и тесты показали обратное — «будильник» не повторялся. Стали разбираться в чем дело, посмотрели документацию. И именно там нашли ответ на вопрос — начиная с 19 API lvl (Kitkat) абсолютно все «будильники» в системе стали разовыми. Вывод — всегда читайте документацию.

Эти грабли не были поводом для расстройства, ведь решение задачи довольно простое — запускать единоразовый «будильник» и после срабатывания переустанавливать его. При реализации этого подхода мы наткнулись на следующие грабли — оказалось, что для разных уровней API необходимо по разному устанавливать будильники, при этом в документации ничего сказано не было. Но данная проблема решилась достаточно просто — методом «тыка» и «гугления». Ниже представлен пример кода, позволяющий правильно устанавливать «будильники»:

private static void setUpAlarm(final Context context, final Intent intent, final int timeInterval)
{
    final AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
    final PendingIntent pi = PendingIntent.getBroadcast(context, timeInterval, intent, 0);
    am.cancel(pi);
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
    {
        final AlarmManager.AlarmClockInfo alarmClockInfo = new AlarmManager.AlarmClockInfo(System.currentTimeMillis() + timeInterval, pi);
        am.setAlarmClock(alarmClockInfo, pi);
    }
    else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT)
        am.setExact(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + timeInterval, pi);
    else
        am.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + timeInterval, pi);
}

Хочу обратить внимание на флаг AlarmManager.RTC_WAKEUP — именно с помощью него система позволит нашему приложению «проснуться» при неактивном экране, когда устройство находится в заблокированном состоянии.

Данный подход с «будильникам» дал нам нужный результат — приложение в фоне корректно опрашивает сервер на наличие новых данных. Сейчас мы дорабатываем алгоритм. На данный момент реализуем и тестируем следующую оптимизацию, которая позволит сузить круг устройств и тем самым уменьшить нагрузку на сервер:

  • в сообщении, отправленном средствами GCM на устройство, содержится некоторый уникальный ID;
  • получив данные GET запросом в фоновом режиме проверяем, существуют ли уже запись с таким ID на устройстве;
  • если локально на устройстве таких данных нет, мы запоминаем этот ID и время его получения T1;
  • ждем PUSH с таким же ID, при получении запоминаем время T2 и проверяем разницу между T2 и T1;
  • если разница составляет больше некоторого временного критерия (значения), то на устройстве наблюдается проблема с доставкой уведомлений и для корректной работы сервиса необходимо постоянно запрашивать данные в фоновом режиме с сервера (критерий советую выбирать исходя из решаемой задачи. В нашем случае, был выбран критерий равный 5 минутам);
  • данную разницу стоит вычислять несколько раз, например 5-10 раз, только после этого делать вывод о том, что устройство действительно содержит проблему с получением Push уведомлений (таким образом исключается ситуация банального разрыва соединения, таймаута и пр.);
  • необходимо прогонять данный алгоритм периодически (например, раз в неделю, или после обновления ОС на устройстве).

Всем добра. И поменьше подобных костылей.

P.S.
В процессе тестирования очень помог инструмент, который дает возможность посмотреть информацию по отправленным пушам. Этот инструмент доступен разработчикам бесплатно. Рекомендую всем его использовать.

P.S.S.
Предрекаю, в комментариях наверняка будут вопросы о расходе батарейки. Я провел несколько тестов, оставив личный телефон на ночь с включенным мобильным интернетом. Результаты были в районе 20-25% расхода заряда за 8-9 часов. Так же клиенты, которым мы отправляли тестовые сборки, не жаловались на проблемы с увеличением расхода заряда батареи.

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


  1. zo_oz
    28.12.2015 12:53
    +5

    Результаты были в районе 20-25% расхода заряда за 8-9 часов.

    Это не нормально. Как вообще можно принять после таких тестов такое решение? 3%/час при спящем телефоне? Конечно, все зависит от объема аккумулятора, но это же просто издевательство над людьми…


    1. gang018
      28.12.2015 13:06

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


    1. kekekeks
      29.12.2015 11:31
      +1

      Издевательство над людьми — выпускать устройства с не работающими нормально Push-уведомлениями.


  1. jvIlya
    28.12.2015 13:01

    Есть такой экран Settings->Battery->History details
    Интересно глянуть его с вашим приложением и без него. Особенно строчку с awake.

    Я думаю, если приложение все ночь будит устройство каждые 5 минут (проснуться+слазить в интернет), то батарейка должна заметно садится.


    1. gang018
      28.12.2015 13:08

      Нашел этот пункт. Сегодня обязательно попробую провести тест и отписаться по результатам


  1. forceLain
    28.12.2015 13:06
    +2

    Необходимо в пределах 30-60 секунд оповестить пользователя о некотором действии

    В конечном счете было принято решение о том, чтобы держать свое соединение с сервером в связке с GCM.

    Надо было сразу с этого начинать. И убрать GCM.
    Нам это уведомление не нужно, поэтому мы воспользовались следующим велосипедом… запустить одновременно с первым сервисом второй… затем убить второй сервис.

    Жесть конечно. А потом все плюются от качества ПО в маркете…


    1. bejibx
      29.12.2015 14:54
      +1

      Согласен, жесть, но во многих ситуациях иначе никак. Знаете ли вы, например, что поддержка карт памяти появилась в Android SDK только в API 4.4 (KitKat)? Все приложения, поддерживающие более ранние версии операционки используют хаки и костыли чтобы понять по какому пути смонтирована карта памяти, ведь каждый вендор монтирует куда ему вздумается. Как у разработчика, у тебя есть выбор: либо писать хаки и костыли и потом страдать от последствий ради того, чтобы предоставить пользователю функционал, который он хочет, либо мягко послать его в пеший эротический тур, сославшись на «извините, злой Google не даёт нам так делать». Во втором случае, вам, мягко говоря, не поверят, ведь всегда найдётся кто-то, кто не постесняется накидать хаков и костылей ради функционала и тогда пользователи справедливо возразят — «Но у Васьки то работает». И после этого попробуйте втолковать людям что так делать нехорошо. Sad but true.


  1. forceLain
    28.12.2015 13:16

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


    1. BupycNet
      28.12.2015 16:34
      +1

      Кстати. Интересно можно ли сделать соединение с сокетом, чтобы телефон спал (мало потреблял) но и мог принять через сокет данные?


      1. bejibx
        29.12.2015 14:45

        Только вместе с WakeLock'ом, а это автоматически означает работающий на-полную процессор. Более того, на некоторых устройствах нужно (кто бы сомневался) использовать грязнейший хак, описанный тут — Reception of UDP packets in sleep mode.


  1. Xcam
    28.12.2015 15:07
    +3

    И именно там нашли ответ на вопрос — начиная с 19 API lvl (Kitkat) абсолютно все «будильники» в системе стали разовыми. Вывод — всегда читайте документацию.

    в документации нашел только такие строки
    Note: as of API 19, all repeating alarms are inexact. If your application needs precise delivery times then it must use one-time exact alarms, rescheduling each time as described above. Legacy applications whose {@code targetSdkVersion} is earlier than API 19 will continue to have all of their alarms, including repeating alarms, treated as exact.


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


  1. Alexey_Bespaly
    28.12.2015 15:16
    +2

    начиная с 19 API lvl (Kitkat) абсолютно все «будильники» в системе стали разовыми.

    Да где же такое написано?

    Документация:
    Note: as of API 19, all repeating alarms are inexact. If your application needs precise delivery times then it must use one-time exact alarms, rescheduling each time as described above. Legacy applications whose targetSdkVersion is earlier than API 19 will continue to have all of their alarms, including repeating alarms, treated as exact.


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


  1. h0tkey
    28.12.2015 18:45

    Я, вероятно, что-то упустил, но для достижения latency уведомлений в 30-60 секунд вам пришлось и «будильник» с таким же периодом запускать? Если да, то подозреваю, что устройства в сон уходить почти не будут, потому что это, ЕМНИП, не мгновенный процесс, сон многоуровневый и наступает постепенно.


    1. BupycNet
      28.12.2015 21:53

      Странно, что автор сразу такое не нагуглил.
      stackoverflow.com/questions/13534732/how-to-make-the-android-device-hold-a-tcp-connection-to-internet-without-wake-lo
      Судя по всему можно таки сделать постоянное TCP соединение и усыпить девайс. Причём потом разбудить его приходом пакета.


      1. Bo_bda
        29.12.2015 00:22

        а вы уверены, что у вас система не вырубит инет сама тем более с введением новых систем сна в 6.0? хотя если мне не изменяет память, то она вообще сейчас может убить все если не ставить специальные флаги.

        Если не прав, поправьте.


        1. BupycNet
          29.12.2015 01:43

          Я тут не сильно специалист. Сам не знал, что такое может вообще работать. Но технически — если у нас например есть процесс и он не убит, и есть например 3G-4G сеть, или Wifi в режиме «не выключать в режиме сна» то вполне все должно работать.
          Ну в крайнем случае — можно эти флаги и использовать.


      1. bejibx
        29.12.2015 15:52

        Для меня данная информация выглядит сомнительно. Не указаны ни версии Android ни конкретные модели устройств на которых это заработало. В противовес могу привести несколько ссылок на stackoverflow, где утверждается обратное:

        1. Android and SO_KEEPALIVE — will a sleeping device still send keepalive segments?
        2. Android: Listen to packet data when Android in sleep mode
        3. ServerSocket in android not accepting while on SLEEP MODE [screen off]
        4. Android socket gets killed imidiatelly after screen goes blank
        5. Android, keeping socket/wireless connection while screen off

        Скорее всего, как обычно, поведение очень сильно зависит от конкретного устройства и версии ОС. Для меня невозможность получить данные от сокета в режиме сна без WakeLock'а выглядит абсолютно логично — процессор в режиме сна просто прекращает обработку процесса вашего приложения, а без доступного процессорного времени как понять что на сокет что-то прилетело?


        1. BupycNet
          29.12.2015 16:34

          Мне что то подсказывает, что проблема может решиться api для приложений связанное с администратором устройства.
          Вообще тот же gcm ведь по сути вроде как держит соединение и в wakelock может принимать пуши. Что мешает сделать тоже самое?
          Вообще проблема для меня едкая — сам хочу в PushAll сделать резервный сервис. У нас уже есть веб сокет как резерв помогает тем, кто с компа заходит с закрытыми портами (сокет проксируется через nginx на обычном порту)
          Вполне можно опционально подключать, если GCM отвалился.


          1. bejibx
            29.12.2015 19:42

            Вообще тот же gcm ведь по сути вроде как держит соединение и в wakelock может принимать пуши. Что мешает сделать тоже самое?
            Не путайте сторонние приложения, системные приложения и системные компоненты, для всех разные условия. GSM — это системный компонент, он вполне может работать в обход ограничений API, потому что сам является его частью.
            Мне что то подсказывает, что проблема может решиться api для приложений связанное с администратором устройства.
            Не факт. В любом случае тут всё упирается в необходимость запросить у пользователя разрешение на администрирование устройства для вашего приложения. С большой долей вероятности пользователь просто удалит приложение за такую наглость.


            1. BupycNet
              29.12.2015 20:22

              Зависит от направленности. Например если описать пользователю например статистику, что с его телефоном неполадки и многие оповещения не доходят — то он вполне сможет сделать выбор — включить эту функцию или остаться как есть.


              1. bejibx
                30.12.2015 11:44

                Проблема «Администратора устройства» в том, что этот режим даёт приложению кучу полномочий сразу, без возможности выборочной отмены. Получается вы выводите пользователю эту статистику и просите его разрешить админку. Он думает — «Почему бы и нет?», открывается системное окно и там написано, что приложение получит возможность «удалять данные устройства», «запрещать использование камеры»,… «увести жену», «забрать кота» и вот на этом шаге у многих может возникнуть недоверие. Я бы точно не стал давать приложению такие полномочия только ради уведомлений, разве что это приложение написано мной лично.