📜 ⬆️ ⬇️

Reactive applications with Model-View-Intent. Part 3: State Reducer



In the previous section, we discussed how to implement a simple screen with a Model-View-Intent pattern using a unidirectional data stream. In the third part, we will build a more complex screen with MVI using the State Reducer.

If you haven’t read the second part yet, you should do it before further reading, because it describes how we connected View with business logic through Presenter and how data moves in one direction.

Now let's create a more complex screen:
')

As you can see, this screen displays a list of items (products) grouped by category. The application displays only 3 items of each category and the user can click “Download more” to download all products of the selected category (http request). The user can also do Pull-To-Refresh, and when he reaches the end of the list, more categories will be loaded (pagination ) Of course, all these actions can be performed simultaneously, and each of them may not be executed (for example, in the absence of the Internet)

Let's implement it step by step. First, let's define the View interface.

public interface HomeView { public Observable<Boolean> loadFirstPageIntent(); public Observable<Boolean> loadNextPageIntent(); public Observable<Boolean> pullToRefreshIntent(); public Observable<String> loadAllProductsFromCategoryIntent(); public void render(HomeViewState viewState); } 

The implementation of View is fairly straightforward, and therefore I will not show the code here (can be found on github )
Now let's focus on the model. As mentioned in the previous parts - the model should reflect the state. So, I present to you a model called HomeViewState .

 public final class HomeViewState { private final boolean loadingFirstPage; private final Throwable firstPageError; private final List<FeedItem> data; private final boolean loadingNextPage; private final Throwable nextPageError; private final boolean loadingPullToRefresh; private final Throwable pullToRefreshError; // ...  ... // ...  ... } 

Note that FeedItem is simply an interface that must implement each item displayed in RecyclerView. For example, Product implements FeedItem . The section category display name also implements the FeedItem . The UI element, which indicates that additional category items can be loaded, is a FeedItem and contains its own state in order to display whether we load additional items in a specific category:

 public class AdditionalItemsLoadable implements FeedItem { private final int moreItemsAvailableCount; private final String categoryName; private final boolean loading; private final Throwable loadingError; // ...  ... // ...  ... } 

And also create an element of business logic HomeFeedLoader , which is responsible for loading FeedItems :

 public class HomeFeedLoader { public Observable<List<FeedItem>> loadNewestPage() { ... } public Observable<List<FeedItem>> loadFirstPage() { ... } public Observable<List<FeedItem>> loadNextPage() { ... } public Observable<List<Product>> loadProductsOfCategory(String categoryName) { ... } } 

Now let's connect everything step by step in our Presenter. Keep in mind that some code presented here, as part of Presenter, most likely should be transferred to Interactor in a real application (which I did not do for better readability). First, load the initial data:

 class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> { private final HomeFeedLoader feedLoader; @Override protected void bindIntents() { Observable<HomeViewState> loadFirstPage = intent(HomeView::loadFirstPageIntent) .flatMap(ignored -> feedLoader.loadFirstPage() .map(items -> new HomeViewState(items, false, null) ) .startWith(new HomeViewState(emptyList, true, null) ) .onErrorReturn(error -> new HomeViewState(emptyList, false, error)) subscribeViewState(loadFirstPage, HomeView::render); } } 

While everything is going well, there are no big differences with the way we implemented the “search screen” in part 2. Now let's try adding support for Pull-To-Refresh:

 class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> { private final HomeFeedLoader feedLoader; @Override protected void bindIntents() { Observable<HomeViewState> loadFirstPage = ... ; Observable<HomeViewState> pullToRefresh = intent(HomeView::pullToRefreshIntent) .flatMap(ignored -> feedLoader.loadNewestPage() .map( items -> new HomeViewState(...)) .startWith(new HomeViewState(...)) .onErrorReturn(error -> new HomeViewState(...))); Observable<HomeViewState> allIntents = Observable.merge(loadFirstPage, pullToRefresh); subscribeViewState(allIntents, HomeView::render); } } 

But wait: feedLoader.loadNewestPage () returns only new items, but what about the previous items we downloaded earlier? In the “traditional” MVP, someone can do view.addNewItems (newItems) , but in the first part we have already discussed why this is a bad idea (“State Problem”). The problem we are facing now is: Pull-To-Refresh depends on the previous HomeViewState, since we want to combine the previous elements with the elements that returned from Pull-To-Refresh.

Ladies and Gentlemen, I beg to love and favor - State Reducer


image

State Reducer is a concept from functional programming. It takes the previous state to the input and calculates the new state from the previous state:

 public State reduce( State previous, Foo foo ){ State newState; // ...   State      Foo return newState; } 

The idea is that the reduce () method combines the previous state with foo to calculate the new state. Foo usually represents the changes we want to apply to the previous state. In our case, we want to combine the previous HomeViewState (originally received from loadFirstPageIntent) with the results from Pull-To-Refresh. It turns out that in RxJava there is a special operator for this - scan () . Let's change our code a bit. We need to create another class that will reflect a partial change (in the code above it is called Foo) and be used to calculate the new state:

Homepresenter
 class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> { private final HomeFeedLoader feedLoader; @Override protected void bindIntents() { Observable<PartialState> loadFirstPage = intent(HomeView::loadFirstPageIntent) .flatMap(ignored -> feedLoader.loadFirstPage() .map(items -> new PartialState.FirstPageData(items) ) .startWith(new PartialState.FirstPageLoading(true) ) .onErrorReturn(error -> new PartialState.FirstPageError(error)) Observable<PartialState> pullToRefresh = intent(HomeView::pullToRefreshIntent) .flatMap(ignored -> feedLoader.loadNewestPage() .map( items -> new PartialState.PullToRefreshData(items) .startWith(new PartialState.PullToRefreshLoading(true))) .onErrorReturn(error -> new PartialState.PullToRefreshError(error))); Observable<PartialState> allIntents = Observable.merge(loadFirstPage, pullToRefresh); HomeViewState initialState = ... ; //     Observable<HomeViewState> stateObservable = allIntents.scan(initialState, this::viewStateReducer) subscribeViewState(stateObservable, HomeView::render); } private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){ ... } } 


Each Intent now returns an Observable <PartialState> instead of an Observable <HomeViewState>. Then we combine them into one Observable using Observable.merge () and finally apply the Observable.scan () operator. This means that whenever a user runs an intent, this intent will create PartialState objects, which will be reduced to HomeViewState , which in turn will be displayed on View (HomeView.render (HomeViewState)). The only missing part is the information function itself. By itself, the HomeViewState class has not changed, but we have added the Builder (Builder pattern) and now we can create new HomeViewState objects in a convenient way. Now let's implement the details function:

viewStateReducer
 private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){ if (changes instanceof PartialState.FirstPageLoading) return previousState.toBuilder() .firstPageLoading(true) .firstPageError(null) .build() if (changes instanceof PartialState.FirstPageError) return previousState.builder() .firstPageLoading(false) .firstPageError(((PartialState.FirstPageError) changes).getError()) .build(); if (changes instanceof PartialState.FirstPageLoaded) return previousState.builder() .firstPageLoading(false) .firstPageError(null) .data(((PartialState.FirstPageLoaded) changes).getData()) .build(); if (changes instanceof PartialState.PullToRefreshLoading) return previousState.builder() .pullToRefreshLoading(true) .nextPageError(null) .build(); if (changes instanceof PartialState.PullToRefreshError) return previousState.builder() .pullToRefreshLoading(false) // Hide pull to refresh indicator .pullToRefreshError(((PartialState.PullToRefreshError) changes).getError()) .build(); if (changes instanceof PartialState.PullToRefreshData) { List<FeedItem> data = new ArrayList<>(); data.addAll(((PullToRefreshData) changes).getData()); data.addAll(previousState.getData()); return previousState.builder() .pullToRefreshLoading(false) .pullToRefreshError(null) .data(data) .build(); } throw new IllegalStateException("Don't know how to reduce the partial state " + changes); } 


I know that all these instanceof checks are not very good, but this is not the point of this article. Why do technical bloggers write bad code, like in the example above? Because we want to concentrate on a specific topic without forcing the reader to keep in mind all the source code (for example, our application with a basket of goods) or to know certain design patterns. Therefore, I believe that it is better to avoid patterns in the article that will make the code better, but can also lead to worse readability. The focus of this article is State Reducer. Looking at him with the instanceof checks, anyone can understand what he is doing. Should you use instanceof checks in your application? No, use design patterns or other solutions. For example, you can declare a PartialState interface with the public HomeViewState method computeNewState (previousState). In general, you may find Paco Estevez RxSealedUnions useful when developing applications with MVI.

Okay, I think you get the idea of ​​running a State Reducer. Let's implement the remaining functionality: pagination and the ability to load more items of a certain category.

Homepresenter
 class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> { private final HomeFeedLoader feedLoader; @Override protected void bindIntents() { Observable<PartialState> loadFirstPage = ... ; Observable<PartialState> pullToRefresh = ... ; Observable<PartialState> nextPage = intent(HomeView::loadNextPageIntent) .flatMap(ignored -> feedLoader.loadNextPage() .map(items -> new PartialState.NextPageLoaded(items)) .startWith(new PartialState.NextPageLoading()) .onErrorReturn(PartialState.NexPageLoadingError::new)); Observable<PartialState> loadMoreFromCategory = intent(HomeView::loadAllProductsFromCategoryIntent) .flatMap(categoryName -> feedLoader.loadProductsOfCategory(categoryName) .map( products -> new PartialState.ProductsOfCategoryLoaded(categoryName, products)) .startWith(new PartialState.ProductsOfCategoryLoading(categoryName)) .onErrorReturn(error -> new PartialState.ProductsOfCategoryError(categoryName, error))); Observable<PartialState> allIntents = Observable.merge(loadFirstPage, pullToRefresh, nextPage, loadMoreFromCategory); HomeViewState initialState = ... ; Observable<HomeViewState> stateObservable = allIntents.scan(initialState, this::viewStateReducer) subscribeViewState(stateObservable, HomeView::render); } private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){ if (changes instanceof PartialState.NextPageLoading) { return previousState.builder().nextPageLoading(true).nextPageError(null).build(); } if (changes instanceof PartialState.NexPageLoadingError) return previousState.builder() .nextPageLoading(false) .nextPageError(((PartialState.NexPageLoadingError) changes).getError()) .build(); if (changes instanceof PartialState.NextPageLoaded) { List<FeedItem> data = new ArrayList<>(); data.addAll(previousState.getData()); data.addAll(((PartialState.NextPageLoaded) changes).getData()); return previousState.builder().nextPageLoading(false).nextPageError(null).data(data).build(); } if (changes instanceof PartialState.ProductsOfCategoryLoading) { int indexLoadMoreItem = findAdditionalItems(categoryName, previousState.getData()); AdditionalItemsLoadable ail = (AdditionalItemsLoadable) previousState.getData().get(indexLoadMoreItem); AdditionalItemsLoadable itemsThatIndicatesError = ail.builder() .loading(true).error(null).build(); List<FeedItem> data = new ArrayList<>(); data.addAll(previousState.getData()); data.set(indexLoadMoreItem, itemsThatIndicatesError); return previousState.builder().data(data).build(); } if (changes instanceof PartialState.ProductsOfCategoryLoadingError) { int indexLoadMoreItem = findAdditionalItems(categoryName, previousState.getData()); AdditionalItemsLoadable ail = (AdditionalItemsLoadable) previousState.getData().get(indexLoadMoreItem); AdditionalItemsLoadable itemsThatIndicatesError = ail.builder().loading(false).error( ((ProductsOfCategoryLoadingError)changes).getError()).build(); List<FeedItem> data = new ArrayList<>(); data.addAll(previousState.getData()); data.set(indexLoadMoreItem, itemsThatIndicatesError); return previousState.builder().data(data).build(); } if (changes instanceof PartialState.ProductsOfCategoryLoaded) { String categoryName = (ProductsOfCategoryLoaded) changes.getCategoryName(); int indexLoadMoreItem = findAdditionalItems(categoryName, previousState.getData()); int indexOfSectionHeader = findSectionHeader(categoryName, previousState.getData()); List<FeedItem> data = new ArrayList<>(); data.addAll(previousState.getData()); removeItems(data, indexOfSectionHeader, indexLoadMoreItem); //     (  ) data.addAll(indexOfSectionHeader + 1,((ProductsOfCategoryLoaded) changes).getData()); return previousState.builder().data(data).build(); } throw new IllegalStateException("Don't know how to reduce the partial state " + changes); } } 


Implementing pagination (loading the next “page” with items) is quite similar to pull-to-refresh, except that we add loaded items to the end of the list, instead of adding them to the beginning (as we do with pull-to-refresh ) It’s interesting how we load more items of a certain category. To display the load indicator or the repeat / error button for the selected category, we simply need to find the corresponding AdditionalItemsLoadable object in the list of all FeedItem. Then we change this item to display the download indicator or the repeat / error button. If we have successfully loaded all the elements in a particular category, we are looking for a SectionHeader and AdditionalItemsLoadable, and then replace all the elements in between with the new loaded elements.

Conclusion


The purpose of this article was to show how State Reducer can help us design complex screens with small, clear code. Just take a step back and think about how you would implement it with a “traditional” MVP or MVVM without a State Reducer? The key point that allows us to use State Reducer is that we have a model that reflects the state. Therefore, it was very important to understand from the first part of this series of articles what a Model is. Also, the State Reducer can be used only if we can be sure that the State (or rather, the Model) comes from a single source. Therefore, unidirectional data flow is also very important. I think it is now clear why we stopped on these topics in the first and second part of this series of articles, and I hope you have the same “aha!” Moment when all the dots are connected together. If not - do not worry, for me it took a lot of time (and a lot of practice, and a lot of mistakes and repetitions).
You may be wondering why we did not use the State Reducer on the search screen (in the second part). Using a State Reducer basically makes sense when we are somehow dependent on the previous state.

Last but not least, I want to dwell on - if you have not noticed (without immersion in details) all our data is unchanged (we always create a new HomeViewState, we never call the setter method on any of the objects). Therefore, you should not have problems with multithreading. A user can pull-to-refresh and at the same time load a new page and load more items of a certain category, because the State Reducer is able to produce the correct state without depending on the order of http responses. In addition, we wrote our code using simple functions, with no side effects . This makes our code super-tested, reproducible, highly parallelizable, and easy to discuss.

Of course, the State Reducer was not invented for MVI. You can find State Reducer concepts in a variety of libraries, frameworks, and systems in different programming languages. State Reducer is superbly embedded in the Model-View-Intent philosophy with a unidirectional data stream and a Model reflecting State.

In the next part, we will focus on how to create reusable and reactive UI components with MVI.

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


All Articles