написать клиент серверное приложение
Пример простого клиент-серверного приложения на Java
«Клиент-сервер» это очень распространенная и логичная архитектура приложений. Мне кажется, что в наши дни редко можно встретить standalone-клиентское приложение. Поэтому я принял решение рассмотреть пример построения клиент-серверного приложения на Java без привязки к конкретной задаче. Сначала вкратце пробежимся по классовой структуре приложения, потом посмотрим на отдельную реализацию каждого класса. В самом конце статьи я дам ссылку на скачивание архива с готовой структурой приложения. Итак, начнем.
Основные компоненты приложения
Основными компонентами, естественно, являются непосредственно клиент и сервер. Однако, кроме них необходим еще пакет вспомогательных классов, которые, в простейшем случае, будут отвечать за обмен сообщениями между клиентом и сервером. В минимальной комплектации нужны такие классы: MessageReader/MessageWriter(считывает/записывает сообщение в поток на сокете), MessageFactory(содержит идентификаторы всех возможных сообщений), набор сообщений-запросов(Request) и набор сообщений-ответов(Response). Все они будут размещены в пакете «core», который должны иметь у себя и клиент и сервер.
Рассмотрим классовую структуру всего проекта, а потом перейдем к реализации.
Классовая структура клиент-серверного приложения
Исходный код клиента на Java
Разобраться с клиентом гораздо проще, он по сути своей не делает ничего супер сложного, просто создает сокет и подключается к сервер-сокету с помощью связки host:port. Лаунчер создает объект класса Client и запускает его работу. Исходный код привожу без импортов, ибо любая IDE вам их подключит(те, кто пишет на Java точно знают, что без IDE очень сложно). Кроме того, в конце статьи вы сможете скачать архив с этим проектом.
ClientLauncher.java
Client.java
Под словами «логика приложения» я подразумеваю протокол обмена сообщениями с сервером, передачу каких-либо данных для достижения конечной цели.
Исходный код сервера на Java
Задача сервера поднять свой серверный сокет на нужном адресе и ждать новых подключений. Для каждого подключения, которое принято называть клиентской сессией, создается отдельный поток обработки логики работы с клиентом.
Напомню, что в классе ClientSession описан основной алгоритм работы с клиентом, обмен сообщениями, данными и прочее. В классе Context содержится общая информация для всех клиентов сервера, например, пути для сохранения логов.
ServerLauncher.java
Server.java
Context.java
ClientSession.java
SessionsManager.java
Вспомогательные классы из пакета «core»
Помещу все вспомогательные классы под один кат, название классов в точности соответствует названиям из списка «классовая структура» выше, по нему вы можете определить пакет каждого класса.
Пара слов о сообщениях, классы Request и Response являются абстрактными и играют роль классификаторов сообщения. Благодаря этому очень удобно разграничивать «запросы» от «ответов». В этом примере я привел только одно сообщение — Handshake, которое отвечает за первое «рукопожатие» клиента и сервера. Все последующие сообщения должны быть прописаны в классе MessageFactory по примеру этих двух.
Скачать архив с шаблоном клиент-серверного приложения на Java
Заключение
Главная цель, которую я преследовал при написании этой статьи заключается в том, чтобы дать возможность мне или кому-либо еще за считанные минуты «собрать» готовое клиент-серверное приложение. Кажется, я с этим справился, если будут дополнения или замечания, пишите в комментариях или на почту. А на сегодня у меня все, спасибо за внимание!
Клиент-сервер шаг — за — шагом, от однопоточного до многопоточного (Client-Server step by step)
Цель публикации показать начинающим Java программистам все этапы создания многопоточного сервера. Для полного понимания данной темы основная информация содержится в комментариях моего кода и в выводимых в консоли сообщениях для лучшего понимания что именно происходит и в какой именно последовательности.
В начале будет рассмотрено создание элементарного клиент-сервера, для усвоения базовых знаний, на основе которых будет строиться многопоточная архитектура.
— Потоки: для того чтобы не перепутать что именно подразумевается под потоком я буду использовать существующий в профессиональной литературе синоним — нить, чтобы не путать Stream и Thread, всё-таки более профессионально выражаться — нить, говоря про Thread.
— Сокеты(Sockets): данное понятие тоже не однозначно, поскольку в какой-то момент сервер выполняет — клиентские действия, а клиент — серверные. Поэтому я разделил понятие серверного сокета — (ServerSocket) и сокета (Socket) через который практически осуществляется общение, его будем называть сокет общения, чтобы было понятно о чём речь.
Спасибо за подсказку про Thread.sleep();!
Конечно в реальном коде Thread.sleep(); устанавливать не нужно — это моветон! В данной публикации я его использую только для того чтобы выполнение программы было нагляднее, что бы успевать разобраться в происходящем.
Так что тестируйте, изучайте и в своём коде никогда не используйте Thread.sleep();!
1) Однопоточный элементарный сервер.
2) Клиент.
3) Многопоточный сервер – сам по себе этот сервер не участвует в общении напрямую, а лишь является фабрикой однонитевых делегатов(делегированных для ведения диалога с клиентами серверов) для общения с вновь подключившимися клиентами, которые закрываются после окончания общения с клиентом.
4) Имитация множественного обращения клиентов к серверу.
Итак, начнём с изучения структуры однопоточного сервер, который может принять только одного клиента для диалога. Код приводимый ниже необходимо запускать в своей IDE в этом идея всей статьи. Предлагаю все детали уяснить из подробно задокументированного кода ниже:
Сервер запущен и находится в блокирующем ожидании server.accept(); обращения к нему с запросом на подключение. Теперь можно подключаться клиенту, напишем код клиента и запустим его. Клиент работает когда пользователь вводит что-либо в его консоли (внимание! в данном случае сервер и клиент запускаются на одном компьютере с локальным адресом — localhost, поэтому при вводе строк, которые должен отправлять клиент не забудьте убедиться, что вы переключились в рабочую консоль клиента!).
После ввода строки в консоль клиента и нажатия enter строка проверяется не ввёл ли клиент кодовое слово для окончания общения дальше отправляется серверу, где он читает её и то же проверяет на наличие кодового слова выхода. Оба и клиент и сервер получив кодовое слово закрывают ресурсы после предварительных приготовлений и завершают свою работу.
Посмотрим как это выглядит в коде:
А что если к серверу хочет подключиться ещё один клиент!? Ведь описанный выше сервер либо находится в ожидании подключения одного клиента, либо общается с ним до завершения соединения, что делать остальным клиентам? Для такого случая нужно создать фабрику которая будет создавать описанных выше серверов при подключении к сокету новых клиентов и не дожидаясь пока делегированный подсервер закончит диалог с клиентом откроет accept() в ожидании следующего клиента. Но чтобы на серверной машине хватило ресурсов для общения со множеством клиентов нужно ограничить количество возможных подключений. Фабрика будет выдавать немного модифицированный вариант предыдущего сервера(модификация будет касаться того что класс сервера для фабрики будет имплементировать интерфейс — Runnable для возможности его использования в пуле нитей — ExecutorServices). Давайте создадим такую серверную фабрику и ознакомимся с подробным описанием её работы в коде:
Для имитации множественного обращения клиентов к серверу, создадим и запустим (после запуска серверной части) фабрику Runnable клиентов которые будут подключаться серверу и писать сообщения в цикле:
Как видно из предыдущего кода фабрика запускает — TestRunnableClientTester() клиентов, напишем для них код и после этого запустим саму фабрику, чтобы ей было кого исполнять в своём пуле:
Запускайте, вносите изменения в код, только так на самом деле можно понять работу этой структуры.
Вступление
Здесь я должен сделать отступление и немного рассказать о себе, что бы в дальнейшем было понятнее, почему именно такие шаги в разработке я предпринимал.
На данный момент я занимаю должность Технического Артиста в одной игровой студии, мой опыт программирования на C# строился только на написании скриптов и утилит для Unity и в довесок к этому создание плагинов для низкоуровневой работы с андроид девайсами. За пределы этого мирка я ещё не выбирался и тут подвернулась такая возможность.
Часть 1. Прототипирование рамы
Решив, что из себя будет представлять данный сервис, я принялся искать варианты для реализации. Проще всего было бы найти какой то готовое решение, на которое, как сову на глобус, можно натянуть наши механики и выложить всё это дело на общественное порицание.
Но это же не интересно, никакого челенджа и смысла в этом я не видел, а посему начал изучать веб технологии и методы взаимодействия с ними.
Сделав первую попытку с ASP я его сразу же отмёл, на мой взгляд это было слишком тяжёлым решением для нашего сервиса. Мы не будем использовать и трети возможностей этой платформы, поэтому я продолжил поиски. Выбор встал между TCP и Http клиент-сервером. Здесь же, на Хабре, я наткнулся на статью про многопоточный сервер, собрав и протестировав который, я решил остановиться именно на взаимодействии с TCP подключениями, почему то я посчитал, что http не позволит создать мне кроссплатформенное решение.
Первая версия сервера включала в себя обработку подключений, отдавала статическое содержимое веб-страниц и включала в себя базу данных пользователей. И для начала я решил строить функционал для работы с сайтом, что бы в последствии прикрутить сюда и обработку приложения на андроиде и ios.
Основной поток, в бесконечном цикле принимающий клиентов:
Сам обработчик клиентов:
И первая база данных построенная на local SQL:
Как можно заметить, эта версия мало отличается от той, что была в статье. По сути здесь только добавилась подгрузка страниц из папки на компьютере и база данных (которая кстати в данной версии не заработала, из-за неверной архитектуры подключения).
Глава 2. Прикручивание колёс
Протестировав работу сервера, я пришёл к выводу, что это будет отличным решением(спойлер: нет), для нашего сервиса, поэтому проект начал обрастать логикой.
Шаг за шагом начали появляться новые модули и функционал сервера разрастался. Сервер обзавёлся тестовым доменом и ssl шифрованием соединения.
Обновлённый вариант сервера, включающий в себя использование сертификата.
А так же новый обработчик клиента с авторизацией по ssl:
Но так как сервер работает исключительно на TCP подключении, то необходимо создать модуль, который мог распознавать контекст запроса. Я решил что здесь подойдёт парсер который будет разбивать запрос от клиента на отдельные части, с которыми я смогу взаимодействовать, что бы отдавать клиенту нужные ответы.
Суть его заключается в том, что бы при помощи регулярных выражений разбить запрос на части. Получаем сообщение от клиента, выделяем первую строку, в которой содержится метод и url запроса. Затем читаем заголовки, которые загоняем в массив вида ИмяЗаголовка=Содержимое, а так же находим, если имеется, сопроводительный контент (например querystring) который так же загоняем в аналогичный массив. К тому же, парсер выясняет, авторизован ли текущий клиент и сохраняет в себе его данные. Все запросы от авторизованных клиентов содержат хэш авторизации, который хранится в куках, благодаря этому можно разделять дальнейшую логику работы для двух типов клиентов и отдавать им правильные ответы.
Ну и небольшая, приятная фича, которую стоило бы вынести в отдельный модуль, преобразование запросов вида «site.com/@UserName» в динамически генерируемые страницы пользователей. После обработки запроса в дело вступают следующие модули.
Глава 3. Установка руля, смазывание цепи
Как только парсер отработал, в дело вступает обработчик, отдающий дальнейшие указания серверу и разделяющий управление на две части.
По сути здесь всего одна проверка на авторизацию юзера, после чего начинается обработка запроса.
Если юзер не авторизован, то для него функционал базируется только на отображении профилей пользователя и окне регистрации\авторизации. Код для авторизованного пользователя выглядит примерно так же, поэтому не вижу смысла его дублировать.
Ну и конечно же, пользователь должен получать какое то содержимое страниц, поэтому для ответов существует следующий модуль, отвечающий за ответ на запрос ресурсов.
Но что бы показывать пользователю его профиль и профили других пользователей я решил использовать RazorEngine, вернее его часть. Он так же включает в себя обработку неверных запросов и выдачу соответствующего кода ошибки.
Ну и конечно же, для того, что бы работала проверка авторизованных пользователей, нужна авторизация. Модуль авторизации взаимодействует с базой данных. Полученные данные из форм на сайте парсятся из контекста, юзер сохраняется и получает взамен куки и доступ к сервису.
А так выглядит обработка базы данных:
И всё работает как часы, авторизация и регистрация работает, минимальный функционал доступа к сервису уже имеется и пришла пора писать приложение и обвязывать всё это дело основными функциями, ради которых всё и делается.
Глава 4. Выбрасывание велосипеда
Что бы сократить трудозатраты на написание двух приложений под две платформы, я решил сделать кроссплатформу на Xamarin.Forms. Опять же, благодаря тому, что она на C#. Сделав тестовое приложение, которое просто отсылает серверу данные, я столкнулся с одним интересным моментом. Для запроса от устройства я для интереса реализовал его на HttpClient и кинул на сервер HttpRequestMessage в котором содержатся данные из формы авторизации в формате json. Особо ничего не ожидая, открыл лог сервера и увидел там реквест с девайса со всеми данными. Лёгкий ступор, осознание всего, что было проделано за последние 3 недели томных вечером. Для проверки верности отправленных данных собрал тестовый сервер на HttpListner. Получив очередной запрос уже на нём, я за пару строк кода разобрал его на части, получил KeyValuePair данных из формы. Разбор запроса уменьшился до двух строк.
Начал тестировать дальше, ранее не упоминалось, но на прежнем сервере я ещё реализовывал чат построенный на вебсокетах. Он довольно неплохо работал, но сам принцип взаимодействия через Tcp был удручающим, слишком много лишнего приходилось плодить, что бы грамотно построить взаимодействие двух пользователей с ведением лога переписки. Это и парсинг запроса на предмет переключения соединения и сбор ответа по протоколу RFC 6455. Поэтому в тестовом сервере я решил создать простое вебсокет соединение. Чисто ради интереса.
И оно заработало. Сервер сам настраивал соединение, генерировал ответный ключ. Мне даже не пришлось отдельно настраивать регистрацию сервера по ssl, достаточно того, что в системе уже установлен сертификат на нужном порту.
На стороне девайса и на стороне сайта два клиента обменивались сообщениями, всё это логировалось. Никаких огромных парсеров, замедляющих работу сервера, ничего этого не требовалось. Время отклика сократилось с 200мс до 40-30мс. И я пришёл к единственному верному решению.
Выкинуть текущую реализацию сервера на Tcp и переписать всё под Http. Теперь же проект находится в стадии перепроектирования, но уже по совсем другим принципам взаимодействия. Работа устройств и сайта синхронизирована и отлажена и имеет общую концепцию, с тем лишь отличием, что для девайсов не нужно генерировать html страницы.
Простой клиент-сервер на Android (интернет-мессенджер)
Важно. Все написанное ниже не представляет собой какой либо ценности для профессионалов, но может служит полезным примером для начинающих Android разработчиков! В коде старался все действия комментировать и логировать.
Поехали. Многие мобильные приложения (и не только) используют архитектуру клиент-сервер. Общая схема, думаю, понятна.
Уделим внимание каждому элементу и отметим:
Клиент, установленный на устройстве А, посылает сообщение для клиента, установленного на устройстве Б. И наоборот. Сервер играет роль связующего звена между устройством А и Б… С, Д… и т.д. Также он играет роль «накопителя» сообщений, для их восстановления, на случай удаления на одном из клиентских устройств.
Для хранения сообщений используем SQL БД как на сервере, так и на устройствах-клиентах (в принципе, вся работа клиентов интернет-мессенджеров и сводится к постоянной синхронизации локальной и удаленной БД с сообщениями). Дополнительно, наш интернет-чат будет уметь стартовать вместе с запуском устройства и работать в фоне. Взаимодействие будет происходить путем HTTP запросов и JSON ответов.
Более логично, если синхронизация происходит через порт/сокет, это с одной стороны упрощает задачу (не нужно циклично слать HTTP запросы на проверку новых сообщений, достаточно проверять состояние прослушиваемого сокета), но с другой стороны, это усложняет создание серверной части приложения.
Делаем сервер
Для реализации «сервера», нам нужно зарегистрироваться на любом хостинге, который дает возможность работы с SQL и PHP.
Создаем пустую SQL БД, в ней создаем таблицу.
Структура запросов к api:
Клиентская часть
Теперь структура Android приложения:
В фоне работает FoneService.java, который, в отдельном потоке, каждые 15 секунд делает запрос на сервер. Если ответ сервера содержит новые сообщения, FoneService.java записывает их в локальную БД и отправляет сообщение ChatActivity.java о необходимости обновить ListView, с сообщениями. ChatActivity.java (если она в этот момент открыта) получает сообщение и обновляет содержимое ListView из локальной БД.
Отправка нового сообщения из ChatActivity.java происходит сразу на сервер, минуя FoneService.java. При этом наше сообщение НЕ записывается в локальную БД! Там оно появится только после получения его назад в виде ответа сервера. Такую реализацию я использовал в связи с важным нюансом работы любого интернет-чата — обязательной группировкой сообщений по времени. Если не использовать группировку по времени, будет нарушена последовательность сообщений. Учитывая, что клиентские приложения просто физически не могут быть синхронизированы с точностью до миллисекунд, а возможно будут работать даже в разных часовых поясах, логичнее всего будет использовать время сервера. Так мы и делаем.
Создавая новое сообщение, мы передаем запросом на сервер: имя автора сообщения, имя получателя сообщения, текст сообщения. Получая эту запись назад, в виде ответа сервера, мы получаем то, что отправляли + четвертый параметр: время получения сообщения сервером.
Простое клиент-серверное приложение для Android с нуля
В этом уроке мы напишем небольшое, но полноценное и самодостаточное приложение, не потратив при этом ни копейки. Приложение представляет собой каталог обоев (не настенных, а фонов для рабочего стола) и состоит из трёх экранов:
Урок состоит из трёх частей:
Если вам интересно только клиентское приложение, первые две части можно безболезненно пропустить.
Цель урока — объяснить в общих чертах принципы работы клиент-серверных приложений. Если вы можете установить и настроить IDE, немного знаете Java и\или Kotlin и можете самостоятельно написать простое приложение, этот урок для вас. Если же нет, боюсь, будет сложно. Объяснять постараюсь максимально подробно и понятно, но без фанатизма. Все исходники доступны на github.
Ретроспектива
Я перечитал данную статью через 10 месяцев после её публикации. За это время я многому научился и сейчас сделал бы всё по-другому. Но, пусть всё остаётся так, как написано изначально.
Данные
В нашем приложении данные, это изображения. Нужно создать или найти хотя бы пару десятков изображений. Вы можете найти, например, фотографии котят в поиске изображений от гугла или яндекса или выбрать лучшие фото из личного фотоархива. Я собрал фотографии с сайта nasa, их разрешено использовать для некоммерческих и образовательных целей. Для простоты можно взять мой набор. Важно, чтобы имена файлов были на латинице, так как кириллица может вызвать проблемы со стороны сервера. А может и не вызвать, но лучше подстраховаться. Нужно разложить изображения по папкам и положить их в общую папку под именем images. Важно, чтобы был только один уровень вложенности:
Каждая папка представляет собой условную категорию. Количество категорий и количество фото в категории значения не имеют. Позже нам потребуются уменьшенные версии всех изображений — точно такая же папка под названием images_previews с теми же изображениями, но в уменьшенном виде. Нужны они для быстрой загрузки списков (первые два экрана на скриншоте с экранами выше), то есть для экономии трафика и времени загрузки.
Напишем небольшую программу для уменьшения изображений. Будем использовать IntelliJ IDEA Community Edition. Если не установлен JDK, устанавливаем. Создадим общую папку для проекта и назовём её Wallpapers, создадим в ней ещё три папки: Client, Server и Resizer. Запускаем IDEA и создаём Gradle Java проект:
Нажимаем Next и заполняем оставшиеся данные, примерно, как на следующих скриншотах:
В качестве Project location указываем ранее созданную папку Wallpapers/Resizer:
Открываем файл build.gradle и приводим его к следующему виду:
В dependencies подключаются три библиотеки: junit jupiter api для тестирования, junit jupiter engine для запуска тестов и imgscalr для ресайза изображений.
Создаём в main/java/com.illuzor.lesson.resizer класс Paths в котором инициализируем статические константы для путей к папкам:
Создаём класс Resizer с одним методом resize():
Выглядит громоздко, но всё довольно просто. Первый цикл проходит по папкам в images и создаёт папки с такими же именами в images_previes, второй аналогично проходит по файлам категории и создаёт соответствующие уменьшенные файлы изображений. Для этого берётся ширина и высота оригинального изображения, а затем меньшая сторона изображения приравнивается к 400 (константа MIN_SIZE), а бóльшая пропорционально соответствующей стороне оригинального изображения. Или обе стороны приравниваются к 400, если изображение квадратное. Самое интересное происходит в этих строках:
В первой строке создаётся объект уменьшенного изображения, а во второй — записывается в файл.
Для запуска кода создаём класс Main:
Здесь создаём объект класса Resizer и вызываем его метод resize().
В структуре проекта появится папка images_previews. Если нужно добавить новую категорию или изображения в существующие категории — просто добавляем их в папку images и запускаем код ещё раз. Обработаны будут только новые файлы. Для удаления файла или категории удалять нужно из обеих папок.
Теперь напишем тесты для проверки результатов работы программы. Добавляем новый класс ImagesTests в test/java/com.illuzor.lesson.resizer:
Класс содержит два метода. testFilesExistence () проверяет, что для каждого файла в images есть соответствующий файл в images_previews. Метод testPreviewsSizes() проверяет размеры изображений. Размер меньшей стороны должен быть 399 или 400, а большей — больше или равен 399. Откуда взялось число 399, если мы задавали 400? Где-то в библиотеке imgscalr иногда получается погрешность и минимальный размер становится равным 399. Для нас это не критично.
С данными всё, переходим к серверу.
Сервер
Данные нужно где-то хранить и как-то отдавать их клиентскому приложению. Для этого нужен серверный код и хостинг. Вариантов с хостингом множество: можно использовать платный PHP хостинг, настроить VPS/VDS или собственный домашний сервер. Мы пойдёт самым простым путём и воспользуемся бесплатным PHP хостингом. Стоит понимать, что бесплатный хостинг всегда имеет различные ограничения и не гарантирует бесперебойную работу.
Напишем серверный код на PHP. Можно воспользоваться любым текстовым редактором. Я для небольших проектов предпочитаю Atom или Notepad++. Код очень простой и короткий, поэтому подойдёт даже стандартный блокнот.
В папке Wallpapers/Server создаём папку src, а в ней файл categories.php:
Это список категорий, у каждой из которых есть имя (имя соответствующей папки) и preview — относительный путь до уменьшенной версии файла изображения.
Создаём второй файл под именем gallery.php:
В переменную $gallery записывается GET параметр gallery, в массив $images добавляются имена всех файлов категории и возвращаются в виде json массива. Если категория не существуют или параметр gallery отсутствует, возвращается сообщение об ошибке. Результатом будет json массив со строками:
Это весь серверный код. PHP — интерпретируемый язык, компилировать код не нужно, но запустить его просто так не получится, для этого нужен сервер с интерпретатором.
Также создадим файл robots.txt, это специальный файл с правилами для индексации поисковиками. Нам индексация не нужна, поэтому запретим её для всех поисковиков:
Теперь разберёмся с хостингом. Я рассмотрю бесплатный PHP хостинг 000webhost. Работа с любым другим хостингом будет схожей. Переходим на страницу регистрации 000webhost.com/free-website-sign-up. Вводим email, пароль и имя сайта, которое станет частью домена третьего уровня вида your_domain.000webhost.com:
Жмём Get Free Hosting и перемещаемся в личный кабинет, предварительно закрыв пару рекламных баннеров. Ждём прихода на email письма с подтверждением регистрации и подтверждаем регистрацию. Открываем страницу со списком сайтов и видим, что наш сайт уже запущен:
В верхнем меню нажимаем Settings/General и попадаем на страницу настроек. Отключаем Sendmail и отображение ошибок:
Убеждаемся, что включен доступ по FTP:
Нужно загрузить PHP код и файлы изображений на сервер. Для этого можно воспользоваться любым FTP клиентом, например FileZilla. Запускаем FTP клиент и вводим данные из FTP details:
Нажимаем кнопку Quickconnect. После подключения в правой части появятся серверные директории. Открываем папку public_html и перетаскиваем из левой части (локальная файловая система) три файла из Wallpapers/Server/src и наши папки images и images_previews. Загрузка может занять некоторое время. Структура файлов на сервере после загрузки:
Проверить работу скриптов можно через браузер. Для этого нужно к адресу сайта дописать /categories.php или /gallery.php. Адрес сайта можно найти на странице со списком сайтов. Я буду использовать свой адрес, а вы подменяйте его на свой.
При каждом обновлении страницы значения preview будут разными.
Нужно добавить GET параметр. Обязательно, чтобы его значение было именем одной из категорий (названием одной из папок). Открываем wallpapers.illuzor.com/gallery.php?gallery=Earth и видим список имён файлов соответствующей категории:
Теперь у нас есть сервер, который отвечает на запросы и отдаёт данные в удобном виде.
Небольшое отступление. Клиент не может и не должен знать, на чём написан сервер и как он работает. Тот же самый сервер можно было бы написать на любом другом языке: C++, Java, Kotlin, Ruby, Python… Всё, что должен знать клиент: на какие адреса отправлять запросы, как эти запросы должны выглядеть и в каком виде приходят ответы. Также можно написать клиентское приложение для IOS, Windows/Linux/MacOS или Web с тем же самым сервером без каких-либо изменений.
Клиент
Самая большая и сложная часть — клиентское приложение для Android. Урок разбит на 7 частей, для каждой из которых есть коммит в репозитории. Каждый коммит можно изучить на github или локально в склонированном репозитории. Это экспериментальный формат, не знаю, насколько удачный. Для работы понадобится Android Studio версии 3.2 или новее.
01) Создание и настройка проекта
Запускаем Android Studio и создаём новый проект. В качестве Project location указываем папку Wallpapers/Client и убеждаемся, что включена поддержка Kotlin:
Жмём Finish и ждём сборки gradle.
Возьмём прицел на будущее и перейдём на androidx. Возможно, следующие версии Android Studio будут сразу создавать проекты с androidx. Если видите в файле build.gradle в dependencies пакет androidx.appcompat:appcompat, пропустите этот шаг. Если же там com.android.support:appcompat-v7, выбираем в верхнем меню Refactor/ Migrate to AndroidX. Появится панель Refactoring Preview:
Жмём Do Refactor и ждём.
Открываем build.gradle уровня проекта (build.gradle Project: Client), добавляем список версий библиотек и репозиторий jitpack:
В build.gradle уровня модуля приложения (build.gradle Module: app) добавляем kotlin-kapt плагин и нужные библиотеки в dependencies:
Список сторонних библиотек в проекте:
В файле gradle-wrapper.properties обновляем версию gradle до 4.10.1:
Сверху выскочит напоминание о необходимости синхронизации проекта. Нажимаем Sync Now и ждём.
02) Ресурсы
Добавим необходимые ресурсы.
В структуре проекта находим файл res/values/strings.xml и добавляем строки:
Строку app_name оставляем на месте. Открываем файл res/values/colors.xml и добавляем один новый цвет blackTextColor:
В файле res/values/styles.xml меняем тему с Theme.AppCompat.Light.DarkActionBar на Theme.AppCompat.Light.NoActionBar:
В поиск вводим «error» и выбираем иконку error:
Нажимаем Ok, меняем имя на ic_error:
Нажимаем Next и Finish. То же самое повторяем с ic_refresh, ic_save и ic_share. Добавляем в drawable файл placeholder.png, он будет индикатором для незагруженных изображений. В итоге каталог drawable содержит 4 иконки и одно png изображение (вдобавок к двум уже существующим файлам):
Настроим заодно манифест (app/manifest/AndroidManifest.xml). Нужно добавить три разрешения (интернет, запись во внешнее хранилище и установка обоев):
В application меняем значение параметра android:allowBackup с true на false. Итоговый вид манифеста:
03) Базовые и вспомогательные классы
Создадим заранее все пакеты:
Это класс для Glide Generated API, нам оно нужно для возможности использовать плейсхолдер.
В пакете extensions создаём файл (не класс) toast.kt:
Это два расширения для Activity и Fragment для упрощённого показа тоста.
В тот же пакет добавляем файл async.kt:
Это файл содержит две функции: runInBackground() для запуска кода в фоновом потоке и runInMainThread() для запуска кода в UI потоке. В каждую из функций нужно передать лямбду без параметров и без возвращаемого значения.
В пакете model создаём класс Category:
Это data класс для представления категории (список которых возвращает categories.php), то есть pojo класс.
Для Retrofit нужно создать интерфейс с описанием запросов к серверу. В пакете api создаём интерфейс WallpapersApi:
Интерфейс содержит два метода: categories () делает запрос на адрес categories.php и возвращает Call со списком категорий (pojo объектов). Второй метод gallery() запрашивает адрес gallery.php и требует аргумент galleryName, который будет передан в качестве get параметра, а возвращает Call со списком строк — имён файлов выбранной галереи.
В этом же пакете создаём файл api.kt:
Строковая константа BASE_URL содержит базовый адрес сервера. Замените адрес на свой (или не заменяйте и оставьте мой). Переменная api — объект WallpapersApi, через который будут осуществляться запросы к серверу. Обратите внимание на строку .addConverterFactory(GsonConverterFactory.create()). Здесь билдеру передаётся GsonConverterFactory. Это один из конвертеров для retrofit, который под капотом будет превращать json строки в Java объекты. Чтобы не воспринимать это, как магию, будет полезно посмотреть, как работает Gson сам по себе.
В пакет ui добавляем класс SquareConstraintLayout:
Это класс расширяет ConstraintLayout и переопределяет метод onMeasure. В super.onMeasure() в качество обоих параметров передаётся width, таким образом ширина всегда будет равна высоте, то есть лайаут будет квадратным. Нам он нужен для элементов списков с превью.
Макет состоит из кругового прогресс-бара и TextView для отображения текста сообщения.
В пакет ui добавляем класс ProgressDialog:
Свойство title задаётся извне, в методе onCreateDialog() выводится в tv_title и затем метод возвращает диалог.
Теперь перетаскиваем класс MainActivity в пакет screens и переименовываем(Shift+F6) в AbstractActivity. Этот класс будет базовым для всех(трёх) наших активити. Класс должен быть абстрактным. Для этого добавляем ключевое слово abstract перед определением класс (перед ключевым словом class). Рассмотрим код:
Абстрактное поле layoutId — id макета для активити. Абстрактный метод getFragment() возвращает фрагмент, который будет отображён в активити. В переопределённом методе onCreate() задаётся contentView. Далее проверяется, есть ли фрагмент во fragmenManager:
Если фрагмент не найден(равен null), получаем его через метод getFragment и добавляется во fragmentManager:
Макетов для активити пока что нет, поэтому идентификатор R.id.container будет подсвечен линтом, это не страшно, позже мы добавим для всех активити макеты, в каждом из которых будет container.
Через метод setToolbar() настраивается тулбар и включается кнопка up (стрелка на тулбаре).
В переопределённом методе onOptionsItemSelected() проверяется — если нажата кнопка up, завершаем активити.
Осталось удалить AbstractActivity из манифеста:
Теперь манифест выглядит так:
На этом с подготовкой всё. Можно переходить к созданию экранов приложения.
04) Экран категорий
Экран категорий представляет собой список категорий в две колонки. Каждый элемент списка состоит из изображения и текста с названием категории. Что нужно для для реализации такой функциональности? Макеты для активити и фрагмента, классы для активити и фрагмента, RecyclerView для отображения списка и адаптер для него, а также макет для элемента списка. Данные нужно загрузить с сервера и в виде списка категорий (List ) передать в адаптер для RecyclerView. Для инкапсуляции загрузки данных через Retrofit будет использоваться библиотека ViewModel, кроме этого она поможет легко пережить смену конфигурации, например поворот экрана. Для загрузки изображений воспользуемся библиотекой Glide.
Начнём с вёрстки макетов. Добавляем в res/layout файл activity_fragment.xml:
Это макет для активити. Он содержит только FrameLayout, который будет контейнером для фрагмента.
Добавляем ещё один файл fragment_list.xml:
Добавляем последний макет item_category.xml:
Это макет для элемента списка (RecyclerView). Обратите внимание на корневой элемент, это SqureConstraintLayout, класс которого мы создавали ранее. Макет содержит: ImageView (iv_preview) для превью изображения, View — подложка для текста и TextView(tv_title) для отображения названия категории.
Теперь к коду. Займёмся адаптером. В первую очередь для адаптера нужен ViewHolder. Создаём в пакете adapters новый класс CategoryViewHolder:
Теперь напишем класс адаптера, но так как все адаптеры в нашем приложении (все два) во многом идентичны, напишем сначала абстрактный класс, который будет содержать общую функциональность.
Создаём новый класс AbstractAdapter:
Создаём класс CategoriesAdapter:
Теперь подумаем над загрузкой данных. С сервера нужно загрузить список категорий, для этого у нас есть объект WallpapersApi — переменная api в файле api.kt. Применим библиотеку ViewModel. Саму загрузку мы инкапсулируем в отдельном классе ViewModelCategories, расширяющем androidx.lifecycle.ViewModel. Объект такого класса можно получить через androidx.lifecycle.ViewModelProviders примерно таким кодом:
Этот объект переживёт смену конфигурации, это значит, что состояние полностью сохранится. В нашем случае не потеряется прогресс загрузки и данные. Кроме того не придётся вручную перезапускать загрузку и сохранять данные, например через savedInstanceState. Объект погибнет вместе с активити или фрагментом.
Создадим в пакете model базовый класс ViewModelBase:
В первую очередь объявляется enum State. Это список возможных состояний:
Для отражения текущего состояния есть свойство state с protected сеттером. Это значит, что получить переменную можно извне, но изменить только из текущего или унаследованного класса. Поле loadListener — слушатель загрузки, лямбда без параметров, которая ничего не возвращает. А также метод setListener() для установки этого слушателя.
Теперь создаём класс ViewModelCategories, унаследованный от ViewModelBase:
Осталось создать только фрагмент и активити. Сначала создадим абстрактный фрагмент, который упростит отображение прогресса. Создаём в пакете screens класс AbstractFragment:
Поле contentView у разных фрагментов будет разным, его нужно будет задать вручную для каждого унаследованного фрагмента. Абстрактное поле layoutId, которое нужно будет переопределить так же, как в AbstractActivity. В переопределённом методе onCreateView() разворачивается и возвращается View фрагмента. Три метода: showProgress() и showContent() скрывают и показывают нужные view, а метод showError, кроме этого устанавливает текст ошибки и обрабатывает клик по кнопке Retry — вызывает переданную лямбду.
Теперь реализация фрагмента для категорий. Создаём новый класс CategoriesFragment, унаследованный от AbstractFragment():
Здесь переопределяется поле layoutId, и есть ещё два поля: adapter(CategoriesAdapter), который мы реализовали ранее и model(ViewModelCategories), который у нас тоже есть. В методе onCreate() получаем объект класса ViewModelCategories:
В методе onActivityCreated задаём contentView, назначаем адаптеру layoutManager и adapter и вызывает метод checkData(), который пуст. Нужно его реализовать:
В этом методе мы проверяем, в каком состоянии находится model. Если он только что создан (CREATED), показываем индикатор загрузки — showProgress(), вешаем на model слушатель, в котором вызывается сам метод checkData() и запускаем загрузку вызовом model.load(). Если загрузка в процессе (PROGRESS), показываем индикатор загрузки и вешаем слушатель. Если произошла ошибка (ERROR), показываем ошибку со слушателем, который делает то же самое, что и при CREATED. И самое главное, если данные загружены(LOADED), отдаём их в адаптер отсортированными по имени, показываем контент (RecyclerView) и вешаем слушатель на адаптер. Он, пока что останется пустым.
В самом начале метода проверяем: если view равно null, завершаем метод. Это нужно для избежания одной ошибки. Так как model переживает смену конфигурации, может случиться так, что загрузка может завершиться между уничтожением старого Activity, но перед созданием нового. Тогда сработает слушатель и случится NullPointerException, так как никакие View ещё не существуют, а мы к ним обращаемся через методы showContent()/showProgress()/showError(). На современных устройствах эта ошибка маловероятна, но на старых с медленным интернетом она вполне реальна.
Нажимаем Finish. Активити автоматически пропишется в манифесте и будет стартовой. Открываем созданный класс CategoriesActivity, наследуем его от AbstractActivity вместо AppCompatActivity и удаляем метод onCreate. Всё, что нужно сделать — переопределить свойство layoutId и метод getFragment():
Можно запустить(Shift+F10) приложение на устройстве или на эмуляторе и посмотреть, что получилось. На экране появятся две колонки белых превью, которые быстро заменяются загруженными изображениями. При каждом запуске превью будут разными. Список можно прокрутить, но нажатие на элементы, пока что, не обрабатываются. Чтобы увидеть сообщение об ошибке, нужно выключить wifi и мобильный интернет и перезапустить приложение. Если теперь включить интернет и нажать на кнопку Retry, всё заработает, как и должно. Если повернуть телефон во время загрузки, она не прервётся, благодаря ViewModel. Если перевернуть телефон, когда данные уже загружены, загрузка не начнётся заново, так как данные остаются во ViewModel.
05) Экран категории
На этом экране будет отображаться список превью изображений из выбранной категории. Для его создания нужно примерно то же самое, что для экрана галереи — классы для активити и фрагмента, класс адаптера, класс ViewModel. Будет немного попроще, так как все базовые классы у нас уже есть. Макет для фрагмента будет использовать тот же самый, а для активити и элемента списка нужны новые.
Создаём макет для активити с именем activity_with_toolbar.xml:
От макета activity_fragment.xml он отличается только наличием тулбара.
И ещё один макет item_gallery.xml для элемента списка:
Здесь всё то же самое, что в item_category.xml, но без TextView, то есть SquareConstraintLayout с ImageView для превью.
Переходим к адаптеру. Создаём в пакете adapters ViewHolder класс под названием GalleryViewHolder:
Код класса полностью аналогичен коду CategoryViewHolder, за исключением отсутствия поля tvTitle и метода setTitle(). Дубликаты кода, это плохо. Можно унаследовать CategoryViewHolder от GalleryViewHolder и удалить дублирующийся код. Можете сделать это самостоятельно.
Создаём в пакете adapters класс GalleryAdapter:
Создаём ViewModel — класс ViewModelGallery в пакете model:
В пакете screen создаём класс GalleryFragment:
Основное отличие этого класса от CategoriesFragment в том, что появилось поле category — это строковая переменная, которая берётся из аргументов. О том, откуда она взялась скоро узнаем. Эта переменная передаётся параметром в model.load().
Осталось создать активити:
Открываем код класса GalleryActivity и меняем его на следующий:
В метод setToolbar() передаётся id тулбара и строка с id «category», которую берётся из интента. В переопределённом методе getFragment () создаём переменную Bundle и добавляем туда ту же строку «category», создаём фрагмент, устанавливаем наш Bundle в качестве arguments фрагмента и возвращаем фрагмент. Откуда в интенте взялась строка «category»? Открываем класс CategoriesFragment и дописываем код в методе checkData() в блоке LOADED:
Весь код обновлённого класса CategoriesFragment.
Экран галереи готов. Можно запускать приложение.
06) Экран изображения. Часть первая: загрузка файла и создание меню
Это последний экран, на котором будет отображено полноразмерное изображение с возможностью увеличения и уменьшения жестами. В тулбаре будут три кнопки: установить обои, поделиться изображением, сохранить изображение в галерею. Для реализации нам понадобится загрузить файл полноразмерного изображения с сервера на устройство. Этот файл можно будет легко загрузить в ImageView (точнее в PhotoView, который расширяет ImageView) через Glide, его можно будет установить на обои, поделиться им и сохранить в галерею.
Начнём с вёрстки. Создаём файл fragment_wallpaper.xml:
Просто скопируйте код из fragment_list.xml и замените RecyclerView на PhotoView.
Открываем интерфейс WallpapersApi из пакета api и добавляем метод downloadFile():
Метод принимает строковый параметр fileUrl с аннотацией @ Url. Этот параметр будет преобразован в относительный путь. Метод возвращает Call с ResponseBody, из которого можно будет достать InputStream с байтами файла.
Создаём в пакете model класс ViewModelWallpaper:
и записываем его в новый FileOutputStream с файлом:
Метод copyTo — это расширение из стандартной библиотеки Kotlin. Когда сохранение файла завершится, вызываем функцию runInMainThread(), которая выполнит код в UI потоке. В её лямбде присваиваем свойству state значение LOADED и вызываем слушатель.
Также есть переопределённый метод onCleared(). Он будет вызван автоматически, когда погибнет фрагмент, к которому привязан ViewModel. Если загрузка файла началась, но ещё не закончилась, то есть файл загружен частично, этот файл будет удалён, так как частично загруженный файл нам не нужен.
Создадим в пакете screens класс WallpaperFragment и унаследуем его от AbstractFragment:
Пусть, пока что, будет пустым.
Создадим в том же пакете новую активити:
Тут всё аналогично GalleryActivity, кроме того, что в Bundle добавляются две строки — «filename» и «category».
Открываем класс GalleryFragment и в методе checkData() дописываем код в блоке LOADED:
Займёмся меню для WallpaperFragment. Для этого создаём директорию menu в res и добавляем туда файл menu_wallpaper.xml:
Меню содержит три пункта: для установки обоев, для шаринга изображения и для сохранения изображения в галерею.
Открываем класс WallpaperFragment. Дальше работать будет только с ним. Надо подумать над загрузкой и хранением файлов изображений. Мы будем загружать файлы в директорию кэша (context.cacheDir), а точнее, в отдельную поддиректорию с именем wallpapers. Файлы на устройстве занимают довольно много места, поэтому надо придумать простой кэш. Сделаем так, чтобы после накопления некоторого количества файлов, все они удалялись из директории wallpapers. Приводим класс WallpaperFragment к следующему виду:
В начале объявляется несколько полей. loaded для понимания, загружен файл или нет. relativeUrl — относительный путь для загрузки файла изображения. model — собственно ViewModelWallpaper. imageFile — файл изображения. CACHE_SIZE — размер кэша(количество файлов).
В методе onCreate() включаем меню через setHasOptionsMenu(), получаем объект ViewModelWallpaper, достаём filename и category из аргументов, инициализируем relativeUrl и передаём в метод createFile() имя файла, состоящее из категории и имени файла с сервера.
В методе createFile() инициализируем файл и проверяем, существует ли он. Если существует, просто прерываем метод через return. Проверяем существование каталога wallpapers и равно ли количество файлов в нём CACHE_SIZE. Если да, удаляем все файлы, если нет, создаём каталог wallpapers.
Добавляем ещё три метода:
В методе loadFileToImageView() происходит загрузка изображения из файла в pv_wallpaper, то есть в PhotoView. Вначале показываем контент через showContent(), затем грузим изображение и в конце присваиваем переменной loaded значение true. В onActivityCreated() определяем contentView, затем проверяем — если model.state не равен PROGRESS и файл существует, загружаем файл, иначе вызываем метод checkData (), который в целом аналогичен методам в двух других фрагментах.
Добавим меню. Ещё несколько методов:
Ссылка на весь код класса.
Запускаем приложение и смотрим, что получилось.
07) Экран изображения. Часть вторая: сохранить, поделиться, установить на обои.
Начнём с самого простого — с сохранения изображения в галерею, то есть в каталог Pictures на устройстве. Для этого сначала нужно запросить разрешение на запись (WRITE_EXTERNAL_STORAGE) для Android M(23) и старше.
В начале класса объявляем новое поле REQUEST_STORAGE_PERMISSION_CODE:
Это код для запроса разрешения.
Реализуем метод checkPermissionForImageSave() и добавляем два новых:
В методе checkPermissionForImageSave() проверяем, если версия ниже, чем M(23) или разрешение WRITE_EXTERNAL_STORAGE подтверждено, вызываем saveImageToGallery (), иначе запрашиваем разрешение WRITE_EXTERNAL_STORAGE. В onRequestPermissionsResult() проверяем, дал ли пользователь разрешение. Если да, сохраняем изображение, иначе показываем тост с сообщением о необходимости дать разрешение. И в методе saveImageToGallery () сохраняем изображение. Сначала показываем ProgressDialog, затем в фоновом потоке сохраняем изображение через MediaStore. Когда сохранение завершится, закрываем диалог и выводим тост об удачном сохранении в ui потоке
Можно запустить и проверить. После сохранения изображение можно будет увидеть в приложении галереи.
Ссылка на весь код класса.
Для того, чтобы поделиться изображением и установить его на обои, нужен провайдер для доступа других приложений к файлам из кэша. В res создаём каталог xml и добавляем в него файл files.xml:
Здесь прописан путь к папке wallpapers в директории кэша (cache-path).
В манифесте прописываем провайдер в ноде application:
Ссылка на весь код манифеста.
Возвращаемся в класс WallpaperFragment и добавляем несколько полей класса:
SHARING_REQUEST_CODE и SETTING_REQUEST_CODE — коды для запуска соответствующих активити. Строка PROVIDER_AUTHORITY нужна для идентификации провайдера и должна совпадать с android:authorities из провайдера в манифесте. imageFileUri — Uri для файла imageFile, переменная объявлена, как ленивая, так как она в большинстве случаев не понадобится.
Реализуем метод shareWallpaper():
Добавляем метод onActivityResult:
resultCode нам не интересен. Важно только одно — если requestCode равен SHARING_REQUEST_CODE или SETTING_REQUEST_CODE, отменяем разрешение на доступ к Uri.
Ссылка на весь код класса.
Можно снова запустить приложение и проверить.
Самое последнее, что осталось — реализовать метод setWallpaper():
Показываем диалог, в фоновом потоке устанавливаем обои через WallpaperManager, в ui потоке закрываем диалог и показываем тост с сообщением об удачной установке. Можно запустить и проверить, как это работает.
Вроде, всё готово, но нет. Такой способ не очень хорош, лучше оставить его на крайний случай, а установку обоев делегировать через неявный интент кому-нибудь, кто справится с этим лучше. Меняем код метода setWallpaper():
Объявляем интент на установку обоев, проверяем, есть ли в системе активити, которые этот интент могут обработать. Если нет, запускаем код из прошлой версии метода setWallpaper(). Если же такие активити есть, донастраиваем интент, раздаём всем активити разрешение на доступ к Uri и в конце запускаем активити через startActivityForResult().
Ссылка на весь код класса.
Готово. Запускаем приложение и смотрим на результат.
Задания
Наше приложение готово и работает, как задумывалось, но показывать его миру в таком виде было бы стыдно. Нужны доработки. Предлагаю несколько заданий для самостоятельной работы.
Кэширование на сервере
Постоянный проход по папкам с файлами — довольно дорогая операция, особенно если файлов будет много и у приложения будет большое количество пользователей. Один из вариантов решения этой проблемы — кэширование. Можно записывать json данные в файл и отдавать его содержимое и, например, раз в сутки файл обновлять. То есть, если файл не существует или существует, но старше суток, проходим по папкам, записываем данные в файл и возвращаем их, иначе просто возвращаем содержимое файла. Но есть одна проблема: если с сервера будут удалены какие-то файлы, а клиенту уйдут закэшированные данные, получится, что клиент не сможет загрузить несуществующие файлы. В этом случае вместе с удалением файлов изображений с сервера нужно вручную удалять закэшированные файлы.
Кэширование в приложении
Сейчас в приложении после накопления определённого количество файлов изображений все эти файлы удаляются. Предлагаю доработать эту систему так, чтобы удалялись не все файлы, а только один, созданный раньше других.
Внешний вид приложения
Мы не обращали внимания на внешний вид приложения и выглядит оно не очень привлекательно. Стоит поработать над темой и добавить анимаций. Тут всё ограничивается только вашей фантазией. Полезная документация: стили и темы, анимации, адаптивная иконка.
Адаптация для горизонтального режима и планшетов
Если повернуть телефон горизонтально, мы увидим следующее:
Выглядит не очень хорошо. Было бы лучше отобразить список экрана категорий в виде трёх колонок, а список экрана галереи — в виде двух. На планшетах всё ещё хуже. Исправить эту проблему можно с помощью GridLayoutManager для RecyclerView. Для определения, когда сколько колонок отображать есть, как минимум, два способа:
Перелистывание обоев
При открытом WallpaperActivity было бы удобно листать следующие/предыдущие изображение свайпами влево/вправо. Сделать это можно с помощью ViewPager. Есть один не очевидный баг при использовании PhotoView совместно с ViewPager. Если столкнётесь с ним, постарайтесь исправить самостоятельно, если не получится, свяжитесь со мной, я подскажу.
Заключение
И напоследок небольшая просьба. Вы можете без ограничений использоваться мои ссылки с доменом wallpapers.illuzor.com для обучения, но я прошу не публиковать приложения с этими ссылками.
На этом всё, спасибо за внимание и интерес к материалу.