[Kotlin, Разработка мобильных приложений, Разработка под Android] Приручая MVI
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
О том, как распутать джунгли MVI, используя Джунгли собственного производства, и получить простое и структурированное архитектурное решение.
Предисловие
Впервые наткнувшись на статью о Model-View-Intent (MVI) под Android, я даже не открыл ее.
— Серьезно!? Архитектура на Android Intents?
Это была глупая идея. Намного позже я прочел про MVI и узнал, что главным образом данная архитектура сосредоточена на однонаправленных потоках данных и управлении состояния.
Изучая MVI, я невольно столкнулся с проблемой, что весь подход выглядит как-то запутанно, как какие-то дебри. Да, на выходе получается решение с плюсами по отношению к MVP и MVVM, но, смотря на эту всю комплексность, задаешься вопросом: "А стоило ли?".
Просмотрев несколько популярных библиотек на эту тему, у меня так и не появилось фаворита из существующих решений, так как что-нибудь мне да не нравилось; появлялись различные вопросы, на которые не всегда можно было найти однозначные ответы.
Так я решил написать свое решение. Основные требования (по значимости):
- Простое;
- Покрывает все UI кейсы, которые я только могу придумать;
- Структурированное.
И что это?
Позвольте представить — Джунгли (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.
Все отношения, показанные на картинке, — опциональны
Как это работает?
Как мне кажется, лучший способ понять это — разобрать пример. Представим, нам нужен экран со списком стран, который должен быть загружен из Интернета. Также существуют следующие условия:
- Показывать PrgoressBar во время загрузки;
- Отображать Button для перезагрузки списка и Toast с сообщением об ошибке в случае ошибки;
- Если страны были успешно загружены, отображать список стран;
- Попробовать загрузить страны на открытии окна автоматически, без каких-либо действий пользователя.
Давайте напишем нашу 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. Отвечу на любые вопросы. Буду рад, если вам данное решение покажется полезным!
===========
Источник:
habr.com
===========
Похожие новости:
- [IT-инфраструктура, ReactJS, Планшеты, Разработка мобильных приложений] «Сим-сим, откройся!»: доступ в ЦОД без бумажных журналов
- [Разработка мобильных приложений, Разработка под iOS] Бюджетный DI на антипаттернах
- [Разработка под Android] Редактор кода на Android: часть 1
- [Разработка мобильных приложений, Разработка под Android, Разработка под iOS, Тестирование мобильных приложений] Релиз мобильных приложений одной кнопкой
- [Информационная безопасность, Разработка мобильных приложений, Разработка под iOS] Почему разработчики отказываются от авторизации через Apple с фейковым email
- [API, Конференции, Разработка мобильных приложений, Разработка под Android] Приглашаем на Mobile Meetup Innopolis
- [Информационная безопасность, Монетизация мобильных приложений, Разработка мобильных приложений, Разработка под iOS] TikTok влезает в буфер обмена на iOS и попал под полный запрет в Индии
- [Разработка под iOS, Разработка мобильных приложений, Дизайн мобильных приложений, Тестирование мобильных приложений] Как смотреть WWDC 2020, если ты не разработчик
- [Java, Ненормальное программирование, Разработка под Android] Блокировка двойного клика. Велосипед?
- [Машинное обучение, Обработка изображений, Разработка мобильных приложений] Камера, мотор, панорама: как создаются 3D-фото автомобилей в приложении Авто.ру
Теги для поиска: #_kotlin, #_razrabotka_mobilnyh_prilozhenij (Разработка мобильных приложений), #_razrabotka_pod_android (Разработка под Android), #_mvi, #_arhitektura_androidprilozhenij (архитектура android-приложений), #_kotlin, #_razrabotka_mobilnyh_prilozhenij (
Разработка мобильных приложений
), #_razrabotka_pod_android (
Разработка под Android
)
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 03-Дек 23:02
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
О том, как распутать джунгли MVI, используя Джунгли собственного производства, и получить простое и структурированное архитектурное решение. Предисловие Впервые наткнувшись на статью о Model-View-Intent (MVI) под Android, я даже не открыл ее. — Серьезно!? Архитектура на Android Intents? Это была глупая идея. Намного позже я прочел про MVI и узнал, что главным образом данная архитектура сосредоточена на однонаправленных потоках данных и управлении состояния. Изучая MVI, я невольно столкнулся с проблемой, что весь подход выглядит как-то запутанно, как какие-то дебри. Да, на выходе получается решение с плюсами по отношению к MVP и MVVM, но, смотря на эту всю комплексность, задаешься вопросом: "А стоило ли?". Просмотрев несколько популярных библиотек на эту тему, у меня так и не появилось фаворита из существующих решений, так как что-нибудь мне да не нравилось; появлялись различные вопросы, на которые не всегда можно было найти однозначные ответы. Так я решил написать свое решение. Основные требования (по значимости):
И что это? Позвольте представить — Джунгли (Jungle). Под капотом — только RxJava с ее реактивным подходом. База
Все отношения, показанные на картинке, — опциональны Как это работает? Как мне кажется, лучший способ понять это — разобрать пример. Представим, нам нужен экран со списком стран, который должен быть загружен из Интернета. Также существуют следующие условия:
Давайте напишем нашу 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 и ничего больше. Как мне кажется, это простое решение, так как требует небольших усилий как для понимания, так и для использования. Структурировано? Для того, чтобы поддерживать структурированность, необходимо:
Также стоит помнить, что Middleware — про одну функциональность, что делает его похожим на UseCase (Interactor). На мой взгляд, присутствие последнего (и, как следствие, какого-то domain layer) говорит о хорошо структурированном проекте. По этой же аналогии, я считаю, что использование Middleware способствует улучшению структуры проекта. Заключение С использованием Джунгей у меня есть четкое представление того, как организуется навигация внутри подхода. Я также уверен, что проблема SingleLiveEvent может быть легко разрешена с использованием Actions. Более подробные разборы работы можно найти на wiki. Отвечу на любые вопросы. Буду рад, если вам данное решение покажется полезным! =========== Источник: habr.com =========== Похожие новости:
Разработка мобильных приложений ), #_razrabotka_pod_android ( Разработка под Android ) |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 03-Дек 23:02
Часовой пояс: UTC + 5