Всем привет! В этой статье я хочу рассказать о новой библиотеке, которая привносит шаблон проектирования MVI в Android. Эта библиотека называется MVIDroid, написана 100% на языке Kotlin, легковесная и использует RxJava 2.x. Автор библиотеки лично я, исходный код её доступен на GitHub, а подключить её можно через JitPack (ссылка на репозиторий в конце статьи). Эта статья состоит из двух частей: общее описание библиотеки и пример её использования.
MVI
И так, в качестве предисловия, позвольте напомнить что такое вообще MVI. Model — View — Intent или, если по-русски, Модель — Представление — Намерение. Это такой шаблон проектирования, в котором Модель (Model) является активным компонентом, принимающим на вход Намерения (Intents) и производящая Состояния (State). Представление (View) в свою очередь принимает Модели Представления (View Model) и производит те самые Намерения. Состояние преобразуется в Модель Представления при помощи функции-трансформера (View Model Mapper). Схематически шаблон MVI можно представить следующим образом:

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

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

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

Как видно из схемы, компонент выполняет важную функцию преобразования и перенаправления событий.
Полный список функции Компонента выглядит следующим образом:
- Связывает входящие События Представлений и Метки с каждой Моделью используя предоставленные функции-трансформеры
- Выводит исходящие Метки Моделей наружу
- Разрушает все Модели и разрывает все связи при разрушении Компонента
Компонент тоже имеет свой интерфейс:
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 — тип Событий Представления. Одно поле uiEvents — Observable Событий Представления, дающее возможность клиентам подписаться на эти самые события. И один метод subscribe(), дающий возможность подписаться на Модели Представления.
Пример использования
Теперь самое время попробовать что-нибудь на деле. Предлагаю сделать что-то очень простое. Что-то, что не потребует больших усилий для понимания, и в то же время даст представление о том, как же это всё использовать и в каком направлении двигаться дальше. Пусть это будет генератор UUID: по нажатию кнопки будем генерировать UUID и отображать его на экране.
Представление
Для начала опишем Модель Представления:
data class ViewModel(val text: String)
И События Представления:
sealed class UiEvent { object OnGenerateClick: UiEvent() }
Теперь реализуем само Представление, для этого нам понадобится наследование от абстрактного класса MviAbstractView:
class View(activity: Activity) : MviAbstractView<ViewModel, UiEvent>() { private val textView = activity.findViewById<TextView>(R.id.text) init { activity.findViewById<Button>(R.id.button).setOnClickListener { dispatch(UiEvent.OnGenerateClick) } } 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() } }
Здесь всё просто: наш интерфейс расширяет интерфейс MviStore, указывая типы Состояния (State) и Намерений (Intent). Тип Меток — Nothing, т. к. у наша Модель их не производит. Также в интерфейсе содержатся классы Состояния и Намерений.
Для того что реализовать Модель, надо понять как она работает. На вход Модели поступают Намерения (Intent), которые преобразуются в Действия (Action) при помощи специальной функции IntentToAction. Действия поступают на вход Исполнителю (Executor), который выполняет их и производит Результаты (Result) и Метки (Label). Результаты затем поступают в Редуктор (Reducer), который преобразует текущее Состояние в новое.
Все четыре состовляющие Модели:
- 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 ) } }
Мы использовали метод bind(), который принимает Компонент и массив Представлений с мапперами их Моделей. Этот метод является extension-методом над LifecycleOwner (коими являются Activity и Fragment) и использует DefaultLifecycleObserver из пакета Arch, который требует Java 8 source compatibility. Если по каким-либо причинам Вы не можете использовать Java 8, то Вам подойдёт второй метод bind(), который не являеся extension-методом и возвращает MviLifecyleObserver. В этом случае, Вам придётся вызывать методы жизненного цикла самостоятельно.
Ссылки
Исходный код библиотеки, а также подробную инструкцию по подключению и использованию можно найти на GitHub.
