В предыдущей статье я рассказал о создании основной функциональности приложения «TrendNow», сосредоточившись на настройке проекта, отображающего трендовые новости с помощью Jetpack Compose.
В этой статье речь пойдет о добавлении в приложение новой функции — темы новостей.
Напоминания:
- Исходный код в репозитории Github может отличаться от приведенных здесь фрагментов кода, поскольку я работал над ним до написания этой статьи.
- В приложении я использовал Bonai News API от rapidapi.com (это бесплатно) с возможностью получить API-ключ здесь: rapidapi.com/bonainewsapi.
Что я собираюсь сделать?
- Добавить новый раздел для отображения поддерживаемых тем на
NewsScreen(экране новостей) с помощью конечной точки info/topics поверх списка трендовых новостей.
- Темы должны быть частью элементов
LazyColumn.
Создание модели тем
{
"success": true,
"data": [
{
"id": "business",
"name": "Business",
"subtopics": [
{
"id": "cryptocurrency",
"name": "cryptocurrency"
},
...
]
},
...
]
}
Приведенная выше json-структура — пример ответа от конечной точки info/topics. На ее основе будет создаваться модель.
Есть только одна модель Topic для объекта внутри массива data. Подтемы пока проигнорирую.
data class Topic(val id: String, val name: String)
Модель создается в файле Topic.kt в каталоге data/model/.
data/
├── model/
│ ├── News.kt
│ ├── Topic.kt
Поскольку ответ немного отличается от PaginationResponse, который был создан в предыдущей статье, создам новый класс данных для ответа Retrofit.
// используйте обобщенный тип, чтобы ответ можно было повторно использовать для разных моделей
data class BasicResponse<T>(val success: Boolean, val data: T)
Класс данных ответа создается в файле BasicResponse.kt в каталоге data/api/response/.
data/
├── api/
│ ├── response/
│ │ ├── BasicResponse.kt
│ │ ├── PaginationResponse.kt
Ознакомьтесь с исходным кодом на Github: Topic.kt и BasicResponse.kt.
Обновление сервиса Retrofit
interface NewsService {
...
@GET("info/topics")
suspend fun fetchSupportedTopics(): Response<BasicResponse<List<Topic>>>
}
Рассматривая темы как часть существующего сервиса новостей NewsService, добавлю туда функцию fetchSupportedTopics() вместо того, чтобы создавать новый сервис.
Ознакомьтесь с исходным кодом на Github: NewsService.kt.
Обновление источника новостных данных
interface NewsDataSource {
...
suspend fun getSupportedTopics(): ApiResult<List<Topic>>
}
Как и в случае с NewsService, буду рассматривать темы как часть существующего источника данных NewsDataSource, добавив туда функцию getSupportedTopics() вместо того, чтобы создавать новый источник данных.
Удаленный источник новостных данных
@ActivityRetainedScoped // для внедрения зависимости Hilt
class NewsRemoteDataSource @Inject constructor(
private val retrofit: Retrofit
) : NewsDataSource {
...
override suspend fun getSupportedTopics(): ApiResult<List<Topic>> =
try {
val response = retrofit.create(NewsService::class.java)
// получение данных тем из конечной точки info/topics
.fetchSupportedTopics()
val body = response.body()
if (body != null) {
ApiResult.Success(body.data)
} else {
// ЧТО НУЖНО СДЕЛАТЬ: обработать сообщение об ошибке
}
} catch (e: Exception) {
// ЧТО НУЖНО СДЕЛАТЬ: обработать другую ошибку
}
Обновляю NewsRemoteDataSource, чтобы добавить реализацию getSupportedTopics(), которая только что была создана в интерфейсе NewsDataSource.
Ознакомьтесь с исходным кодом на Github: NewsDataSource.kt и NewsRemoteDataSource.kt.
Обновление репозитория новостей
interface NewsRepository {
...
suspend fun fetchSupportedTopics(): ApiResult<List<Topic>>
}
@ActivityRetainedScoped
class NewsRepositoryImpl @Inject constructor(
private val remoteDataSource: NewsRemoteDataSource
) : NewsRepository {
...
override suspend fun fetchSupportedTopics(): ApiResult<List<Topic>> =
withContext(Dispatchers.IO) {
remoteDataSource.getSupportedTopics()
}
}
Обновляю NewsRepository, чтобы добавить функцию fetchSupprtedTopics(), и NewsRepositoryImpl, чтобы добавить реализацию.
Ознакомьтесь с исходным кодом на Github: NewsRepository.kt.
Создание ViewModel тем
Хотя я использую прежние новостной сервис, источник данных и репозиторий, в части ViewModel применю другой подход. Создам новую ViewModel — TopicsViewModel. Зачем?
- Чтобы разделить задачи.
- Чтобы упростить логику
ViewModel(позже это поможет при создании модульного теста).
Ранее я использовал LazyColumn для отображения новостных данных. При добавлении тем новостей за это будет отвечать новый элемент внутри LazyColumn.
LazyColumn(...) {
item(...) {
// раздел тем новостей
// элементы для горизонтальной прокрутки
}
items(items = trendingNews, ...) {
// список актуальных новостей
}
}
- Используя отдельную
ViewModel, я хочу, чтобы раздел тем новостей имел свой независимый жизненный цикл. Это позволит избежать тесной связи с трендовыми новостями.
- При таком подходе изменения в
TopicsViewModel(такие как обновленияStateFlow) будут оставаться изолированными и не вызовут ненужных изменений или побочных эффектов в списке трендовых новостей вLazyColumn.
- Такое разделение предотвратит любые каскадные обновления и поможет улучшить общую производительность экрана.
@HiltViewModel
class TopicsViewModel @Inject constructor(
private val newsRepository: NewsRepository
) : ViewModel() {
private val _topics = MutableStateFlow<List<Topic>>(listOf())
val topics: StateFlow<List<Topic>> = _topics
// Не использую здесь состояние UI, так как
// не хочу запускать рекомпозицию для состояния загрузки
private var loading: Boolean = false
init {
// получение тем новостей при инициализации ViewModel
fetchTopics()
}
private fun fetchTopics() {
if (loading) return
loading = true
viewModelScope.launch {
val result = newsRepository.fetchSupportedTopics()
when (result) {
is ApiResult.Success -> _topics.value = result.data
is ApiResult.Error -> {
// пока что проигнорирую результат ошибки
// ЧТО НУЖНО СДЕЛАТЬ: обработать ошибку APIResult
}
}
loading = false
}
}
}
fetchTopics()вызывается внутри init, чтобы темы новостей автоматически загружались при открытии экрана.
- Мне не придется ждать завершения работы
fetchTopics(), прежде чем будут отображены трендовые новости. Приложения со сложными главными экранами, часто управляемые несколькими командами, обычно загружают каждый раздел отдельно. Хотя обработка ошибок возможна, пока пропущу ее.
- Хотя главный экран в этом приложении не сложный, учет будущего роста приложения чрезвычайно важен. Благодаря такой структуре, главный экран (в данном случае
NewsScreen) становится более масштабируемым по мере добавления новых функций.
ViewModel создается в файле TopicsViewModel.kt в каталоге ui/feature/news/topic/.
ui/
├── feature/
│ ├── news/
│ │ ├── topic/
│ │ │ ├── TopicsViewModel.kt
│ │ ├── NewsViewModel.kt
Ознакомьтесь с исходным кодом на Github: TopicsViewModel.kt.
Отображение данных о новостных темах
Я собираюсь создать composable-функцию TopicsSection для отображения новостных тем в контейнере с горизонтальной прокруткой с помощью LazyRow.
@Composable
fun TopicsSection(
modifier: Modifier = Modifier,
viewModel: TopicsViewModel,
topicListState: LazyListState = rememberLazyListState()
) {
// сбор потока состояния тем
val topics by viewModel.topics.collectAsState()
TopicsRow(
// добавьте animateContentSize,
// чтобы показывать анимацию при загрузке темы
modifier = modifier.animateContentSize(
animationSpec = tween()
),
topics = topics,
topicListState = topicListState
)
}
@Composable
private fun TopicsRow(
modifier: Modifier,
topics: List<Topic>,
topicListState: LazyListState
) {
LazyRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 8.dp
),
state = topicListState
) {
items(topics) { topic ->
FilterChip(
label = {
Text(
text = topic.name,
fontWeight = if (selected) {
FontWeight.Bold
} else {
FontWeight.Normal
}
)
},
shape = CircleShape,
selected = ..., // ЧТО НУЖНО СДЕЛАТЬ: установить выбранное состояние
onClick = {
// ЧТО НУЖНО СДЕЛАТЬ: обработка при нажатии или выборе темы
},
)
}
}
}
Можно создать еще одну composable-функцию для предварительного просмотра строки Topic следующим образом:
@Preview
@Composable
private fun TopicsRowPreview(modifier: Modifier = Modifier) {
TopicsRow(
modifier = modifier
.background(color = MaterialTheme.colorScheme.surface),
topics = listOf(
Topic(...), // заполнение тем
...
),
topicListState = rememberLazyListState()
) { }
}
Результат предварительного просмотра будет выглядеть так, как показано на приведенном ниже изображении:

Ознакомьтесь с исходным кодом на Github: TopicsSection.kt.
Реализация раздела тем на экране новостей
@Composable
fun NewsScreen(
modifier: Modifier = Modifier,
// пусть NewsScreen управляет созданием тем ViewModel,
// чтобы раздел тем использовал один и тот же экземпляр ViewModel
// когда раздел тем повторно создается при прокрутке
// (от невидимого до видимого на экране)
topicsViewModel: TopicsViewModel = hiltViewModel(),
newsViewModel: NewsViewModel = hiltViewModel()
) {
Scaffold(
...
) { innerPadding ->
LazyColumn(...) {
// сюда добавляется раздел тем ("topics-section")
// этот раздел тем не связан с состоянием загрузки
item(key = "topics-section") {
TopicsSection(
modifier = Modifier.fillParentMaxWidth(),
viewModel = topicsViewModel
)
}
if (trendingNewsUiState.loading) {
// показывать круговую загрузку
} else {
// показывать список новостей
}
}
}
}
TopicsSectionне наблюдает никаких потоков, связанных с трендовыми новостями, — он независим.- Поэтому изменения в
TopicsViewModelне будут вызывать ненужную рекомпозицию вLazyColumn.
Ознакомьтесь с полной реализацией на Github: NewsScreen.kt.
Recomposition counts (число рекомпозиций) в Layout Inspector (инспекторе макетов)
Recomposition counts при открытии приложения будет выглядеть так, как показано на приведенном ниже изображении:

NewsScreen (экран новостей)
Теперь NewsScreen будет выглядеть так, как показано на приведенном ниже изображении:


Итоги реализации раздела тем

- Добавление нескольких элементов или разделов в
LazyColumn— довольно сложная задача для новичка в работе с Jetpack Compose. - Сложность заключается в следующем: вам нужно убедиться, что изменения конкретных элементов или разделов не вызовут ненужных рекомпозиций всего
LazyColumn.
- В целом я был вполне доволен результатом, проверяя число рекомпозиций с помощью инспектора макетов Android Studio.
- Хотя некоторые из вас могут поставить под сомнение мой подход к использованию двух разных ViewModel, считаю, что он соответствует лучшим практикам и принципам ViewModel.
- Добавление отдельной ViewModel дает такие преимущества, как более тщательное разделение задач, более простое тестирование, независимая логика и более эффективная UI-композиция.
В следующей статье я расскажу, как реализовал тематический фильтр для трендовых новостей, чтобы они отображались на основе выбранной пользователем темы, а также о сохранении выбранной темы в локальном хранилище с помощью DataStore и оптимизации рекомпозиции.
Читайте также:
- Предварительный просмотр Jetpack Compose-анимации по ключевым кадрам в Android Studio
- Производительность в Jetpack Compose: стабильность и неизменяемость
- Создание снэкбара с обратным отсчетом времени в Android с помощью Jetpack Compose
Читайте нас в Telegram, VK и Дзен
Перевод статьи Dani Mahardhika: Implement Horizontal Scroll in LazyColumn Jetpack Compose





