Я занимаюсь Android-разработкой с 2016 года. С тех пор работаю Android-инженером, наблюдая за стремительной эволюцией платформы — от традиционных подходов до использования современных архитектур. Наиболее значительные изменения произошли после появления Jetpack Compose.

В этой статье расскажу о своем освоении современной разработки Android-приложений с помощью Jetpack Compose и Jetpack-библиотек. Потерпите: первая статья будет длинной.

Еще одно замечание:

  • Исходный код в репозитории Github может отличаться от приведенных здесь фрагментов кода, поскольку я работал над ним до написания этой статьи.

Что я собираюсь создать?

Приложение-агрегатор новостей «TrendNow». Я буду использовать Bonai News API от rapidapi.com — он предлагает бесплатный тариф. Получить API-ключ можно на странице: rapidapi.com/bonainewsapi.

В Bonai News API есть несколько конечных точек для получения различных новостных данных, но в этой статье речь пойдет об использовании только одной конечной точки: 

  • trendings (предназначенной для отображения трендовых новостей).

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

Планирование работы над приложением

  • Использование одной Activity с MVVM (Model-View-ViewModel — архитектурный шаблон для разделения UI от бизнес-логики и данных). Никакой чистой архитектуры (пока что): для создания довольно простого приложения это излишество.
  • Никакой модульности (пока что).
  • Отображение трендовых новостей только на первой странице, без пагинации (разбивки на страницы).

Настройка проекта

Я использую Android Studio Ladybug Feature Drop | 2024.2.2. Оставил все так, как было при создании проекта с помощью шаблона Empty Activity. Только обновил версию Compose BOM до 2024.12.01.

Для внедрения зависимостей буду использовать библиотеку Hilt с KSP (Kotlin Symbol Processing — инструмент, позволяющий создавать легковесные плагины для компилятора). 

# внутри libs.version.toml
[versions]
hilt = "2.55"
androidxHilt = "1.2.0"

[libraries]
hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-ksp = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
# это тоже нужно для Jetpack Compose
androidx-hilt-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHilt" }

При использовании Hilt KSP также необходимо установить KSP-плагин, следуя этому руководству.

Подготовка сети

Для работы с сетью буду использовать библиотеки:

  • Retrofit, OkHttp и Retrofit с Gson-конвертером (для автоматического преобразования json-ответа в класс данных Kotlin).
# внутри libs.version.toml
[versions]
retrofit = "2.11.0"
okhttp = "4.12.0"

[libraries]
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }

Создание сетевого модуля для внедрения зависимостей

После установки зависимостей нужно настроить внедрение зависимостей. Зарегистрирую Retrofit, OkHttp и Gson как синглтон на модуль Hilt.

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder().build()

@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit =
Retrofit.Builder()
// пока что используется gson по умолчанию из Retrofit
.addConverterFactory(GsonConverterFactory.create())
.client(okHttpClient)
.build()
}
  • Использовал @Singleton и зарегистрировал на SingletonComponent, потому что OkHttp и Retrofit будут применяться глобально во всем приложении.

Модуль Hilt создается в файле NetworkModule.kt в каталоге di/.

di/
├── NetworkModule.kt

Ознакомьтесь с исходным кодом на Github: NetworkModule.kt.

Настройка новостного API

После создания сетевого модуля необходимо настроить Bonai News API. Сначала устанавливаю url новостного API в качестве базового url для сервиса Retrofit.

// внутри сетевого модуля
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit =
Retrofit.Builder()
// сюда добавляется url bonai news API
.baseUrl("https://news-api14.p.rapidapi.com/v2/")
...

Теперь можно добавить API-токен в заголовок запроса, для чего использую перехватчик заголовков OkHttp.

class HeaderInterceptor : Interceptor {

override fun intercept(chain: Interceptor.Chain): Response =
chain.proceed(
chain.request()
.newBuilder()
.addHeader("Content-Type", "application/json")
.addHeader("x-rapidapi-host", "news-api14.p.rapidapi.com")
.addHeader(
"x-rapidapi-key",
// сюда добавляется API-ключ
"..."
)
.build()
)
}

Перехватчик заголовков создается в файле HeaderInterceptor.kt в каталоге /core/network/.

core/
├── network/
│ ├── HeaderInterceptor.kt
di/

Остается зарегистрировать перехватчик в OkHttp в сетевом модуле.

// внутри сетевого модуля
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
// здесь регистрируется перехватчик
.addInterceptor(HeaderInterceptor())
...

Ознакомьтесь с исходным кодом на Github: HeaderInterceptor.kt.

Создание новостных моделей

{
  "success": true,
  "data": [
    {
      "title": "Germany - Scotland: Game time and where to watch the 2024 UEFA Euro Championship match from the USA",
      "url": "https://www.marca.com/en/football/uefa-euro/2024/06/13/666a487646163f33868b45e0.html",
      "excerpt": "Germany and Scotland face off this June 14th 2024 at the Allianz Arena in the Group Aof the 2024 UEFA Euro Championship. On what TV channel can I watch the 2024 UEFA Euro match be",
      "thumbnail": "https://phantom-marca.unidadeditorial.es/8a2d8860373b30d05ddc237133e0e70b/resize/1200/f/webp/assets/multimedia/imagenes/2024/06/13/17182413687262.jpg",
      "language": "en",
      "paywall": false,
      "contentLength": 2542,
      "date": "2024-06-13T21:21:25.000Z",
      "authors": [ "CALÍOPE GARCÍA" ],
      "keywords": [
        "uefa-euro",
        "Germany National Football Team - English",
        "Soccer",
        "Deportivo - English"
      ],
      "publisher": {
        "name": "Marca.com",
        "url": "https://www.marca.com",
        "favicon": "https://www.marca.com/apple-touch-icon-precomposed.png"
      }
    }
  ],
  "size": 10,
  "totalHits": 1128,
  "hitsPerPage": 10,
  "page": 1,
  "totalPages": 113,
  "timeMs": 241
}

Приведенная json-структура — пример ответа от конечной точки trendings. На ее основе буду создавать 2 модели:

  • News — для объекта внутри массива data;
  • Publisher — для объекта publisher внутри объекта News.
// Удалил некоторые поля, которые не буду использовать.
data class News(
val title: String,
val url: String,
val excerpt: String,
val thumbnail: String,
val date: String,
val publisher: Publisher
)

data class Publisher(val name: String, val url: String, val favicon: String)

Указанные модели создаются в файле News.kt в каталоге data/model/.

di/
data/
├── model/
│ ├── News.kt

Помимо моделей, необходимо создать класс данных для ответа, который будет использоваться сервисом Retrofit.

// применяется обобщенный тип, чтобы ответ можно было повторно использовать для разных моделей
data class PaginationResponse<T>(
val success: Boolean,
val size: Int,
val page: Int,
val totalPages: Int,
val data: T
)

Приведенный класс данных ответа создается в файле PaginationResponse.kt в каталоге data/api/response/.

data/
├── api/
│ ├── response/
│ │ ├── PaginationResponse.kt
├── model/

Ознакомьтесь с исходным кодом на Github: News.kt и PaginationResponse.kt.

Создание сервиса Retrofit для интеграции с API

Конечная точка trendings поддерживает следующие параметры запроса:

  • topic — интересующая тема новости;
  • language — язык новости;
  • country — страна происхождения новости;
  • date — дата публикации новости;
  • limit — количество новостей, включаемых в ответ (поскольку я использую бесплатный тарифный план, максимальное количество — 10).
  • page — целевая страница ответа для пагинации.

На данный момент буду использовать только параметры topic и language, поскольку оба они обязательны, а остальные — опциональны. Более подробное объяснение поддерживаемых параметров запроса можно найти здесь.

interface NewsService {

    @GET("trendings")
    suspend fun fetchTrendingNews(
        @Query("topic") topic: String,
        @Query("language") language: String
        // ответ пагинации не используется напрямую,
        // так что можно просмотреть необработанный ответ позже
    ): Response<PaginationResponse<List<News>>>
}

Упомянутый сервис новостей NewsService создается в файле NewsService.kt  в каталоге data/api.

data/
├── api/
│ ├── response/
│ ├── NewsService.kt

Для стандартизации обработки ответов API надо создать класс ApiResult.

sealed class ApiResult<out T> {
data class Success<T>(val data: T, val meta: Meta = Meta()) : ApiResult<T>()
data class Error(val code: Int, val message: String) : ApiResult<Nothing>()
}

data class Meta(val size: Int = 0, val page: Int = 0, val totalPages: Int = 0)

Класс ApiResult создается в файле ApiResult.kt в каталоге core/network/.

core/
├── network/
│ ├── ApiResult.kt
data/

Ознакомьтесь с исходным кодом на Github: NewsService.kt и ApiResult.kt.

Создание источника новостных данных 

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

interface NewsDataSource {
suspend fun getTrendingNews(topic: String, language: String): ApiResult<List<News>>
}

Приведенный выше интерфейс источника новостных данных создается в файле NewsDataSource.kt в каталоге data/datasource/.

data/
├── datasource/
│ ├── NewsDataSource.kt

Удаленный источник новостных данных

@ActivityRetainedScoped // для внедрения зависимости Hilt
class NewsRemoteDataSource @Inject constructor(
private val retrofit: Retrofit
) : NewsDataSource {

override suspend fun getTrendingNews(
topic: String,
language: String
): ApiResult<List<News>> = try {
val response = retrofit.create(NewsService::class.java)
// получение данных новостей из конечной точки trendings
.fetchTrendingNews(topic = topic, language = language)
val body = response.body()
if (body != null) {
ApiResult.Success(
body = body.data,
meta = Meta(body.size, body.page, body.totalPages)
)
} else {
// ЧТО НУЖНО СДЕЛАТЬ: обработать сообщение об ошибке
}
} catch (e: Exception) {
// ЧТО НУЖНО СДЕЛАТЬ: обработать другую ошибку
}
}

Буду использовать @ActivityRetainedScoped. Думаю, этого достаточно для указания того, что данная зависимость относится только к жизненному циклу Activity, а не к жизненному циклу приложения.

Вышеуказанный удаленный источник данных создается в файле NewsRemoteDataSource.kt в каталоге data/datasource/.

data/
├── datasource/
│ ├── NewsDataSource.kt
│ ├── NewsRemoteDataSource.kt

Создание репозитория новостей

interface NewsRepository {
suspend fun fetchTrendingNews(
topic: String,
language: String,
): ApiResult<List<News>>
}

@ActivityRetainedScoped // для внедрения зависимости Hilt
class NewsRepositoryImpl @Inject constructor(
private val remoteDataSource: NewsRemoteDataSource
) : NewsRepository {

override suspend fun fetchTrendingNews(
topic: String,
language: String,
): ApiResult<List<News>> = withContext(Dispatchers.IO) {
remoteDataSource.getTrendingNews(
topic = topic,
language = language
)
}
}
  • Аналогично NewsDataSource, использую @ActivityRetainedScoped для указания того, что данная зависимость относится только к жизненному циклу Activity.
  • Централизую обработку контекста корутины внутри репозитория, чтобы избежать накладных расходов на переключение контекста корутины.

Указанный репозиторий создается в файле NewsRepository.kt в каталоге data/repository/.

data/
├── datasource/
├── repository/
│ ├── NewsRepository.kt

Ознакомьтесь с полной реализацией на Github: NewsRemoteDataSource.kt и NewsRepository.kt.

Регистрация источника данных и репозитория для внедрения зависимостей

После создания источника данных и репозитория новостей необходимо зарегистрировать их в модуле Hilt для внедрения зависимостей.

@Module
@InstallIn(ActivityRetainedComponent::class)
object NewsModule {

@Provides
@ActivityRetainedScoped
fun provideNewsRepository(
remoteDataSource: NewsRemoteDataSource
): NewsRepository = NewsRepositoryImpl(remoteDataSource)

@Provides
@ActivityRetainedScoped
fun provideNewsRemoteDataSource(
retrofit: Retrofit
): NewsRemoteDataSource = NewsRemoteDataSource(retrofit, gson)
}

Использую @ActivityRetainedScoped и регистрируюсь на ActivityRetainedComponent, потому что оба они используют Activity Retained Scoped.

Приведенный выше модуль новостей создается в файле NewsModule.kt в каталоге di/.

di/
├── NetworkModule.kt
├── NewsModule.kt

Ознакомьтесь с исходным кодом на Github: NewsModule.kt.

Создание новостной модели ViewModel

Прежде чем создавать ViewModel, надо создать класс данных для хранения UI-состояния трендовых новостей.

data class NewsUiState(
val data: List<News> = listOf(),
val success: Boolean = false,
val loading: Boolean = true
)

 UI-состояние создается в файле NewsUiState.kt в каталоге ui/feature/news/.

data/
ui/
├── feature/
│ ├── news/
│ │ ├── NewsUiState.kt

Теперь можно приступить к созданию ViewModel.

@HiltViewModel
class NewsViewModel @Inject constructor(
private val newsRepository: NewsRepository
) : ViewModel() {

private val _trendingNewsUiState = MutableStateFlow(
// значение загрузки по умолчанию устанавливается на true,
// чтобы состояние экрана загружалось при первом открытии
NewsUiState(loading = true)
)
val trendingNewsUiState: StateFlow<NewsUiState> = _trendingNewsUiState

init {
// получение трендовых новостей при инициализации ViewModel
fetchTrendingNews()
}

private fun fetchTrendingNews() = viewModelScope.launch {
val result = newsRepository.fetchTrendingNews(
// пока что будет жестко закодированы значения topic и language
topic = "general",
language = "en"
)
when (result) {
is ApiResult.Success -> {
_trendingNewsUiState.value = _trendingNewsUiState.value.copy(
// обновление состояния загрузки
loading = false,
data = result.data
)
}
is ApiResult.Error -> {
// Пока что оставлю это место пустым
// ЧТО НУЖНО СДЕЛАТЬ: обработать ошибку APIResult
}
}
}
}
  • fetchTrendingNews() вызывается внутри init, чтобы при открытии экрана автоматически загружались трендовые новости.

ViewModel создается в файле NewsViewModel.kt в каталоге ui/feature/news/.

ui/
├── feature/
│ ├── news/
│ │ ├── NewsViewModel.kt
│ │ ├── NewsUiState.kt

Ознакомьтесь с исходным кодом на Github: NewsUiState.kt и NewsViewModel.kt.

Отображение новостных данных 

Прежде чем перейти к экрану, надо создать UI-компонент для отображения новостей.

Компонент карточки новостей (NewsCard) 

Для появления на экране новостных изображений буду использовать Coil. Подобно Glide (который я использую уже долгое время), Coil применяется для асинхронной загрузки изображений из url.

// зависимость Coil внутри libs.version
[versions]
coil = "3.0.4"

[libraries]
coil = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }

После установки можно использовать composable-функцию AsyncImage для загрузки изображения в компонент NewsCard. 

@Composable
fun NewsCard(modifier: Modifier = Modifier, news: News, onClick: () -> Unit) {
Column(
modifier = modifier.clickable(onClick = onClick)
) {
Row(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
// установите вес 1f, чтобы эта часть
// заняла свободное место в родительском Row выше
.weight(1f)
.padding(end = 16.dp)
) {
// используется Surface, чтобы задать цвет фона
// в качестве заполнителя загрузки
Surface(
color = MaterialTheme.colorScheme.secondary,
// настройте shape, чтобы автоматически обрезать изображение внутри
shape = CircleShape
) {
// изображение издателя
AsyncImage(
model = news.publisher.favicon,
modifier = Modifier.size(16.dp),
...
)
}
// имя издателя
Text(
text = " – ${news.publisher.name}",
style = MaterialTheme.typography.labelSmall,
maxLines = 1
)
}
// дата новости
Text(
text = news.timeSince(),
style = MaterialTheme.typography.labelSmall,
maxLines = 1
)
}
// как и в случае с изображением издателя
// используется Surface, чтобы задать цвет фона в качестве заполнителя загрузки
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 16.dp)
.height(164.dp),
color = MaterialTheme.colorScheme.secondary,
// настройте shape, чтобы автоматически обрезать изображение внутри
shape = RoundedCornerShape(12.dp)
) {
// изображение новости
AsyncImage(
model = news.thumbnail,
...
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
}
// заголовок новости
Text(
text = news.title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(start = 16.dp, end = 16.dp)
)
// краткое описание новости
Text(
text = news.excerpt,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp)
)
// горизонтальная линия в нижней части NewsCard
HorizontalDivider(modifier = Modifier.padding(top = 16.dp))
}
}

Можно создать еще одну composable-функцию для предварительного просмотра NewsCard следующим образом:

@Preview
@Composable
private fun NewsCardPreview(modifier: Modifier = Modifier) {
NewsCard(
modifier = modifier
.background(color = MaterialTheme.colorScheme.surface),
news = News(...) // заполните необходимое поле для новости
) { }
}

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

  • Значение «1w ago» (одна неделя назад) генерируется с помощью функции расширения News.timeSince(). Она генерирует дату, читаемую человеком.
  • Ознакомьтесь с исходным кодом функции News.timeSince() на Github: NewsExt.kt.

Composable-функция компонента NewsCard, представленная выше, создается в файле NewsCard.kt в каталоге ui/feature/news/component.

data/
ui/
├── feature/
│ ├── news/
│ │ ├── component
│ │ │ ├── NewsCard.kt

Ознакомьтесь с исходным кодом на Github: NewsCard.kt.

Новостной экран (NewsScreen)

После создания NewsCard все готово к отображению новостных данных.

@OptIn(ExperimentalMaterial3Api::class) // так как TopAppBar - все еще экспериментальная функция
@Composable
fun NewsScreen(
modifier: Modifier = Modifier,
newsViewModel: NewsViewModel = hiltViewModel()
) {
// сбор трендовых новостей, поток UI-состояния
val trendingNewsUiState by newsViewModel.trendingNewsUiState.collectAsState()

Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
// верхняя панель (top bar) для отображения названия приложения
TopAppBar(
title = {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineSmall,
fontFamily = FontFamily.Serif,
fontWeight = FontWeight.Black
)
}
)
}
) { innerPadding ->
LazyColumn(
// необходим внутренний отступ, чтобы
// добавить отступы для панели состояния и нижней панели
modifier = Modifier.padding(innerPadding)
contentPadding = PaddingValues(top = 0.dp, bottom = 16.dp),
) {
// показывать круговую загрузку при загрузке состояния экрана
if (trendingNewsUiState.loading) {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
// поскольку я не обрабатываю состояние ошибки (пока),
// просто отображаю доступные данные, когда они не загружаются
items(
items = trendingNewsUiState.data,
// установить ключ для элементов
key = { it.url.hashCode() }
) { news ->
// создание NewsCard
NewsCard(
modifier = Modifier.fillParentMaxWidth(),
news = news
) {
// ЧТО НУЖНО СДЕЛАТЬ: обработать клик по NewsCard
}
}
}
}
}
}
  • Добавляю Scaffold в экран, а не в Activity, чтобы дать экрану возможность гибко настраивать верхнюю панель приложения.
  • Использую элемент LazyColumn, обладающий возможностью прокрутки и оптимизированный для отображения большого количества данных; элемент будет уничтожаться и создаваться заново при прокрутке. Этот LazyColumn похож на RecyclerView.
  • Устанавливаю уникальный key для элемента LazyColumn в качестве идентификатора, чтобы избежать ненужной перекомпоновки при обновлении списка (не совсем понимаю принцип его работы «под капотом», планирую изучить это поведение позже).
  • UI будет обновляться, когда поток состояния trendingNewsUiState будет выдавать новое значение. Это реактивный процесс.

Теперь можно вызвать composable-функцию NewsScreen из MainActivity.

@AndroidEntryPoint // для внедрения зависимости Hilt
class MainActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
TrendNowTheme {
NewsScreen()
}
}
}
}

На экране появятся результаты, как на приведенных ниже изображениях:

NewsScreen создается в файле NewsScreen.kt в каталоге ui/feature/news/.

ui/
├── feature/
│ ├── news/
│ │ ├── NewsScreen.kt

Ознакомьтесь с полной реализацией на Github: NewsScreen.kt.

Подведение итогов первого этапа создания приложения 

  • Настройка проекта, сети, внедрения зависимостей, источника данных и репозитория занимает довольно много времени.
  • Но как только это сделано, создание UI происходит довольно быстро. Декларативный подход Jetpack Compose упрощает создание UI, делая его интуитивно понятным и эффективным по сравнению с традиционными Android-подходами.
  • Возможность создавать компоненты прямо в коде и осуществлять предварительный просмотр, не запуская приложение, ощущается как переломный момент.
  • Подход Jetpack Compose к созданию UI напоминает Flutter-разработку (я также занимаюсь разработкой приложений с использованием Flutter с 2021 года, но предпочитаю Compose из-за Kotlin).

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

  • Bonai News API предлагает дополнительные конечные точки, которые могут быть интегрированы в новые функции.
  • Библиотеки Android Jetpack богаты различными возможностями, но в этом проекте еще многое предстоит изучить и реализовать.

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

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Dani Mahardhika: Build an Android News App with Jetpack Compose

Предыдущая статьяcin.ignore() на C++
Следующая статьяОт кода до APK: полный разбор задач Android-сборки