Android WebSocket. Обратная связь, или Когда важна каждая доля секунды

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

Итак, клиенту понадобилась какая-то важная для него информация, для ее получения он обратился в справочное бюро. Подав запрос, он получает ответ (в рамках данной задачи не важно какой именно). Проходит некоторое время, в бюро обновляется информация, которая может быть полезна этому пользователю. Что делать дальше?

Можно, конечно, сообщить о новостях при следующем обращении клиента в бюро (Long pulling). Что не совсем удобно, но, как-никак, решает часть проблемы. Но что же делать, если мы имеем дело с информацией, которая быстро теряет актуальность? Возникает потребность в канале двусторонней связи между клиентом и бюро, к примеру в телефоне (WebSocket).

Прекрасно, у бюро есть телефон клиента, в любой момент оно может позвонить ему для предоставления оперативной информации. Hо и здесь есть одно НО. Клиент может не взять трубку. Тогда бюро отправляет ему текстовое извещение (Push-уведомление) об обновлении актуальной для него информации, в зависимости от объема которой ему достаточно будет извещения, либо вскоре после прочтения извещения он сам обратится в бюро за подробностями.

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

Что такое WebSocket и как приложения его используют

WebSocket — протокол полнодуплексной связи поверх TCP-соединения, предназначенный для обмена сообщениями между клиентом и веб-сервером в режиме реального времени. В настоящее время в W3C осуществляется стандартизация API Web Sockets. Черновой вариант стандарта этого протокола утвержден IETF (Wikipedia). Углубляться в подробности реализации протокола не буду, так как по этому поводу в сети есть достаточно много технической документации. Здесь же мы рассмотрим кейсы, в которых используется WebSocket, упрощенную бизнес логику и вспомогательные библиотеки.

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

Приведу несколько приложений которые используют сокет для реализации своей бизнес логики. Котировки валют, акций, биржевая статистика — это все мониторинг в реальном времени, данные на клиентах постоянно обновляются посредством сокет-соединения. Еще примеры:

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

Это все конечно довольно абстрактно, и бизнес логика данных процессов в жизни немного сложнее. Но главное требование, которое выполняет здесь сокет-соединение — полная двусторонняя связь (полнодуплексное соединение), сервер в любой момент может обратиться к клиенту, а тот ему ответить и наоборот.

Реализация WebSocket-соединения в приложении

Итак, для использования сокет-соединения была выбрана библиотека, поддерживающая стандарт RFC 6455 — Java-WebSocket, но в скором времени выяснилось, что при использовании wss-соединения она повреждает пакеты на некоторых версиях Android. Установить закономерность не удалось, при этом на репозитории есть уже несколько сообщений о таких проблемах, поэтому было решено ее заменить. Следующим подходящим вариантом был nv-websocket-client, который хорошо себя зарекомендовал.

nv-websocket-client реализует полный стек методов и интерфейсов для работы с сокет-соединением. Для разработчиков, имеющих хоть небольшой опыт в написании сетевых приложений, не составит труда разобраться с документацией.

Когда вы приступаете к имплементации сокета в приложении, возникает вполне уместный вопрос: каким образом лучше архитектурно реализовать соединение внутри приложения? Исходя из логики работы соединения и его жизненного цикла (ЖЦ), это должен быть компонент, который будет работать вне зависимости от того, на каком экране сейчас находится пользователь.

Реализовывать соединение в виде отдельного сервиса неудобно в силу нужды в постоянном биндинге последнего, да и не целесообразно по причине того, что соединение нам нужно только в момент активности пользователя. Соединение можно реализовать компонентом, который будет осуществлять коммуникацию в отдельном потоке, экземпляр данного компонента хранить в Application вашего приложения и управлять его состоянием через публичные методы, к примеру так:

publicclass ExampleApp extends Application {
 
   private ExampleSocketConnection exampleSocketConnection;
 
   @Override
   publicvoid onCreate(){super.onCreate();
 
       exampleSocketConnection =new ExampleSocketConnection(this);
       BackgroundManager.get(this).registerListener(appActivityListener);}
 
 
   publicvoid closeSocketConnection(){
       exampleSocketConnection.closeConnection();}
 
   publicvoid openSocketConnection(){
       exampleSocketConnection.openConnection();}
 
   publicboolean isSocketConnected(){return exampleSocketConnection.isConnected();}
 
   publicvoid reconnect(){
       exampleSocketConnection.openConnection();}

Сам компонент и клиент есть на нашем GitHub.

Рассмотрим подробнее клиент ClientWebSocket. Простой конструктор получает на вход коллбек для возврата сообщений и адрес соединения. Клиент поддерживает wss-соединение, которое открывается в отдельном потоке. Взаимодействие с соединением осуществляется с помощью экземпляра WebSocket для отправки сообщений и слушателя SocketListener для их получения.

Для отправки текстового сообщения используется метод sendText(), можно также отправлять бинарные последовательности посредством sendBinary(). В свою очередь, для получения SocketListener имеет ряд методов, и некоторые из них мы переопределили:

publicvoid onConnected(WebSocket websocket, Map<String, List<String>> headers)publicvoid onTextMessage(WebSocket websocket, String message)
 
@Override
publicvoid onError(WebSocket websocket, WebSocketException cause)
 
@Override
publicvoid onDisconnected(WebSocket websocket,
                          WebSocketFrame serverCloseFrame, WebSocketFrame clientCloseFrame,
                          boolean closedByServer)
@Override
publicvoid onUnexpectedError(WebSocket websocket, WebSocketException cause)
 
@Override
publicvoid onPongFrame(WebSocket websocket, WebSocketFrame frame)

Большая часть данных методов касается ЖЦ соединения, но о них немного позже. Сейчас нас интересует onTextMessage(), в данном случае мы получаем текстовые сообщения, которые содержат обертку json c отправленной нам моделью данных. Обертка выглядит так:

publicclass RealTimeEvent {
 
 
   @SerializedName("event")privateint event;
 
   @SerializedName("params")private JsonObject params;
 
   publicint getType(){return event;}
 
   publicString getUserId(){return userId;}
 
   public<T> T getParams(Class<T> type){returnnew Gson().fromJson(params.toString(), type);}
 
}

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

Message message = event.getParams(Message.class)

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

Также есть интересный момент с поддержкой соединения. Менеджментом всех соединений занимается сервер. Для того, чтобы понимать, какое соединение активно, а какое можно закрывать, он периодически их опрашивает. Данный процесс называется PingPong, и на каждый Pong от сервера, клиент должен ответить Ping-значением. Это выглядит так:

@Override
publicvoid onPongFrame(WebSocket websocket, WebSocketFrame frame)throwsException{super.onPongFrame(websocket, frame);
   websocket.sendPing("Are you there?");}

Жизненный цикл WebSocket соединения

Что же насчет жизненного цикла? А он напрямую вытекает из бизнес-логики приложения. У нас есть несколько кейсов поведения клиента и сервера:

  • Со стороны сервера: у нас появилась информация, которую нужно передать. Проверяем, есть ли сокет-соединение. Если есть, то отправляем данные на сокет, нет — отправляем push-уведомление.
  • Со стороны клиента: когда клиент онлайн, соединение активно и сообщение придет на сокет. Если же он выключил экран либо свернул приложение — соединение закрывается*, все сообщения придут в виде push-уведомлений.

*Можно конечно не закрывать соединение, реализовав для этого тот же сервис и передав в него экземпляр компонента. Но такой случай должен быть обоснован бизнес-логикой приложения и разработчики серверной части должны предусмотреть тот факт, что соединения будут практически постоянными и их количество будет прямо пропорционально расти с количеством пользователей.

Из сказанного выше, ЖЦ сокет соединения принуждает разработчика следить за состоянием девайса, приложения, статуса авторизации пользователя, и в момент ухода в оффлайн или разлогинивания закрывать соединение. Реализуется это несколькими путями. За состоянием девайса следит Receiver, настроенный на прием событий включения-выключения экрана.

private BroadcastReceiver screenStateReceiver =new BroadcastReceiver(){
   @Override
   publicvoid onReceive(Context context, Intent intent){if(intent.getAction().equals(Intent.ACTION_SCREEN_ON)){
           Log.i("Websocket", "Screen ON");
           openConnection();}elseif(intent.getAction().equals(Intent.ACTION_SCREEN_OFF)){
           Log.i("Websocket", "Screen OFF");
           closeConnection();}}};

За состоянием приложения следит специальный компонент BackgroundManager. Тут нужно немного объяснить целесообразность его существования. Дело в том что, логичным путем было бы закрывать соединение на методе onPause жизненного цикла активности. Но у нас, как правило, не одна активность в приложении, и при переходе между экранами происходит переподключение соединения. Это можно было бы решить банальным булевым флажком, но тут есть еще один скользкий момент… активности работают асинхронно, то есть возникают ситуации, когда onStart одной срабатывает раньше чем onPause другой и наоборот. В общем, это вносит некоторую непрозрачность в реализации ЖЦ соединения.

BackgroundManager дает возможность абстрагироваться от активности и через Application подписаться на onActivityResumed и onActivityPaused. На onPause он генерирует отложенное действие, которое впоследствии сообщит о том, что приложение свернуто. На onResume действие отменяется, если оно до этого не было выполнено, и при необходимости следует оповещение о том, что приложение находится в фокусе.

@Override
publicvoid onActivityResumed(Activity activity){if(mBackgroundTransition !=null){
       mBackgroundDelayHandler.removeCallbacks(mBackgroundTransition);
       mBackgroundTransition =null;}
 
   if(mInBackground){
       mInBackground =false;
       notifyOnBecameForeground();
       Log.i(LOG, "Application went to foreground");}}
 
privatevoid notifyOnBecameForeground(){for(Listener listener : listeners){try{
           listener.onBecameForeground();}catch(Exception e){
           Log.e(LOG, "Listener threw exception!"+ e);}}}
 
@Override
publicvoid onActivityPaused(Activity activity){if(!mInBackground && mBackgroundTransition ==null){
       mBackgroundTransition =newRunnable(){
           @Override
           publicvoid run(){
               mInBackground =true;
               mBackgroundTransition =null;
               notifyOnBecameBackground();
               Log.i(LOG, "Application went to background");}};
       mBackgroundDelayHandler.postDelayed(mBackgroundTransition, BACKGROUND_DELAY);}}
 
privatevoid notifyOnBecameBackground(){for(Listener listener : listeners){try{
           listener.onBecameBackground();}catch(Exception e){
           Log.e(LOG, "Listener threw exception!"+ e);}}}

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

Observer pattern и EventBus

Observer — поведенческий шаблон проектирования. Также известен как «подчиненные» (Dependents). Создает механизм у класса, который позволяет получать экземпляру объекта этого класса оповещения от других объектов об изменении их состояния, тем самым наблюдая за ними (Wikipedia). С помощью данного шаблона следует реализовывать обновление данных в приложении после получения сообщения в реальном времени. Observer позволяет построить слабосвязанную архитектуру, что в дальнейшем позитивно скажется на масштабировании приложения в целом.

В реализации данного паттерна поможет библиотека EventBus, которую мы и используем. Она проста до безобразия. В любой класс-наблюдатель можно добавить метод-обработчик определенного события, пометив его аннотацией @Subscribe, ну и зарегистрировать наблюдателя с помощью метода EventBus.getDefault().register(this). Важно не забывать отписывать наблюдателей после того, как в них отпадет надобность, таким вот образом: EventBus.getDefault().unregister(this), иначе получим серьезные утечки памяти. Пример активности-наблюдателя в данном случае будет выглядеть так:

@Override
publicvoid onResume(){
   EventBus.getDefault().register(this);}
 
@Override
publicvoid onPause(){
   EventBus.getDefault().unregister(this);}
 
@Subscribe
publicvoid handleMessage(RealTimeEvent event){if(event.getType()== RealTimeEvent.EVENT_TEXT_MESSAGE){
	...
   }}

Наблюдателем может быть любой объект, главное чтобы все логично вписывалось в архитектуру приложения и правильно взаимодействовало с ЖЦ. К примеру, имплементировать наблюдателя в холдере не является целесообразным, так как у него свой специфичный ЖЦ и зона доступа к данным. А вот переместив логику наблюдения в адаптер мы улучшим ситуацию, но, опять же, не совсем правильно заставлять такой архитектурный элемент как адаптер заниматься менеджментом данных, он ведь занимается лишь их отображением. Потому правильным решением будет реализовывать логику наблюдения в активности или в модели представления, в зависимости от того, какая у вас архитектура.

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

EventBus.getDefault().post(gson.fromJson(message, RealTimeEvent.class));

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

P.S. И о дополнительной логике, которая появляется в приложении в связи с обработкой событий в реальном времени. Казалось бы, получил событие, обновил UI, вывел непрочитанное сообщение, сменил статус пользователя — что тут сложного? Да, сложного ничего вроде как и нет, но, как правило, разработчики привыкли проектировать линейные приложения, а вот в данном случае возникает серьезный такой момент асинхронности. А проблем будет ой как много, если на старте разработки забыть, что мы разрабатываем приложение с асинхронной логикой.

К примеру, было у вас 2 Fragment со списками с одинаковых элементов, но отсортированных по разным критериям. Если элемент одного обновил свое состояние, значит тот же элемент другого должен обновиться. И не забывайте, что правило действует и в другую сторону — если пользователь сам обновляет состояние элемента, во втором списке мы опять же должны увидеть обновление. Задумайтесь на минутку, как вы будете это реализовывать?

Так же в UI возникает множество моментов, которые не всегда могут быть предусмотрены дизайнером и отображены в мокапах. К примеру, пользователь перешел на некий экран с контентом, состояние которого могут менять другие пользователи, использующие приложение. В таком случае контент должен обновиться, при этом не вызвав у текущего пользователя замешательства.

Вывод

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

А вот и ссылка на GitHub.

Нужен MVP, разработка под iOS, Android или прототип приложения? Ознакомьтесь с нашим портфолио и сделайте заказ уже сегодня!