Создаем мобильное приложение на Kotlin с Jetpack Compose и Clean Architecture MVVM: три урока с пошаговой инструкцией и использованием REST API, Retrofit, Repository pattern, ViewModel.
Принцип работы мобильного приложения “Книжная полка”
В процессе серии из трех уроков вы создадите мобильное нативное приложение “Книжная полка” для Андроид на языке программирования Kotlin с использованием Jetpack Compose, REST API, Retrofit, Repository pattern и ViewModel. Мы пошагово пройдем все этапы разработки приложения. С помощью мобильного приложения можно будет загружать список книг по произвольному поисковому запросу из сети и отображать их в виде сетки, где каждый элемент содержит название книги и изображение обложки. По нажатию на элемент, пользователь сможет перейти на страницу с информацией о книге в Google Books в браузере. В уроках будет рассматриваться клиент серверная архитектура мобильного приложения MVVM.
Курс Jetpack Compose
На официальном сайте developer.android.com есть хороший курс для новичков, как научиться создавать приложения с нуля – Android Basics with Compose. Это один из новых курсов для новичков, позволяющий погрузиться во все базовые аспекты разработки мобильных приложений: создание основных компонентов UI, материальный дизайн, навигация и архитектура, работа с сетью, хранение данных, фоновая работа. Ссылку на этот курс я оставлю в описании видео.
В этом курсе изучается перспективная технология декларативного подхода к созданию пользовательского интерфейса – Jetpack Compose. Эта технология позволяет описывать разметку для экранов не в xml файлах, а в коде на языке Kotlin. Такой подход становится все более популярным – с помощью него создаются различные виды мобильных приложений.
Курс поделен на юниты по темам, и каждый юнит кроме теории содержит практические задания, где нужно самостоятельно создать приложение. В этом видеоуроке мы разберем одно из таких заданий, где нужно создать приложение “Книжная полка”. Нужно создать приложение, которое будет отображать список книг по статичному запросу. Но мы в этом уроке пойдем дальше и расширим функционал приложения. Добавим возможность загружать из сети список книг по произвольному поисковому запросу и отображать в виде сетки, где каждый элемент имеет название книги и изображение обложки, а по нажатию открывает в браузере страничку с информацией о книге в Google Books.
Как создать мобильное приложение на Kotlin с Jetpack Compose
Для начала нужно создать проект в Android Studio. Выбираем шаблон Empty Compose Activity.
Добавляем зависимости в файл build.gradle (Module App)
// Retrofit implementation "com.squareup.retrofit2:retrofit:2.9.0" implementation "com.squareup.retrofit2:converter-gson:2.9.0"
Библиотека Retrofit используется для работы с сетевыми запросами, второй строчкой мы подключаем конвертер Gson для парсинга json ответа сервера.
//Coil implementation "io.coil-kt:coil-compose:2.2.2"
А библиотеку Coil будем использовать для загрузки изображений. Синхронизируем проект.
Добавим ресурсы
В папку res\drawable нужно добавить векторные изображения:
loading_img.xml , ic_book_96.xml, ic_connection_error.xml, ic_broken_image.xml
Вы можете взять эти файлы в архиве по ссылке.
В папке res\values\strings нужно создать такие строковые ресурсы:
<string name="loading_failed">Failed to load</string> <string name="retry">Retry</string> <string name="loading">Loading</string> <string name="content_description">Book</string>
Запрос к серверу и JSON ответ
Получать данные о книгах мы будем из Google Books API, который размещен на сайте https://developers.google.com/
Чтобы реализовать поиск информации о книгах, нам нужно выполнить такой GET запрос к серверу:
https://www.googleapis.com/books/v1/volumes?q=search+terms
Если вызвать этот запрос в браузере, мы получим ответ сервера в формате JSON, из которого нам нужно извлечь название книги и изображение ее обложки для отображения в нашем приложении.
Если внимательно посмотреть на ответ сервера, то можно понять, что он возвращает некий объект, содержащий в себе список других вложенных объектов, заключенный в квадратные скобки. В свою очередь, каждый объект из списка содержит в себе другие объекты или списки объектов. Например, объект “volumeInfo” содержит поле “title”, хранящее заголовок книги. Также он содержит объект “imageLinks” с полями-ссылками на изображения обложки. Чтобы обработать ответ сервера и сохранить данные, нам нужно в приложении воссоздать всю эту иерархию объектов.
К счастью, нам не нужно делать это вручную, для этого есть удобные онлайн-сервисы, например JSON to Kotlin Data Class Generator Online https://www.json2kt.com/ генерирующий все необходимые классы по JSON-ответу сервера.
Копируем ответ сервера и вставляем его в окно генератора. Укажем имя пакета приложения и желаемое имя дата класса (BookShelf), и загрузим архив сгенерированных классов.
В коде приложения создаем папку network, внутри нее папку model и копируем туда все сгенерированные дата классы.
Их довольно много, но нас интересуют только четыре: BookShelf, содержащий список классов Items. Внутри Items есть класс VolumeInfo, где мы будем брать заголовок и ссылку на информацию о книге. VolumeInfo также содержит класс ImageLinks, у которого есть поля со ссылками на картинки.
Поскольку все нужные нам данные разбросаны по разным классам, мы создадим отдельную модель, в которую будем мапить необходимые нам поля из разных дата классов.
В главном пакете создаем папку data. В папке data создаем data class Book :
data class Book( val title: String?, val previewLink: String?, val imageLink: String? )
Он содержит три поля: заголовок, ссылка на страницу информации о книге и ссылка на картинку. Вот и все данные, которые нам нужны для отображения списка книг и работы с ним в приложении.
Теперь напишем запрос для получения информации о книгах из Google Books API.
Работа с библиотекой Retrofit-2
Мы будем использовать библиотеку Retrofit-2, для работы с ней нужно создать три компонента:
- Model класс, который используется как модель JSON
- Интерфейсы, которые определяют возможные HTTP операции (GET для получения, POST для отправки данных и т.д.)
- Класс Retrofit — экземпляр, который использует интерфейс и API Builder, чтобы задать определение конечной точки URL для операций HTTP
Первый пункт мы выполнили – сохранили data-классы моделей с Kotlin Data Class Generator Online.
Займемся интерфейсом. Каждый метод интерфейса представляет собой один из возможных вызовов API. Он должен иметь HTTP аннотацию (GET), чтобы указать тип запроса и относительный URL.
Чтобы создать метод для нашего запроса, разберем структуру запроса к серверу:
- https://www.googleapis.com/books/v1/ – это URL, нам он пока не нужен,
- volumes – это собственно запрос,
- q – это параметр запроса, который может содержать текст для поиска – фамилию автора или название книги.
В папке network создаем интерфейс BooksService:
import com.example.bookshelf.BookShelf import retrofit2.http.GET import retrofit2.http.Query interface BookService { @GET("volumes") suspend fun bookSearch( @Query("q") searchQuery: String, @Query("maxResults") maxResults: Int ): BookShelf }
Аннотация @GET указывает тип запроса, в скобках пишем запрос volumes в кавычках
функцию booksSearch обозначим словом suspend – это значит, что функцию можно приостанавливать. Это нужно для того, чтобы вызывать ее из корутины. Корутины Kotlin позволяют выполнять какую-то потенциально долгую работу в отдельных потоках, чтобы не нагружать главный поток приложения. Сетевые запросы как раз относятся к тяжелым видам работы, и их нельзя выполнять в главном потоке, иначе ваше приложение может зависнуть или даже упасть.
В качестве аргументов добавляем переменные для параметров, они обозначены аннотацией @Query, в скобках указываем имена параметров из запроса.
Параметр q будет хранить текст поискового запроса, а второй параметр maxResults позволит указать размер списка книг в ответе сервера. Он взят из документации Google Books API. Максимальное число возвращаемых объектов 40. Чтобы получить больше – нужно использовать пагинацию, но в этом уроке мы не будем ее использовать.
Функция booksSearch будет возвращать объект дата класса BooksSearch, который нам возвращает сервер.
Нам осталось создать объект класса Retrofit и передать ему BooksService для вызова.
В папке data создаем новый файл AppContainer. Внутри объявляем одноименный интерфейс.
import com.example.bookshelf.network.BookService import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory interface AppContainer { } class DefaultAppContainer : AppContainer { private val BASE_URL = "https://www.googleapis.com/books/v1/" private val retrofit: Retrofit = Retrofit.Builder() .addConverterFactory(GsonConverterFactory.create()) .baseUrl(BASE_URL) .build() private val retrofitService: BookService by lazy { retrofit.create(BookService::class.java) } }
Далее создаем класс DefaultAppContainer , который будет наследовать этот интерфейс. Немного позже объясню, зачем это нужно.
Создаем константу для хранения BASE_URL – первой части запроса к сервису поиска книг.
Ниже создаем объект Retrofit с помощью билдера, указываем тип конвертера (GsonConverterFactory), с помощью которого Retrofit будет конвертировать json ответ сервера в объекты дата классов. Также передаем базовый URL .
Далее создаем вызов к API с помощью экземпляра ретрофит, в его методе create() указываем наш класс интерфейса с запросами к сайту.
Таким образом, все три условия работы с Ретрофит выполнены – мы создали дата классы, интерфейс для запроса и объект Ретрофит для отправки запроса на сервер и получения ответа сервера.
Clean Architecture, MVVM
Наше приложение будет разделено на несколько слоев. Разделение кода на разные слои делает приложение более масштабируемым, надежным и простым для тестирования. Наличие нескольких слоев с четко определенными границами также упрощает работу нескольких разработчиков над одним и тем же приложением – каждый может работать над отдельным слоем, не оказывая негативного влияния друг на друга.
На официальном сайте есть пример рекомендуемой архитектуры приложений Android. Он гласит, что приложение должно иметь как минимум 2 слоя: уровень пользовательского интерфейса и уровень данных.
Уровень данных отвечает за бизнес-логику, а также за поиск и сохранение данных для приложения. Этот уровень предоставляет данные на уровень пользовательского интерфейса, используя шаблон однонаправленного потока данных . Данные могут поступать из нескольких источников, таких как сетевой запрос, локальная база данных или из файла на устройстве.
Repository
Такая архитектура предполагает наличие репозитория для каждого типа источника данных, используемого вашим приложением.
В общем класс репозитория выполняет такие функции:
- Предоставляет данные остальной части приложения.
- Централизует изменения данных.
- Разрешает конфликты между несколькими источниками данных.
- Абстрагирует источники данных от остальной части приложения.
- Содержит бизнес-логику.
В нашем приложении единственный источник данных — это вызов сетевого API. В нем нет никакой бизнес-логики, так как он просто извлекает данные. Данные предоставляются приложению через класс репозитория, который абстрагирует источник данных.
Создадим репозиторий в папке data – добавим файл BooksRepository.
import com.example.bookshelf.network.BookService interface BooksRepository { suspend fun getBooks(query: String, maxResults: Int) : List<Book> } class NetworkBooksRepository( private val bookService: BookService ) : BooksRepository { override suspend fun getBooks( query: String, maxResults: Int ): List<Book> = bookService.bookSearch(query, maxResults).items.map { items -> Book( title = items.volumeInfo?.title, previewLink = items.volumeInfo?.previewLink, imageLink = items.volumeInfo?.imageLinks?.thumbnail ) } }
Внутри него напишем одноименный интерфейс с единственным методом getBooks, который принимает такие параметры: текст для поиска и число возвращаемых результатов, а возвращает список объектов Book. Метод обозначен как suspend для возможности его вызова внутри корутины.
Здесь же пишем класс NetworkBooksRepository, который принимает наш вызов к серверу и реазизует интерфейс BooksRepository.
В теле класса переопределяем метод getBooks, который принимает параметры вызова сервера и затем возвращает ответ сервера, который мы тут же преобразуем в список объектов Book.
Метод booksSearch запроса BooksService возвращает объект BookShelf, который, как мы помним, содержит список объектов Items. Мы через точку обращаемся к каждому экземпляру Items и с помощью оператора .map трансформируем его в Book, вытягивая по цепочке необходимые нам поля и перенося их данные в поля объекта Book. В итоге на выходе мы получим список объектов Book с нужными нам свойствами – заголовком и ссылками на страницу информации и изображение обложки.
Dependency Injection
Теперь вернемся в файл AppContainer и поговорим о том, зачем нам здесь интерфейс. Внутри интерфейса AppContainer добавим абстрактное свойство типа BooksRepository.
interface AppContainer { val booksRepository: BooksRepository }
Поскольку класс DefaultAppContainer наследует этот интерфейс, нам нужно переопределить это свойство. Через делегат by lazy мы лениво инициализируем наш репозиторий и передаем ему в конструкторе ретрофит сервис.
class DefaultAppContainer : AppContainer { ...
override val booksRepository: BooksRepository by lazy { NetworkBooksRepository(retrofitService) } }
Этот способ гарантирует, что экземпляр репозитория будет создан только однажды в процессе работы.
Такой подход реализует паттерн Dependency Injection – внедрение зависимости. В больших проектах для реализации этого паттерна используются библиотеки, такие как Даггер или Хилт. А в небольших проектах можно применить так называемый мануал Dependency Injection – внедрение зависимости через конструктор или интерфейс. Поскольку нашему репозиторию нужен экземпляр ретрофит для вызова АПИ сервера и получения данных, можно сказать, что репозиторий зависит от экземпляра ретрофит – это его зависимость. Мы могли бы просто создать объект Ретрофит прямо в классе репозитория. Но такой подход создает жесткую связь. Жесткие связи затрудняют совместную разработку, тестирование и масштабирование кода.
Чтобы сделать код более гибким и адаптируемым, класс не должен создавать экземпляры объектов, от которых он зависит. Объекты зависимостей должны создаваться вне класса, а затем передаваться внутрь. Такой подход создает более гибкий код, поскольку класс больше не привязан к одному конкретному объекту. Реализация требуемого объекта может быть изменена без необходимости модификации вызывающего кода.
Реализация внедрения зависимостей:
- Помогает с возможностью повторного использования кода. Код не зависит от конкретного объекта, что обеспечивает большую гибкость.
- Облегчает рефакторинг. Код слабо связан, поэтому рефакторинг одной части кода не влияет на другую часть кода.
- Помогает с тестированием. Тестовые объекты могут быть переданы во время тестирования.
Для реализации такого подхода мы разместили сетевой код внутри контейнера, поддерживающего зависимости, в файле AppContainer, инициализируем здесь наш репозиторий и передаем ему в конструкторе ретрофит сервис.
Далее нам нужно связать контейнер с приложением, для этого создадим класс BooksApplication и унаследуем его от Application.
import android.app.Application import com.example.bookshelf.data.AppContainer import com.example.bookshelf.data.DefaultAppContainer class BooksApplication : Application() { lateinit var container: AppContainer override fun onCreate() { super.onCreate() container = DefaultAppContainer() } }
Внутри класса объявите переменную с именем container типа AppContainer для хранения объекта DefaultAppContainer . Переменная инициализируется только во время вызова onCreate(), поэтому она должна быть помечена модификатором lateinit .
Теперь нужно обновить манифест, чтобы приложение использовало только что определенный класс приложения. Откройте файл manifests/AndroidManifest.xml . В разделе application добавьте атрибут android:name со значением имя класса приложения “.BooksApplication “.
<application android:name=".BooksApplication" ...
Также в манифест нужно не забыть добавить разрешение на выход приложение в интернет:
<uses-permission android:name=”android.permission.INTERNET” />
Слой data у нас готов. Исходный код проекта здесь.
В следующем уроке перейдем к слою пользовательского интерфейса.
Как создать мобильное приложение на Kotlin с Jetpack Compose и Clean Architecture. Часть 2. Слой UI