MVIDroid: обзор новой библиотеки MVI (Model-View-Intent)

в 10:49, , рубрики: android development, architecture, mvi, pattern, архитектура, Разработка под android, шаблон

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

MVI

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

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 — тип Меток
  • Содержит три поля: states — текущее состояние Модели, 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 — тип Событий Представления. Одно поле 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.

Автор: Аркадий Иванов

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js