
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.
