Отправка событий из ViewModel в Activity/Fragment в MVVM

  • Tutorial
Сегодня речь пойдет о том, как обмениваться события между Activities/Fragments и ViewModel в MVVM. Для получения данных из ViewModel, рекомендуется в Activity/Fragment подписываться на данные LiveData, находящиеся во ViewModel. Но что делать для отправки единичных (и не только) событий, таких как показать уведомление или, например, открыть другой фрагмент?



Итак, всем привет!

Меня зовут Алексей, я андроид-разработчик в Банке «Хоум Кредит».

В данной статье поделюсь нашим способом реализации отправления и получения событий от ViewModels к Views (Activities/Fragments).

В нашем приложении «Товары в рассрочку от Банка Хоум Кредит» мы используем фрагменты, поэтому будем говорить о них, но всё актуально и для Activity.

Что мы хотим?


У нас есть Fragment, включающий в себя несколько ViewModel-ей, данные связываются DataBinding-ом. Все события пользователей получают ViewModel-и. Если событие является навигационным: необходимо открыть другой fragment/activity, показать AlertDialog, Snackbar, системный запрос на Permissions и т.п., то такое событие должно выполниться во фрагменте.

А в чем, собственно, проблема?


Жизненные циклы Views и ViewModels не связаны. Связывать колбеками (Listeners) нельзя, так как ViewModel не должны ничего знать о фрагментах, а также не должны содержать в себе ссылку на фрагменты, иначе, как известно, память начнет «ликовать».

Стандартным подходом взаимодействия Fragments и ViewModels является подписка на LiveData, находящуюся во ViewModel. Сделать передачу событий напрямую через LiveData нельзя, всвязи с тем, что такой подход не учитывает, было уже выполнено событие или нет.

Какие существуют решения:


1. Использовать SingleLiveEvent
Плюсы: событие выполняется один раз.
Минусы: одно событие – один SingleLiveEvent. При большом количестве событий во ViewModel появляется N event-объектов, на каждый из которых придется подписываться во фрагменте.

2. Неплохой пример.
Плюсы: одно событие выполняется также единожды, можно передавать данные из viewModel во fragment.
Минусы: данные в событии обязательны, если же необходимо выполнить событие без данных (val content: T), необходимо будет создать еще один класс. Не решает проблему исполнения одного типа события один раз (само событие выполняется один раз, но данный тип события будет выполнятся столько раз, сколько мы его запустим из ViewModel). Например, у нас асинхронно уходят N-запросов, но сети нет. Каждый запрос вернется с ошибкой сети, и запулит во фрагмент N событий об ошибке сети, во фрагменте откроется N алертов. Пользователь не одобрит такое решение :). Мы ему должны показать один раз сообщение с данной ошибкой. Другими словами данный тип события должен выполнится один раз.

Решение


За основу возьмем идею SingleLiveEvent по сохранению информации о хендлинге события.

Определим возможные типы событий


enum class Type {
    EXECUTE_WITHOUT_LIMITS, //Самый распространенный тип события – выполнять столько раз, сколько было вызвано без проверки существует ли хоть один подписчик или нет
    EXECUTE_ONCE, //Выполнить данный тип события один раз
    WAIT_OBSERVER_IF_NEEDED,//Событие должно быть обязательно обработано, поэтому необходимо дождаться первого подписчика-обзёрвера на получение события и выполнить его
    WAIT_OBSERVER_IF_NEEDED_AND_EXECUTE_ONCE //Событие должно быть обязательно обработано, поэтому необходимо дождаться первого подписчика-обзёрвера на получение события и выполнить данный тип события один раз
}

Создаем базовый класс события – NavigationEvent


isHandled указывает на то, было ли событие получено (мы считаем, что оно выполнено, если было получено Observer во фрагменте).

open class NavigationEvent(var isHandled: Boolean = false, var type: Events.Type)

Создаем класс Эмиттера – Emitter


Класс эмиттера событий наследуется от LiveData<NavigationEvent>. Данный класс будет использоваться во ViewModel для отправки событий.

class Emitter : MutableLiveData<NavigationEvent>() {
    private val waitingEvents: ArrayList<NavigationEvent> = ArrayList()
    private var isActive = false

    override fun onInactive() {
        isActive = false
    }

    override fun onActive() {
        isActive = true
        val postingEvents = ArrayList<NavigationEvent>()
        waitingEvents
            .forEach {
                if (hasObservers()) {
                    this.value = it
                    postingEvents.add(it)
                }
            }.also { waitingEvents.removeAll(postingEvents) }
    }

    private fun newEvent(event: NavigationEvent, type: Type) {
        event.type = type
        this.value = when (type) {
            Type.EXECUTE_WITHOUT_LIMITS,
            Type.EXECUTE_ONCE -> if (hasObservers()) event else null

            Type.WAIT_OBSERVER_IF_NEEDED,
            Type.WAIT_OBSERVER_IF_NEEDED_AND_EXECUTE_ONCE -> {
                if (hasObservers() && isActive) event
                else {
                    waitingEvents.add(event)
                    null
                }
            }
        }
    }

    /** Clear All Waiting Events */
    fun clearWaitingEvents() =  waitingEvents.clear()
}

isActive – необходим нам для понимания – подписан ли на Emitter хотя бы один Observer. И в случае, когда подписчик появился и накопились события, ожидающие его, мы отправляем эти события. Важное уточнение: отправлять события необходимо не через this.postValue(event), а через сеттер this.value = event. Иначе, подписчик получит только последнее событие в списке.

Сам метод отправки нового события newEvent(event, type) – принимает два параметра – собственно, само событие и тип этого события.

Чтобы не запоминать все типы событий (длинные названия), создадим отдельные public-методы, которые будут принимать только само событие:

class Emitter : MutableLiveData<NavigationEvent>() {
    …

    /** Default: Emit Event for Execution */
    fun emitAndExecute(event: NavigationEvent) = newEvent(event, Type.EXECUTE_WITHOUT_LIMITS)

    /** Emit Event for Execution Once */
    fun emitAndExecuteOnce(event: NavigationEvent) = newEvent(event, Type.EXECUTE_ONCE)

    /** Wait Observer Available and Emit Event for Execution */
    fun waitAndExecute(event: NavigationEvent) = newEvent(event, Type.WAIT_OBSERVER_IF_NEEDED)

    /** Wait Observer Available and Emit Event for Execution Once */
    fun waitAndExecuteOnce(event: NavigationEvent) = newEvent(event, Type.WAIT_OBSERVER_IF_NEEDED_AND_EXECUTE_ONCE)
}

Формально, уже можно подписываться на Emitter во ViewModel и получать события без учета их хендлинга (было ли событие уже обработано или нет).

Создадим класс наблюдателя событий – EventObserver


class EventObserver(private val handlerBlock: (NavigationEvent) -> Unit) : Observer<NavigationEvent> {
    private val executedEvents: HashSet<String> = hashSetOf()
    
    /** Clear All Executed Events */
    fun clearExecutedEvents() =  executedEvents.clear()
    
    override fun onChanged(it: NavigationEvent?) {
        when (it?.type) {
            Type.EXECUTE_WITHOUT_LIMITS,
            Type.WAIT_OBSERVER_IF_NEEDED -> {
                if (!it.isHandled) {
                    it.isHandled = true
                    it.apply(handlerBlock)
                }
            }
            Type.EXECUTE_ONCE,
            Type.WAIT_OBSERVER_IF_NEEDED_AND_EXECUTE_ONCE -> {
                if (it.javaClass.simpleName !in executedEvents) {
                    if (!it.isHandled) {
                        it.isHandled = true
                        executedEvents.add(it.javaClass.simpleName)
                        it.apply(handlerBlock)
                    }
                }
            }
        }
    }
} 

На вход данный Observer принимает функцию высшего порядка – обработка событий будет написана во фрагменте (пример ниже).

Метод clearExecutedEvents() для очистки выполненных событий (те, которые должны были выполнится один раз). Необходим при обновлении экрана, например, в swipeToRefresh().

Ну и собственно, главный метод onChange(), который наступает при получении новых данных эмиттера, на который подписывается данный наблюдатель.

В случае, если событие имеет тип выполнения неограниченного количества раз, то мы проверяем, было ли событие выполнено и обрабатываем его. Выполняем событие и указываем, что оно получено и обработано.

if (!it.isHandled) {
    it.isHandled = true
    it.apply(handlerBlock)
}

Если событие с типом, который должен выполнится единожды, то проверяем, находится ли класс данного события в хеш-таблице. В случае отсутствия – выполняем событие и добавляем класс данного события в хэш-таблицу.

if (it.javaClass.simpleName !in executedEvents) {
    if (!it.isHandled) {
        it.isHandled = true
        executedEvents.add(it.javaClass.simpleName)
        it.apply(handlerBlock)
    }
}

А как же передавать данные внутри событий?


Для этого создается интерфейс MyFragmentNavigation, который будет состоять из классов, наследуемых от NavigationEvent(). Ниже созданы различные классы с передаваемыми параметрами и без них.

interface MyFragmentNavigation {
    class ShowCategoryList : NavigationEvent()
    class OpenProduct(val productId: String, val productName: String) : NavigationEvent()
    class PlayVideo(val url: String) : NavigationEvent()
    class ShowNetworkError : NavigationEvent()
}

Как это работает на практике


Отправка событий из ViewModel:

class MyViewModel : ViewModel() {
    val emitter = Events.Enitter()
    
    fun doOnShowCategoryListButtonClicked() = emitter.emitAndExecute(MyNavigation.ShowCategoryList())
    
    fun doOnPlayClicked() = emitter.waitAndExecuteOnce(MyNavigation.PlayVideo(url = "https://site.com/abc"))

    fun doOnProductClicked() = emitter.emitAndExecute(MyNavigation.OpenProduct(
            productId = "123", 
            productTitle = "Часы Samsung")
        )
    
    fun doOnNetworkError() = emitter.emitAndExecuteOnce(MyNavigation.ShowNetworkError())

    fun doOnSwipeRefresh(){
        emitter.clearWaitingEvents()
        ..//loadData()
    }
}

Получение событий во фрагменте:

class MyFragment : Fragment() {
    private val navigationEventsObserver = Events.EventObserver { event ->
            when (event) {
                is MyFragmentNavigation.ShowCategoryList -> ShowCategoryList()
                is MyFragmentNavigation.PlayVideo -> videoPlayerView.loadUrl(event.url)
                is MyFragmentNavigation.OpenProduct -> openProduct(id = event.productId, name = event.otherInfo)
                is MyFragmentNavigation.ShowNetworkError -> showNetworkErrorAlert()
            }
        }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
            super.onViewCreated(view, savedInstanceState)
            //Один Observer на несколько ViewModels в рамках одного фрагмента
            myViewModel.emitter.observe(viewLifecycleOwner, navigationEventsObserver)
            myViewModelSecond.emitter.observe(viewLifecycleOwner, navigationEventsObserver)
            myViewModelThird.emitter.observe(viewLifecycleOwner, navigationEventsObserver)
        }
    
    private fun ShowCategoryList(){
        ...
    }
    
    private fun openProduct(id: String, name: String){
        ...
    }
    
    private fun showNetworkErrorAlert(){
        ...
    }
}

По сути получился аналог Rx-BehaviorSubject-a и EventBus-a, только основанный на LiveData, в котором Emitter умеет собирать события до появления исполнителя-Observer-а, и в котором Observer умеет следить за типами событий и по необходимости вызывать их только один раз.

Велком в комментарии с предложениями.

Ссылка на исходники.
Рассрочка от Банка Хоум Кредит.
ООО «Хоум Кредит Энд Финанс Банк»
Компания

Комментарии 10

    0
    Жизненные циклы Views и ViewModels не связаны.

    Как раз они связаны. ViewModel живет до onDestroy() в Activity, потом вызывается onCleared().
      +1

      Здесь о фрагментах думал, конечно, т. к. мы с ними. Уточнение верное, спасибо!

      0

      Что будете делать, когда у разных событий возникнут одинаковые аргументы? Расширять иерархию MyFragmentNavigation абстрактным классом? Кажется тут самое место sealed class. Вы пишите, что получился аналог Rx-BehaviorSubject-a, но RxJava хороша своей расширяемостью. Для ее существует куча операторов, можно и свой какой-нибудь написать. Если речь про взаимодействие Presenter и View не проще использовать Moxy? Там организованы похожие стратегии, которые можно расширять.

        0
        Разные события — разные классы с необходимыми аргументами в данном событии. Иначе, как отличать события и их последующую обработку, если события разные? А если обработка событий одинаковая и аргументы одинаковые, то может и событие одно? :)
        Использовать можно хоть sealed class, хоть просто class (но не data class).

        Вы пишите, что получился аналог Rx-BehaviorSubject-a, но RxJava хороша своей расширяемостью.

        У нас в проекте нет RxJava.

        Если речь про взаимодействие Presenter и View не проще использовать Moxy? Там организованы похожие стратегии, которые можно расширять.

        Здесь про MVVM, не MPV.
        0
        Старая басня о главном, а именно о навигации в MVVM, она не проста, а ух реализация с протаскиванием событий навигации из ViewModel в View это совсем грустно.

        Я предпочитаю, делать, что то вроде такого github.com/Viacheslav01/joom2/tree/master/app/src/main/java/ru/smityukh/joom2/navigation
          0
          Вы прокидываете навигатор во ViewModel и не важно, где и как навигация будет осуществлена.
          Есть пара вопросов:
          1. Во ViewModel вам нужно показать Snackbar, который привязывается к конкретной View фрагмента. Как это реализовать в вашем случае?
          2. Нужно обработать ошибку сети. Данная ошибка одновременно пришла от 5 запросов. Как показать один AlertDialog?
            0
            Вы прокидываете навигатор во ViewModel и не важно, где и как навигация будет осуществлена.

            Да в этом и смысл

            1. Во ViewModel вам нужно показать Snackbar, который привязывается к конкретной View фрагмента. Как это реализовать в вашем случае?
            2. Нужно обработать ошибку сети. Данная ошибка одновременно пришла от 5 запросов. Как показать один AlertDialog?

            Оба вопроса не имеют отношения к навигации, для них нужен свой механизм.

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

            Можно в принципе их развить, почему нет, получится вполне полезное обсуждение.

            1) Что значит привязать к конкретной вьюхе? Вы используете снекбары, не только внизу экрана? Но и в других местах? Я за свою практику такого не встречал, но не отрицаю, что возможно где то это очень даже полезно. С своей стороны знаю, что снекбары даже при указании конкретной вьюхи все равоно будут искать подходящую вьюху по их понятиям.

            2) Не совсем понятно с вопросом, т.е. я не понимаю как одна технология передачи сообщения в отличии от другой решает проблему объединения и отображения ошибок?
              0
              Оба вопроса не имеют отношения к навигации, для них нужен свой механизм.

              Статья о прокидывании событий, а не о навигации. Навигацией занимается отдельный Навигатор. Поэтому и вопросы такие :)

              Вообще, сама навигация происходит (должна) от одного фрагмента/активити к другому фрагменту/активити, то есть от одной View к другой. ViewModel — слой, отвечающий за данные и бизнес-логику и она не должна сама производить навигацию (конечно, разработчики могут делать что угодно и внедрять навигаторы в любое место приложения, это вопрос больше к личному предпочтению разделения отвественности, не будем холиварить на эту тему :).

              Мы не производим навигацию между View(Fragment/Activity) из ViewModel, так как у них разные зоны ответственности. Вместо этого, мы отправляем события во View, в котором есть Navigator, который непосредственно управляет навигацией. И далее, если событие навигационное, с помощью Navigator-a открываются другие Fragments/Activities, а если событие локальное, но связанное с фреймворком андроид – показать Alert, Snackbar, запрос на системные пермишены – то такие события обрабатываются самим фрагментом.
                0
                Да это действительно может скатиться в холивар.

                Я исхожу из другого подхода, навигация как раз зона ответственности бизнес логики, а не представления. Так же как и вызов общей функциональности, такой как аутентификация, сообщения об ошибках и т.п.

                Опять же такой подход значительно упрощает архитектуру, убирает лишние связи и сокращает бойлерплейт.
                  0

                  Позволю себе небольшое уточнение. Бизнес-логика (ViewModel) отвечает за то, в какой момент и куда необходимо переключить внимание пользователя. Грубо говоря, она просто указывает пальцем на место, которое нужно показать (разумеется, абстрактно, а не указывая конкретные классы активити/фрагментов).
                  В то время как представление, совершенно не рефлексируя, производит переключение в указанное место. То есть, это исполнитель, которому думать вообще не нужно.
                  Так что я скорее на стороне Viacheslav01.


                  1. Во ViewModel вам нужно показать Snackbar

                  И такую постановку задачи лично я считаю неверной. ViewModel максимум можно поставить задачу "Вывести сообщение пользователю". Реализация через Snackbar — подробности уровня представления.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое