Всем привет! В этой статье я хочу рассказать о новой библиотеке, которая привносит шаблон проектирования 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.