Приручая MVI

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


image


Предисловие


Впервые наткнувшись на статью о Model-View-Intent (MVI) под Android, я даже не открыл ее.
— Серьезно!? Архитектура на Android Intents?


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


Изучая MVI, я невольно столкнулся с проблемой, что весь подход выглядит как-то запутанно, как какие-то дебри. Да, на выходе получается решение с плюсами по отношению к MVP и MVVM, но, смотря на эту всю комплексность, задаешься вопросом: "А стоило ли?".


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


Так я решил написать свое решение. Основные требования (по значимости):


  1. Простое;
  2. Покрывает все UI кейсы, которые я только могу придумать;
  3. Структурированное.

И что это?


Позвольте представить — Джунгли (Jungle). Под капотом — только RxJava с ее реактивным подходом.


База


  • State — "устойчивые" данные об UI, которые должны быть показаны даже после пересоздания View;
  • Action — "неустойчивые" данные об UI, которые не должны быть показаны после пересоздания View (например, данные о Snackbar и Toast);
  • Event Intent из Model-View-Intent;
  • MviView — интерфейс, через который поставляются новые Actions и обновления State;
  • Middleware — посредник между одной функциональностью бизнес логики и UI;
  • Store — посредник между Model и View, который решает, как обрабатывать Events, поставлять обновления State и новые Actions.

image
Все отношения, показанные на картинке, — опциональны


Как это работает?


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


  1. Показывать PrgoressBar во время загрузки;
  2. Отображать Button для перезагрузки списка и Toast с сообщением об ошибке в случае ошибки;
  3. Если страны были успешно загружены, отображать список стран;
  4. Попробовать загрузить страны на открытии окна автоматически, без каких-либо действий пользователя.

Давайте напишем нашу UI часть:


sealed class DemoEvent {
   object Load : DemoEvent()
}

sealed class DemoAction {
   data class ShowError(val error: String) : DemoAction()
}

data class DemoState(
   val loading: Boolean = false,
   val countries: List<Country> = emptyList()
)

class DemoFragment : Fragment, MviView<DemoState, DemoAction> {

   private lateinit var demoStore: DemoStore
   private var adapter: DemoAdapter? = null

   /*Initializations are skipped*/

   override fun onViewCreated(view: View, bundle: Bundle?) {
      super.onViewCreated(view, bundle)
      demoStore.run {
         attach(this@DemoFragment)
         dispatchEventSource(
            RxView.clicks(demo_load)
               .map { DemoEvent.Load }
         )
      }
   }

   override fun onDestroyView() {
      super.onDestroyView()
      demoStore.detach()
   }

   override fun render(state: DemoState) {
      val showReload = state.run {
         !loading && countries.isEmpty()
      }
      demo_load.visibility = if (showReload)
         View.GONE else
         View.VISIBLE
      demo_progress.visibility = if (state.loading)
         View.VISIBLE else
         View.GONE
      demo_recycler.visibility = if (state.countries.isEmpty())
         View.GONE else
         View.VISIBLE
      adapter?.apply {
         setItems(state.countries)
         notifyDataSetChanged()
      }
   }

   override fun processAction(action: DemoAction) {
      when (action) {
         is DemoAction.ShowError ->
            Toast.makeText(
               requireContext(),
               action.error,
               Toast.LENGTH_SHORT
            ).show()
      }
   }
}

Что из этого (пока) можно понять? Мы можем послать DemoEvent.Load нашему DemoStore (по клику на Reload кнопку); получить DemoAction.ShowError (с данными об ошибке) и отобразить Toast; получить обновление по DemoState (с данными о странах и состоянии загрузки) и отобразить UI компоненты в соответствии с требованиями. Вроде бы не так уж и сложно.


Теперь приступим к нашему DemoStore. В первую очередь, унаследуем его от Store, разрешим получать DemoEvent, производить DemoAction и изменять DemoState:


class DemoStore (
   foregroundScheduler: Scheduler,
   backgroundScheduler: Scheduler
) : Store<DemoEvent, DemoState, DemoAction>(
   foregroundScheduler = foregroundScheduler,
   backgroundScheduler = backgroundScheduler
)

Затем, создадим CountryMiddleware, который будет ответственным за предоставление данных о загрузке стран:


class CountryMiddleware(
   private val getCountriesInteractor: GetCountriesInteractor
) : Middleware<CountryMiddleware.Input>() {
   override val inputType = Input::class.java

   override fun transform(upstream: Observable<Input>) =
      upstream.switchMap<CommandResult> {
         getCountriesInteractor.execute()
            .map<Output> { Output.Loaded(it) }
            .onErrorReturn {
               Output.Failed(it.message ?: "Can't load countries")
            }
            .startWith(Output.Loading)
      }

   object Input : Command

   sealed class Output : CommandResult {
      object Loading : Output()
      data class Loaded(val countries: List<Country>) : Output()
      data class Failed(val error: String) : Output()
   }
}

Что такое Command? Это специфичный сигнал, который побуждает "что-то" сделать. А CommandResult? Это результат выполнения этого "чего-то".


В нашем случае CountryMiddleware.Input сигнализирует, что логика CountryMiddleware должна быть выполнена. Каждое выполнение логики Middleware возвращает CommandResult; для лучшей структуры приложения можно хранить этот результат внутри sealed класса (CountryMiddleware.Output).


В нашем случае мы попросту возвращаем Observable, который испустит Output.Loading во время загрузки, Output.Loaded с данными на успешную загрузку, Output.Failed с информацией об ошибке на ошибку.


Давайте вернемся к DemoStore и заставим обработать CountryMiddleware на нажатие Reload кнопку:


class DemoStore (..., countryMiddleware: CountryMiddleware) ... {
   override val middlewares = listOf(countryMiddleware)

   override fun convertEvent(event: DemoEvent) = when (event) {
      is DemoEvent.Load -> CountryMiddleware.Input
   }
}

Переопределяя поле middlewares мы указываем, какие Middlewares наш DemoStore может обработать. Под капотом Store использует Commands. Поэтому нам следует сконвертировать наш DemoEvent.Load в CountryMiddleware.Input (для того, чтобы принудить перезагрузку).


Итак, теперь мы можем получать результат от CountryMiddleware. Давайте позволим последнему изменять наш DemoState:


class DemoStore ... {

   ...

   override val initialState = DemoState()

   override fun reduceCommandResult(
      state: DemoState,
      result: CommandResult
   ) = when (result) {
      is CountryMiddleware.Output.Loading ->
         state.copy(loading = true)
      is CountryMiddleware.Output.Loaded ->
         state.copy(loading = false, countries = result.countries)
      is CountryMiddleware.Output.Failed ->
         state.copy(loading = false)
      else -> state
   }
}

Прежде чем изменять State, необходимо указать его начальное состояние в initialState. После этого в методе reduceCommandResult описывается логика того, как каждый CommandResult изменяет State.


Для отображения ошибки загрузки используется DemoAction.ShowError. Чтобы сгенерировать последний, необходимо предоставить новую Command (из CommandResult) и связать ее с нашим Action:


class DemoStore ... {

   ...

   override fun produceCommand(commandResult: CommandResult) =
      when (commandResult) {
         is CountryMiddleware.Output.Failed ->
            ProduceActionCommand.Error(commandResult.error)
         else -> null
      }

   override fun produceAction(command: Command) =
      when (command) {
         is ProduceActionCommand.Error ->
            DemoAction.ShowError(command.error)
         else -> null
      }

   sealed class ProduceActionCommand : Command {
      data class Error(val error: String) : ProduceActionCommand()
   }
}

Последнее, что осталось сделать — привязать автоматический запуск выполнения CountryMiddleware. Все, что нужно сделать, это добавить его Command в bootstrapCommands:


class DemoStore ... {

   ...

   override val bootstrapCommands = listOf(CountryMiddleware.Input)
}

Сделано!


Просто?


Можно использовать конкретно только то, что вам нужно, без какой-либо лишней логики. Несколько классов и щепотка магии под капотом. Один Store, опционально несколько Middlewares, опционально имплементация MviView.


Ваша View должна только отображать обновления какой-то функциональности бизнес логики? Вам даже не нужны Events, только Store, Middleware и переопределение метода render функции в MviView.


Только кнопка, по клику которой происходит какая-то навигация? Окей, стоит только поиграться с Event внутри Store и ничего больше.


Как мне кажется, это простое решение, так как требует небольших усилий как для понимания, так и для использования.


Структурировано?


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


  • Хранить Commands в sealed классах внутри Store, группируя их по назначения: генерирующие Actions или напрямую изменяющие State?
  • Хранить Commands, относящихся к Middlewares, внутри последних.

Также стоит помнить, что Middleware — про одну функциональность, что делает его похожим на UseCase (Interactor). На мой взгляд, присутствие последнего (и, как следствие, какого-то domain layer) говорит о хорошо структурированном проекте. По этой же аналогии, я считаю, что использование Middleware способствует улучшению структуры проекта.


Заключение


С использованием Джунгей у меня есть четкое представление того, как организуется навигация внутри подхода. Я также уверен, что проблема SingleLiveEvent может быть легко разрешена с использованием Actions.


Более подробные разборы работы можно найти на wiki. Отвечу на любые вопросы. Буду рад, если вам данное решение покажется полезным!

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

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

    0

    Было бы здорово описать минусы тех. решений о которых вы говорите "дебри". Возможно у тех решений есть плюсы, которых нет у вас. И на долгой дистанции(или большого проекта) ваше решение не будет работать.


    Лично для меня пока из существующих решений для среднего проекта мне подошёл MvRx от AirBnb.
    Во-первых они построили его на существующих элементах(Fragment, ViewModel). Во-вторых всю работу со Store/Reducer скрыли с глаз долой.

      0
      Очень много минусов. За простотой скрыто понимание как там работает внутри.
      Store монолит. нельзя отдельно протестировать actor, reducer.
      К стору также нужно обязательно аттачить view, что также сказывается на тестировании. Нельзя к нему приаттачить TestObservable какой нибудь и протестировать смену стейтов.
      В результате теряется главный плюс MVi, слабосвязанность и хорошая тестируемость отдельных компонентов.
        0

        Store без View вне тестирования не имеет смысла. Делать Store наследником ObservableSource, к которому можно подписаться, я не считаю правильным, т.к. не нравится идея работы с подпиской из View (или, что хуже, дополнительной прослойки).
        О тестировании в целом. Тестирование того же Reducer все же возможно, но не обособленно. И TestObservable приаттачить можно, но, как и в случае с Reducer, придется приложить дополнительные усилия в виде различных оберток.

        0
        Минус существующих решений, на мой взгляд, — один. И он очень субъективен: если нет четкого, полного понимания, что и как работает внутри самого подхода, процесс изучения усложняется при использовании данных решений (по крайней мере, я столкнулся именно с этим).
        Что мне не нравилось (и из-за чего возникали сложности с данными решениями):
        1) Отход от стандартных терминов (вообще, здесь сложно говорить о каких-то стандартных терминах в MVI), навязывая свои. Не критично, но вызывает своего рода сложности;
        2) Раздутость. Некоторые под каждый кусок функционала используют отдельный класс. SRP — это здорово, не спорю. Изначально делал также, но мне не понравилось, как выглядело конечное решение; по итогу — многое заинлайнил.
        3) Подписка / .subscribe() / Disposable внутри View.

        Основной минус получившегося решения (с чем согласен с linyaKoder) — потеря хорошей тестируемости, предполагаемой подходом. Вырос он из того, что на этапе «сейчас» об этом пункте я попросту не подумал. Постараюсь исправить это в дальнейшем.

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

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