Как создать мобильное приложение на Kotlin с Jetpack Compose и Clean Architecture. Часть 2. Слой UI

Продолжение урока о том, как создать мобильное приложение “Книжная полка” на языке Kotlin с Jetpack Compose, REST API, Retrofit, Repository pattern, ViewModel, Clean Architecture. Приложение будет загружать из сети список книг по произвольному поисковому запросу и отображать в виде сетки, где каждый элемент имеет название книги и изображение обложки, а по нажатию открывает в браузере страничку с информацией о книге в Google Books.

UI – слой пользовательского интерфейса

Слой пользовательского интерфейса в андроид-приложениях, использующих архитектуру MVVM, обычно состоит из двух вещей: 

Как создать мобильное приложение на Kotlin с Jetpack Compose и Clean Architecture. Часть 2. Слой UI
The UI layer’s role in app architecture. https://developer.android.com/topic/architecture/ui-layer?authuser=1
  • UI – элементы пользовательского интерфейса, отображающие данные на экране. Мы создаем эти элементы, используя функции Views или Jetpack Compose. В этом приложении будем использовать второй подход.  
  • State holders – держатели состояния (такие как классы ViewModel ), которые хранят данные, предоставляют их пользовательскому интерфейсу и обрабатывают логику. 

Разработчики рекомендуют не хранить никаких состояний непосредственно в классах UI, а использовать для этого ViewModel. 

Создаем ViewModel

ViewModel – это часть архитектуры приложения, которая отвечает за представление данных, необходимых для отображения пользовательского интерфейса. Проще говоря, это класс или объект, который содержит данные и методы для работы с ними, которые используются для отображения информации на экране пользователя.

ViewModel обычно используется в сочетании с паттерном проектирования Model-View-ViewModel (MVVM), который разделяет приложение на три компонента: модель (Model), отображение (View) и ViewModel. ViewModel связывает модель и отображение, обеспечивая обработку данных, полученных из модели, и их передачу в отображение. Это позволяет создавать более модульный и масштабируемый код, разделяя логику приложения на более мелкие компоненты.

Для использоования ViewModel нужно имплементировать библиотеку lifecycle-viewmodel-compose в файл build.gradle модуля App и синхронизировать проект: 

implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1" 

Теперь создадим класс BooksViewModel, который будет наследовать ViewModel. В конструктор этому классу будем передавать объект репозитория. 

import androidx.compose.runtime.*
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.example.bookshelf.BooksApplication
import com.example.bookshelf.data.Book
import com.example.bookshelf.data.BooksRepository
import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.io.IOException

sealed interface BooksUiState {
    data class Success(val bookSearch: List<Book>) : BooksUiState
    object Error : BooksUiState
    object Loading : BooksUiState
}

class BooksViewModel(
    private val booksRepository: BooksRepository
) : ViewModel() {

    var booksUiState: BooksUiState by mutableStateOf(BooksUiState.Loading)
        private set
    

    init {
        getBooks()
    }

    fun getBooks(query: String = "book", maxResults: Int = 40) {
        viewModelScope.launch {
            booksUiState = BooksUiState.Loading
            booksUiState =
                try {
                BooksUiState.Success(booksRepository.getBooks(query, maxResults))
            } catch (e: IOException) {
                BooksUiState.Error
            } catch (e: HttpException) {
                BooksUiState.Error
            }
        }
    }

    companion object {
        val Factory: ViewModelProvider.Factory = viewModelFactory {
            initializer {
                val application = (this[APPLICATION_KEY] as BooksApplication)
                val booksRepository = application.container.booksRepository
                BooksViewModel(booksRepository = booksRepository)
            }
        }
    }
}

Поскольку платформа Android не позволяет ViewModel передавать значения в конструктор при создании, мы реализуем объект ViewModelProvider.Factory , который позволяет обойти это ограничение. 

Выражение companion object  помогает нам иметь один экземпляр объекта, который используется всеми без необходимости создавать новый экземпляр дорогостоящего объекта. Код внутри выражения создает фабрику ViewModel, которая создает экземпляры BooksViewModel с помощью DI-контейнера, используя экземпляр BooksRepository, полученный из BooksApplication. Factory — это порождающий паттерн проектирования, используемый для создания объектов. Объект BoksViewModel.Factory использует контейнер приложения для извлечения BoоksRepository, а затем передает экземпляр репозитория при создании ViewModel объекта.

Внутри объекта “Factory” мы используем функцию “viewModelFactory”, которая принимает блок кода “initializer”. Внутри блока кода “initializer” мы создаем экземпляр BooksViewModel, который зависит от экземпляра BooksRepository.

Для получения экземпляра BooksRepository мы используем переменную “application”, которая получается из Bundle методом get() с ключом “APPLICATION_KEY”. Мы приводим значение, полученное из Bundle, к типу BooksApplication, и затем используем его свойство container для получения экземпляра BooksRepository.

Над объявлением класса BooksViewModel объявлен изолированный интерфейс BooksUiState, с такими полями – дата класс Success, который будет хранить список книг, и объекты синглтоны Error и Loading – все имеют тип BooksUiState. 

Это тип данных, который ViewModel будет передавать интерфейсу, и в зависимости от результата эти данные будут иметь вид либо процесса загрузки (Loading ), либо ошибки (Error ) в случае сбоя при загрузке данных, а в случае успеха возвратится объект (Success ), содержащий список книг. 

Внутри класса BooksViewModel есть переменая типа BooksUiState для хранения состояния – инициализируем ее через делегат класса MutableState. UI мы реализуем посредством Jetpack Compose, который будет наблюдать за любым чтением/записью объекта  MutableState и запускать перекомпоновку для обновления пользовательского интерфейса. Передадим туда статус по умолчанию BooksUiState.Loading, в процессе работы он будет хранить последнее переданное состояние. 

Далее метод getBooks, который принимает такие аргументы: строку запроса для поиска книг и число максимального количества элементов возвращаемого списка книг. Согласно документации, сервер может вернуть за один раз максисум сорок элементов, укажем это значение по умолчанию. 

Собственно запрос к серверу будем выполнять внутри корутины, для ее запуска пишем viewModelScope.launch. Сначала в  BooksUiState передаем состояние Loading, затем в блоке try/catch пытаемся передать объект Success, которому передаем экземпляр репозитория с вызовом метода getBooks с параметрами для запроса к серверу. Запрос должен вернуть в объект Success список книг, в случае успеха. А на случай ошибки будем ее отлавливать в секции catch. Таких секций две – на случай ошибки ввода-вывода и на случай ошибки сервера. 

Метод getBooks вызываем в секции init. Параметры можно не передавать, поскольку им присвоено значение по умолчанию. Секция init будет вызываться при инициализации объекта BooksViewModel и будет вызывать метод getBooks, который будет обновлять  BooksUiState.  

Пишем UI с Jetpack Compose

Что такое Jetpack Compose? Это новый фреймворк для разработки пользовательских интерфейсов на языке программирования Kotlin для платформы Android. С помощью Jetpack Compose вы можете создавать пользовательские интерфейсы приложений быстрее, проще и более декларативно, чем с использованием традиционных инструментов разработки пользовательского интерфейса Android.

Вместо написания разного рода сложного и длинного XML-кода, с Jetpack Compose вы создаете пользовательский интерфейс с помощью набора Composable-функций на языке Kotlin, которые описывают, как должен выглядеть интерфейс вашего приложения. Jetpack Compose также предоставляет множество готовых компонентов, которые можно использовать для быстрой и удобной разработки пользовательского интерфейса.

Для отображения списка создадим несколько экранных компонентов. В папке ui создадим папку screens, внутри которой создадим несколько файлов, содержащих composable функции для отображения списка книг, а также статусов загрузки и ошибки. При открытии приложения будет отображаться экран загрузки, а в случае ошибки или отсутствия подключения будет отображаться экран ошибки с кнопкой повторной загрузки.  

Экран загрузки

Пишем функцию LoadingScreen – она будет просто отображать картинку процесса загрузки, простой значок лоадера.

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.example.bookshelf.R

@Composable
fun LoadingScreen(modifier: Modifier = Modifier) {
    Box(
        modifier = modifier.fillMaxWidth(),
        contentAlignment = Alignment.Center
    ) {
        Image(
            modifier = Modifier.size(200.dp),
            painter = painterResource(id = R.drawable.loading_img),
            contentDescription = stringResource(id = R.string.loading))
    }
}

Аннотация Composable говорит компилятору, что это комопонент пользовательского интерфейса. Функция принимает модификатор для указания параметров отображения компонентов пользовательского интерфейса. В теле функции добавляем компонент Box, которому через модификатор указываем занять всю ширину родителя. Внутри Box добавим компонент image для отображения картинки.  Он имеет параметр painter, в который с помощью встроенной Composable-функции painterResource нужно передать ссылку на векторное изображение loading_img, которое заранее было сохранено в папку res\drawable. А в параметр contentDescription передаем строковый ресурс loading, который мы заранее сохранили в папку res\values\strings. Для этого есть функция stringResource. Через модификатор укажем размер 200 dp. 

Все компоненты внутри нашей Composable функции – Box,  Image, а также Text, и другие, которые мы будем использовать далее –  тоже являются Composable функциями, определенными в библиотеке androidx.compose. 

Экран ошибки

Аналогично создадим компонент ErrorScreen, аргументами будет функция retryAction, которую мы будем вызывать для повторной загрузки списка книг, и модификатор.

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.example.bookshelf.R

@Composable
fun ErrorScreen(retryAction: () -> Unit, modifier: Modifier = Modifier) {
    Column(
        modifier = modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = stringResource(id = R.string.loading_failed))
        Button(onClick = retryAction) {
            Text(text = stringResource(id = R.string.retry))
            
        }
        
    }
}

В теле функции добавим компонент Column, который представляет собой вертикальный столбец, компоненты внутри которого размещаются сверху вниз друг за другом. Внутри Column разместим компонент Text, коорому передадим ссылку на текстовый ресурс loading_failed. Под текстом разместим кнопку Button, при нажатии которой будет вызываться функция retryAction, ее нужно присвоить параметру onClick нашей кнопки. Надпись на кнопке реализуется вложенным компонентом Text, которому передаем строку retry. 

Список книг

Далее создадим экран BooksGridScreen, который будет отображать список книг. 

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

@Composable
fun BooksCard(
    book: Book,
    modifier: Modifier
) {
    Card(
        modifier = modifier
            .padding(4.dp)
            .fillMaxWidth()
            .requiredHeight(296.dp),
        elevation = 8.dp
    ) {
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            book.title?.let {
                Text(
                    text = it,
                    textAlign = TextAlign.Center,
                    modifier = modifier
                        .padding(top = 4.dp, bottom = 8.dp)
                )
            }
            AsyncImage(
                modifier = modifier.fillMaxWidth(),
                model = ImageRequest.Builder(context = LocalContext.current)
                    .data(book.imageLink?.replace("http", "https"))
                    .crossfade(true)
                    .build(),
                error = painterResource(id = R.drawable.ic_book_96),
                placeholder = painterResource(id = R.drawable.loading_img),
                contentDescription = stringResource(id = R.string.content_description),
                contentScale = ContentScale.Crop
            )
        }
    }
}

Это функция BooksCard, аргументами которой будет экземпляр класса Book, и модификатор. Внутри функции добавим компонент Card, которому задаем такие параметры через модификатор – отступ 4 dp, заполнение по ширине родителя, требуемую высоту 296 дп для того, чтобы обложки книг выглядели симметрично и однотипно. Также у Card есть параметр elevation, который создает эффект приподнятия карточки над поверхностью, зададим ему 8 dp.  

В теле функции Card добавим уже знакомый нам Column, выровняем его горизонтально по центру. 

Внутри  Column добавим текст, который будем вытягивать из объекта Book, будем отображать название книги. Поскольку поле title может быть нулевым, будем вызывать компонент Текст через оператор let, который позволяет нам обойтись без лишних проверок и будет выполнять этот блок кода только если поле title не пустое. 

Для отображения обложки книги воспользуемся Composable функцией из библиотеки Coil –  AsyncImage, которая асинхронно выполняет запрос изображения по ссылке и возвращает результат. Это сэкономит нам много лишней работы. Через модификатор указываем, что можно заполнить всю ширину родителя.  

Параметр model принимает ImageRequest.Builder, передаем ему контекст. В метод билдера data передаем ссылку на изображение обложки из объекта Book. С помощью метода replace класса String заменяем в ссылке “http:” на “https:”, иначе картинка не будет загружаться. В метод crossfade передаем true для плавного перехода, и вызываем метод build. 

В параметр error нужно передать изображение, которое будет отображаться, если основное изображение не загрузится по какой либо- причине. В placeholder передаем картинку загрузки. 

В contentDescription передаем строку описания, а в contentScale указываем способ мастабирования картинки – ContentScale.Crop. 

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

@Composable
fun BooksGridScreen(
    books: List<Book>,
    modifier: Modifier
) {
    LazyVerticalGrid(
        columns = GridCells.Adaptive(150.dp), 
        contentPadding = PaddingValues(4.dp)
    ) {
        itemsIndexed(books) { _, book -> 
            BooksCard(book = book, modifier)
        }
    }
}

Внутри нее реализуем LazyVerticalGrid – ленивую сетку, которая будет подгружаться по мере прокручивания. Параметру columns предаем тип GridCells.Adaptive  со значением 150 dp. Это позволит нам разместить сетку шириной в две колонки на экране смартфона. Если задать меньшее значение, ширина колонок уменьшится, а количество колонок увеличится, чтобы заполнить всю ширину экрана. Количество колонок изменяется адаптивно в зависимости от размера экрана. При повороте экрана количество колонок увеличивается, чтобы заполнить весь экран, а их относительная ширина сохраняется.  

 Параметр contentPadding позволяет указать отступ по краям списка. 

 Внутри LazyVerticalGrid вызываем метод itemsIndexed и передаем ему список книг, затем идет лямбда, в которой первым параметром будет индекс, а вторым – экземпляр класса Book из списка. Вызываем здесь ранее созданную Composable функцию BooksCard и передаем ей экземпляр Book для отображения. 

Теперь создадим объединяющий компонент HomeScreen, внутри которого будут отображаться все написанные нами экраны.  

import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.example.bookshelf.ui.BooksUiState

@Composable
fun HomeScreen(
    booksUiState: BooksUiState,
    retryAction: () -> Unit,
    modifier: Modifier
) {
    when (booksUiState) {
        is BooksUiState.Loading -> LoadingScreen(modifier)
        is BooksUiState.Success -> BooksGridScreen(
            books = booksUiState.bookSearch,
            modifier = modifier
        )
        is BooksUiState.Error -> ErrorScreen(retryAction = retryAction, modifier)
    }
}

Пишем функцию HomeScreen, аргументами которой будут экземпляр BooksUiState, передающий состояние и данные из ViewModel, функция retryAction, для обработки кнопки загрузки на экране ошибки, и модификатор.  

В теле функции HomeScreen пишем условный оператор when, который будет, в зависимости от состояния BooksUiState вызывать такие функции: экран загрузки, экран со списком книг или экран ошибки. 

Отдельно создадим файл BooksApp, внутри которого напишем одноименную composable функцию.

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.bookshelf.R
import com.example.bookshelf.ui.screens.HomeScreen


@Composable
fun BooksApp(
    modifier: Modifier = Modifier
) {
    val booksViewModel: BooksViewModel =
        viewModel(factory = BooksViewModel.Factory)

    Scaffold(
        modifier = modifier.fillMaxSize(),
       topBar = {
           TopAppBar (
               title = {
                   Text(text = stringResource(id = R.string.app_name))
               }
           )
       }

    ) {
        Surface(modifier = modifier
            .fillMaxSize()
            .padding(it),
            color = MaterialTheme.colors.background
        ) {
            HomeScreen(
                booksUiState = booksViewModel.booksUiState,
                retryAction = { booksViewModel.getBooks() },
                modifier = modifier
            )
        }
    }
}

Здесь мы инициализируем наш ViewModel через фабрику. Добавим компонент Scaffold – это корневая composable функция для размещения всех экранных компонентов. В его параметр appbar передадим TopAppBar – это верхняя панель, также зададим ей свойство текст с названием приложения. 

Внутри Scaffold добавляем компонент Surface – это поверхность для отрисовки компонентов. Surface следует общим шаблонам материального дизайна, и вы можете адаптировать его, изменив тему своего приложения.  Но это выходит за рамки нашего урока. 

Внутри Surface добавим наш HomeScreen, передадим в него состояние из ViewModel, а также реализуем функцию  retryAction, здесь будем вызывать метод ViewModel getBooks. Можно вызывать его вообще без параметров, если параметр query задать по умолчанию в классе BooksViewModel.  

Теперь идем в MainActivity, удаляем все лишнее и прописываем в методе onCreate внутри компонента BookShelfTheme наш компонент BooksApp. 

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import com.example.bookshelf.ui.BooksApp
import com.example.bookshelf.ui.theme.BookShelfTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BookShelfTheme {
                BooksApp()
            }
        }
    }
}

Тестируем приложение

Давайте запустим наше приложение и проверим, как оно работает. На экране после изображения лоадера должен появится список – сетка с изображениями обложек книг и их названиями. 

Как создать мобильное приложение на Kotlin с Jetpack Compose, REST API и Clean Architecture. Часть 2. Слой UI

Сразу отрабатывает экран загрузки, лоадер отображается по центру. Загружается список, карточки книг нормально отображаютя в списке, список скроллится, подгружая картинки по мере прокрутки.  

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

Как создать мобильное приложение на Kotlin с Jetpack Compose, REST API и Clean Architecture. Часть 2. Слой UI

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

Исходный код проекта можно скачать здесь.

Коментарі: 2
  1. anjey
    anjey

    Вітаю. А в мене книжки не завантажуються! Спрацювує екран завантаження та помилки. При натисканні кнопки Retry – ситуація повторюється. Екран завантаження та помилки. Можно якось в LogCat подивитися відповідь з сервера?

    1. Виталий Непочатов
      admin (автор)

      В BooksViewModel в catch (e: HttpException) { допишіть e.printStackTrace()

Додати коментар