Простой клиент-сервер на Android (интернет-мессенджер)
Важно. Все написанное ниже не представляет собой какой либо ценности для профессионалов, но может служит полезным примером для начинающих Android разработчиков! В коде старался все действия комментировать и логировать.
Поехали. Многие мобильные приложения (и не только) используют архитектуру клиент-сервер. Общая схема, думаю, понятна.
Уделим внимание каждому элементу и отметим:
- сервер — представляет собой некую программу, работающую на удаленном компьютере, и реализующую функционал «общения» с приложениями-клиентами (слушает запросы, распознает переданные параметры и значения, корректно отвечает на них);
- клиент — в нашем случае, программа на мобильном устройстве, которая умеет формировать понятный серверу запрос и читать полученный ответ;
- интерфейс взаимодействия — некий формат и способ передачи/получения запросов/ответов обеими сторонами.
Неважно, как реализован любой из этих элементов, все они в любом случае присутствуют. Давайте реализуем примитивный сервер и Android клиент, работающий с ним. Как пример, будем использовать любой популярный мобильный интернет-мессенджер (Viber, ICQ), а приложение условно назовем «интернет-чат».
Схема взаимодействия следующая:
Клиент, установленный на устройстве А, посылает сообщение для клиента, установленного на устройстве Б. И наоборот. Сервер играет роль связующего звена между устройством А и Б… С, Д… и т.д. Также он играет роль «накопителя» сообщений, для их восстановления, на случай удаления на одном из клиентских устройств.
Для хранения сообщений используем SQL БД как на сервере, так и на устройствах-клиентах (в принципе, вся работа клиентов интернет-мессенджеров и сводится к постоянной синхронизации локальной и удаленной БД с сообщениями). Дополнительно, наш интернет-чат будет уметь стартовать вместе с запуском устройства и работать в фоне. Взаимодействие будет происходить путем HTTP запросов и JSON ответов.
Более логично, если синхронизация происходит через порт/сокет, это с одной стороны упрощает задачу (не нужно циклично слать HTTP запросы на проверку новых сообщений, достаточно проверять состояние прослушиваемого сокета), но с другой стороны, это усложняет создание серверной части приложения.
Делаем сервер
Для реализации «сервера», нам нужно зарегистрироваться на любом хостинге, который дает возможность работы с SQL и PHP.
Создаем пустую SQL БД, в ней создаем таблицу.
- author — автор сообщения;
- client — получатель сообщения;
- data — время и дата получения сообщения на сервере;
- text — сообщение.
В двух следующих файлах необходимо изменить переменные, содержащие данные для доступа к БД, на свои, полученные Вами при регистрации Вашего«сервера».
Структура запросов к api:
- обязательный атрибут action — может быть равен select (сервер ответит списком записей из своей БД), insert (сервер добавить новую запись в свою БД), delete (сервер очистит свою БД)
- если action=insert, нам нужно будет передать дополнительные параметры: author (кто написал сообщение), client (кому адресовано сообщение), text (сообщение)
- action=select может содержать дополнительный параметр data, в этом случае ответ сервера содержит не все сообщения из БД, а только те, у которых время создания позднее переданного
Примеры:
- chat.php?action=delete – удалит все записи на сервере
- chat.php?action=insert&author=Jon&client=Smith&text=Hello — добавит на сервере новую запись: автор Jon, получатель Smith, содержание Hello
- chat.php?action=select&data=151351333 — вернет все записи, полученные после переданного времени в long формате
Клиентская часть
Теперь структура Android приложения:
В фоне работает FoneService.java, который, в отдельном потоке, каждые 15 секунд делает запрос на сервер. Если ответ сервера содержит новые сообщения, FoneService.java записывает их в локальную БД и отправляет сообщение ChatActivity.java о необходимости обновить ListView, с сообщениями. ChatActivity.java (если она в этот момент открыта) получает сообщение и обновляет содержимое ListView из локальной БД.
Отправка нового сообщения из ChatActivity.java происходит сразу на сервер, минуя FoneService.java. При этом наше сообщение НЕ записывается в локальную БД! Там оно появится только после получения его назад в виде ответа сервера. Такую реализацию я использовал в связи с важным нюансом работы любого интернет-чата — обязательной группировкой сообщений по времени. Если не использовать группировку по времени, будет нарушена последовательность сообщений. Учитывая, что клиентские приложения просто физически не могут быть синхронизированы с точностью до миллисекунд, а возможно будут работать даже в разных часовых поясах, логичнее всего будет использовать время сервера. Так мы и делаем.
Создавая новое сообщение, мы передаем запросом на сервер: имя автора сообщения, имя получателя сообщения, текст сообщения. Получая эту запись назад, в виде ответа сервера, мы получаем то, что отправляли + четвертый параметр: время получения сообщения сервером.
Источник
Пишем клиент для Хабра под Android
Забегая вперед, вот что получилось:
12:56. Я буду делать это параллельно с написанием топика (так интересней). По ходу написания клиента поясняя все шаги. Итак, покурили, налили чай, подготовили плейлист и, пока чай остывает — проверяем не занято ли имя habrahabr в маркете. Отлично, переходим к созданию приложения.
13:02 Создаем новый проект.
Скриншот
API level равен 4, по той причине, что при меньшем значении — на планшетниках Samsung Galaxy Tab разрешение экрана будет некорректным и обладатели данных чудо-девайсов не преминут насовать вам кучу минусов в маркет (хотя в принципе, врятли это косяк разработчика).
13:08 Фиксим манифест.
Необходимо добавить две строчки:
— android:configChanges=«orientation», данная строка нужна для того, чтобы при смене ориентации экрана не разрушалось наше активити.
— , запрашиваем разрешение на доступ в интернет
AndroidManifest
* играет front242 — headhunter v3.0
13:13 Фиксим layout.
Стираем всё и добавляем один единственный элемент — webview — на весь экран
13:16 Иконка.
При помощи фаербага подрезаем абсолютную ссылку и при помощи фотошопа обрезаем до 48*48 px и кидаем в res/drawable…
13:27 С иконкой всё сложнее оказалось. Пятно логотипа после уменьшения превратилось в мутную хрень, пришлось нагуглить. Надеюсь автор не обидится.
Уф, самое сложное закончили, наконец то можно покодить.
13:39 Загружаем хабр
public class habr extends Activity <
private WebView wv;
/** Called when the activity is first created. */
@Override
public void onCreate(Bundle savedInstanceState) <
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
wv = (WebView) findViewById(R.id.wv);
WebSettings webSettings = wv.getSettings();
webSettings.setSavePassword( true );
webSettings.setSaveFormData( true );
webSettings.setJavaScriptEnabled( true );
* This source code was highlighted with Source Code Highlighter .
* Здесь мы просто натравили наше вью на habr, предварительно включив джаваскрипт и запоминалку форм/паролей. Выглядит пока уродливо, но уже работает. Перекур.
13:53 Продолжаем разговор.
public class habr extends Activity <
private WebView wv;
private String LASTURL = «» ;
/** Called when the activity is first created. */
@Override
public void onCreate(Bundle savedInstanceState) <
super.onCreate(savedInstanceState);
this .getWindow().requestFeature(Window.FEATURE_PROGRESS);
setContentView(R.layout.main);
wv = (WebView) findViewById(R.id.wv);
WebSettings webSettings = wv.getSettings();
webSettings.setSavePassword( true );
webSettings.setSaveFormData( true );
webSettings.setJavaScriptEnabled( true );
final Activity activity = this ;
wv.setWebChromeClient( new WebChromeClient() <
public void onProgressChanged(WebView view, int progress)
<
activity.setTitle( » » +LASTURL);
activity.setProgress(progress * 100);
if (progress == 100)
activity.setTitle( » » +LASTURL);
>
>);
wv.setWebViewClient( new WebViewClient() <
public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) <
Toast.makeText(getApplicationContext(), «Error: » + description+ » » + failingUrl, Toast.LENGTH_LONG).show();
>
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url)
<
if (url.indexOf( «habrahabr» ) // the link is not for a page on my site, so launch another Activity that handles URLs
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
startActivity(intent);
return true ;
>
return false ;
>
public void onPageStarted (WebView view, String url, Bitmap favicon) <
LASTURL = url;
>
public void onPageFinished (WebView view, String url) <
* This source code was highlighted with Source Code Highlighter .
Итак, нам нужен градусник загрузки.
1. Запрашиваем фичу: this.getWindow().requestFeature(Window.FEATURE_PROGRESS);
2. На прогрессчейндж — заполняем окно градусника
activity.setTitle(» «+LASTURL);
activity.setProgress(progress * 100);
if(progress == 100) activity.setTitle(» «+LASTURL);
3. На начало загрузки запоминаем url в переменной LASTURL = url;
Обрабатываем отвалившийся вайфай на онресивед еррор:
Toast.makeText(getApplicationContext(), «Error: » + description+ » » + failingUrl, Toast.LENGTH_LONG).show();
(всплывает сообщение для совсем уж дебилов, хотя итак всё будет на странице написано )
14:05 Фиксим вёрстку.
Немного теории. На загруженной странице можно выполнить джаваскрипт. Если например набрать в адресной строке браузера что то вроде javasсript:alert(document.body.innerHTML) — то мы увидим тело страницы (копипастерам — в примере выше — буква «с» — русская, чтобы парсер пропустил).
Ну а дальше — дерево ДОМ и т.д., твори что хочешь, хоть полностью страницу переделывай. Однако мы не зайдем так далеко (я надеюсь) и просто скроем сайдбар, для улучшения читабельности. А браузер уже сам растянет полезный контент по странице. Итак, пробуем добавить обработчик на окончание загрузки страницы:
* This source code was highlighted with Source Code Highlighter .
/* Отвлекли по работе */
Такс, сайдбар скрывается, но с неким скачком. Фиксим загрузку изображений:
14:34 Ускоряем загрузку
Для этого отключим картинки при старте страницы:
view.getSettings().setLoadsImagesAutomatically(false);
и включим на финише, после хака с джаваскриптом:
view.getSettings().setLoadsImagesAutomatically(true);
Такс, грузить контент стал ощутимо быстрее (на моей полуживой Йоте, в эмуляторе, по крайней мере)
15:23 Продолжаем разговор
Пока ходил на обед — заглянул в зону бесплатного вайфая, заодно и потестил. С сожалением узрел панель поиска, нелепо висящую в пустом правом углу. Попробуем с ней чтоть сделать.
Для начала тупо скроем.
* музыка: submatakana — the krypt (это что то с чем то)
* This source code was highlighted with Source Code Highlighter .
Мда, ломать не строить. Такс, попробуем «приаппендить её к списку блогов.
«var parent = document.getElementsByClassName(‘page-navigation’)[0];» +
«var panel = document.getElementsByClassName(‘panel-tools’)[0];» +
«var div = document.createElement(‘div’);» +
«div.innerHTML = panel.innerHTML;» +
«parent.appendChild(div);» +
* This source code was highlighted with Source Code Highlighter .
опс, теперь у нас две панели)
* This source code was highlighted with Source Code Highlighter .
15:57 Опс, одна кавычечка не там и весь скрипт рушится как карточный домик.
Надо так: div.style[‘margin-left’] = ’30px’;
Так попробую ещё вырубить рекламные блоки в меню, но нет так нет, что то я долго вожусь с грешной вёрсткой (ненавижу).
16:04 Так как данные элементы не поименованы — попробовал так:
«var urls=document.getElementsByTagName(‘a’);for(var i=0;i +
* This source code was highlighted with Source Code Highlighter .
16:16 Попробуем ударить по площадям:
«var imgs=document.getElementsByTagName(‘IMG’);for(var i=0;i +
* This source code was highlighted with Source Code Highlighter .
Картинки скрылись, но пустое место всё равно торчит( Ладно, пусть это останется на домашнее задание желающим. Пусть пока живут на радость рекламодателям.
Перекур.
16:45 Поиск, пожалуй вернем назад, немного ужав по ширине:
«var panel = document.getElementById(‘search’);» +
«panel.style[‘width’] = ’55px’;» +
* This source code was highlighted with Source Code Highlighter .
Ну и займемся наконец андроидом.
16:47 Перекрываем аппаратную кнопку назад.
- @Override
- public boolean onKeyDown( int keyCode, KeyEvent event ) <
- if ((keyCode == KeyEvent.KEYCODE_BACK) && wv.canGoBack()) <
- wv.goBack();
- return true ;
- >
- return super.onKeyDown(keyCode, event );
- >
* This source code was highlighted with Source Code Highlighter .
16:57 Создаем меню
- @Override
- public boolean onCreateOptionsMenu(Menu menu)
- <
- super.onCreateOptionsMenu(menu);
- this .myMenu = menu;
- MenuItem item = menu.add(0, 1, 0, «MAIN PAGE» );
- item.setIcon(R.drawable.home);
- MenuItem item2 = menu.add(0, 2, 0, «BACK» );
- item2.setIcon(R.drawable.arrowleft);
- MenuItem item3 = menu.add(0, 3, 0, «F5» );
- item3.setIcon(R.drawable.s);
- MenuItem item4 = menu.add(0, 4, 0, «CLEAR CACHE» );
- item4.setIcon(R.drawable.trash);
- MenuItem item5 = menu.add(0, 5, 0, «VOID» );
- item5.setIcon(R.drawable.vote);
- return true ;
- >
- @Override
- public boolean onOptionsItemSelected(MenuItem item) <
- switch (item.getItemId())
- <
- case 1:
- wv.loadUrl( «http://habrahabr.ru» );
- break ;
- case 2:
- if (wv.canGoBack()) <
- wv.goBack();
- >
- break ;
- case 3:
- wv.loadUrl(LASTURL);
- break ;
- case 4:
- wv.clearCache( true );
- break ;
- case 5:
- Intent marketIntent2 = new Intent(Intent.ACTION_VIEW, Uri.parse(
- «http://market.android.com/details?id=» + getPackageName()));
- startActivity(marketIntent2);
- break ;
- >
- return true ;
- >
* This source code was highlighted with Source Code Highlighter .
Здесь пояснять особо даже и ничего вроде…
Вебвью хранит данные в локальном изолированном кеше и функция clearCache — удаляет закешированные картики и т.п.
В маркет пользователя отправляем при помощи интента + старт активити, это стандартный механизм взаимодействия с внешними приложениями.
17:01 Такс, пожалуй, стоит сделать режим с картиками/без картинок
Фиксим меню:
menu.add(0, 6, 0, „IMG ON“);
menu.add(0, 7, 0, „IMG OFF“);
17:05 Мутим функции сохранения настроек
- private void saveSettings(Boolean val)
- <
- SharedPreferences settings = getSharedPreferences(PREFS_NAME, 0);
- SharedPreferences.Editor editor = settings.edit();
- editor.putBoolean( «IMGMODE» , val);
- editor.commit();
- >
* This source code was highlighted with Source Code Highlighter .
В неё будем передавать настройки (грузить или нет картинки), а она пусть запихивает в переменную переданное значение.
(выше объявили константу PREFS_NAME — это как бы имя конфига)
Теперь просто вызываем её в обработчике меню:
- case 6:
- saveSettings( true );
- break ;
- case 7:
- saveSettings( false );
- break ;
* This source code was highlighted with Source Code Highlighter .
17:18 И читаем константу при создании приложения из нашего конфига
- SharedPreferences settings = getSharedPreferences(PREFS_NAME, 0);
- imgOn = settings.getBoolean( «IMGMODE» , false );
- webSettings.setLoadsImagesAutomatically(imgOn);
* This source code was highlighted with Source Code Highlighter .
17:19 Тестируем ещё раз
17:25 Вроде косяков нет. Экспортируем проект.
В эклипсе это правая кнопка на проекте экспорт и запускается мастер, позволяющий создать/выбрать сертификат и упаковывающий проект. Далее топаем в маркет
17:38 Публикуем.
На самом деле не самая простая задача. Где то надо добыть кучу промографики определенного размера, поэтому не дизайнерам тут тяжко.
По параметрам.
— Чтобы установить основным языком приложения русский — надо сперва добавить русский, и только после этого появится возможность удалить английский.
— В полях дескрипшн и промотекст желательно упомянуть ключевые слова, по которым могут искать приложение.
— Если устновить цену free — потом сделать его платным — невозможно
— Не ставьте галку copy protection, приложение не будет ставиться на часть девайсов
— Лучше укажите все страны, даже если приложение только для русскоязычных, например.
Мда, с иконкой не очень красиво получилось( Почему то меня гложет эта мысль. Так что если кто-ть может изобразить чтоть 48*48 — буду премного благодарен.
Полностью, итоговый исходник:
- package ru.habrahabr.android;
- import android.app.Activity;
- import android.content.Intent;
- import android.content.SharedPreferences;
- import android.graphics.Bitmap;
- import android.net.Uri;
- import android.os.Bundle;
- import android.view.KeyEvent;
- import android.view.Menu;
- import android.view.MenuItem;
- import android.view.Window;
- import android.webkit.WebChromeClient;
- import android.webkit.WebSettings;
- import android.webkit.WebView;
- import android.webkit.WebViewClient;
- import android.widget.Toast;
- public class habr extends Activity <
- private WebView wv;
- private String LASTURL = «» ;
- Menu myMenu = null ;
- private static final String PREFS_NAME = «MyPrefs» ;
- private Boolean imgOn;
- /** Called when the activity is first created. */
- @Override
- public void onCreate(Bundle savedInstanceState) <
- super.onCreate(savedInstanceState);
- this .getWindow().requestFeature(Window.FEATURE_PROGRESS);
- setContentView(R.layout.main);
- wv = (WebView) findViewById(R.id.wv);
- WebSettings webSettings = wv.getSettings();
- webSettings.setSavePassword( true );
- webSettings.setSaveFormData( true );
- webSettings.setJavaScriptEnabled( true );
- SharedPreferences settings = getSharedPreferences(PREFS_NAME, 0);
- imgOn = settings.getBoolean( «IMGMODE» , false );
- webSettings.setLoadsImagesAutomatically(imgOn);
- final Activity activity = this ;
- wv.setWebChromeClient( new WebChromeClient() <
- public void onProgressChanged(WebView view, int progress)
- <
- activity.setTitle( » » +LASTURL);
- activity.setProgress(progress * 100);
- if (progress == 100)
- activity.setTitle( » » +LASTURL);
- >
- >);
- wv.setWebViewClient( new WebViewClient() <
- public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) <
- Toast.makeText(getApplicationContext(), «Error: » + description+ » » + failingUrl, Toast.LENGTH_LONG).show();
- >
- @Override
- public boolean shouldOverrideUrlLoading(WebView view, String url)
- <
- if (url.indexOf( «habrahabr» ) // the link is not for a page on my site, so launch another Activity that handles URLs
- Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
- startActivity(intent);
- return true ;
- >
- return false ;
- >
- public void onPageStarted (WebView view, String url, Bitmap favicon) <
- LASTURL = url;
- view.getSettings().setLoadsImagesAutomatically( false );
- >
- public void onPageFinished (WebView view, String url) <
- view.loadUrl( «javascript:(function() < " +
- «hide(‘sidebar’);» +
- //»var parent = document.getElementsByClassName(‘page-navigation’)[0];»+
- //»var panel = document.getElementsByClassName(‘panel-tools’)[0];»+
- //»var div = document.createElement(‘div’);»+
- //»div.innerHTML = panel.innerHTML;»+
- //»parent.appendChild(div);»+
- //»panel.innerHTML = »;»+
- //»div.style[‘margin-left’] = ’31px’;»+
- «var panel = document.getElementById(‘search’);» +
- «panel.style[‘width’] = ’55px’;» +
- //»var imgs=document.getElementsByTagName(‘IMG’);for(var i=0;i
- //»var urls=document.getElementsByTagName(‘li’);for(var i=0;i
- //»hideByClass(‘panel-tools’);»+
- «function hide(id)
>» + - //»function hideByClass(c)
- «>)()» );
- if (imgOn) view.getSettings().setLoadsImagesAutomatically( true );
- >
- >);
- wv.loadUrl( «http://habrahabr.ru» );
- >
- @Override
- public boolean onKeyDown( int keyCode, KeyEvent event ) <
- if ((keyCode == KeyEvent.KEYCODE_BACK) && wv.canGoBack()) <
- wv.goBack();
- return true ;
- >
- return super.onKeyDown(keyCode, event );
- >
- @Override
- public boolean onCreateOptionsMenu(Menu menu)
- <
- super.onCreateOptionsMenu(menu);
- this .myMenu = menu;
- MenuItem item = menu.add(0, 1, 0, «MAIN PAGE» );
- item.setIcon(R.drawable.home);
- MenuItem item2 = menu.add(0, 2, 0, «BACK» );
- item2.setIcon(R.drawable.arrowleft);
- MenuItem item3 = menu.add(0, 3, 0, «F5» );
- item3.setIcon(R.drawable.s);
- MenuItem item4 = menu.add(0, 4, 0, «CLEAR CACHE» );
- item4.setIcon(R.drawable.trash);
- MenuItem item5 = menu.add(0, 5, 0, «VOID» );
- item5.setIcon(R.drawable.vote);
- menu.add(0, 6, 0, «IMG ON» );
- menu.add(0, 7, 0, «IMG OFF» );
- return true ;
- >
- @Override
- public boolean onOptionsItemSelected(MenuItem item) <
- switch (item.getItemId())
- <
- case 1:
- wv.loadUrl( «http://habrahabr.ru» );
- break ;
- case 2:
- if (wv.canGoBack()) <
- wv.goBack();
- >
- break ;
- case 3:
- wv.loadUrl(LASTURL);
- break ;
- case 4:
- wv.clearCache( true );
- break ;
- case 5:
- Intent marketIntent2 = new Intent(Intent.ACTION_VIEW, Uri.parse(
- «http://market.android.com/details?id=» + getPackageName()));
- startActivity(marketIntent2);
- break ;
- case 6:
- saveSettings( true );
- break ;
- case 7:
- saveSettings( false );
- break ;
- >
- return true ;
- >
- private void saveSettings(Boolean val)
- <
- SharedPreferences settings = getSharedPreferences(PREFS_NAME, 0);
- SharedPreferences.Editor editor = settings.edit();
- editor.putBoolean( «IMGMODE» , val);
- editor.commit();
- >
- >
* This source code was highlighted with Source Code Highlighter .
UPD: Очень сильно переделал вёрстку.
За основу взял стиль разработанный almalexa.habrahabr.ru и существенно доработал его напильником под маленькое разрешение.
Получившийся стиль: userstyles.org/styles/46932/habr
На этом считаю разработку законченной. Клиент в маркете — обновлён.
Итого: на всё про всё ушли сутки.
Источник