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

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

Отже, клієнтові знадобилася якась важлива для нього інформація, для її отримання він звернувся до довідкового бюро. Подавши запит, він отримує відповідь (у рамках даного завдання не важливо, який саме). Проходить деякий час, в бюро оновлюється інформація, яка може бути корисною для цього користувача. Що робити далі?

Можна, звичайно, повідомити новини при наступному зверненні клієнта до бюро (Long pulling). Що не зовсім зручно, але, як-не-як, вирішує частину проблеми. Але що робити, якщо ми маємо справу з інформацією, яка швидко втрачає актуальність? Виникає потреба у каналі двостороннього зв'язку між клієнтом і бюро, наприклад у телефоні (WebSocket).

Прекрасно, бюро має телефон клієнта, у будь-який момент воно може зателефонувати йому для надання оперативної інформації. Але і тут є одне АЛЕ. Клієнт може взяти трубку. Тоді бюро надсилає йому текстове повідомлення (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 з надісланою нам моделлю даних. Обгортка виглядає так:

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 життєвого циклу активності. Але у нас, як правило, не одна активність у додатку, і під час переходу між екранами відбувається перепідключення з'єднання. Це можна було б вирішити банальним булевим прапорцем, але тут є ще один слизький момент… активності працюють асинхронно, тобто виникають ситуації, коли наStart однієї спрацьовує раніше ніж наPause інший і навпаки. Загалом, це вносить деяку непрозорість реалізації ЖЦ сполуки.

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 або прототип програми? Ознайомтеся з нашим портфоліо та зробіть замовлення вже сьогодні!