[译]使用 MVI 开发响应式 APP — 第三部分 — 状态折叠器(state reducer)

使用 MVI 开发响应式 APP — 第三部分 — 状态折叠器(state reducer)

前面的系列里 咱们已经讨论了如何用 Model-View-Intent 模式和单向数据流去实现一个简单的页面。在这篇博客里咱们将要实现更加复杂页面,这个页面将有助于咱们理解状态折叠器(state reducer)。前端

若是你没读第二部分,你应该先去读一下第二部分,而后再读这篇博客, 由于第二部分博客描述咱们如何将业务逻辑经过 Presenter 与 View 进行沟通,若是让数据进行单向流动。java

如今咱们构建一个更加复杂的场景,像下面演示的内容:android

正如你所见,上面的演示内容,就是根据不一样的类型显示商品列表。这个 APP 中每一个类型只显示三个项,用户能够点击加载更多,来加载更多的商品(http请求)。另外,用户可使用下拉刷新去更新不一样类型下的商品,而且,当用户加载到最底端的时候,能够加载更多类型的商品(加载下一页的商品)。固然,当出现异常的时候,全部的这些动做执行过程与正常加载时候相似,只不过显示的内容不一样(例如:显示网络错误)。ios

让咱们一步一步实现这个页面。第一步定义View的接口。git

public interface HomeView {

  /** * 加载首页意图 * * @return 发射的值能够被忽略,不管true或者false都没有其余任何不同的意义 */
  public Observable<Boolean> loadFirstPageIntent();

  /** * 加载下一页意图 * * @return 发射的值能够被忽略,不管true或者false都没有其余任何不同的意义 */
  public Observable<Boolean> loadNextPageIntent();

  /** * 下拉刷新意图 * * @return 发射的值能够被忽略,不管true或者false都没有其余任何不同的意义 */
  public Observable<Boolean> pullToRefreshIntent();

  /** * 上拉加载更多意图 * * @return 返回类别的可观察对象 */
  public Observable<String> loadAllProductsFromCategoryIntent();

  /** * 渲染 */
  public void render(HomeViewState viewState);
}
复制代码

View的具体实现灰常简单,而且我不想把代码贴在这里(你能够在github上看到)。下一步,让咱们聚焦Model。我前面的文章也说过Model应该表明状态(State)。所以让咱们去实现咱们的 HomeViewState:github

public final class HomeViewState {

  private final boolean loadingFirstPage; // 显示加载指示器,而不是 recyclerView
  private final Throwable firstPageError; //若是不为 null,就显示状态错误的 View
  private final List<FeedItem> data;   // 在 recyclerview 显示的项
  private final boolean loadingNextPage; // 加载下一页时,显示加载指示器
  private final Throwable nextPageError; // 若是!=null,显示加载页面错误的Toast
  private final boolean loadingPullToRefresh; // 显示下拉刷新指示器 
  private final Throwable pullToRefreshError; // 若是!=null,显示下拉刷新错误

   // ... constructor ...
   // ... getters ...
}
复制代码

注意 FeedItem 是每个 RecyclerView 所展现的子项所须要实现的接口。例如Product 就是实现了 FeedItem 这个接口。另外展现类别标签的 SectionHeader一样也实现FeedItem。加载更多的UI元素也是须要实现FeedItem,而且,它内部有一个小的状态,去标示咱们在当前类型下是否加载更多项:编程

public class AdditionalItemsLoadable implements FeedItem {
  private final int moreItemsAvailableCount;
  private final String categoryName;
  private final boolean loading; // 若是为true,那么正在下载
  private final Throwable loadingError; // 用来表示,当加载过程当中出现的错误

   // ... constructor ...
   // ... getters ...
复制代码

最后,也是比较重要的是咱们的业务逻辑部分 HomeFeedLoader 的责任是加载其 FeedItems:后端

public class HomeFeedLoader {

  // Typically triggered by pull-to-refresh
  public Observable<List<FeedItem>> loadNewestPage() { ... }

  //Loads the first page
  public Observable<List<FeedItem>> loadFirstPage() { ... }

  // loads the next page (pagination)
  public Observable<List<FeedItem>> loadNextPage() { ... }

  // loads additional products of a certain category
  public Observable<List<Product>> loadProductsOfCategory(String categoryName) { ... }
}
复制代码

如今让咱们一步一步的将上面分开的部分用Presenter链接起来。请注意,当在正式环境中这里展示的一部分Presenter的代码须要被移动到一个Interactor中(我没按照规范写是由于能够更好理解)。第一,让咱们开始加载初始化数据设计模式

class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> {

  private final HomeFeedLoader feedLoader;

  @Override protected void bindIntents() {
    //
    // In a real app some code here should rather be moved into an Interactor
    //
    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);
  }
}
复制代码

到如今为止,貌似和咱们在第二部分(已翻译)描述的构建搜索页面是同样的。 如今,咱们须要添加下拉刷新的功能。网络

class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> {

  private final HomeFeedLoader feedLoader;

  @Override protected void bindIntents() {
    //
    // In a real app some code here should rather be moved into an Interactor
    //
    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);
  }
}
复制代码

使用Observable.merge()将多个意图合并在一块儿。

可是等等: feedLoader.loadNewestPage() 仅仅返回"最新"的项,可是关于前面咱们已经加载的项如何处理?在"传统"的MVP中,那么能够经过调用相似于 view.addNewItems(newItems) 来处理这个问题。可是咱们已经在这个系列的第一篇(已翻译)中讨论过这为何是一个很差的办法(“状态问题”)。如今咱们面临的问题是下拉刷新依赖于先前的HomeViewState,咱们想当下拉刷新完成之后,将新取得的项与原来的项合并。

女士们,先生们让咱们掌声有请--Mr.状态折叠器(STATE REDUCER)

MVI

状态折叠器(STATE REDUCER)是函数式编程里面的重要内容,它提供了一种机制可以让之前的状态做为输入如今的状态做为输出:

public State reduce( State previous, Foo foo ){
  State newState;
  // ... compute the new State by taking previous state and foo into account ...
  return newState;
}
复制代码

这个想法是这样一个 reduce() 函数结合了前一个状态和 foo 来计算一个新的状态。Foo类型表明咱们想让先前状态发生的变化。在这个案例中,咱们经过下拉刷新,想"减小(reduce)"HomeViewState的先前状态生成咱们但愿的结果。你猜如何,RxJava提供了一个操做符叫作 scan(). 让咱们重构一点咱们的代码。咱们不得不去描述另外一个表明部分变化(在先前的代码片断中,咱们称之为 Foo)的类,这个类将用来计算新的状态。

class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> {

  private final HomeFeedLoader feedLoader;

  @Override protected void bindIntents() {
    //
    // In a real app some code here should rather be moved into an Interactor
    //
    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 = ... ; // Show loading first page
    Observable<HomeViewState> stateObservable = allIntents.scan(initialState, this::viewStateReducer)

    subscribeViewState(stateObservable, HomeView::render);
  }

  private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){
    ...
  }
}
复制代码

所以,咱们这里在作的是。每一个意图(Intent)如今会返回一个 Observable 而不是直接返回 Observable。而后,咱们用 Observable.merge() 去合并它们到一个观察流,最后再应用减小(reducer)方法(Observable.scan())。这也就意味着,不管什么时候用户开启一个意图,这个意图将生成一个 PartialState 对象,这个对象将被"减小(reduced)"成为 HomeViewState 而后将被显示到View上(HomeView.render(HomeViewState))。还有一点剩下的部分,就是reducer函数本身的状态。HomeViewState 类它本身没有变化(向上滑动你可看到这个类的定义)。可是咱们须要添加一个 Builder(Builder模式)所以咱们能够建立一个新的 HomeViewState 对象用一种比较方便的方式。所以让咱们实现状态折叠器(state reducer)的方法:

private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){
    if (changes instanceof PartialState.FirstPageLoading)
        return previousState.toBuilder() // creates a new copy by taking the internal values of previousState
        .firstPageLoading(true) // show ProgressBar
        .firstPageError(null) // don't show error view
        .build()

    if (changes instanceof PartialState.FirstPageError)
     return previousState.builder()
         .firstPageLoading(false) // hide ProgressBar
         .firstPageError(((PartialState.FirstPageError) changes).getError()) // Show error view
         .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) // Show pull to refresh indicator
            .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()); // insert new data on top of the list
      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);
}
复制代码

我知道,全部的 instanceof 检查不是一个特别好的方法,可是,这个不是这篇博客的重点。为啥技术博客就不能写"丑"的代码?我仅仅是想让个人观点可以让读者很快的理解和明白。我认为这是一个好的方法去避免一些博客写的一手好代码可是没几我的能看懂。咱们这篇博客的聚焦点在状态折叠器上。经过 instanceof 检查全部的东西,咱们能够理解状态折叠器究竟是什么玩意。你应该用 instanceof 检查在你的 APP 中么?不该该,用设计模式或者其余的解决方法像定义 PartialState 做为接口带有一个 public HomeViewState computeNewState(previousState)。方法。一般状况下Paco Estevez 的 RxSealedUnions 库变得十分有用当咱们使用MVI构建App的时候。

好的,我认为你已经理解了状态折叠器(state reducer)的工做原理。让咱们实现剩下的方法:当前种类加载更多的功能:

class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> {

  private final HomeFeedLoader feedLoader;

  @Override protected void bindIntents() {

    //
    // In a real app some code here should rather be moved to an Interactor
    //

    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 = ... ; // Show loading first page
    Observable<HomeViewState> stateObservable = allIntents.scan(initialState, this::viewStateReducer)

    subscribeViewState(stateObservable, HomeView::render);
  }

  private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){
    // ... PartialState handling for First Page and pull-to-refresh as shown in previous code snipped ...

      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());
        // Add new data add the end of the list
       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() // creates a copy of the ail item
         .loading(true).error(null).build();

         List<FeedItem> data = new ArrayList<>();
         data.addAll(previousState.getData());
         data.set(indexLoadMoreItem, itemsThatIndicatesError); // Will display a loading indicator

         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); // Will display an error / retry button

       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); // Removes all items of the given category

       // Adds all items of the category (includes the items previously removed)
       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);
  }
}
复制代码

实现分页功能(加载下一页的项)相似于下拉刷新,除了在下拉刷新中,咱们把数据是更新到上面,而在这里咱们把数据更新到当前分类数据的后面。固然,显示加载指示器,错误/重试按钮的实现,咱们仅仅只需须要找到对应的 AdditionalltemsLoadable 对象在 FeedItems 列表中。而后,咱们改变项的显示为错误/从新加载按钮。若是咱们已经成功的加载了当前分类的全部的项,咱们找到 SectionHeader和 AdditionaltemsLoadable,而且替换全部的项在新的项加载项以前。

总结

这篇博客的目标是为了向你们展现什么是状态折叠器,状态折叠器如何帮助你们用不多的代码去实现构建复杂的的页面。回过头来看,你能够实现"传统"的 MVP 或 MVVM 而不用状态折叠器?用状态折叠器的关键是咱们用一个 Model 类来反应一种状态。所以,理解第一篇博客所写的什么是 Model 是十分重要的。而且,状态折叠器有且被用在若是咱们明确的知道状态来自单个源头。所以,单项数据流也是十分重要的。我但愿在理解这篇博客值钱吗须要先理解前几篇博客的内容。将全部分离的知识点联系起来。不要慌,这花了我不少时间(不少练习,错误和重试),你会比我花更少的时间的。

你也许会想,为何咱们在第二部分搜索页面不用状态折叠器(看第二部分)。状态折叠器大多数用在,咱们依赖于上一次状态的场景下。在“搜索页面下”咱们不依赖于先前状态。

最后可是一样重要的是,我想指出,若是你也一样注意到(没有太多细节),就是咱们全部的数据都是不变的(咱们老是在不停的建立新的 HomeViewState,咱们没有在任何一个对象里调用任何一个 setter 方法)。所以,多线程将变得很是简单。用户能够下拉刷新的同时上拉加载更多和加载当前分类的更多项由于状态折叠器生成当前状态不依赖于特有的 HTTP 请求。另外,咱们写咱们的代码用的是纯函数没有反作用。它使咱们的代码很是容易的测试,重构,简单的逻辑和高度可并行化(多线程)。

固然,状态折叠器不是 MVI 创造的。你能够在其余库,架构和其余多语言中找到状态折叠器的概念。状态折叠器机制很是符合 MVI 中的单项数据流和 Model 表明状态的这种特性。

在下一个部分咱们将关注与如何用 MVI 来构建可复用的响应式 UI 组件。

这篇博客是"Reactive Apps with Model-View-Intent"这个系列博客的一部分。 这里是内容表:


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索