📜 ⬆️ ⬇️

Reactive applications with Model-View-Intent. Part 2: View and Intent

In the first part, we discussed what a model is, its relationship to the state and how a properly designed model helps to solve some problems in the development for Android. In this article, we will continue our path to creating reactive applications using the M odel- V iew- I ntent pattern.

Before we begin, we will briefly discuss the main idea of ​​MVI.

Model-View-Intent (MVI)


This pattern was described by André Staltz for the JavaScript framework cycle.js . From a theoretical and mathematical point of view, MVI can be described as follows:
')
image



Let's return to our task. We want to create a reactive application. But is MVI reactive? What does reactivity really mean in this context?

By reactivity, we mean an application with a UI that responds to a state change. Since the state reflects the model, it is necessary for our business logic to react to events (intents), and at the output to create a model that could be displayed in View by calling the render (model) method.

Connect the dots with RxJava


We need the data stream to be unidirectional. This is where RxJava comes into play. When creating reactive applications with a unidirectional data flow, it is not necessary to use this particular library. However, RxJava is well suited for event programming. And since the UI is based on events, it makes sense to use it.

In this article I will describe the creation of a simple application for a fictional online store. In the application you can search for products and add them to the cart.

The finished application will look like this:



Source code can be found on github .

Let's start with the implementation of the search screen. First of all, I define a model that will be displayed using View, as described in the first part of this series of articles. I will write all classes of models with the ViewState suffix, since the model reflects the state.

SearchViewState
public interface SearchViewState { final class SearchNotStartedYet implements SearchViewState {} final class Loading implements SearchViewState {} final class EmptyResult implements SearchViewState { private final String searchQueryText; public EmptyResult(String searchQueryText) { this.searchQueryText = searchQueryText; } public String getSearchQueryText() { return searchQueryText; } } final class SearchResult implements SearchViewState { private final String searchQueryText; private final List<Product> result; public SearchResult(String searchQueryText, List<Product> result) { this.searchQueryText = searchQueryText; this.result = result; } public String getSearchQueryText() { return searchQueryText; } public List<Product> getResult() { return result; } } final class Error implements SearchViewState { private final String searchQueryText; private final Throwable error; public Error(String searchQueryText, Throwable error) { this.searchQueryText = searchQueryText; this.error = error; } public String getSearchQueryText() { return searchQueryText; } public Throwable getError() { return error; } } 


Java is a strongly typed language, so I chose a type-safe approach to creating a model, dividing each substate within a class. The business logic will return an object of type SearchViewState , which may be an instance of SearchViewState.Error, etc. This is my personal preference, you can design the model in your own way.

Focus on business logic. Create a SearchInteractor that will be responsible for the search. The result of the execution will be a SearchViewState object.

SearchInteractor
 public class SearchInteractor { final SearchEngine searchEngine; public Observable<SearchViewState> search(String searchString) { if (searchString.isEmpty()) { return Observable.just(new SearchViewState.SearchNotStartedYet()); } return searchEngine.searchFor(searchString) .map(products -> { if (products.isEmpty()) { return new SearchViewState.EmptyResult(searchString); } else { return new SearchViewState.SearchResult(searchString, products); } }) .startWith(new SearchViewState.Loading()) .onErrorReturn(error -> new SearchViewState.Error(searchString, error)); } } 


Let's look at the signature of the SearchInteractor.search () method: there is an input parameter searchString and an output parameter Observable <SearchViewState> . This suggests that on the observed stream, we expect an arbitrary number of SearchViewState instances. The startWith () method is needed to search SearchViewState.Loading before starting a search query. Then View will be able to show the progressBar during the execution of the search.

The onErrorReturn () method catches any exceptions that may occur during the execution of a search, and throws SearchViewState.Error . We cannot just use the onError () callback when subscribing to Observable. This is a common misconception in RxJava: the onError () callback should be used when all of the observed flow encounters fatal errors and the entire flow ends.

In our case, the error of not connecting to the Internet does not fall under the definition of fatal errors - this is just one of the states of our model. In addition, we will be able to switch to another state - SearchViewState.Loading - after the internet connection is available again.

Thus, we create an observable flow from business logic to View, which will emit a new model every time the state changes. We do not need the observed stream to terminate with an error connecting to the Internet, so such errors are treated as a state. Usually, in MVI, the observed stream never terminates (the onComplete or onError () methods are not called).

To summarize: SearchInteractor provides an observable stream of Observable <SearchViewState> and issues a new SearchViewState each time a state changes.

Consider what the View layer looks like, which should display the model. Earlier, I suggested that View had a function render (model) . In addition, the View should allow other layers to respond to UI events. In MVI, these events are called intents . In our case there is only one intent: the user searches for a product by entering a search query in the search field. In MVP there is a good practice to create an interface for the View layer, we will also do this for MVI.

 public interface SearchView { Observable<String> searchIntent(); void render(SearchViewState viewState); } 

In our case, View provides only one intent, but depending on the task there may be several of them.

In the first part, we discussed why using a single render () method is a good solution. Before creating a specific implementation of the View layer, let's take a look at how the final version will look like:



SearchFragment
 public class SearchFragment extends Fragment implements SearchView { @BindView(R.id.searchView) android.widget.SearchView searchView; @BindView(R.id.container) ViewGroup container; @BindView(R.id.loadingView) View loadingView; @BindView(R.id.errorView) TextView errorView; @BindView(R.id.recyclerView) RecyclerView recyclerView; @BindView(R.id.emptyView) View emptyView; private SearchAdapter adapter; @Override public Observable<String> searchIntent() { return RxSearchView.queryTextChanges(searchView) .filter(queryString -> queryString.length() > 3 || queryString.length() == 0) .debounce(500, TimeUnit.MILLISECONDS); } @Override public void render(SearchViewState viewState) { if (viewState instanceof SearchViewState.SearchNotStartedYet) { renderSearchNotStarted(); } else if (viewState instanceof SearchViewState.Loading) { renderLoading(); } else if (viewState instanceof SearchViewState.SearchResult) { renderResult(((SearchViewState.SearchResult) viewState).getResult()); } else if (viewState instanceof SearchViewState.EmptyResult) { renderEmptyResult(); } else if (viewState instanceof SearchViewState.Error) { renderError(); } else { throw new IllegalArgumentException("Don't know how to render viewState " + viewState); } } private void renderResult(List<Product> result) { TransitionManager.beginDelayedTransition(container); recyclerView.setVisibility(View.VISIBLE); loadingView.setVisibility(View.GONE); emptyView.setVisibility(View.GONE); errorView.setVisibility(View.GONE); adapter.setProducts(result); adapter.notifyDataSetChanged(); } private void renderSearchNotStarted() { recyclerView.setVisibility(View.GONE); loadingView.setVisibility(View.GONE); errorView.setVisibility(View.GONE); emptyView.setVisibility(View.GONE); } private void renderLoading() { recyclerView.setVisibility(View.GONE); loadingView.setVisibility(View.VISIBLE); errorView.setVisibility(View.GONE); emptyView.setVisibility(View.GONE); } private void renderError() { recyclerView.setVisibility(View.GONE); loadingView.setVisibility(View.GONE); errorView.setVisibility(View.VISIBLE); emptyView.setVisibility(View.GONE); } private void renderEmptyResult() { recyclerView.setVisibility(View.GONE); loadingView.setVisibility(View.GONE); errorView.setVisibility(View.GONE); emptyView.setVisibility(View.VISIBLE); } } 


The render (SearchViewState) method should look succinct. In searchIntent () I use the RxBindings library. RxSearchView.queryText () creates an Observable that issues a string every time a user enters something into the EditText widget. I use filter () to start a search query after entering three or more characters. We do not need the search request to be sent to the server every time a user enters a new character, so I added a debounce () operator.

We know that the input data stream for this screen is the searchIntent () method, and the output data stream is the render () method.

The following video demonstrates how the interaction between these two streams occurs.



The question remains, how to link the intent and business logic? If you carefully watch the video, you will see the flatMap () operator in the middle. This indicates the presence of an additional component, which I did not mention - Presenter , which is responsible for connecting the layers.

 public class SearchPresenter extends MviBasePresenter<SearchView, SearchViewState> { private final SearchInteractor searchInteractor; @Override protected void bindIntents() { Observable<SearchViewState> search = intent(SearchView::searchIntent) .switchMap(searchInteractor::search) //     flatMap(),      switchMap() .observeOn(AndroidSchedulers.mainThread()); subscribeViewState(search, SearchView::render); } } 

What is MviBasePresenter , methods intent () and subscribeViewState () . This class is part of the Mosby library. It should say a few words about Mosby and how MviBasePresenter works. Let's start with the life cycle: MviBasePresenter doesn't have it. The bindIntent () method binds the intent from the View to business logic. As a rule, flatMap () or switchMap () is used to send an intent to business logic. This method is called once when View joins the Presenter, but is not called after the View joins the Presenter again, for example, after changing the orientation of the screen.

It may be asked whether MviBasePresenter can really survive the screen orientation change, and if so, how does Mosby ensure that the observed stream does not “leak”? For this purpose integer () and subscribeViewState () methods are intended.

intent () creates a PublishSubject inside the Presenter and uses it as a “gateway” for business logic. PublishSubject subscribes to the View. The invoice call (O1) actually returns a PublishSubject that is subscribed to O1.

After changing the screen orientation, Mosby disconnects the View from the Presenter, but only temporarily unsubscribes the internal PublishSubject from the View and reassigns the PublishSubject to the View intent when the View rejoins the Presenter.

subscribeViewState () does the same in the opposite direction. It creates inside the Presenter BehaviorSubject as a “gateway” from business logic to View. Since this is a BehaviorSubject, we can get an updated model from business logic even when View is disconnected from Presenter. The BehaviorSubject always stores the last value it received, and repeats it when View joins the Presenter again.

Simple rule: use the intent () method to wrap any intent. Use subscribeViewState () instead of Observable.subscribe (...).



What about other life cycle events, such as onPause () or onResume () ? I still think that the presenter does not need life cycle events . However, if you really think you need them, you can create them as an intent. In your View, pauseIntent () will appear, the launch of which initiates the Android life cycle, not the user's action.

Conclusion


In this part we talked about the basics of MVI and implemented a simple screen. This example is probably too simple to understand all the advantages of MVI. There is nothing wrong with MVP or MVVM, and I’m not saying that MVI is better than other architectural templates. Nevertheless, I believe that MVI helps us write more elegant code for complex problems, as we will see in the next section, which will talk about state reducer .

Source: https://habr.com/ru/post/338558/


All Articles