Приручая 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. Отвечу на любые вопросы. Буду рад, если вам данное решение покажется полезным!

ссылка на оригинал статьи https://habr.com/ru/post/509378/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *