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.

Наши рекомендации

На примере тестовых случаев мы разобрались, какой инструмент для чего подходит лучше, и можем дать следующие рекомендации: 

  1. Для данных, полученных из репозитория, лучше использовать:

    val flow = MutableStateFlow(STATUS_DATA)

    В данном случае использование StateFlow приведет к более редким обновлениям данных и более удобному их получению.

  2. Для отправки состояний из viewModel лучше использовать:

    private val flow = MutableSharedFlow<T>(
      replay = 1,
      extraBufferCapacity = 0,
      onBufferOverflow = BufferOverflow.DROP_OLDEST
    )


    Так как не происходит проверка данных, то мы будем получать все состояния, а так-же последнее из них будет повторно воспроизводиться при смене конфигурации.

  3. Для отправки единичных эвентов из 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. 

Комментарии (7)


  1. SIDOVSKY
    20.06.2022 22:55

    Для отправки единичных эвентов из viewModel лучше использовать
    MutableSharedFlow<T>(replay = 0, extraBufferCapacity = 1, BufferOverflow.DROP_OLDEST)
    В данном случае будет сохраняться последний отправленный эвент, до первого считывания его подписчиком.

    К сожалению, не будет. При отсутствии подписчиков сохраняются только значения попавшие в replay буфер. Выдержка из документации:

    In the absence of subscribers only the most recent [replay] values are stored and the buffer overflow behavior is never triggered and has no effect.

    Интерактивный пример


  1. w201
    21.06.2022 10:00

    Я так и не понял зачем вы сделали миграцию в сторону сложности... Гибкости я в ваших примерах не увидел. И вы правильно сказали, что flow сделан для общения между корутинами. Но у нас view <-> viewmodel.

    Можно привести пример, что сделать на flow можно лучше, или удобнее в разрезе заявленной задачи. Я как flow не крутил в данном контексте им применения не смог найти. Ну т.е. можно, но зачем?

    Ну и google рекомендует конвертировать flow в livedata перед передачей в view.


    1. ChPr
      21.06.2022 10:22

      Ну и google рекомендует конвертировать flow в livedata

      Уже нет.

      LiveData is still our solution for Java developers, beginners, and simple situations. For the rest, a good option is moving to Kotlin Flows.


      1. w201
        21.06.2022 11:06

        Ну это статья на medium с мнением одного человека.

        Ну и суть не в этом. Такая рекомендация была и вполне была обоснована. Чем обоснован переход на flow я не вижу и даже не вижу попыток это объяснить....


        1. ChPr
          21.06.2022 11:27

          Этот один человек – Developer Relations Engineer @ Google, working on Android. Я крайне сомневаюсь, что у них там нет единой линии между всеми деврелами, которой они придерживаются.

          Чем обоснован переход на flow

          Просто попытка не плодить сущности. Вот есть обсервабл дата холдер в виде LiveData. Сторонняя зависимость по сути из одного класса. Которая еще и в тестах требует под себя целую рулу (InstantTaskExecutorRule). И тут внезапно появляется похожая штука, которая по сути часть стандартной библиотеки Kotlin, так еще и с более обширным API.

          Тратить время на целенаправленную миграцию кмк смысла нет, но потихоньку выпиливать можно.


          1. w201
            21.06.2022 11:50

            Ну если бы это была официальная политика партии, то они бы опубликовали такое заявление на android.com

            Ну и я бы хотел верить, что разногласия там все же есть :) Это полезно для нас :)

            Я согласен, что штука похожая, но вот была отличная штука, которая наконец смогла подружится с ЖЦ view, не сильно затратная. А потом сделали похожая, только без синхронизации ЖЦ с кучей API и вариантов использования.

            И многие тут же ринулись под эти знамёна. Я не против, я просто аргументов не вижу.

            А сущности тут наплодили достаточно. Можно на handler и thread это все сделать, собственно лет 10 назад так и писал. А потом asynctadk, loaders, fragment, workers и теперь корутины... С тех пор не спешу с миграциями, без хороших аргументов :)


  1. w201
    21.06.2022 11:50
    -1


    Deleted