Сегодня речь пойдет о том, как обмениваться события между 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 умеет следить за типами событий и по необходимости вызывать их только один раз.

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

Ссылка на исходники.
Рассрочка от Банка Хоум Кредит.

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


  1. Pro-invader
    27.12.2019 15:00

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

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


    1. Klukwist Автор
      27.12.2019 16:21
      +1

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


  1. turlir
    27.12.2019 21:37

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


    1. Klukwist Автор
      27.12.2019 21:50

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

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

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

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

      Здесь про MVVM, не MPV.