Главная » Хабрахабр » [Из песочницы] MVIDroid: обзор новой библиотеки MVI (Model-View-Intent)

[Из песочницы] MVIDroid: обзор новой библиотеки MVI (Model-View-Intent)

В этой статье я хочу рассказать о новой библиотеке, которая привносит шаблон проектирования MVI в Android. Всем привет! Автор библиотеки лично я, исходный код её доступен на GitHub, а подключить её можно через JitPack (ссылка на репозиторий в конце статьи). Эта библиотека называется MVIDroid, написана 100% на языке Kotlin, легковесная и использует RxJava 2.x. Эта статья состоит из двух частей: общее описание библиотеки и пример её использования.

MVI

Model — View — Intent или, если по-русски, Модель — Представление — Намерение. И так, в качестве предисловия, позвольте напомнить что такое вообще MVI. Представление (View) в свою очередь принимает Модели Представления (View Model) и производит те самые Намерения. Это такой шаблон проектирования, в котором Модель (Model) является активным компонентом, принимающим на вход Намерения (Intents) и производящая Состояния (State). Схематически шаблон MVI можно представить следующим образом: Состояние преобразуется в Модель Представления при помощи функции-трансформера (View Model Mapper).

MVI

Вместо этого оно производит События Представления (UI Events), которые затем преобразуются в Намерения при помощи функции-трансформера. В MVIDroid Представление не производит Намерения напрямую.

View

Основные компоненты MVIDroid

Модель

В библиотеке понятие Модели немного расширено, здесь она производит не только Состояния но и Метки (Labels). Начнём с Модели. Метки одних Моделей могут быть преобразованы в Намерения других Моделей при помощи функций-трансформеров. Метки используются для общения Моделей между собой. Схематически Модель можно представить так:

Model

В MVIDroid Модель представлена интерфейсом MviStore (название Store заимствовано из Redux):

interface MviStore<State : Any, in Intent : Any, Label : Any> : (Intent) -> Unit, Disposable { @get:MainThread val state: State val states: Observable<State> val labels: Observable<Label> @MainThread override fun invoke(intent: Intent) @MainThread override fun dispose() @MainThread override fun isDisposed(): Boolean
}

И так, что мы имеем:

  • Интерфейс имеет три Generic-параметра: State — тип Состояния, Intent — тип Намерений и Label — тип Меток
  • Содержит три поля: state — текущее состояние Модели, states — Observable Состояний и labels — Observable Меток. Последние два поля дают возможность подписаться на изменения Состояния и на Метки соответственно.
  • Является потребителем (Consumer) Намерений
  • Является Disposable, что даёт возможность разрушить Модель и прекратить все происходящие в ней процессы

То же самое справедливо и для любого другого компонента. Обратите внимание, что все методы Модели должны выполняться на главном потоке. Выполнять фоновые задачи, разумеется, можно используя стандартные средства RxJava.

Компонент

Например можно выделить в Компонент все Модели для какого-либо экрана. Компонент в MVIDroid — это группа Моделей, объединённых общей целью. Давайте посмотрим на схему Компонента: Иными словами, Компонент является фасадом для заключённых в него Моделей и позволяют скрыть детали реализации (Модели, функции-трансформеры и их связи).

Component

Как видно из схемы, компонент выполняет важную функцию преобразования и перенаправления событий.

Полный список функции Компонента выглядит следующим образом:

  • Связывает входящие События Представлений и Метки с каждой Моделью используя предоставленные функции-трансформеры
  • Выводит исходящие Метки Моделей наружу
  • Разрушает все Модели и разрывает все связи при разрушении Компонента

Компонент тоже имеет свой интерфейс:

interface MviComponent<in UiEvent : Any, out States : Any> : (UiEvent) -> Unit, Disposable { @get:MainThread val states: States @MainThread override fun invoke(event: UiEvent) @MainThread override fun dispose() @MainThread override fun isDisposed(): Boolean
}

Рассмотрим интерфейс Компонента подробнее:

  • Содержит два Generic-параметра: UiEvent — тип Событий Представления и States — тип Состояний Моделей
  • Содержит поле states, дающее доступ к группе Состояний Моделей (например в виде интерфейса или data-класса)
  • Является потребителем (Consumer) Событий Представления
  • Является Disposable, что даёт возможность разрушить Компонент и все его Модели

Представление (View)

Данные для каждого Представления группируются в Модель Представления (View Model) и обычно представляются в виде data-класса (Kotlin). Как несложно догадаться, Представление нужно для отображения данных. Рассмотрим интерфейс Представления:

interface MviView<ViewModel : Any, UiEvent : Any> { val uiEvents: Observable<UiEvent> @MainThread fun subscribe(models: Observable<ViewModel>): Disposable
}

Два Generic-параметра: ViewModel — тип Модели Представления и UiEvent — тип Событий Представления. Здесь всё несколько проще. И один метод subscribe(), дающий возможность подписаться на Модели Представления. Одно поле uiEvents — Observable Событий Представления, дающее возможность клиентам подписаться на эти самые события.

Пример использования

Предлагаю сделать что-то очень простое. Теперь самое время попробовать что-нибудь на деле. Пусть это будет генератор UUID: по нажатию кнопки будем генерировать UUID и отображать его на экране. Что-то, что не потребует больших усилий для понимания, и в то же время даст представление о том, как же это всё использовать и в каком направлении двигаться дальше.

Представление

Для начала опишем Модель Представления:

data class ViewModel(val text: String)

И События Представления:

sealed class UiEvent { object OnGenerateClick: UiEvent()
}

Теперь реализуем само Представление, для этого нам понадобится наследование от абстрактного класса MviAbstractView:

class View(activity: Activity) : MviAbstractView<ViewModel, UiEvent>() } override fun subscribe(models: Observable<ViewModel>): Disposable = models.map(ViewModel::text).distinctUntilChanged().subscribe { textView.text = it }
}

Всё предельно просто: подписываемся на изменения UUID и обновляем TextView при получении нового UUID, а по нажатию кнопки отправляем событие OnGenerateClick.

Модель

Модель будет состоять из двух частей: интерфейс и реализация.

Интерфейс:

interface UuidStore : MviStore<State, Intent, Nothing> { data class State(val uuid: String? = null) sealed class Intent { object Generate : Intent() }
}

Тип Меток — Nothing, т. к. Здесь всё просто: наш интерфейс расширяет интерфейс MviStore, указывая типы Состояния (State) и Намерений (Intent). Также в интерфейсе содержатся классы Состояния и Намерений. у наша Модель их не производит.

На вход Модели поступают Намерения (Intent), которые преобразуются в Действия (Action) при помощи специальной функции IntentToAction. Для того что реализовать Модель, надо понять как она работает. Результаты затем поступают в Редуктор (Reducer), который преобразует текущее Состояние в новое. Действия поступают на вход Исполнителю (Executor), который выполняет их и производит Результаты (Result) и Метки (Label).

Все четыре состовляющие Модели:

  • IntentToAction — функция, преобразующая Намерения в Действия
  • MviExecutor — исполняет Действия и производит Результаты и Метки
  • MviReducer — преобразует пары (Состояние, Результат) в новые Состояния
  • MviBootstrapper — специальный компонент, позволяющий инициализировать Модель. Выдаёт всё те же Действия, которые также поступают в Исполнитель (Executor). Можно выполнить разовое Действие, а можно подписаться на источник данных и выполнять Действия при определённых событиях. Bootstrapper запускается автоматически при создании Модели.

Она представлена интерфейсом MviStoreFactory и его реализацией MviDefaultStoreFactory. Чтобы создать саму Модель, необходимо использовать специальную фабрику Моделей. Фабрика принимает составляющие Модели и выдаёт готовую к использованию Модель.

Фабрика нашей Модели будет выглядеть следующим образом:

class UuidStoreFactory(private val factory: MviStoreFactory) { fun create(factory: MviStoreFactory): UuidStore = object : UuidStore, MviStore<State, Intent, Nothing> by factory.create( initialState = State(), bootstrapper = Bootstrapper, intentToAction = { when (it) { Intent.Generate -> Action.Generate } }, executor = Executor(), reducer = Reducer ) { } private sealed class Action { object Generate : Action() } private sealed class Result { class Uuid(val uuid: String) : Result() } private object Bootstrapper : MviBootstrapper<Action> { override fun bootstrap(dispatch: (Action) -> Unit): Disposable? { dispatch(Action.Generate) return null } } private class Executor : MviExecutor<State, Action, Result, Nothing>() { override fun invoke(action: Action): Disposable? { dispatch(Result.Uuid(UUID.randomUUID().toString())) return null } } private object Reducer : MviReducer<State, Result> { override fun State.reduce(result: Result): State = when (result) { is Result.Uuid -> copy(uuid = result.uuid) } }
}

Сначала фабричный метод create, затем Действия и Результаты, за ними следует Исполнитель и в самом конце Редуктор. В этом примере представлены все четыре составляющие Модели.

Компонент

Состояния Компонента (группа Состояний) опишем data-классом:

data class States(val uuidStates: Observable<UuidStore.State>)

При добавлении новых Моделей в Компонент, их Состояния следует также добавить в группу.

И, собственно, сама реализация:

class Component(uuidStore: UuidStore) : MviAbstractComponent<UiEvent, States>( stores = listOf( MviStoreBundle( store = uuidStore, uiEventTransformer = UuidStoreUiEventTransformer ) )
) { override val states: States = States(uuidStore.states) private object UuidStoreUiEventTransformer : (UiEvent) -> UuidStore.Intent? { override fun invoke(event: UiEvent): UuidStore.Intent? = when (event) { UiEvent.OnGenerateClick -> UuidStore.Intent.Generate } }
}

Кроме того мы создали функцию-трансформер, которая будет преобразовывать События Представления в Намерения нашей Модели. Мы наследовали абстрактный класс MviAbstractComponent, указали типы Состояний и Событий Представления, передали нашу Модель в super класс и реализовали поле states.

Маппинг Модели Представления

Для этого мы реализуем интерфейс MviViewModelMapper: У нас есть Состояния и Модель Представления, настало время преобразовать одно в другое.

object ViewModelMapper : MviViewModelMapper<States, ViewModel> { override fun map(states: States): Observable<ViewModel> = states.uuidStates.map { ViewModel(text = it.uuid ?: "None") }
}

Связь (Binding)

Чтобы всё начало работать, их необходимо связать. Наличия самих по себе Компонента и Представления не достаточно. Пришло время создать Activity:

class UuidActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_uuid) bind( Component(UuidStoreFactory(MviDefaultStoreFactory).create()), View(this) using ViewModelMapper ) }
}

Этот метод является extension-методом над LifecycleOwner (коими являются Activity и Fragment) и использует DefaultLifecycleObserver из пакета Arch, который требует Java 8 source compatibility. Мы использовали метод bind(), который принимает Компонент и массив Представлений с мапперами их Моделей. В этом случае, Вам придётся вызывать методы жизненного цикла самостоятельно. Если по каким-либо причинам Вы не можете использовать Java 8, то Вам подойдёт второй метод bind(), который не являеся extension-методом и возвращает MviLifecyleObserver.

Ссылки

Исходный код библиотеки, а также подробную инструкцию по подключению и использованию можно найти на GitHub.


Оставить комментарий

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

*

x

Ещё Hi-Tech Интересное!

Спустя пять лет вышла очередная версия DOSBox под номером 0.74-2

К сожалению, пока внимание толп людей было приковано к анонсам таких гигантов индустрии как Apple, релиз одной некоммерческой, но довольно популярной игровой ретро-платформы, прошел почти незаметно. Вышел DOSBox 0.74-2. Это позволяет играть в такие игры во множестве операционных систем, не ...

Данные пользователей Windows на ПК с поддержкой сенсорного ввода пишутся в отдельный файл

Это сделано для удобства пользователя и ускорения процесса его работы. Большое количество моделей ноутбуков и all-in-one рабочих станций в наше время имеют поддержку сенсорного ввода. Но, как оказалось, у компьютерных систем с активированной поддержкой тач-ввода есть одна малоизвестная функция, которая ...