MVIDroid: review of the new MVI library (Model-View-Intent)

Hello! In this article I want to talk about the new library, which introduces the MVI design pattern in Android. This library is called MVIDroid, written 100% in the Kotlin language, lightweight and uses RxJava 2.x. I am the author of the library, its source code is available on GitHub, and you can connect it via JitPack (link to the repository at the end of the article). This article consists of two parts: a general description of the library and an example of its use.


MVI


And so, as a preface, let me remind you what MVI is. Model - View - Intent or, if in Russian, Model - View - Intention. This is a design pattern in which a Model is an active component that takes Intents on input and produces State. View (View), in turn, takes View Models (View Model) and produces those very Intentions. The state is transformed into a View Model using a transformer function (View Model Mapper). Schematically, the MVI pattern can be represented as follows:


MVI


The MVIDroid View does not produce Intentions directly. Instead, it produces View Events (UI Events), which are then converted into Intentions using a transformer function.


View


Main components of MVIDroid


Model


Let's start with the model. In the library, the concept of Model is slightly expanded; here it produces not only States but also Labels. Tags are used to communicate the models with each other. The labels of some Models can be transformed into the Intentions of other Models with the help of transforming functions. Schematically, the Model can be represented as follows:


Model


In MVIDroid, the Model is represented by the MviStore interface (the name Store is borrowed from Redux):


interfaceMviStore<State : Any, in Intent : Any, Label : Any> : (Intent) -> Unit, Disposable {
    @get:MainThreadval state: State
    val states: Observable<State>
    val labels: Observable<Label>
    @MainThreadoverridefuninvoke(intent: Intent)@MainThreadoverridefundispose()@MainThreadoverridefunisDisposed(): Boolean
}

And so what we have:


  • The interface has three Generic parameters: State - the State type, Intent - the Intention type, and Label - the Labels type.
  • It contains three fields: state - the current state of the Model, states - Observable States and labels - Observable Labels. The last two fields provide an opportunity to subscribe to changes in the Status and Tags, respectively.
  • Consumer (Consumer) Intentions
  • It is Disposable, which makes it possible to destroy the Model and stop all processes occurring in it.

Note that all Model methods must be executed on the main thread. The same is true for any other component. Of course, you can perform background tasks using standard RxJava tools.


Component


A component in MVIDroid is a group of Models united by a common goal. For example, you can select in the Component all Models for any screen. In other words, the Component is a facade for the Models enclosed in it and allows to hide implementation details (Models, transforming functions and their links). Let's look at the Component schema:


Component


As can be seen from the diagram, the component performs the important function of transforming and redirecting events.


The full list of Component functions is as follows:


  • Associates the incoming View Events and Tags with each Model using the transforming functions provided.
  • Displays outgoing Model Tags outside
  • Destroys all Models and breaks all ties when Component is destroyed.

The component also has its own interface:


interfaceMviComponent<in UiEvent : Any, out States : Any> : (UiEvent) -> Unit, Disposable {
    @get:MainThreadval states: States
    @MainThreadoverridefuninvoke(event: UiEvent)@MainThreadoverridefundispose()@MainThreadoverridefunisDisposed(): Boolean
}

Consider the Component interface in more detail:


  • Contains two Generic-parameters: UiEvent - Type of Events of the View and States - type of States of Models
  • Contains the states field, which gives access to the Model States group (for example, as an interface or data class)
  • Consumer (Consumer) Event Viewer
  • It is Disposable, which makes it possible to destroy the Component and all its Models.

View


As you can easily guess, you need a view to display data. The data for each View is grouped into a View Model and is usually presented as a data class (Kotlin). Consider the Presentation interface:


interfaceMviView<ViewModel : Any, UiEvent : Any> {
    val uiEvents: Observable<UiEvent>
    @MainThreadfunsubscribe(models: Observable<ViewModel>): Disposable
}

Here everything is somewhat simpler. Two Generic parameters: ViewModel is the Type of the View Model and UiEvent is the Type of the View Events. One uiEvents field is Observable View Events, enabling clients to subscribe to these same events. And one method of subscribe (), giving the opportunity to subscribe to the View Model.


Usage example


Now is the time to try something in practice. I propose to do something very simple. Something that does not require much effort to understand, and at the same time will give an idea of ​​how to use all this and in which direction to go further. Let it be a UUID generator: by pressing a button we will generate a UUID and display it on the screen.


Representation


To begin with we will describe Model of Representation:


dataclassViewModel(val text: String)

And Presentation Events:


sealedclassUiEvent{
    objectOnGenerateClick: UiEvent()
}

Now we are implementing the View itself; for this we need inheritance from the abstract MviAbstractView class:


classView(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
        }
}

Everything is very simple: we subscribe to changes in UUID and update TextView when we receive a new UUID, and at the touch of a button we send the OnGenerateClick event.


Model


The model will consist of two parts: the interface and the implementation.


Interface:


interfaceUuidStore : MviStore<State, Intent, Nothing> {
    dataclassState(val uuid: String? = null)
    sealedclassIntent{
        object Generate : Intent()
    }
}

Everything is simple here: our interface extends the MviStore interface, indicating the types of State (State) and Intentions (Intent). The type of labels is Nothing, since our Model does not produce them. The interface also contains classes of states and intentions.


In order to implement the Model, you need to understand how it works. Intentions (Intent) are input to the Model, which are converted into Actions (Action) using the special IntentToAction function. Actions come to the input to the Executor, which executes them and produces Results (Result) and Labels. The results are then transferred to a Reducer, which converts the current State to a new one.


All four make models:


  • IntentToAction - a function that converts Intentions into Actions
  • MviExecutor - executes Actions and produces Results and Labels
  • MviReducer - converts pairs (State, Result) into new States
  • MviBootstrapper is a special component that allows you to initialize the Model. Gives all the same Actions that also go to the Executor. You can perform a one-time Action, or you can subscribe to a data source and perform Actions on certain events. Bootstrapper starts automatically when creating a model.

To create the Model itself, it is necessary to use a special Factory of Models. It is represented by the MviStoreFactory interface and its implementation MviDefaultStoreFactory. The factory takes the constituent Models and delivers a ready-to-use Model.


The factory of our Model will look as follows:


classUuidStoreFactory(privateval factory: MviStoreFactory) {
    funcreate(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
        ) {
        }
    privatesealedclassAction{
        object Generate : Action()
    }
    privatesealedclassResult{
        classUuid(val uuid: String) : Result()
    }
    privateobject Bootstrapper : MviBootstrapper<Action> {
        overridefunbootstrap(dispatch: (Action) -> Unit): Disposable? {
            dispatch(Action.Generate)
            returnnull
        }
    }
    privateclassExecutor : MviExecutor<State, Action, Result, Nothing>() {
        overridefuninvoke(action: Action): Disposable? {
            dispatch(Result.Uuid(UUID.randomUUID().toString()))
            returnnull
        }
    }
    privateobject Reducer : MviReducer<State, Result> {
        overridefun State.reduce(result: Result): State =
            when (result) {
                is Result.Uuid -> copy(uuid = result.uuid)
            }
    }
}

This example shows all four components of the Model. First, create a factory method, then Actions and Results, followed by the Artist and at the very end of the Reducer.


Component


The states of the Component (the States group) are described by the data class:


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

When adding new Models to a Component, their States should also be added to the group.


And, actually, the implementation itself:


classComponent(uuidStore: UuidStore) : MviAbstractComponent<UiEvent, States>(
    stores = listOf(
        MviStoreBundle(
            store = uuidStore,
            uiEventTransformer = UuidStoreUiEventTransformer
        )
    )
) {
    overrideval states: States = States(uuidStore.states)
    privateobject UuidStoreUiEventTransformer : (UiEvent) -> UuidStore.Intent? {
        overridefuninvoke(event: UiEvent): UuidStore.Intent? =
            when (event) {
                UiEvent.OnGenerateClick -> UuidStore.Intent.Generate
            }
    }
}

We inherited the abstract class MviAbstractComponent, specified the types of States and View Events, passed our Model to the super class and implemented the states field. In addition, we have created a transforming function that will transform View Events into the Intention of our Model.


Mapping Models Representations


We have a State and Model of Presentation, it is time to convert one into the other. To do this, we implement the MviViewModelMapper interface:


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

Communication (Binding)


The availability of the Component and View alone is not enough. For everything to start working, they need to be connected. It's time to create an Activity:


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

We used the bind () method, which accepts a Component and an array of Views with the mappers of their Models. This method is an extension method over LifecycleOwner (which is Activity and Fragment) and uses the DefaultLifecycleObserver from the Arch package, which requires Java 8 source compatibility. If for any reason you cannot use Java 8, then the second bind () method is suitable for you, which is not an extension-method and returns MviLifecyleObserver. In this case, you have to call the life cycle methods yourself.


Links


The source code of the library, as well as detailed instructions for connecting and using it, can be found on GitHub .


Also popular now: