Простое клиент-серверное приложение для Android с нуля

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

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

 

Урок состоит из трёх частей:

  • Данные: сбор коллекции изображений и вспомогательная программа на Java;
  • Сервер: хостинг и серверный код на PHP;
  • Клиент: приложения для Android на Kotlin;
  • Дополнительно: Задания для самостоятельной работы.

Если вам интересно только клиентское приложение, первые две части можно безболезненно пропустить.

Цель урока — объяснить в общих чертах принципы работы клиент-серверных приложений. Если вы можете установить и настроить 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:

Переместим ранее созданную папку images в корень проекта. Создадим пакеты для классов: в структуре проекта правой кнопкой по src/main/java -> New/Package. Вводим имя пакета, в моём случае com.illuzor.lesson.resizer. Можете ввести то же или своё. Повторяем то же самое с src/test/java. Обе папки resources можно удалить, они нам не нужны. После всех манипуляций структура проекта получается следующей:

Открываем файл 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().

Запускаем код: в структуре проекта правой кнопкой мыши по Main -> Run и смотрим за прогрессом в панели Run. Когда увидим сообщение «Finished», работа программы окончена.

В структуре проекта появится папка images_previews. Если нужно добавить новую категорию или изображения в существующие категории — просто добавляем их в папку images и запускаем код ещё раз. Обработаны будут только новые файлы. Для удаления файла или категории удалять нужно из обеих папок.

Теперь напишем тесты для проверки результатов работы программы. Добавляем новый класс ImagesTests в test/java/com.illuzor.lesson.resizer:

Класс содержит два метода. testFilesExistence() проверяет, что для каждого файла в images есть соответствующий файл в images_previews. Метод testPreviewsSizes() проверяет размеры изображений. Размер меньшей стороны должен быть 399 или 400, а большей — больше или равен 399. Откуда взялось число 399, если мы задавали 400? Где-то в библиотеке imgscalr иногда получается погрешность и минимальный размер становится равным 399. Для нас это не критично.

Запускаем тесты: в структуре проекта правой кнопкой по ImagesTests -> Run. Убеждаемся, что тесты заканчиваются успешно:

С данными всё, переходим к серверу.

Ссылка на проект — github.com/illuzor/Wallpapers-Lesson-Resizer

Сервер

Данные нужно где-то хранить и как-то отдавать их клиентскому приложению. Для этого нужен серверный код и хостинг. Вариантов с хостингом множество: можно использовать платный PHP хостинг, настроить VPS/VDS или собственный домашний сервер. Мы пойдёт самым простым путём и воспользуемся бесплатным PHP хостингом. Стоит понимать, что бесплатный хостинг всегда имеет различные ограничения и не гарантирует бесперебойную работу.

Напишем серверный код на PHP. Можно воспользоваться любым текстовым редактором. Я для небольших проектов предпочитаю Atom или Notepad++. Код очень простой и короткий, поэтому подойдёт даже стандартный блокнот.

В папке Wallpapers/Server создаём папку src, а в ней файл categories.php:

В этом коде объявляются две переменные: $folders — массив с именами всех подпапок images_previews и $result — массив объектов, который будет возвращён в виде json. В цикле foreach массив $result заполняется объектами вида

{ "name":"category_name","preview":"images_previews/category/filename.jpeg" }

Функция random_file() возвращает случайный файл из каталога. В конце $result, преобразованный в json, возвращается. На выходе получается json массив с объектами:

Это список категорий, у каждой из которых есть имя (имя соответствующей папки) и 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. Адрес сайта можно найти на странице со списком сайтов. Я буду использовать свой адрес, а вы подменяйте его на свой.

Открываем wallpapers.illuzor.com/categories.php и видим список категорий:

При каждом обновлении страницы значения preview будут разными.

Открываем wallpapers.illuzor.com/gallery.php и видим ошибку:

Нужно добавить GET параметр. Обязательно, чтобы его значение было именем одной из категорий (названием одной из папок). Открываем wallpapers.illuzor.com/gallery.php?gallery=Earth и видим список имён файлов соответствующей категории:

Теперь у нас есть сервер, который отвечает на запросы и отдаёт данные в удобном виде.

Небольшое отступление. Клиент не может и не должен знать, на чём написан сервер и как он работает. Тот же самый сервер можно было бы написать на любом другом языке: C++, Java, Kotlin, Ruby, Python… Всё, что должен знать клиент: на какие адреса отправлять запросы, как эти запросы должны выглядеть и в каком виде приходят ответы. Также можно написать клиентское приложение для IOS, Windows/Linux/MacOS или Web с тем же самым сервером без каких-либо изменений.

Ссылка на проект — github.com/illuzor/Wallpapers-Lesson-Server

Клиент

Самая большая и сложная часть — клиентское приложение для 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:

Список сторонних библиотек в проекте:

  • Lifecycle Extensions — содержит библиотеку ViewModel, которая будет использоваться для инкапсуляции загрузки данных и сохранения состояния фрагментов;
  • Retrofit — http клиент. Нужен для запросов к серверу;
  • Retrofit Gson Converter — конвертер для Retrofit, который через GSON превращает json строку в Java объекты;
  • Glide — библиотека для загрузки изображений;
  • PhotoView — расширение ImageView с поддержкой жестов.

 

В файле 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:

Теперь добавим иконки в drawable. Правой кнопкой по каталогу res/drawable -> New/Vector Asset. Кликаем по иконке андроида:

В поиск вводим «error» и выбираем иконку error:

Нажимаем Ok, меняем имя на ic_error:

Нажимаем Next и Finish. То же самое повторяем с ic_refresh, ic_save и ic_share. Добавляем в drawable файл placeholder.png, он будет индикатором для незагруженных изображений. В итоге каталог drawable содержит 4 иконки и одно png изображение (вдобавок к двум уже существующим файлам):

Настроим заодно манифест (app/manifest/AndroidManifest.xml). Нужно добавить три разрешения (интернет, запись во внешнее хранилище и установка обоев):

Уберём надоедливое предупреждение об индексации от гугла. Клик на нод application -> Alt+Enter в выпадающем меню выбираем Suppress GoogleAppIndexingWarning:

В application меняем значение параметра android:allowBackup с true на false. Итоговый вид манифеста:

Ссылки: коммит, проект.

03) Базовые и вспомогательные классы

Создадим заранее все пакеты:

Для создания пакета правой кнопкой по java/com.illuzor.lesson.wallpapers -> New/Package. В базовый пакет (com.illuzor.lesson.wallpapers) добавим Kotlin класс(Правой кнопкой -> New/Kotlin Class/File) WallpapersGlideModule:

Это класс для 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, таким образом ширина всегда будет равна высоте, то есть лайаут будет квадратным. Нам он нужен для элементов списков с превью.

Нам потребуется ProgressDialog, но так как он deprecated, напишем свою упрощённую реализацию. В первую очередь нужен макет. В res нужно создать каталог layout — правой кнопкой по res -> New/Android Resource Directory, Resource type — layout. В директории res/layout создаём новый Layout Resource File с именем dialog_progress.xml:

Макет состоит из кругового прогресс-бара и TextView для отображения текста сообщения.

В пакет ui добавляем класс ProgressDialog:

Свойство title задаётся извне, в методе onCreateDialog() выводится в tv_title и затем метод возвращает диалог.

Теперь перетаскиваем класс MainActivity в пакет screens и переименовываем(Shift+F6) в AbstractActivity. Этот класс будет базовым для всех(трёх) наших активити. Класс должен быть абстрактным. Для этого добавляем ключевое слово abstract перед определением класс (перед ключевым словом class). Рассмотрим код:

Абстрактное поле layoutId — id макета для активити. Абстрактный метод getFragment() возвращает фрагмент, который будет отображён в активити. В переопределённом методе onCreate() задаётся contentView. Далее проверяется, есть ли фрагмент во fragmenManager:

var fragment = supportFragmentManager.findFragmentById(R.id.container)

Если фрагмент не найден(равен null), получаем его через метод getFragment и добавляется во fragmentManager:

if (fragment == null) {
    fragment = getFragment()
    supportFragmentManager.beginTransaction()
            .add(R.id.container, fragment)
            .commit()
}

Макетов для активити пока что нет, поэтому идентификатор R.id.container будет подсвечен линтом, это не страшно, позже мы добавим для всех активити макеты, в каждом из которых будет container.

Через метод setToolbar() настраивается тулбар и включается кнопка up (стрелка на тулбаре).

В переопределённом методе onOptionsItemSelected() проверяется — если нажата кнопка up, завершаем активити.

Осталось удалить AbstractActivity из манифеста:

Теперь манифест выглядит так:

На этом с подготовкой всё. Можно переходить к созданию экранов приложения.

Структура проекта

[свернуть]

Ссылки: коммит, проект.

04) Экран категорий

 

Экран категорий представляет собой список категорий в две колонки. Каждый элемент списка состоит из изображения и текста с названием категории. Что нужно для для реализации такой функциональности? Макеты для активити и фрагмента, классы для активити и фрагмента, RecyclerView для отображения списка и адаптер для него, а также макет для элемента списка. Данные нужно загрузить с сервера и в виде списка категорий (List<Category>) передать в адаптер для RecyclerView. Для инкапсуляции загрузки данных через Retrofit будет использоваться библиотека ViewModel, кроме этого она поможет легко пережить смену конфигурации, например поворот экрана. Для загрузки изображений воспользуемся библиотекой Glide.

Начнём с вёрстки макетов. Добавляем в res/layout файл activity_fragment.xml:

Это макет для активити. Он содержит только FrameLayout, который будет контейнером для фрагмента.

Добавляем ещё один файл fragment_list.xml:

Элементы накладываются друг на друга, это нормально. Мы будет программно прятать ненужное. Этот макет содержит RecyclerView (recycler_view) для отображения списка, ProgressBar(rotator) для индикации загрузки и три элемента для отображения ошибки: ImageView(iv_error_icon), TextView (tv_error) и Button(btn_retry). Эти три элемента собраны в одну группу (androidx.constraintlayout.widget.Group) для того, чтобы ими можно было управлять, как одним целым. Group никак не отображается.

Добавляем последний макет item_category.xml:

Это макет для элемента списка (RecyclerView). Обратите внимание на корневой элемент, это SqureConstraintLayout, класс которого мы создавали ранее. Макет содержит: ImageView (iv_preview) для превью изображения, View — подложка для текста и TextView(tv_title) для отображения названия категории.

Теперь к коду. Займёмся адаптером. В первую очередь для адаптера нужен ViewHolder. Создаём в пакете adapters новый класс CategoryViewHolder:

Класс расширяет RecyclerView.ViewHolder и содержит два поля — ivPreview и tvTitle, которые представляют собой View из item_category.xml. Метод setTitle() устанавливает текст для TextView, а метод loadImage() загружает изображение и отображает его в ImageView через Glide.

Теперь напишем класс адаптера, но так как все адаптеры в нашем приложении (все два) во многом идентичны, напишем сначала абстрактный класс, который будет содержать общую функциональность.

Создаём новый класс AbstractAdapter:

Класс расширяет RecyclerView.Adapter и содержит два дженерика. T — тип данных, который будет храниться в списке, VH — ViewHolder для адаптера. Поле items — MutableList типа T для хранения данных. Поле clickListener — лямбда с одним строковым параметром, она нужна для обработки кликов по элементам списка, задаётся извне через метод setOnclickListener(). Метод addItems() нужен для добавления данных в список и уведомления адаптера о добавлении. Переопределённый метод getItemCount() возвращает длину списка. И метод getItem() для получения айтема из списка по его позиции.

Создаём класс CategoriesAdapter:

Класс расширяет только что созданный AbstractAdapter. Дженерики — Category и CategoryViewHolder. Класс содержит всего два переопределённых метода. onCreateViewHolder() возвращает наш CategoryViewHolder. В методе onBindViewHolder мы получаем переменную item (её тип — Category)  и работаем с холдером. Передаём ему название категории через метод setTitle() и url для загрузки превью через метод  loadImage(). Url состоит из BASE_URL, который определён в файле api.kt и относительного пути (item.preview). На holder.itemView вешается слушатель клика, в котором вызывается лямбда clickListener, объявленная в классе AbstractAdapter. В неё передаётся название категории.

Теперь подумаем над загрузкой данных. С сервера нужно загрузить список категорий, для этого у нас есть объект WallpapersApi — переменная api в файле api.kt. Применим библиотеку ViewModel. Саму загрузку мы инкапсулируем в отдельном классе ViewModelCategories, расширяющем androidx.lifecycle.ViewModel. Объект такого класса можно получить через androidx.lifecycle.ViewModelProviders примерно таким кодом:

ViewModelProviders.of(this).get(ViewModelCategories::class.java)

Этот объект переживёт смену конфигурации, это значит, что состояние полностью сохранится. В нашем случае не потеряется прогресс загрузки и данные. Кроме того не придётся вручную перезапускать загрузку и сохранять данные, например через savedInstanceState. Объект погибнет вместе с активити или фрагментом.

Создадим в пакете model базовый класс ViewModelBase:

В первую очередь объявляется enum State. Это список возможных состояний:

  • CREATED — экземпляр класса только что создан и загрузка ещё не производилась;
  • PROGRESS — загрузка в процессе;
  • LOADED — данные загружены успешно;
  • ERROR — произошла ошибка, данные не загружены.

Для отражения текущего состояния есть свойство state с protected сеттером. Это значит, что получить переменную можно извне, но изменить только из текущего или унаследованного класса. Поле loadListener — слушатель загрузки, лямбда без параметров, которая ничего не возвращает. А также метод setListener() для установки этого слушателя.

Теперь создаём класс ViewModelCategories, унаследованный от ViewModelBase:

Свойство data с приватным сеттером — список категорий, который потом будет передан в адаптер. В методе load() устанавливаем state значение PROGRESS и начинаем загрузку: вызываем api.categories().enqueue() и передаём объект Callback (object — аналог анонимного класса в Java), так загрузка будет выполняться асинхронно. Метод onResponse вызывается в случае успешной загрузки. response.body() — это List<Category>, то есть наш список категорий. Устанавливаем state значение LOADED и вызываем loadListener. Если произошла ошибка, вызывается метод onFailure(). В нём задаём state значение ERROR и вызываем loadListener.

Осталось создать только фрагмент и активити. Сначала создадим абстрактный фрагмент, который упростит отображение прогресса. Создаём в пакете screens класс AbstractFragment:

Поле contentView у разных фрагментов будет разным, его нужно будет задать вручную для каждого унаследованного фрагмента. Абстрактное поле layoutId, которое нужно будет переопределить так же, как в AbstractActivity. В переопределённом методе onCreateView() разворачивается и возвращается View фрагмента. Три метода: showProgress() и showContent() скрывают и показывают нужные view, а метод showError, кроме этого устанавливает текст ошибки и обрабатывает клик по кнопке Retry — вызывает переданную лямбду.

Теперь реализация фрагмента для категорий. Создаём новый класс CategoriesFragment, унаследованный от AbstractFragment():

Здесь переопределяется поле layoutId, и есть ещё два поля: adapter(CategoriesAdapter), который мы реализовали ранее и model(ViewModelCategories), который у нас тоже есть. В методе onCreate() получаем объект класса ViewModelCategories:

model = ViewModelProviders.of(this).get(ViewModelCategories::class.java)

В методе 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(). На современных устройствах эта ошибка маловероятна, но на старых с медленным интернетом она вполне реальна.

Весь код класса

Осталось только добавить активити. Правой кнопкой по пакету screens -> New/Activity/Empty Activity. Задаём следующие параметры:

Нажимаем Finish. Активити автоматически пропишется в манифесте и будет стартовой. Открываем созданный класс CategoriesActivity, наследуем его от AbstractActivity вместо AppCompatActivity и удаляем метод onCreate. Всё, что нужно сделать — переопределить свойство layoutId и метод getFragment():

Можно запустить(Shift+F10) приложение на устройстве или на эмуляторе и посмотреть, что получилось. На экране появятся две колонки белых превью, которые быстро заменяются загруженными изображениями. При каждом запуске превью будут разными. Список можно прокрутить, но нажатие на элементы, пока что, не обрабатываются. Чтобы увидеть сообщение об ошибке, нужно выключить wifi и мобильный интернет и перезапустить приложение. Если теперь включить интернет и нажать на кнопку Retry, всё заработает, как и должно. Если повернуть телефон во время загрузки, она не прервётся, благодаря ViewModel. Если перевернуть телефон, когда данные уже загружены, загрузка не начнётся заново, так как данные остаются во ViewModel.

Структура проекта

[свернуть]

Ссылки: коммит, проект.

05) Экран категории

На этом экране будет отображаться список превью изображений из выбранной категории. Для его создания нужно примерно то же самое, что для экрана галереи — классы для активити и фрагмента, класс адаптера, класс ViewModel. Будет немного попроще, так как все базовые классы у нас уже есть. Макет для фрагмента будет использовать тот же самый, а для активити и элемента списка нужны новые.

Создаём макет для активити с именем activity_with_toolbar.xml:

От макета activity_fragment.xml он отличается только наличием тулбара.

activity_with_toolbar.xml

[свернуть]

И ещё один макет item_gallery.xml для элемента списка:

Здесь всё то же самое, что в item_category.xml, но без TextView, то есть SquareConstraintLayout с ImageView для превью.

Переходим к адаптеру. Создаём в пакете adapters ViewHolder класс под названием GalleryViewHolder:

Код класса полностью аналогичен коду CategoryViewHolder, за исключением отсутствия поля tvTitle и метода setTitle(). Дубликаты кода, это плохо. Можно унаследовать CategoryViewHolder от GalleryViewHolder и удалить дублирующийся код. Можете сделать это самостоятельно.

Создаём в пакете adapters класс GalleryAdapter:

Опять же, класс во многом аналогичен классу CategoriesAdapter, но первый дженерик типа String, так как gallery.php возвращает список строк (имён файлов). Второй дженерик типа GalleryViewHolder. В holder.loadImage() путь передаётся немного по-другому, так как gallery.php возвращает только имена файлов, а не относительные пути. При клике в clickListener передаётся item, то есть String с именем файла.

Создаём ViewModel — класс ViewModelGallery в пакете model:

И снова класс во многом аналогичен ViewModelCategories, за исключением того, что вызывается метод api.gallery(), которому передаётся название категории, а тип данных тут String.

В пакете screen создаём класс GalleryFragment:

Основное отличие этого класса от CategoriesFragment в том, что появилось поле category — это строковая переменная, которая берётся из аргументов. О том, откуда она взялась скоро узнаем. Эта переменная передаётся параметром в model.load().

Осталось создать активити:

Открываем код класса GalleryActivity и меняем его на следующий:

Поле id переопределяем со значением R.layout.activity_with_toolbar. В методе onCreate() устанавливаем тулбар:

setToolbar(R.id.toolbar, intent.getStringExtra("category"))

В метод setToolbar() передаётся id тулбара и строка с id «category», которую берётся из интента. В переопределённом методе getFragment() создаём переменную Bundle и добавляем туда ту же строку «category», создаём фрагмент, устанавливаем наш Bundle в качестве arguments фрагмента и возвращаем фрагмент. Откуда в интенте взялась строка «category»? Открываем класс CategoriesFragment и дописываем код в методе checkData() в блоке LOADED:

Получается, что при клике на элемент списка создаётся интент для запуска GalleryActivity, в который добавляется та самая строка «category», а затем активити запускается методом startActivity(). Затем в классе GalleryActivity «category» передаётся во фрагмент. 

Весь код обновлённого класса CategoriesFragment.

Экран галереи готов. Можно запускать приложение.

Структура проекта

[свернуть]

Ссылки: коммит, проект.

06) Экран изображения. Часть первая: загрузка файла и создание меню

Это последний экран, на котором будет отображено полноразмерное изображение с возможностью увеличения и уменьшения жестами. В тулбаре будут три кнопки: установить обои, поделиться изображением, сохранить изображение в галерею. Для реализации нам понадобится загрузить файл полноразмерного изображения с сервера на устройство. Этот файл можно будет легко загрузить в ImageView (точнее в PhotoView, который расширяет ImageView) через Glide, его можно будет установить на обои, поделиться им и сохранить в галерею.

Начнём с вёрстки. Создаём файл fragment_wallpaper.xml:

Просто скопируйте код из fragment_list.xml и замените RecyclerView на PhotoView.

Открываем интерфейс WallpapersApi из пакета api и добавляем метод downloadFile():

Метод принимает строковый параметр fileUrl с аннотацией @Url. Этот параметр будет преобразован в относительный путь. Метод возвращает Call с ResponseBody, из которого можно будет достать InputStream с байтами файла.

Создаём в пакете model класс ViewModelWallpaper:

Поле file — объект файла, который передаётся извне. Загрузка будет происходить в этот файл. Метод load() принимает два параметра: url — относительный путь до файла на сервере и файл, в который будет происходить загрузка. В целом всё примерно так же, как в других наших ViewModel, но метод onResponse() сильно отличается. В первую очередь проверяется, удачно ли прошёл запрос (response.isSuccessful), если нет, присваиваем state ERROR и вызываем слушатель. Если всё в порядке, запускаем в фоновом потоке код через написанную ранее функцию runInBackground() из файла async.kt: получаем InputStream из response:

val inputStream = response.body()!!.byteStream()

и записываем его в новый FileOutputStream с файлом:

inputStream.copyTo(FileOutputStream(file))

Метод copyTo — это расширение из стандартной библиотеки Kotlin. Когда сохранение файла завершится, вызываем функцию runInMainThread(), которая выполнит код в UI потоке. В её лямбде присваиваем свойству state значение LOADED и вызываем слушатель.

Также есть переопределённый метод onCleared(). Он будет вызван автоматически, когда погибнет фрагмент, к которому привязан ViewModel. Если загрузка файла началась, но ещё не закончилась, то есть файл загружен частично, этот файл будет удалён, так как частично загруженный файл нам не нужен.

Создадим в пакете screens класс WallpaperFragment и унаследуем его от AbstractFragment:

Пусть, пока что, будет пустым.

Создадим в том же пакете новую активити:

Тут всё аналогично GalleryActivity, кроме того, что в Bundle добавляются две строки — «filename» и «category».

Открываем класс GalleryFragment и в методе checkData() дописываем код в блоке LOADED:

Обновлённый код класса GalleryFragment.

Займёмся меню для 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(), который в целом аналогичен методам в двух других фрагментах.

Добавим меню. Ещё несколько методов:

В onCreateOptionsMenu() разворачивается меню. В onOptionsItemSelected() обрабатываются клики по элементам меню, но только в случае, когда изображение загружено, то есть loaded == true, а нажатие на up (android.R.id.home) всегда разрешено. Если изображение не загружено, отображается тост через ранее написанное расширение из toast.kt, а если загружено, вызывается соответствующий метод. Этих методов три и реализованы они будут в следующей части.

Ссылка на весь код класса.

Запускаем приложение и смотрим, что получилось.

Структура проекта

[свернуть]

Ссылки: коммит, проект.

07) Экран изображения. Часть вторая: сохранить, поделиться, установить на обои.

Начнём с самого простого — с сохранения изображения в галерею, то есть в каталог Pictures на устройстве. Для этого сначала нужно запросить разрешение на запись (WRITE_EXTERNAL_STORAGE) для Android M(23) и старше.

В начале класса объявляем новое поле REQUEST_STORAGE_PERMISSION_CODE:

private val REQUEST_STORAGE_PERMISSION_CODE = 0

Это код для запроса разрешения.

Реализуем метод 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():

Вначале создаём интент через ShareCompat.IntentBuilder, затем проверяем, есть ли вообще активити, способные обработать этот интент. Если нет, показываем тост с текстом ошибки, если есть, продолжаем: добавляем в интент Uri (imageFileUri), получаем список активити для интента и в цикле раздаём им разрешение на доступ к Uri. И в конце запускаем активити методом startActivityForResult().

Добавляем метод onActivityResult:

resultCode нам не интересен. Важно только одно — если requestCode равен SHARING_REQUEST_CODE или SETTING_REQUEST_CODE, отменяем разрешение на доступ к Uri.

Ссылка на весь код класса. 

Можно снова запустить приложение и проверить.

Самое последнее, что осталось — реализовать метод setWallpaper():

Показываем диалог, в фоновом потоке устанавливаем обои через WallpaperManager, в ui потоке закрываем диалог и показываем тост с сообщением об удачной установке. Можно запустить и проверить, как это работает.

Вроде, всё готово, но нет. Такой способ не очень хорош, лучше оставить его на крайний случай, а установку обоев делегировать через неявный интент кому-нибудь, кто справится с этим лучше. Меняем код метода setWallpaper():

Объявляем интент на установку обоев, проверяем, есть ли в системе активити, которые этот интент могут обработать. Если нет, запускаем код из прошлой версии метода setWallpaper(). Если же такие активити есть, донастраиваем интент, раздаём всем активити разрешение на доступ к Uri и в конце запускаем активити через startActivityForResult().

Ссылка на весь код класса.

Готово. Запускаем приложение и смотрим на результат.

Структура проекта

[свернуть]

Ссылки: коммит, проект.

Задания

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

Кэширование на сервере

Постоянный проход по папкам с файлами — довольно дорогая операция, особенно если файлов будет много и у приложения будет большое количество пользователей. Один из вариантов решения этой проблемы — кэширование. Можно записывать json данные в файл и отдавать его содержимое и, например, раз в сутки файл обновлять. То есть, если файл не существует или существует, но старше суток, проходим по папкам, записываем данные в файл и возвращаем их, иначе просто возвращаем содержимое файла. Но есть одна проблема: если с сервера будут удалены какие-то файлы, а клиенту уйдут закэшированные данные, получится, что клиент не сможет загрузить несуществующие файлы. В этом случае вместе с удалением файлов изображений с сервера нужно вручную удалять закэшированные файлы.

Кэширование в приложении

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

Внешний вид приложения

Мы не обращали внимания на внешний вид приложения и выглядит оно не очень привлекательно. Стоит поработать над темой и добавить анимаций. Тут всё ограничивается только вашей фантазией. Полезная документация: стили и темы, анимации, адаптивная иконка.

Адаптация для горизонтального режима и планшетов

Если повернуть телефон горизонтально, мы увидим следующее:

Экраны

[свернуть]

Выглядит не очень хорошо. Было бы лучше отобразить список экрана категорий в виде трёх колонок, а список экрана галереи — в виде двух. На планшетах всё ещё хуже. Исправить эту проблему можно с помощью GridLayoutManager для RecyclerView. Для определения, когда сколько колонок отображать есть, как минимум, два способа:

  1. Хранить int значения в ресурсах для разных ориентаций экрана и дополнительно для планшетов;
  2. Определять количество колонок динамически по размеру дисплея в dp.

Полезная документация: работа с ресурсами, класс GridLayoutManager.

Перелистывание обоев

При открытом WallpaperActivity было бы удобно листать следующие/предыдущие изображение свайпами влево/вправо. Сделать это можно с помощью ViewPager. Есть один не очевидный баг при использовании PhotoView  совместно с ViewPager. Если столкнётесь с ним, постарайтесь исправить самостоятельно, если не получится, свяжитесь со мной, я подскажу.

Заключение

Мой доработанный вариант приложения — play.google.com/store/apps/details?id=com.illuzor.wallpapers

И напоследок небольшая просьба. Вы можете без ограничений использоваться мои ссылки с доменом wallpapers.illuzor.com для обучения, но я прошу не публиковать приложения с этими ссылками.

На этом всё, спасибо за внимание и интерес к материалу.

Простое клиент-серверное приложение для Android с нуля: 1 комментарий

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *