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

TrendNow: Создание новостного Android-приложения с помощью Jetpack Compose. Часть 1

Добавление новостных тем в приложение TrendNow. Часть 2

В этой статье добавлю фильтр трендовых новостей на основе выбранной пользователем темы. Выбранная тема будет сохранена локально.

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

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

  • Добавить возможность выбора темы новости в разделе тем.
  • Сделать выбранную тему постоянной и сохранять ее локально с помощью DataStore.
  • Изменить поток NewsScreen и получать трендовые новости на основе выбранной темы.

Создание репозитория пользовательских настроек

interface UserPrefRepository {
val selectedTopic: Flow<String>
suspend fun setSelectedTopic(topicId: String)

companion object {
val selectedTopicPref = stringPreferencesKey("selected_topic")

const val DEFAULT_TOPIC = "general"
}
}

@Singleton // для внедрения зависимости Hilt
class UserPrefRepositoryImpl @Inject constructor(
private val dataStore: DataStore<Preferences>
) : UserPrefRepository {

override val selectedTopic: Flow<String> = dataStore.data
// пока что сделаем "general" выбранной темой по умолчанию
.map { it[selectedTopicPref] ?: UserPrefRepository.DEFAULT_TOPIC }

override suspend fun setSelectedTopic(topicId: String) {
dataStore.edit { it[selectedTopicPref] = topicId }
}
}
  • Использование неизменяемого потока для публичного selectedTopic позволяет предотвратить непреднамеренное изменение. Модификация должна производиться непосредственно в DataStore через функцию setSelectedTopic.

Репозиторий создается в файле UserPrefRepository.kt в каталоге data/repository/.

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

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

Создаю новый модуль UserPrefModule, чтобы зарегистрировать репозиторий в модуле Hilt для внедрения зависимостей.

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

private const val NEWS_PREFERENCES = "news_preferences"

@Provides
@Singleton
fun provideDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> = PreferenceDataStoreFactory.create {
context.preferencesDataStoreFile(NEWS_PREFERENCES)
}

@Provides
@Singleton
fun provideUserPrefRepository(
dataStore: DataStore<Preferences>
): UserPrefRepository = UserPrefRepositoryImpl(dataStore)
}
  • Использую @Singleton и регистрирую на SingletonComponent, потому что хранилище будет использоваться глобально во всем приложении.

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

di/
├── NetworkModule.kt
├── UserPrefModule.kt

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

Обработка изменений выбранной темы

Выполнить это не так-то просто: нужно убедиться, что изменения selectedTopic не вызовут ненужной рекомпозиции, поскольку и trendingNews, и topics зависят от этого. Перепробовав несколько подходов, я решил остановиться на следующем:

  • Сделать NewsViewModel и TopicsViewModel  наблюдающими за selectedTopic. Надо, чтобы у каждой ViewModel была своя ответственность.
  • NewsViewModel будет обрабатывать fetchTrendingNews() при изменении selectedTopic.
  • TopicsViewModel будет обрабатывать состояние выбранных тем новостей.

Получение трендовых новостей в NewsViewModel

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

...

init {
viewModelScope.launch {
// наблюдение за потоком selectedTopic в DataStore
userPrefRepository.selectedTopic
// пропускать эмиты, когда значение пустое
.filter { it.isNotBlank() }
// пропускать эмиты, когда значения идентичны
.distinctUntilChanged()
.collect {
// получение трендовых новостей каждый раз при смене выбранной темы
fetchTrendingNews()
}
}
}

...
}
  • Наблюдение за потоком selectedTopic в DataStore реализуется через UserPrefRepository.
  • Ранее функция fetchTrendingNews() вызывалась при открытии NewsScreen. Теперь fetchTrendingNews() будет вызываться каждый раз при изменении значения selectedTopic.
  • filter: используется для фильтрации эмитов (emits) и предотвращения запуска эмитов при пустом значении.
  • distinctUntilChanged(): используется для предотвращения срабатывания эмитов с одинаковым значением.
  • Комбинированное использование filter и distinctUntilChanged() обеспечивает срабатывание эмитов только один раз при открытии NewsScreen, предотвращая ненужную рекомпозицию. Поскольку существует задержка при получении значения из DataStore, в течение этого времени StateFlow будет выдавать два разных значения — начальное и фактическое.
  • Сбор потока состояний selectedTopic внутри viewModelScope, чтобы он следовал жизненному циклу ViewModel .

После этого обновляю реализацию fetchTrendingNews().

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

...

private fun fetchTrendingNews() {
// обновить состояние для того,
// чтобы показывать круговую загрузку
_trendingNewsUiState.update {
_trendingNewsUiState.value.copy(loading = true)
}

viewModelScope.launch {
val result = newsRepository.fetchTrendingNews(
// здесь установить выбранную тему
topic = userPrefRepository.selectedTopic.first(),
language = "en"
)
...
}
}
}
  • Обновление трендовых новостей для отображения загрузки.
  • Передача значения selectedTopic в параметр запроса для получения трендовых новостей. Значение берется непосредственно из DataStore.

Обновление выбранной темы в разделе TopicsSection

Эта задача тоже непростая: необходимо свести к минимуму ненужные рекомпозиции. В разделе TopicsSection две переменных могут вызвать рекомпозицию:

  1. Значение topics, изменяемое после завершения функции fetchTopics().
  1. Изменяемое значение selectedTopic.

Чтобы справиться с этим, надо:

  • заставить fetchTopics() ждать значение selectedTopic перед выполнением;
  • объединить selectedTopic и topics в одно UI-состояние и обновлять их одновременно.
data class TopicsUiState(
val topics: List<Topic> = listOf(),
val selectedTopic: String = ""
)

Сначала создаю UI-состояние topics. Этот класс данных создается в файле TopicsUiState.kt в каталоге ui/features/news/topic/.

ui/
├── features/
│ ├── news/
│ │ ├── topic/
│ │ │ ├── TopicsUiState.kt

Затем изменяю реализацию в TopicsViewModel для обработки первого открытия NewsScreen.

@HiltViewModel
class TopicsViewModel @Inject constructor(
private val newsRepository: NewsRepository,
// внедрение UserPrefRepository
private val userPrefRepository: UserPrefRepository
) : ViewModel() {

...

private val _uiState = MutableStateFlow(TopicsUiState())
val uiState: StateFlow<TopicsUiState> = _uiState

// наблюдение за selectedTopic в DataStore
private val selectedTopic = userPrefRepository.selectedTopic
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = ""
)

...

init {
viewModelScope.launch {
// ожидание получения значения selectedTopic
selectedTopic
// пропуск начального значения
.filter { it.isNotBlank() }
// наблюдение только за временем, остановка после получения первого эмита
.take(1)
.collect {
// получение тем новостей, когда готово значение selectedTopic
fetchTopics()
}
}
}

private fun fetchTopics() {
...
viewModelScope.launch {
val result = newsRepository.fetchSupportedTopics()
when (result) {
is ApiResult.Success -> {
_uiState.update {
_uiState.value.copy(
// настройка тем
topics = result.data,
// и значения selectedTopic
selectedTopic = selectedTopic.value
)
}
}
...
}
...
}
}
}
  • Реализация несколько сложна, но, благодаря ей, композиция TopicsSection будет срабатывать только один раз при открытии NewsScreen.
  • Сбор selectedTopic выполняется только один раз, чтобы вызвать fetchTopics().
  • После завершения функции fetchTopics() обновляется UI-состояние topics, устанавливаются topics и selectedTopics. Таким образом, рекомпозиция будет вызвана только один раз.

Затем обрабатываются изменения значения selectedTopic.

@HiltViewModel
class TopicsViewModel @Inject constructor(
private val newsRepository: NewsRepository,
// внедрение UserPrefRepository
private val userPrefRepository: UserPrefRepository
) : ViewModel() {

...

init {
...

viewModelScope.launch {
// наблюдение за selectedTopic в DataStore
userPrefRepository.selectedTopic
// пропуск начального значения
.filter { it.isNotBlank() }
// пропуск первого фактического значения, так как оно уже было предварительно обработано
.drop(1)
// пропуск при условии идентичности значений
.distinctUntilChanged()
.collect { topic ->
// обновление значения selectedTopic UI-состояния
_uiState.update {
_uiState.value.copy(selectedTopic = topic)
}
}
}
}

...

/**
* Сохранить и обновить selectedTopic
* @param topicId Идентификатор темы из [Topic.id]
*/
fun selectTopic(topicId: String) = viewModelScope.launch {
userPrefRepository.setSelectedTopic(topicId)
}
}
  • Наблюдаю за изменениями selectedValue, но пропускаю первое значение, чтобы обновить UI-состояние topics (поскольку оно уже было обработано ранее).
  • Это вызовет еще одну рекомпозицию для обновления визуального отображения выбранной темы (selectedTopic).

Наконец, вызываю selectTopic(…) из composable-функции TopicsSection, чтобы сохранить и обновить выбранную тему.

@Composable
fun TopicsSection(
modifier: Modifier = Modifier,
viewModel: TopicsViewModel,
topicListState: LazyListState = rememberLazyListState()
) {
// сбор потока UI-состояния
val uiState by viewModel.uiState.collectAsState()

TopicsRow(
// передача выбранной темы
selectedTopic = uiState.selectedTopic,
// передача темы
topics = uiState.topics,
...
) { topic ->
// сохранение и обновление выбранной темы
// использование ID темы в качестве идентификатора
viewModel.selectTopic(topic.id)
}
}

@Composable
private fun TopicsRow(
modifier: Modifier,
// добавление выбранной темы
selectedTopic: String,
topics: List<Topic>,
topicListState: LazyListState,
// добавить слушатель onItemClick
onItemClick: (topic: Topic) -> Unit
) {
LazyRow(
...
) {
items(topics) { topic ->
FilterChip(
...,
// проверка, является ли текущая тема идентичной selectedTopic или нет
selected = topic.id == selectedTopic,
onClick = {
// запуск onItemClick
onItemClick(topic)
},
)
}
}
}

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

Количество рекомпозиций в Layout inspector (инспекторе макета)

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

  • Верхнее изображение (первое): количество рекомпозиций при открытии приложения.
  • Нижнее изображение (второе): количество рекомпозиций при изменении значения selectedTopic.

Для сравнения: количество рекомпозиций с использованием LaunchedEffect при изменении значения selectedTopic будет выглядеть так, как показано на приведенном ниже изображении.

@Composable
fun TopicsSection(
modifier: Modifier = Modifier,
viewModel: TopicsViewModel,
topicListState: LazyListState = rememberLazyListState()
) {
// сбор потока состояния topics
val topics by viewModel.topics.collectAsState()
// сбор потока состояния selectedTopic
val selectedTopic by viewModel.selectedTopic.collectAsState()

LaunchedEffect(selectedTopic) {
// здесь - получение тем при изменении значения selectedTopic;
// добавить условие в fetchTopics() для получения только тогда, когда
// значение selectedTopic не является пустым и topics является пустым (не загружено)
viewModel.fetchTopics()
}

TopicsRow(
selectedTopic = uiState.selectedTopic,
topics = uiState.topics,
...
) { topic ->
...
}
}
Количество рекомпозиций при использовании LaunchedEffect

Экран новостей

После всех вышеописанных изменений экран новостей (NewsScreen) будет выглядеть следующим образом:

Итоги реализации DataStore для сохранения пользовательских предпочтений

  • Хотя DataStore служит той же цели, что и SharedPreferences, его использование совершенно иное.
  • Асинхронный процесс в DataStore требует другого подхода к получению значений по сравнению с синхронным поведением SharedPreferences.
  • Мне нравится возможность наблюдения DataStore, которая согласуется с реактивным подходом при использовании Jetpack Compose. Но поскольку получение значения DataStore происходит асинхронно, приходится искать оптимальный подход для предотвращения ненужной рекомпозиции, что довольно сложно.

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

Ознакомьтесь с полным исходным кодом на Github

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

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


Перевод статьи Dani Mahardhika: Save User Preferences with DataStore and Optimize Recomposition

Предыдущая статьяC++: практическое руководство по rotate
Следующая статьяПринципы SOLID на Go