LiveData была нужна нам еще в 2017 году. Паттерн наблюдателя
облегчил нам жизнь, но такие опции, как RxJava, в то время были
слишком сложными для новичков. Команда Architecture Components
создала LiveData: очень авторитетный класс наблюдаемых хранилищ
данных, разработанный для Android. Он был простым, чтобы облегчить
начало работы, а для более сложных случаев реактивных потоков
рекомендовалось использовать RxJava, используя преимущества
интеграции между ними.
DeadData?
LiveData по-прежнему остается нашим решением для
Java-разработчиков, новичков и простых ситуаций. В остальном,
хорошим вариантом является переход на Kotlin Flows. Flows (потоки)
все еще имеют крутую кривую обучения, но они являются частью языка
Kotlin, поддерживаемого Jetbrains; кроме того, на подходе Compose,
который хорошо сочетается с реактивной моделью.
Мы уже
говорили об использовании Flows для соединения различных
частей вашего приложения, за исключением представления и ViewModel.
Теперь, когда у нас есть более
безопасный способ сбора потоков из пользовательских интерфейсов
Android, мы можем создать полное руководство по
миграции.
В этом посте вы узнаете, как отобразить Flows в представление,
как собирать их, и как точно настроить под конкретные нужды.
Flow: простые вещи труднее, а сложные легче
LiveData выполняла одну вещь и делала это хорошо:
она показывала данные, одновременно кэшируя последние
значения и учитывая жизненные циклы Android. Позже мы
узнали, что она также может
запускать корутины и
создавать сложные преобразования, но это требовало
немного больше усилий.
Давайте рассмотрим некоторые паттерны LiveData и их эквиваленты
Flow:
#1: Показ результата однократной операции с модифицированным
держателем данных
Это классический паттерн, в котором вы мутируете держатель
состояния с результатом выполнения корутины:
Показ результата однократной операции с
модифицированным (Mutable) держателем данных (LiveData)
<!-- Copyright 2020 Google LLC. SPDX-License-Identifier: Apache-2.0 -->class MyViewModel { private val _myUiState = MutableLiveData<Result<UiState>>(Result.Loading) val myUiState: LiveData<Result<UiState>> = _myUiState // Load data from a suspend fun and mutate state init { viewModelScope.launch { val result = ... _myUiState.value = result } }}
Чтобы сделать то же самое с потоками, мы используем
модифицированный StateFlow:
Показ результата однократной операции с
модифицированным держателем данных (StateFlow)
class MyViewModel { private val _myUiState = MutableStateFlow<Result<UiState>>(Result.Loading) val myUiState: StateFlow<Result<UiState>> = _myUiState // Load data from a suspend fun and mutate state init { viewModelScope.launch { val result = ... _myUiState.value = result } }}
StateFlow это особый вид
SharedFlow (который является особым типом Flow),
наиболее близкий к LiveData:
-
У него всегда есть значение.
-
У него только одно значение.
-
Он поддерживает несколько наблюдателей (поэтому поток является
общим).
-
Он всегда воспроизводит последнее значение при подписке,
независимо от количества активных наблюдателей.
При отображении состояния пользовательского интерфейса для
представления используйте StateFlow. Это безопасный и эффективный
наблюдатель, предназначенный для хранения состояния
пользовательского интерфейса.
#2: Показ результата однократной операции
Это эквивалент предыдущего сниппета, демонстрирующий результат
вызова корутины без модифицированного теневого свойства.
В LiveData мы использовали для этого конструктор корутин
liveData:
Показ результата однократной операции (LiveData)
class MyViewModel(...) : ViewModel() { val result: LiveData<Result<UiState>> = liveData { emit(Result.Loading) emit(repository.fetchItem()) }}
Поскольку держатели состояния всегда имеют значение, хорошей
идеей будет обернуть наше UI-состояние в какой-нибудь класс
Result
, который поддерживает такие состояния, как
Loading
, Success
и
Error
.
Эквивалент Flow немного сложнее, потому что вам придется
выполнить некоторую настройку:
Показ результата однократной операции (StateFlow)
class MyViewModel(...) : ViewModel() { val result: StateFlow<Result<UiState>> = flow { emit(repository.fetchItem()) }.stateIn( scope = viewModelScope, started = WhileSubscribed(5000), // Or Lazily because it's a one-shot initialValue = Result.Loading )}
stateIn
это оператор Flow, который преобразует его
в StateFlow
. Давайте пока доверимся этим параметрам,
так как позже нам понадобится более сложная информация для
правильного объяснения.
#3: Однократная загрузка данных с параметрами
Допустим, вы хотите загрузить некоторые данные, которые зависят
от ID пользователя, и вы получаете эту информацию от
AuthManager
, который показывает Flow:
Однократная загрузка данных с параметрами (LiveData)
С помощью LiveData можно сделать примерно следующее:
class MyViewModel(authManager..., repository...) : ViewModel() { private val userId: LiveData<String?> = authManager.observeUser().map { user -> user.id }.asLiveData() val result: LiveData<Result<Item>> = userId.switchMap { newUserId -> liveData { emit(repository.fetchItem(newUserId)) } }}
switchMap
это преобразование, тело которого
выполняется, а результат подписывается при изменении
userId
.
Если нет причин для того, чтобы userId
был
LiveData, лучшей альтернативой этому является объединение потоков с
Flow и окончательное преобразование полученного результата в
LiveData.
class MyViewModel(authManager..., repository...) : ViewModel() { private val userId: Flow<UserId> = authManager.observeUser().map { user -> user.id } val result: LiveData<Result<Item>> = userId.mapLatest { newUserId -> repository.fetchItem(newUserId) }.asLiveData()}
Выполнение этого действия с помощью Flows выглядит очень
похоже:
Однократная загрузка данных с параметрами (StateFlow)
class MyViewModel(authManager..., repository...) : ViewModel() { private val userId: Flow<UserId> = authManager.observeUser().map { user -> user.id } val result: StateFlow<Result<Item>> = userId.mapLatest { newUserId -> repository.fetchItem(newUserId) }.stateIn( scope = viewModelScope, started = WhileSubscribed(5000), initialValue = Result.Loading )}
Обратите внимание, что если вам нужна большая гибкость, вы также
можете использовать transformLatest
и
emit
элементы в явном виде:
val result = userId.transformLatest { newUserId -> emit(Result.LoadingData) emit(repository.fetchItem(newUserId)) }.stateIn( scope = viewModelScope, started = WhileSubscribed(5000), initialValue = Result.LoadingUser // Note the different Loading states )
#4: Наблюдение за потоком данных с параметрами
Теперь сделаем пример более реактивным. Данные не
извлекаются, а наблюдаются, поэтому мы автоматически распространяем
изменения в источнике данных на пользовательский интерфейс.
Продолжая наш пример: вместо вызова fetchItem
на
источнике данных мы используем гипотетический оператор
observeItem
, который возвращает Flow.
С помощью LiveData вы можете преобразовать поток в LiveData и
все обновления emitSource
:
Наблюдение за потоком с параметрами (LiveData)
class MyViewModel(authManager..., repository...) : ViewModel() { private val userId: LiveData<String?> = authManager.observeUser().map { user -> user.id }.asLiveData() val result = userId.switchMap { newUserId -> repository.observeItem(newUserId).asLiveData() }}
Или, лучше всего, объединить оба потока с помощью
flatMapLatest и преобразовать только выход в
LiveData:
class MyViewModel(authManager..., repository...) : ViewModel() { private val userId: Flow<String?> = authManager.observeUser().map { user -> user?.id } val result: LiveData<Result<Item>> = userId.flatMapLatest { newUserId -> repository.observeItem(newUserId) }.asLiveData()}
Имплементация Flow похожа, но в ней нет преобразований
LiveData:
Наблюдение за потоком с параметрами (StateFlow)
class MyViewModel(authManager..., repository...) : ViewModel() { private val userId: Flow<String?> = authManager.observeUser().map { user -> user?.id } val result: StateFlow<Result<Item>> = userId.flatMapLatest { newUserId -> repository.observeItem(newUserId) }.stateIn( scope = viewModelScope, started = WhileSubscribed(5000), initialValue = Result.LoadingUser )}
StateFlow будет получать обновления каждый раз, когда меняется
пользователь или изменяются его данные в репозитории.
#5 Объединение нескольких источников: MediatorLiveData ->
Flow.combine
MediatorLiveData позволяет вам наблюдать за одним или
несколькими источниками обновлений (наблюдаемыми LiveData) и что-то
делать, когда они получают новые данные. Обычно вы обновляете
значение MediatorLiveData:
val liveData1: LiveData<Int> = ...val liveData2: LiveData<Int> = ...val result = MediatorLiveData<Int>()result.addSource(liveData1) { value -> result.setValue(liveData1.value ?: 0 + (liveData2.value ?: 0))}result.addSource(liveData2) { value -> result.setValue(liveData1.value ?: 0 + (liveData2.value ?: 0))}
Эквивалент Flow намного проще:
val flow1: Flow<Int> = ...val flow2: Flow<Int> = ...val result = combine(flow1, flow2) { a, b -> a + b }
Можно также использовать функцию
combineTransform или
zip.
Настройка открытого StateFlow (оператор stateIn)
Ранее мы использовали stateIn
для преобразования
регулярного потока в StateFlow, но это требует некоторой настройки.
Если вы не хотите вдаваться в подробности прямо сейчас и вам просто
нужно копировать-вставлять, я рекомендую использовать эту
комбинацию:
val result: StateFlow<Result<UiState>> = someFlow .stateIn( scope = viewModelScope, started = WhileSubscribed(5000), initialValue = Result.Loading )
Однако если вы не уверены в этом, казалось бы, случайном
5-секундном параметре started
, читайте дальше.
StateIn
имеет 3 параметра (из документации):
@param scope the coroutine scope in which sharing is started.@param started the strategy that controls when sharing is started and stopped.@param initialValue the initial value of the state flow.This value is also used when the state flow is reset using the [SharingStarted.WhileSubscribed] strategy with the `replayExpirationMillis` parameter.
started
может принимать 3 значения:
Для одноразовых операций вы можете использовать
Lazily
или Eagerly
. Однако, если вы
наблюдаете за другими потоками, вам следует использовать
WhileSubscribed
для выполнения небольших, но важных
оптимизаций, как описано ниже.
Стратегия WhileSubscribed
WhileSubscribed отменяет восходящий поток, когда нет
коллекторов. StateFlow, созданный с помощью stateIn
,
передает данные в View, но он также наблюдает за потоками,
поступающими из других слоев или приложения (восходящий поток).
Поддержание этих потоков активными может привести к напрасной трате
ресурсов, например, если они продолжают считывать данные из других
источников, таких как подключение к базе данных, аппаратные датчики
и т.д. Когда ваше приложение переходит в фоновый режим, будет
хорошо, если вы остановите эти корутины.
WhileSubscribed
принимает два параметра:
public fun WhileSubscribed( stopTimeoutMillis: Long = 0, replayExpirationMillis: Long = Long.MAX_VALUE)
Таймаут остановки
Из документации:
stopTimeoutMillis
настраивает задержку (в
миллисекундах) между исчезновением последнего абонента и остановкой
восходящего потока. По умолчанию она равна нулю (остановка
происходит немедленно).
Это полезно, поскольку вы не хотите отменять восходящие потоки,
если представление перестало воспринимать данные на долю секунды.
Это происходит постоянно например, когда пользователь поворачивает
устройство и представление разрушается и воссоздается в быстрой
последовательности.
Решение в конструкторе корутины liveData заключалось в
добавлении задержки в 5 секунд, после которой корутина
будет остановлена, если нет подписчиков.
WhileSubscribed(5000)
делает именно это:
class MyViewModel(...) : ViewModel() { val result = userId.mapLatest { newUserId -> repository.observeItem(newUserId) }.stateIn( scope = viewModelScope, started = WhileSubscribed(5000), initialValue = Result.Loading )}
Этот подход отвечает всем требованиям:
-
Когда пользователь отправляет ваше приложение в фоновый режим,
обновления, поступающие с других уровней, прекращаются через пять
секунд, что позволяет экономить заряд батареи.
-
Последнее значение все еще будет кэшироваться, чтобы, когда
пользователь вернется к нему, представление сразу же получило
данные.
Истечение срока воспроизведения
replayExpirationMillis
настраивает задержку (в
миллисекундах) между завершением работы программы совместного
доступа и сбросом кэша воспроизведения (что делает кэш пустым для
оператора shareIn
и возвращает кэшированное значение к
исходному initialValue
для stateIn
). По
умолчанию он равен Long.MAX_VALUE
(кэш воспроизведения
сохраняется постоянно, буфер никогда не сбрасывается). Используйте
нулевое значение для немедленного истечения срока действия
кэша.
Наблюдение StateFlow из представления
Как мы уже видели, для представления очень важно сообщить
StateFlows во ViewModel, что они больше не прослушиваются. Однако,
как и во всем, что связано с жизненными циклами, все не так
просто.
Для того чтобы собрать поток, вам нужна корутина. Действия и
фрагменты предлагают множество конструкторов корутин:
LaunchWhenStarted, launchWhenResumed...
Специализированные версии launch, называемые
launchWhenX
, будут ждать, пока
lifecycleOwner
находится в состоянии X, и приостановят
выполнение корутины, когда lifecycleOwner
упадет ниже
состояния X. Важно отметить, что они не отменяют выполнение
программы до тех пор, пока жизненный цикл не будет закончен.
Сбор потоков с помощью launch/launchWhenX небезопасен
Получение обновлений, когда приложение находится в фоновом
режиме, может привести к сбоям, что решается приостановкой сбора в
View. Однако восходящие потоки остаются активными, пока приложение
находится в фоновом режиме, что может привести к трате
ресурсов.
Это означает, что все, что мы делали до сих пор для настройки
StateFlow, было бы совершенно бесполезно; однако в нашем
распоряжении есть новый API.
lifecycle.repeatOnLifecycle на помощь
Этот новый конструктор корутин (доступный в
lifecycle-runtime-ktx 2.4.0-alpha01) делает именно то,
что нам нужно: он запускает корутины в определенном состоянии и
останавливает их, когда уровень жизненного цикла опускается ниже
этого состояния.
Различные
методы сбора потока
Например, во Фрагменте:
onCreateView(...) { viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycle.repeatOnLifecycle(STARTED) { myViewModel.myUiState.collect { ... } } }}
Сбор начнется, когда представление фрагмента будет
STARTED
, продолжится до RESUMED
и
остановится, когда оно вернется к STOPPED
. Читайте об
этом в статье
Более безопасный способ сбора потоков из пользовательских
интерфейсов Android.
Сочетание API repeatOnLifecycle
с приведенным выше
руководством по StateFlow обеспечит вам наилучшую
производительность при рациональном использовании ресурсов
устройства.
StateFlow выставляется с помощью
WhileSubscribed(5000) и собирается с помощью
repeatOnLifecycle(STARTED)
Предупреждение:
Поддержка StateFlow, недавно добавленная в Data
Binding, использует launchWhenCreated
для сбора обновлений, и она начнет использовать
repeatOnLifecycle
` вместо этого, когда достигнет
стабильности.
Для Data Binding вы должны использовать Flows
везде и просто добавить asLiveData()
, чтобы отобразить
их в представлении. Привязка данных будет обновлена, когда
lifecycle-runtime-ktx 2.4.0
станет стабильным.
Резюме
Лучшим способом предоставления данных из ViewModel и сбора их из
представления является:
Любая другая комбинация будет поддерживать восходящие потоки
активными, расходуя ресурсы:
Перевод материала подготовлен в рамках курса "Android
Developer. Basic". Если вам интересно узнать о курсе
больше, приходите на день открытых
дверей онлайн. На нем вы сможете узнать подробнее о программе и
формате обучения, познакомиться с преподавателем.