В предыдущих двух статьях я отобразил трендовые новости и темы в приложении. Можете ознакомиться с этими статьями, если пропустили их:
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 две переменных могут вызвать рекомпозицию:
- Значение topics, изменяемое после завершения функции
fetchTopics().
- Изменяемое значение 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 ->
...
}
}

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

Итоги реализации DataStore для сохранения пользовательских предпочтений
- Хотя
DataStoreслужит той же цели, что иSharedPreferences, его использование совершенно иное.
- Асинхронный процесс в
DataStoreтребует другого подхода к получению значений по сравнению с синхронным поведениемSharedPreferences.
- Мне нравится возможность наблюдения
DataStore, которая согласуется с реактивным подходом при использовании Jetpack Compose. Но поскольку получение значенияDataStoreпроисходит асинхронно, приходится искать оптимальный подход для предотвращения ненужной рекомпозиции, что довольно сложно.
В следующей статье я расскажу, как реализовал бесконечную прокрутку списка трендовых новостей, позволяющую загрузить больше новостного контента.
Ознакомьтесь с полным исходным кодом на Github.
Читайте также:
- Создание снэкбара с обратным отсчетом времени в Android с помощью Jetpack Compose
- 5 функций-расширений в арсенале каждого разработчика Jetpack Compose
- Как создать импульсный эффект в Jetpack Compose
Читайте нас в Telegram, VK и Дзен
Перевод статьи Dani Mahardhika: Save User Preferences with DataStore and Optimize Recomposition






