Из этой статьи вы узнаете, как Kotlin Flow позволяет отображать на экране состояние UI с помощью типа Observable, а также ознакомитесь с парадигмой реактивного программирования в целом. Итак, начнем.
Отображение состояния UI на экране в Android
Для начала напомню, что обычно состояние UI экспонируют на экране во ViewModel, поскольку именно здесь размещается бизнес-логика и создается состояние UI приложения путем объединения данных из слоя данных репозитория. Даже состояние такого UI-элемента, как Textfield, может быть размещено во ViewModel, если бизнес-логика чтения или записи нуждается в этом.
Теперь перейдем к нашей теме. UI должен быть реактивным, то есть не напрямую получать данные из ViewModel, а собирать их. При изменении данных он мгновенно уведомляет коллектор. В данном случае UI — результат работы Kotlin Flow. Рассмотрим подробно этот тип программирования и его специфическую для Android реализацию Kotlin Flow.
Парадигма реактивного программирования
Реактивное программирование — парадигма программирования, используемая во многих современных приложениях с графическим интерфейсом, мобильных и веб-приложениях. Она направлена на создание программного обеспечения, реагирующего на изменения данных и события в режиме реального времени. Это ключевой компонент современной разработки программного обеспечения.
Реактивное программирование использует логику асинхронного программирования для обработки потоков данных и распространения изменений. В его основе лежат концепции потоков и наблюдаемых объектов.
Потоки (streams) — последовательность текущих данных, например отправка текущей информации о местоположении на карте, воспроизведение видео или обновление фондового рынка.
Наблюдаемые объекты (observables) — тип потоков, который можно наблюдать, чтобы реагировать на входящие данные и слушать их без необходимости извлекать данные вручную.
Реализация реактивной парадигмы
ReactiveX (Rx, Reactive Extensions) — библиотека, изначально созданная компанией Microsoft для разработки асинхронных и событийно-ориентированных программ с использованием наблюдаемых последовательностей.
Эта библиотека является средством реализации реактивного программирования, предоставляя концептуальные проекты для реализации инструментов на различных языках программирования, таких как RxJava, RxJS, Rx.NET и другие.
Как реализовать реактивный подход для UI на Android
Для этого конкретного случая можно использовать Asynchronous Flow как часть Kotlin-корутин. В отличие от suspend-функции в корутине, возвращающей одно значение, Flow — тип, генерирующий потоки данных последовательно.
Flow подобен итератору, который последовательно перебирает элементы коллекции, а также производит и потребляет значение асинхронно. Это обеспечивает выполнение сетевых операций или затратных заданий в фоновом потоке, не блокируя основной поток.
В потоках данных участвуют три сущности:
- производитель (producer) производит данные из DataSource, которые добавляются в поток (как уже было сказано, благодаря поддержке корутин Flow может производить данные асинхронно);
- (необязательно) посредники (intermediaries) могут изменять каждое значение, передаваемое в поток, или сам поток, не собирая его, например filter, map, take и другие;
- потребитель (consumer) потребляет значения из потока, который он обычно использует в UI.

Использование Kotlin Flow имеет множество преимуществ по сравнению с другими держателями наблюдаемых данных, такими как Live Data, RxJava. Основное преимущество использования Flow заключается в возможности применения операторов-посредников: от базовых (map, filter, take) до сложных (flattening flows). Поэтому, если вы только начинаете осваивать Flow, рекомендую ознакомиться с этими документами.
Как экспонировать состояние UI на экране с помощью потоков
Для начала стоит разобраться в типах потоков и их особенностях.
Типы потоков:
- Flow;
- StateFlow;
- SharedFlow.
Эти три типа потоков можно разделить на два вида:
- Cold Flows (холодные потоки).
- Hot Flows (горячие потоки).
Холодные потоки
Холодные потоки похожи на последовательности. Код в блоке-производителе или flow-конструкторе не выполняется до тех пор, пока поток не будет собран с помощью терминального оператора, например collect.
Вот пример Kotlin-кода для демонстрации:
fun simple(): Flow<Int> = flow { // flow-конструктор
println("Flow started")
for (i in 1..3) {
delay(100)
emit(i)
}
}
fun main() = runBlocking<Unit> {
println("Calling simple function...")
val flow = simple() // функция вызывается, но оператор print не выполняется, потому что поток еще не собран.
println("Calling collect...")
flow.collect { value -> println(value) }
println("Calling collect again...")
flow.collect { value -> println(value) }
}
Вывод:
Calling simple function…
Calling collect…
Flow started 1 2 3
Calling collect again…
Flow started 1 2 3
Потоки являются холодными и ленивыми, если иное не задано другими операторами-посредниками, такими как .stateIn или .sharedIn.
2. Горячие потоки
Для горячих потоков не важно, собирает наблюдатель (observer) значение или нет. Они производят поток данных, даже если ни один коллектор активно не присутствует.
Примерами горячих потоков являются StateFlow и SharedFlow.
В большинстве случаев вам не нужно выполнять роль производителя для Flow: эту работу выполняют большинство популярных Jetpack-библиотек. В их число входят Room, DataStore, Retrofit, WorkManager.
Для Room:
@Dao
interface NoteDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertNote(note: Note)
@Query("SELECT * FROM note ORDER BY Title ASC")
fun getAllNotes(): Flow<List<Note>>
@Query(
"""
SELECT * FROM note
JOIN note_fts on note_fts.rowid = note_id
WHERE note_fts match :searchQuery
"""
)
fun getSearchNote(searchQuery: String): Flow<List<Note>>
@Query("SELECT * FROM note WHERE note_id =:noteId ")
fun getNoteStream(noteId: Long): Flow<Note>
@Query("SELECT * FROM note WHERE note_id =:noteId ")
suspend fun getNoteById(noteId: Long): Note
}
Вам просто нужно вернуть Flow в качестве наблюдаемого держателя данных, как в приведенном выше примере, где Flow<List<Note>> возвращается для функций getAllNotes() и getSearchNote.
Для DataStore:
class TodoPreferenceDataStore @Inject constructor(
private val dataStore: DataStore<Preferences>
) : UserPreference {
override suspend fun setNoteLayout(noteLayout: String) {
dataStore.edit {
it[NOTE_LAYOUT_KEY] = noteLayout
}
}
override fun getNoteLayout(): Flow<String> // to get a notelayout we return this is a flow
= dataStore.data.map {
it[NOTE_LAYOUT_KEY] ?: NoteLayout.LIST.name
}
companion object {
val NOTE_LAYOUT_KEY = stringPreferencesKey(NOTE_ITEM_LAYOUT_KEY)
}
}
interface UserPreference {
suspend fun setNoteLayout(noteLayout: String)
fun getNoteLayout():Flow<String>
}
Теперь, разобравшись с основными типами Flow, перейдем к основной теме, чтобы узнать, какие потоки используются для отображения состояния UI на экране.
Перемещение данных из источника данных в UI с помощью потоков
Обычно перемещение потоков из источников данных отображается, как в классах Repository, например так:
class DefaultOsbRepository @Inject constructor(
private val osbDatabase: OsbDatabase,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
):OsbRepository {
// ..
override fun getAllNote(): Flow<List<Note>> { // отображение Flow из репозитория
return osbDatabase.getDao().getAllNotes()
}
override fun getNoteBySearch(searchQuery: String): Flow<List<Note>> {
return osbDatabase.getDao().getSearchedNote(searchQuery)
}
override suspend fun deleteAllNotes() {
osbDatabase.getDao().deleteAllNotes()
}
override fun getNoteStreamById(noteId: Long): Flow<Note> {
return osbDatabase.getDao().getNoteStream(noteId)
}
// ...
}
Здесь мы имеем дело с различными типами жизненного цикла UI. Обычно пересоздание процесса (изменение конфигурации) происходит при повороте устройства. Но ViewModel может справиться с этим при создании состояния UI на экране.
Природа Flow такова, что при его отмене во время сбора данных из UI из-за изменения конфигурации воссоздается новый потоковый конвейер при следующем сборе данных. Лучший способ справиться с этим — использовать StateFlow. Stateflow — горячий поток, который не отменяется и не создается заново, если коллектор или наблюдатель прекращает сбор данных. Кроме того, Stateflow передает текущее и новое состояние своим сборщикам. Значение текущего состояния также можно прочитать через свойство value.
Таким образом, StateFlow идеально подходит для обеспечения потоков данных от ViewModel к UI. Любой поток может быть преобразован в StateFlow с помощью следующей функции расширения:
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = NewsFeedUiState.Loading,
)
Scope указывает область действия корутины, в которой запускается совместный доступ, обычно viewmodelScope в ViewModel. Started — это стратегия, управляющая запуском и остановкой совместного доступа. (Правило запуска SharingStarted.WhileSubscribed()
сохраняет активным производителя восходящего потока (upstream), пока есть активные подписчики. Доступны и другие правила запуска, например SharingStarted.Eagerly
, чтобы немедленно запустить производителя, или SharingStarted.Lazily
, чтобы начать обмен после появления первого подписчика и поддерживать Flow в вечно активном состоянии). initialValue — это начальное значение состояния потока.
@HiltViewModel
class HomeViewModel @Inject constructor(
private val newsRepository: NewsRepository
) : ViewModel() {
val newsUiState: StateFlow<NewsFeedState> =
newsRepository.getNewsFeed() // returns Flow<List<News>>
.map(NewsFeedUiState::Success)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = NewsFeedUiState.Loading,
)
}
sealed interface HomeUiState {
data class Success(val newsList: List<News> = emptyList()) : HomeUiState
data object Error : HomeUiState
data object Loading : HomeUiState
}
Сбор UI из ViewModel (Compose):
val newsUiState by viewModel.newsUiState.collectAsStateWithLifecycle()
Сбор UI State (состояния UI) из ViewModel (Views):
lifecycleScope.launch {
// repeatOnLifecycle запускает блок в новой корутине каждый раз,
// когда жизненный цикл находится в состоянии STARTED (или выше), и отменяет его, когда его статус STOPPED.
repeatOnLifecycle(Lifecycle.State.STARTED) {
// Запустите поток и начните прослушивать значения.
// Обратите внимание: это происходит, когда жизненный цикл находится в состоянии STARTED, а прекращается сбор,
когда жизненный цикл STOPPED.
viewModel.newsUiState.collect { uiState ->
}
}
}
Функция collectAsStateWithLifecycle() собирает значения из Flow с учетом жизненного цикла, позволяя экономить ресурсы приложения и предоставляя последнее значение, выданное из Compose State. Используйте этот API как рекомендуемый способ сбора потоков в приложениях Android.
Для сбора UI State из других типов наблюдаемых держателей данных см. раздел документации «Другие поддерживаемые типы».
Если нет возможности использовать Flow в качестве наблюдаемого держателя данных, можно применить следующий подход:
sealed interface HomeUiState {
data class Success(val newsFeed: NewsFeed) : HomeUiState
data object Error : HomeUiState
data object Loading : HomeUiState
}
@HiltViewModel
class HomeViewModel @Inject constructor(
private val newsRepository: NewsRepository
) : ViewModel() {
var newsUiState: HomeUiState by mutableStateOf(HomeUiState.Loading)
private set
init {
getNewsFeed()
}
private fun getNewsFeed() {
viewModelScope.launch {
newsUiState = HomeUiState.Loading
newsUiState = try {
HomeUiState.Success(newsRepository.getNewsFeed())
} catch (e: IOException) {
HomeUiState.Error
} catch (e: Exception) {
HomeUiState.Error
} catch (e: HttpException) {
HomeUiState.Error
}
}
}
}
Рекомендую использовать Compose State непосредственно для представления состояния UI на экране, потому что newsRepository.getNewsFeed() возвращает все в одном классе-обертке, как здесь.
interface NewsRepository {
suspend fun getNewsFeed(): NewsFeed
suspend fun getSearchNews(searchQuery:String): SearchNews
}
@Serializable
data class NewsFeed(
@SerialName("tfa")
val todayFeaturedArticle: Tfa? = null,
@SerialName("mostread")
val mostRead: MostRead? = null, // новости
@SerialName("news")
val news: List<News>?= null,
@SerialName("onthisday")
val onThisDay: List<Onthisday>? = null,
)
Чтобы показать все типы контента, а не только новости, нужно вернуть только NewsFeed, иначе он не будет соответствовать тому же типу.
data class Success(val newsFeed: NewsFeed /** Вместо List<Flow> должен быть полноценный класс-обертка. */) : HomeUiState
SharedFlow
SharedFlow используется для трансляции событий и потоков данных нескольким сборщикам. В отличие от StateFlow, SharedFlow не содержит последнего состояния, но легко настраивается с помощью свойства replay, т.е. сохранения определенного количества прошлых событий.
Основные различия между StateFlow и SharedFlow
StateFlow
:
- Хранит последнее значение состояния.
- Идеально подходит для отображения состояния UI, которое должно сохраняться при изменении конфигурации.
- Передает сборщикам новые значения только при изменении состояния.
SharedFlow
:
- Не хранит никакого состояния.
- Используется для передачи событий нескольким сборщикам (например, событий навигации).
- Можно настроить параметры воспроизведения для сохранения ограниченной истории событий.
Заключение
В Android использование Kotlin-потоков, таких как StateFlow
и SharedFlow
, предлагает реактивный способ отображения наблюдаемых состояний и событий UI. StateFlow
отлично подходит для отслеживания текущего состояния UI, а SharedFlow
— для трансляции событий. Оба инструмента, поддерживаемые Kotlin-корутинами, помогают создавать отзывчивые, неблокирующие и реактивные пользовательские интерфейсы.
Читайте также:
- Шпаргалка по Kotlin Flow для продвинутых инженеров Android
- Сортировка и фильтрация записей с помощью базы данных Room и Kotlin Flow
- Корутины в Kotlin: топ-50 вопросов для собеседования с Android-разработчиками в 2024 году
Читайте нас в Telegram, VK и Дзен
Перевод статьи Mubarak Native: Using Kotlin Flows for Exposing Observable Screen UI state in android