Intro
Мы - Дима (@fonfon) и Настя, Android-разработчики в компании СберЗдоровье. В этой статье мы хотим рассказать о том, как мы перевели весь наш проект с LiveData на Flow, с какими трудностями столкнулись и что полезного узнали. Эта статья будет полезна тем, кто работает с LiveData, уже пробовал / хочет попробовать Flow для хранения состояний во ViewModel, а также командам, которые планируют миграцию всего проекта на новый инструмент.
Почему мы решили отказаться от LiveData в пользу Flow?
Вместе с Kotlin Coroutines JetBrains предоставил нам такие средства для общения между корутинами, как Channels и Flow. Изначально мы начали использовать корутины в других частях проекта, в частности, для сетевого слоя. В желании унифицировать инструментарий и подключаемые библиотеки мы решили перейти на Flow вместо LiveData для взаимодействия наших ViewModel с View-слоем.
Мы старались сделать переход поэтапным, чтобы снизить риски ошибок. Начали с внедрения Flow во вьюмодели для некоторых новых экранов в приложении, и постепенно, спустя несколько релизов, стали переписывать старый код. Сейчас в нашем проекте совсем не осталось LiveData.
В этой статье я расскажу подробно о плюсах и минусах каждого инструмента, а также о шагах, которые мы предприняли при миграции. Возможно, на примере опыта нашей команды вы сможете избежать некоторых ошибок и принять решение для своего проекта.
Тестовые случаи
Чтобы разобрать различия между инструментами, мы выделили несколько наиболее показательных тестовых случаев. Ниже рассмотрим каждый из подходов на их примере:
Наличие подписки в момент отправки данных - есть/нет;
То, как отправляются данные - моментально/с задержками/есть ли реакция на различные потоки;
Зависимость от данных - отправляется ли новый пакет данных/такой-же или те-же самые данные;
Возможность повторно прочитать текущее значение;
Отправка единичных событий;
Привязка к жизненному циклу - механизм подписки к жизненному циклу.
LiveData - просто, но не гибко
Google предлагает нам 3 основных инструмента для хранения состояний во ViewModel: LiveData, MutableLiveData, MediatorLiveData. Чаще всего для переключения состояний мы пользуемся MutableLiveData, поэтому рассмотрим ее ближе.
MutableLiveData позволяет отправлять данные через свойство value и метод postValue, при этом value позволяет мгновенно отправлять новые данные, но только в основном потоке, а postValue позволяет это делать из разных потоков, но с некоторой задержкой на синхронизацию.
В основных тестах использовалась именно MutableLiveData и с ней мы получаем результаты, от которых и будем отталкиваться:
При наличии подписки приходят все эвенты, во время подписки приходит последнее значение;
При отправке данных нужно следить за потоками и вызываемыми методами отправки, при этом гарантирована стабильная отправка данных;
LiveData никак не проверяет новые данные и при отправке одного и того-же объекта он придет 2 раза;
В случае MutableLiveData, текущее значение мы можем получить через value;
Для отправки единичных эвентов требуется Дополнительный класс - LiveDataEvent;
Подписка происходит через функцию observe, c привязкой к жизненному циклу или через
observeForever
.
LiveDataEvent
data class LiveDataEvent<out T>(private val content: T) {
var hasBeenHandled = false
private set
fun getContentIfNotHandled(): T? {
return if (hasBeenHandled) {
null
} else {
hasBeenHandled = true
content
}
}
fun peekContent(): T = content
}
Channel - Только для эвентов
Channel наследует 2 интерфейса SendChannel и ReceiveChannel:
public interface Channel<E> : SendChannel<E>, ReceiveChannel<E>
SendChannel реализует 2 метода отправки данных:
public suspend fun send(element: E)
public fun trySend(element: E): ChannelResult<Unit>
Функция send
объявлена с модификатором suspend
, поэтому выполнение корутины может быть приостановлено если при попытке отправки буфер канала заполнен или он просто отсутствует. В данном случае приостановка будет до тех пор, пока другая корутина не начнет читать значения из канала.
Функция trySend объявлена без модификатора suspend
и выполняет отправку элемента в канал моментально без блокировки.
Интерфейс ReceiveChannel
также имеет две основные функции для чтения данных из канала:
public suspend fun receive(): E
public fun tryReceive(): ChannelResult<E>
Функция receive
предназначена для чтения значения и последующего его удаления из канала. Имеет модификатор suspend
и корутина приостанавливает свое выполнение при чтении из пустого канала до тех пор, пока не появится новое значение. При попытке чтения из закрытого канала будет сгенерировано исключение либо с типом ClosedReceiveChannelException
, либо с другим классом, если таковой был передан в функцию close
.
Функция tryReceive, по аналогии с функцией trySend для производителя, является не блокирующей.
Для интерфейса Channel
также существует функция фабрика:
public fun <E> Channel(
capacity: Int = RENDEZVOUS,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND,
onUndeliveredElement: ((E) -> Unit)? = null
)
В зависимости от входных параметров можно получить немного различающиеся объекты, реализующие интерфейс.
Так при capacity
> 0 при вызове метода send
корутина не будет блокироваться, пока не заполнится буффер. Более того корутина не будет блокироваться при любом размере, если указать стратегию DROP_OLDEST
или DROP_LATEST
.
Также стоит указать то, так как основной работой, для которой был придуман Channel
- передача данных, то он никак не хранит последнее значение.
Channel
- это первый инструмент, который предложила нам компания JetBrains для общения между корутинами. По некоторой неопытности мы использовали Channel
и делали это неправильно.
Выводы о Channel:
Фактически
Channel
не реализует подписку на получение данных, и получение данных сильно зависит от параметров канала;При наличии получателя данных все отправляемые данные успешно приходят;
Channel
не сравнивает данные, поэтому приходят все эвенты;Нельзя получить значение повторно;
Все эвенты приходят единожды;
Для широковещательной рассылки требуется использовать отдельную реализацию -
BroadcastChannel
со своими особенностями.
Минусы Channel:
Channel
представляет собой интерфейс, который реализуют 3 класса, каждый из которых, в зависимости от входных параметров реализует немного разное поведение;Channel
реализует только P2P отправку данных и если требуется еще одна подписка, то нужно использоватьBroadcastChannel
со своими особенностями реализации;Channel
использует внутри себя синхронизацию потоков, что приводит к более медленной передаче данных;Для того чтобы обработать данные от
Channel
и отправить их далее, потребуется создать дополнительную подписку, на что будет требоваться больше памяти.
Flow - сложно, зато гибко
Более продвинутым инструментом для общения между корутинами является Flow
, который появился в версии 1.4.
Фактически Flow
закрывает все минусы, которые были в Channel
. Чаще всего для переключения состояний мы пользуемся MutableStateFlow
и MutableSharedFlow
, поэтому рассмотрим их ближе.
MutableSharedFlow
Методы отправки данных:
override suspend fun emit(value: T)
public fun tryEmit(value: T): Boolean
Получение данных происходит через collect
функцию, единую для всех Flow
.
Для создания MutableSharedFlow
присутствует Функция-фабрика:
public fun <T> MutableSharedFlow(
replay: Int = 0,
extraBufferCapacity: Int = 0,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
)
В отличие от Channel
можно увидеть, что есть буфер для повторения части событий и буфер для отправки данных. Также можно выделить важное значение - общий буффер, который строится так:
val bufferCapacity0 = replay + extraBufferCapacity
В зависимости от размера общего буффера и стратегии, по аналогии с Channel
, можно получить SharedFlow
, с немного различным поведением.
Так при bufferCapacity0
> 0 при вызове метода send корутина не будет блокироваться, пока не заполнится буффер. Более того корутина не будет блокироваться при любом размере, если указать стратегию DROP_OLDEST
или DROP_LATEST
. <Дублируется из Channel>
Выводы о MutableSharedFlow:
В зависимости от параметров, передаваемых в фабрику мы получаем несколько отличающееся поведение;
При наличии подписки все отправляемые эвенты успешно доходят, при отсутствии подписки - все зависит от размера общего буффера;
SharedFlow
не сравнивает данные, поэтому приходят все эвенты;Можно повторно получить данные из
replayCache
при его размере > 0;Все эвенты могут приходить как единожды, так и с повторением;
Flow
поддерживает широковещательную рассылку.
MutableStateFlow
StateFlow сделан по образу и подобию SharedFlow. Вот почему StateFlow ничего более, как доработанная специализация такой реализации SharedFlow:
public fun <T> MutableSharedFlow(
replay: Int = 1,
extraBufferCapacity: Int = 0,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
)
Если вглядеться подробнее в интерфейс MutableStateFlow
, то можно увидеть интерфейс StateFlow
со значением и функцией compareAndSet
.
public interface MutableStateFlow<T> : StateFlow<T>, MutableSharedFlow<T> {
public override var value: T
public fun compareAndSet(expect: T, update: T): Boolean
}
Основной функцией обновления данных в StateFlow
является compareAndSet
, которая сверяет текущее значение с ожидаемым и обновляет его на значение из поля update
. При этом если value
, expect
и update
равны между собой функция вернет true
, без обновления значения. В этой мелочи кроется маленький дьявол. Если во время обновления данных происходило какое-либо преобразование, отдававшее новый результат и устанавливалось то же значение, то преобразования данных не происходило и не обновлялось фактическое состояние. В какой-то момент мы даже поймали на этом ошибку.
Выводы о MutableStateFlow:
При наличии подписки все состояния обновляются, только если не эмитятся те же самые данные, причем во время подписки мы получим последнее значение;
При достаточно большом объеме данных операция проверки может длиться достаточно долго, что в определенных случаях приводит к результатам, сравнимым с
posValue
уLiveData
;Обновление состояния зависит от предыдущего состояния;
Повторно получить данные можно через
value
;Для отправки единичных эвентов требуется
LiveDataEvent
.
Наши рекомендации
На примере тестовых случаев мы разобрались, какой инструмент для чего подходит лучше, и можем дать следующие рекомендации:
Для данных, полученных из репозитория, лучше использовать:
val flow = MutableStateFlow(STATUS_DATA)
В данном случае использование
StateFlow
приведет к более редким обновлениям данных и более удобному их получению.Для отправки состояний из viewModel лучше использовать:
private val flow = MutableSharedFlow<T>(
replay = 1,
extraBufferCapacity = 0,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
Так как не происходит проверка данных, то мы будем получать все состояния, а так-же последнее из них будет повторно воспроизводиться при смене конфигурации.Для отправки единичных эвентов из viewModel лучше использовать:
private val flow = MutableSharedFlow<T>(
В данном случае будет сохраняться последний отправленный эвент, до первого считывания его подписчиком.
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
Выводы
Ознакомившись с разными инструментами и подходами мы сделали следующие выводы:
С LiveData проще работать, но Flow дает больше гибкости;
При работе с Flow нужно учитывать особенности работы корутин;
Требуется выбирать, где использовать StateFlow, а где - SharedFlow;
Для SharedFlow нужно правильно подбирать параметры;
Возможно использование Channel, но только в ограниченных случаях.
Итоги в цифрах
Мы начали миграцию с написания нового кода с применением Flow. Первой ViewModel на новом подходе стала новая главная в приложении, с нее мы начали писать новые вьюмодели сразу на Flow. Рефакторинг старого кода производился медленно и постепенно, в рамках техдолга.
В процессе рефакторинга мы затронули в том числе и старые презентеры, которые были написаны еще с применением реактивного подхода. Эти работы тоже учитываются в общих сроках миграции. Этап с рефакторингом был самым трудоемким, например, для одного из самых старых разделов приложения время работ составило ~30-40 часов.
Мы закончили переход удалением последнего вхождения LiveData в старом коде, таким образом общие сроки составили чуть меньше 1 года.
На момент старта миграции в проекте было ~55 классов ViewModel, сейчас - более 100.