How to unravel the MVI jungle using your own jungle and get a simple and structured architectural solution.


 image


Foreword


The first time I came across an article on Model-View-Intent (MVI) for Android, I didn’t even open it.
- Seriously!? Architecture on Android Intents?


It was a stupid idea. Much later, I read about MVI and learned that this architecture is mainly focused on unidirectional data flows and state management.


Studying MVI, I involuntarily ran into the problem that the whole approach looks somehow confusing, like some wilds. Yes, the result is a solution with pluses in relation to MVP and MVVM, but, looking at this complexity, one wonders: "Was it worth it?".


Having looked at several popular libraries on this topic, I still didn’t have a favorite from existing solutions, because I didn’t like something; various questions arose, to which it was not always possible to find unambiguous answers.


So I decided to write my decision. Basic requirements (by importance):


  1. Simple;
  2. Covers all UI cases I can think of;
  3. Structured.

And what is it?


Let me introduce you - The Jungle ( Jungle ). Under the hood - only RxJava with its reactive approach.


Base


  • State - "persistent" UI data that should be displayed even after the recreation of the View;
  • Action - "volatile" UI data that should not be shown after the recreation of the View (for example, data about Snackbar and Toast);
  • Event - Intent from Model-View-Intent;
  • MviView - the interface through which new Actions and updates of State are delivered;
  • Middleware - the intermediary between one functionality of business logic and UI;
  • Store is an intermediary between Model and View that decides how to handle Events , deliver updates to State and new Actions .

image
All relationships shown in the image are optional


How does it work?


It seems to me that the best way to understand this is to parse an example. Imagine we need a screen with a list of countries that must be downloaded from the Internet. The following conditions also exist:


  1. Show PrgoressBar at boot time;
  2. Display Button to reload the list and Toast with an error message in case of an error;
  3. If countries were successfully loaded, display a list of countries;
  4. Try to load countries at the window opening automatically, without any user action.

Let's write our UI part:


sealed class DemoEvent { object Load : DemoEvent() } 

sealed class DemoAction { data class ShowError(val error: String) : DemoAction() } 

data class DemoState( val loading: Boolean=false, val countries: List<Country>=emptyList() ) 

class DemoFragment : Fragment, MviView<DemoState, DemoAction> { private lateinit var demoStore: DemoStore private var adapter: DemoAdapter?=null/*Initializations are skipped*/override fun onViewCreated(view: View, bundle: Bundle?) { super.onViewCreated(view, bundle) demoStore.run { attach(this@DemoFragment) dispatchEventSource( RxView.clicks(demo_load) .map { DemoEvent.Load } ) } } override fun onDestroyView() { super.onDestroyView() demoStore.detach() } override fun render(state: DemoState) { val showReload=state.run { !loading && countries.isEmpty() } demo_load.visibility=if (showReload) View.GONE else View.VISIBLE demo_progress.visibility=if (state.loading) View.VISIBLE else View.GONE demo_recycler.visibility=if (state.countries.isEmpty()) View.GONE else View.VISIBLE adapter?.apply { setItems(state.countries) notifyDataSetChanged() } } override fun processAction(action: DemoAction) { when (action) { is DemoAction.ShowError -> Toast.makeText( requireContext(), action.error, Toast.LENGTH_SHORT ).show() } } } 

Which of this (for now) can be understood? We can send the DemoEvent.Load to our DemoStore (by clicking on the Reload button); get DemoAction.ShowError (with error data) and display Toast; Get an update on DemoState (with data on countries and download status) and display the UI components as required. It seems to be not so difficult.


Now let's get started with our DemoStore . First of all, we will inherit it from the Store , allow it to receive DemoEvent , produce DemoAction and change DemoState :


class DemoStore ( foregroundScheduler: Scheduler, backgroundScheduler: Scheduler ) : Store<DemoEvent, DemoState, DemoAction>( foregroundScheduler=foregroundScheduler, backgroundScheduler=backgroundScheduler ) 

Next, create a CountryMiddleware , which will be responsible for providing country load data:


class CountryMiddleware( private val getCountriesInteractor: GetCountriesInteractor ) : Middleware<CountryMiddleware.Input>() { override val inputType=Input::class.java override fun transform(upstream: Observable<Input>)=upstream.switchMap<CommandResult> { getCountriesInteractor.execute() .map<Output> { Output.Loaded(it) } .onErrorReturn { Output.Failed(it.message ?: "Can't load countries") } .startWith(Output.Loading) } object Input : Command sealed class Output : CommandResult { object Loading : Output() data class Loaded(val countries: List<Country>) : Output() data class Failed(val error: String) : Output() } } 

What is Command ? This is a specific signal that prompts "something" to do.What about CommandResult ? This is the result of doing this “something."


In our case, CountryMiddleware.Input signals that the logic of CountryMiddleware must be executed. Each execution of the Middleware logic returns CommandResult ; for a better application structure, you can store this result inside the CDMY0CDMY class ( CountryMiddleware.Output ).


In our case, we simply return an Observable that will emit Output.Loading at boot time, Output.Loaded with data for a successful download, Output.Failed with error information for error.


Let's go back to the DemoStore and force it to process the CountryMiddleware by clicking the Reload button:


class DemoStore (..., countryMiddleware: CountryMiddleware)... { override val middlewares=listOf(countryMiddleware) override fun convertEvent(event: DemoEvent)=when (event) { is DemoEvent.Load -> CountryMiddleware.Input } } 

Overriding the CDMY1CDMY field, we indicate which Middlewares our DemoStore can handle. Under the hood, the Store uses Commands . Therefore, we should convert our DemoEvent.Load to CountryMiddleware.Input (in order to force a reboot).


So, now we can get the result from CountryMiddleware . Let's let the last change our DemoState :


class DemoStore... { ... override val initialState=DemoState() override fun reduceCommandResult( state: DemoState, result: CommandResult )=when (result) { is CountryMiddleware.Output.Loading -> state.copy(loading=true) is CountryMiddleware.Output.Loaded -> state.copy(loading=false, countries=result.countries) is CountryMiddleware.Output.Failed -> state.copy(loading=false) else -> state } } 

Before changing State , you must specify its initial state in CDMY2CDMY. After that, the CDMY3CDMY method describes the logic of how each CommandResult changes the State .


To display a loading error, use DemoAction.ShowError . To generate the latter, you need to provide a new Command (from CommandResult ) and link it to our Action :


class DemoStore... { ... override fun produceCommand(commandResult: CommandResult)=when (commandResult) { is CountryMiddleware.Output.Failed -> ProduceActionCommand.Error(commandResult.error) else -> null } override fun produceAction(command: Command)=when (command) { is ProduceActionCommand.Error -> DemoAction.ShowError(command.error) else -> null } sealed class ProduceActionCommand : Command { data class Error(val error: String) : ProduceActionCommand() } } 

The last thing left to do is bind the automatic start of CountryMiddleware . All you have to do is add it Command to CDMY4CDMY:


class DemoStore... { ... override val bootstrapCommands=listOf(CountryMiddleware.Input) } 

Done!


Is it simple?


You can use specifically only what you need, without any unnecessary logic. A few classes and a pinch of magic under the hood. One Store , optionally several Middlewares , optional implementation of MviView .


Your View should only display updates of some functionality of business logic? You don’t even need Events , just Store , Middleware and overriding the CDMY5CDMY function method in MviView .


Only a button that clicks some kind of navigation? Okay, just play with the Event inside the Store and nothing more.


It seems to me that this is a simple solution, since it requires little effort both for understanding and for use.


Structured?


In order to maintain structure, you must:


  • Store Commands in CDMY6CDMY classes inside the Store , grouping them by purpose: generating Actions or directly modifying State ?
  • Store Commands related to Middlewares inside the latter.

It’s also worth remembering that Middleware is about one functionality, which makes it look like UseCase (Interactor). In my opinion, the presence of the latter (and, as a consequence, of some domain layer) indicates a well-structured project. By the same analogy, I believe that using Middleware helps to improve the project structure.


Conclusion


Using Jungle, I have a clear idea of ​​how navigation is organized within the approach. I am also sure that the problem SingleLiveEvent can be easily resolved using Actions .


More detailed work reviews can be found on the wiki . I will answer any questions. I will be glad if you find this solution useful!

.

Source