Так для чего же нам все таки нужен MVI в мобильной разработке

Много уже сказано про MVI, о том как его правильно прожарить и настроить. Однако не так много времени уделяется тому, насколько этот метод упрощает жизнь в определенных ситуациях, в сравнении с остальными подходами.

Цель этой статьи


Я не буду углубляться в то как технически реализуется MVI (способов больше одного и у каждого есть свои плюсы и минусы). Моя главная цель в короткой статье заинтересовать тебя изучать эту тему в дальнейшем и возможно побудить внедрить данный паттерн на своих боевых проектах или хотя бы проверить на домашних заготовках.

С какой проблемой можно столкнуться


Мой дорогой друг, давай представим такую ситуацию, у нас имеется интерфейс вью, с которым
предстоит работать:

interface ComplexView { 
   fun showLoading()   
   fun hideLoading()   
   fun showBanner()    
   fun hideBanner()    
   fun dataLoaded(names: List<String>)    
   fun showTakeCreditDialog()
   fun hideTakeCreditDialog()
}

На первый взгляд кажется, что ничего сложного. Ты просто выделяешь для работы с этой вьюшкой отдельную сущность, называешь ее презентером (вуаля вот и MVP готов), а это большие проблемы небольшие сложности и сейчас я постараюсь объяснить почему.

А вот и сам презентер:

interface Presenter {  
   fun onLoadData(dataKey: String)    
   fun onLoadCredit()
}

Все просто, вьюшка дергает методы презентера, когда надо загрузить данные, презентер в свою очередь имеет право дергать вьюшку, для того, чтобы отобразить загруженную информацию, а также отобразить прогресс. Но тут и появляется проблема сложность — это абсолютное отсутствие контроля консистентности твоего UI мой товарищ.

К примеру мы хотим вывести диалог предлагающий кредит выгодное предложение пользователю и делаем этот вызов из презентера, имея на руках ссылку на интерфейс вью:

view.hideTakeCreditDialog()


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

Тебе ни в коем случае нельзя вызывать:

view.showBanner()


view.showLoading()


Пока идет показ диалога. Иначе будут плакать котики тестировщики и пользователи, от боли в глазах от одного взгляда на баннер поверх важного диалога c кредитом выгодным предложением.

А сейчас давай еще подумаем с тобой и предположим, что все таки захотелось показать баннер (такое уж требование от бизнеса). О чем же надо помнить?
Дело в том, что при вызове сего метода:

view.showBanner()


Обязательно надо вызывать:

view.hideLoading()


view.hideTakeCreditDialog()


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

Вот и всплывает вопрос, кто ударит тебя по рукам, если сделаешь что-то не так? Ответ прост — НИКТО. В такой реализации у тебя нет абсолютно никакого контроля.

Возможно в будущем понадобится добавить еще какую-либо функциональность во вью, которая также будет связано с тем, что уже есть. Какие минусы мы из этого получаем?

  1. Лапша из зависимостей состояний юайных элементов
  2. Логика переходов из одного состояния отображения в другое будет размазана по
    презентеру
  3. Довольно тяжело добавлять новое состояние экрана, так как велик риск того, что
    забудешь что-то скрыть перед тем как отобразить новый баннер или диалог

И это мы с тобой разбирали кейс когда во вью всего 7 методов. И даже тут получилось встрять на проблемы.

А ведь бывают вот такие вью:

interface ChatView : IView<ChatPresenter> {
    fun setMessage(message: String)    
    fun showFullScreenProgressBar()    
    fun updateExistingMessage(model: ChatMessageModel)    
    fun hideFullScreenProgressBar()    
    fun addNewMessage(localMessage: ChatMessageModel)    
    fun showErrorFromLoading(message: String)       
    fun moveChatToStart()    
    fun containsMessage(message: ChatMessageModel): Boolean    
    fun getChatMessagesSize(): Int    fun getLastMessage(): ChatMessageModel?    
    fun updateMessageStatus(messageId: String, status: ChatMessageStatus)    
    fun setAutoLoading(autoLoadingEnabled: Boolean)
    fun initImageInChat(needImageInChat: Boolean)    
    fun enableNavigationButton()   
    fun hideKeyboard()   
    fun scrollToFirstMessage()    
    fun setTitle(@StringRes titleRes: Int)    
    fun setVisibleSendingError(isVisible: Boolean)    
    fun removeMessage(localId: String)    
    fun setBottomPadding(hasPadding: Boolean)    
    fun initMessagesList(pageSize: Int)    
    fun showToast(@StringRes textRes: Int)   
    fun openMessageDialog(message: String)   
    fun showSuccessRating()    
    fun setRatingAvailability(isEnabled: Boolean)    
    fun showSuccessRatingWithResult(ratingValue: String)
}

Добавлять сюда что-либо новое или править старое будет довольно тяжело, придется посмотреть, что и как связано между собой, а потом начать плакать работать и молиться, чтобы тестировщик ничего не пропустил. И в момент твоего отчаяния появляется он.

MVI



image

Вся суть


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

Давай вспомним наше положение в начале статьи, у нас есть вью, на которой мы показываем диалоги, баннеры и волшебство. Опишем ка мы с тобой стейт вьюшки

data class UIState(
       val loading: Boolean = false,
       val names: List<String>? = null,    
       val isBannerShowing: Boolean = false,    
       val isCreditDialogShowing: Boolean = false
)

Установим правило, мы с тобой можем менять вью только с помощью этого стейта, будет такой интерфейс:

interface ComplexView { 
   fun renderState(state: UIState)
}

А сейчас установим еще одно правило. Мы можем обращаться к владельцу стейта (в нашем случае это будет презентер) только через одну точку входа. Путем отправления ему событий. Хорошая идея назавать эти события экшенами.

sealed class UIAction {    
       class LoadNamesAction(dataKey: String) : UIAction()    
       object LoadBannerAction : UIAction()    
       object LoadCreditDialogInfo : UIAction()
}

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

interface Presenter { 
   fun processAction(action: UIAction)
}

А теперь давай подумаем как связать все это дело:

fun processAction(action: UiAction): UIState {    
return when (action) {        
    is UiAction.LoadNamesAction -> state.copy(
                loading = true, 
                isBannerShowing = false,         
                isCreditDialogShowing = false
    )       
    is UiAction.LoadBannerAction -> state.copy(            
                loading = false,           
                isBannerShowing = true,            
                isCreditDialogShowing = false
    )        
    is UiAction.LoadCreditDialogInfo -> state.copy(    
                loading = false,            
                isBannerShowing = false,            
                isCreditDialogShowing = true
    )    
  }
}

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

Это не стало супер просто, однако твоя жизнь должна стать легче. Плюс в моем примере этого не видно, но мы можем решать как запроцесить наш новый стейт на основе предыдущего стейта (для реализации такого также есть несколько придумок). Я уж не говорю о безумной возможности пере использования, которой добились ребята из badoo, одним из их помощников в достижении этой цели был MVI.

Однако не стоит рано радоваться, у всего в этом мире есть как плюсы так и минусы, а вот и они


  1. Нас ломает об ногу обычный показ toast
  2. При обновлении одного флажка весь стейт будет скопирован заново и отправлен во
    вью, то есть произойдет ненужная перерисовка, если ничего с этим не сделать

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

data class UIState(
       val showToast: Boolean = false,
)

Первое


Берем и меняем стейт в презентере, ставим showToast = true и самое простое, что может произойти это поворот экрана. Все уничтожается взрывы и разрушения активити пересоздается, но так как ты крутой разработчик твой стейт все это дело переживает. А в стейте у нас волшебство флаг, который говорит отобразить toast. Результат — toast показывается дважды. Для решения данной проблемы есть несколько способов и все выглядят как костыли. Опять же об этом будет написано в источниках, приложенных к этой статье.

Ну, а второе


Это уже проблема ненужных отрисовок во вью, которые будут происходить каждый раз даже когда в стейте меняется всего одно из полей. И эта проблема решается несколькими иногда не самыми красивыми способами (порой тупой проверкой перед тем как сетать во вью новое значение, на то, что оно отличается от предыдущего). Но с выходом compose в stable версию эта проблема будет решена, вот тогда мой друг заживем с тобой в преображенном и счастливом мире!

Время для плюсов:


  1. Одна точка входа во вью
  2. Мы всегда под рукой имеем текущее состояние экрана
  3. Еще на стадии реализации приходится продумывать как один стейт будет перетекать
    в другой и какая между ними связь
  4. Unidirectional Data Flow

Любите андроид и никогда не теряйте свою мотивацию!

Список моих вдохновителей



Similar posts

Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 26

    +3

    Остался один маленький шаг до изобретения конечного автомата...

      0
      Эх, к несчастью мы не можем детерминировать все возможные вариантов символов, которые пользователь может ввести например в то же поле ввода, а значит до конечного автомата никак не добраться(
        0

        Ну не обязательно же спускаться аж на уровень пользовательского ввода.


        Вполне можно остаться на уровне состояний интерфейса. Вы это практически сделали в своем последнем примере. Осталось избавиться от флажков и явно прописать все возможные переходы.

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

          Вот компоуз помогает делать нечто подобное без болей в нижней части спины)

              @Composable
              fun NamePreferences(data: UserPreferences){
                  EditableText(
                      text = data.name,
                      onChange = { newValue ->
                          data.name = newValue
                      }
                  )
              }
          

          Осталось поверх этого MVI обвязать)
      0
      Есть лишь одна большая проблема комплексного состояния — мерж изменения. Если у нас поменялся условный флаг isLoading, вовсе не факт, что надо по другому отображать остальные поля. А значит, надо писать явную проверку на соответствие предыдущему отображения. Вот если каждое из свойств независимое, или сгруппировано по принципу конечного автомата — вопросов нет. Но принципиально пользоваться ровно одним состоянием, на мой взгляд, не всегда удобно
        0
        Соласен с вами, в данном подходе появляется такая проблема, но уже придумали несколько решений и можно выбирать то, которое подойдет лучше всего именно вам на проекте, или придумать свое, что-то новое и великое)
          0
          Не придерживаться парадигмы «одна VM — одно состояние». Собственно любой observe паттерн подойдет, и каноничный MVVM это никак не нарушит
            0
            Каноничный MVVM как раз имеет одно существенное отличие, которое было описано мной в одном из комментариев ниже)
        0

        У Вас получилось что-то очень похожее на Redux.

          0
          Название MVI, которое, вроде как, Hannes Dorfmann придумал в 2017, по сути и является недо-Redux-ом. Никогда не понимал зачем было это по-другому называть.
            0

            он его всего лишь популяризовал в среде Android, он сам писал, что упёр идею из мира JS-фреймворков

            0
            Ну, по своей сути этот подход к нам перекочевал как раз из фронтенд мира)
            0
            А MVPVM не решит тех проблем, что приведены в данной статье?
              0
              В описанных вами подходах, мы меньше контролируем то, что отображается на нашем ui.
              Представьте вы стоите перед какой-то неведомой вам машиной и хотите, чтобы она что-то для вас сделала. Вам будет проще ошибиться если на ней будет 20 рубильников, которые можно включать и определенная их комбинация будет давать вам необходимый результат.
              С другой стороны будет гораздо сложнее допустить ошибку, если вам дадут один терминал управления, в который можно будет отправить записку с тем результатом который вам нужен, а машина уже сама вам все покажет на основе этой записки.
              Если брать в общем, то в этом как раз заключается ключевая разница между подходом MVI и остальными MV подходами
              0

              Кто по вашему должен следить за корректностью стейта?
              Скажем в UI пришло: loading = true + isCreditDialogShowing = true, что по нашей логике не верно. Кто контролирует это?

                0
                В MVI, как такового контроля за этим мы не получаем, но, сам подход заставляет нас думать о том какие стейты будет иметь наш юай еще на стадии проектирования. Также мы всегда сможем проследить как стейт перетекает из одного, заглянув в наш стейт редьюсер (либо любую другую сущность, которая у нас отвечает за процессинг стейтов).
                В случае MVP неконсистетное состояние на юай словить гораздо проще, в MVI же вас насторожит момент в котором при создании этого стейта оба флага loading и isCreditDialogShowing будут true.
                К тому же MVI упрощает написание тестов, мы на какие-то инпуты ждем аутпут в виде стейта и проверяем его, если тест падает и приходит тот стейт, которого мы не ждем, то гораздо легче проследить, что пошло неправильно, так как по хорошему переход из одного стейта в другой происходит транзакционно.
                Ну и исходя из практики, гораздо легче бывает дебажить, когда у нас во вью не миллион ручек, которые можно дергать, а всего одна и туда приходит стейт.
                0

                С диффингом изменений можно поступить так: в презентере заводим observable на какую то часть стейта, и применяем оператор distinctUntilChanged. Похоже на mvvm, но на самом деле single source of truth мы не теряем.

                  0
                  Как один из неплохих вариантов порешать проблему диффинга)
                  0
                  А почему бы не сравнить MVI с MVVM?
                  Тогда loading, isBannerShowing, isCreditDialogShowing из вашего примера были бы отдельными наблюдаемыми полями. А вместо processAction стало бы:

                  fun loadNames() {
                      loading = true 
                      isBannerShowing = false         
                      isCreditDialogShowing = false
                  }
                  
                  fun loadBanner() {
                      loading = false 
                      isBannerShowing = true         
                      isCreditDialogShowing = false
                  }
                  
                  fun loadCreditDialogInfo() {
                      loading = false 
                      isBannerShowing = false         
                      isCreditDialogShowing = true
                  }
                  

                  Чем это хуже?
                    0
                    При таком подходе я вижу проблему в том, что эти флаги по нашей логике отображения связаны и зависят друг от друга, но на деле мы получаем два наблюдаемых поля, которые в нашем коде не связаны абсолютно никак.
                    У нас приходит новый разраб, ему надо добавить третий флаг, он также делает его наблюдаемым полем. Пишет свою логику запроса и при успешном его завершении сетает в флаг true, но при этом ему наглядно нигде не увидеть какие еще флаги могут в этот момент быть и какие из них придется делать false.
                    В подходе со стейтами, ему придется добавить либо новый флаг в наш UI стейт, либо описать вообще новый стейт(зависит от подхода к реализации MVI), но в любом случае он уже на стадии проектирования задумается какое именно состояние экрана ему нужно, что должно показываться, а что нет, в таком подходе риск допустить ошибку снижается, ровно как и время ее исправления, если она все же была допущена. На моей практике был случай, когда на то, чтобы понять откуда на экране появляется лоадер уходило около недели, потому что во вью было много ручек и в какой последовательности они дергаются нигде детерминированно увидеть было невозможно, только дебаг и слезы
                      0
                      Получается, нам требуется, чтоб значения полей зависели друг от друга, и не было возможности эту зависимость сломать. В случае MVVM я вижу такое решение.
                      Нужно организовать состояние так, чтоб оно состояло из двух частей:
                      1) Основное состояние — оно изменяемое, его можно менять достаточно свободно, не нарушая при этом никаких зависимостей.
                      2) Производное состояние — напрямую менять его нельзя, оно пересчитывается автоматически как чистая функция от основного состояния.

                      Пример:
                      private var hasCreditOffer by state(false)
                      private var hasAvailableAdvertisement by state(false)
                      private var namesLoading by state(false)
                      
                      val isCreditDialogShowing by computed { hasCreditOffer }
                      
                      val isBannerShowing by computed {
                          hasAvailableAdvertisement && !isCreditDialogShowing 
                      }
                      
                      val progressShowing by computed {
                          namesLoading && !isCreditDialogShowing && !isBannerShowing 
                      }

                      Похожие идеи можно увидеть в библиотеке MobX, которая достаточно популярна во фронтенд-мире.
                        0
                        О, а давайте выделим Ваши state'ы в отдельную сущность типа data класса? Ну, ведь их и десяток может быть, зачем держать их полями presenter'a / viewModel'и? Ой, получился mvi!
                          0
                          Действительно)
                            0
                            Довольно интересная идея, спасибо за комментарий, поизучаю MobX на досуге)
                        +1
                        Немного поразмыслив про toast, мне захотелось к сущности UIState добавить еще сущность UICommand, которая должна выполниться только один раз. Ведь по сути, показ тоста и не влияет никак на состояние экрана, а полностью обрабатывается системой. Помимо показа тостов, это может быть, например, пересоздание активити при изменении языка/темы в настройках приложения (не девайса), или завершение активити (реализация кастомной кнопки «Выход» в приложении).
                          0
                          Вам пришла неплохая идея, нечто похожее реализовано в библиотеке MviCore от ребят их Badoo, можете посмотреть если вам интересно.

                        Only users with full accounts can post comments. Log in, please.