Я занимаюсь 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.
Читайте также:
- 5 функций-расширений в арсенале каждого разработчика Jetpack Compose
- Рекомпозиция в Jetpack Compose и View-рендеринг на основе XML: в чем разница?
- Jetpack Compose Canvas: 10 практических примеров
Читайте нас в Telegram, VK и Дзен
Перевод статьи Dani Mahardhika: Build an Android News App with Jetpack Compose





