转载

Model-View-Intent 构建的响应式应用(三)状态转换机

翻译自 REACTIVE APPS WITH MODEL-VIEW-INTENT - PART3 - STATE REDUCER 。

在上一篇 文章中,我们使用 MVI 模式,结合单向数据流,实现了一个简单的搜索功能。在这一篇文章里,我们会在 状态转换机 的帮助下,实现一个更为复杂的功能。

如果你还没有读过本系列的第二篇文章 ,最好还是先去看一下,我们探讨了 ViewPresenter 与业务逻辑关联的方式,还有,数据单向流动的方法。

现在,我们要来实现这样一个复杂的效果:

点击查看视频

视频里展示了一个经过分类的商品列表,每一个分类一开始只显示 3 件商品,当用户点击了“加载更多”的按钮,应用就会去后台请求数据该类型的商品;除此以外,用户还可以下拉刷新整个列表;当用户滚动到底部时,应用还会联网去获取更多的分类信息;当然,这些操作都能同时进行,还要考虑网络断开的问题。

我们一步步地来看这些功能的实现。首先,先定义一下 View 的接口:

public interfaceHomeView{
  /**
   * intent 加载第一页
   * 
   * @return 发射的数据值(true or false)没有卵用。。。
   */
  Observable<Boolean>loadFirstPageIntent();
  
  /**
   * intent 加载下一页(应该是指分类信息的下一页)
   *
   * @return 和上一个一样,还是没有用的值
   */
  Observable<Boolean>loadNextPageIntent();
  
  /**
   * intent 下拉刷新
   *
   * @return 继续忽视吧
   */
  Observable<Boolean>pullToRefreshIntent();
  
  /**
   * intent 由给定的分类去加载更多商品
   *
   * @return 分类的名称
   */
  Observable<String>loadAllProductsFromCategoryIntent();
  
  /**
   * render 渲染 viewState
   */
  voidrender(HomeViewState viewState);
}

View 的具体实现非常简单,我就不在此赘述了,大家可以从 github 上看到代码。下一步,就是 Model 构建,就像我前面说过的, Model 应当反映 State ,所以,让我们看看这个叫做 HomeViewState Model

public final classHomeViewState{
  private final boolean loadingFirstPage; // loading页展示,recyclerView gone
  private final Throwable firstPageError; // 不为null,则展示error view
  private final List<FeedItem> data; // 在recyclerView中展示的数据
  private final boolean loadingNextPage; // 展示加载下一页的loading图示
  private final Throwable nextPageError; // 不为null,则分页加载错误
  private final boolean loadingPullToRefresh; // 显示下拉刷新的提示按钮
  private final Throwable pullToRefreshError; // 不为null,下拉刷新失败的toast
  
  // ... constructor ...
  // ... getters ...
}

需要注意的是, FeedItem 仅仅是一个接口,只有实现了这个接口的实体类才能在 RecyclerView 中显示。举个例子, Product implements FeedItem ,还有,分类的标题要显示得这么写: SectionHeader implements FeedItem 。UI 中那个表示用于加载某个分类的更多项的元素也是一个 FeedItem ,它自己有自己的 state ,而这个 state 就可以用来表示是否加载该分类的更多项:

public classAdditionalItemsLoadableimplementsFeedItem{
  private final int moreItemsAvailableCount;
  private final String categoryName;
  private final boolean loading; // true表示正在加载
  private final Throwable loadingError; // 表示加载时发生错误
  
  // ... constructor ...
  // ... getters ...
}

最后,不要忘了最重要的业务逻辑:

public classHomeFeedLoader{
  // 通常由下拉刷新触发
  public Observable<List<FeedItem>> loadNewestPage() { ... }
  
  // 加载第一页
  public Observable<List<FeedItem>> loadFirstPage() { ... }
  
  // 加载下一页
  public Observable<List<FeedItem>> loadNextPage() { ... }
  
  // 加载某项分类的其余项
  public Observable<List<Product>> loadProductsOfCategory(String categoryName) { ... }
}

接下来,就可以在 Presenter 中将这些内容组合起来。注意一点,我在这儿的 Presenter 中展示的某些代码其实更应该放置到 Interactor 中,这里这样写只是为了更好的阅读效果。好,第一步,加载初始数据:

classHomePresenterextendsMviBasePresenter<HomeView,HomeViewState>{
  private final HomeFeedLoader feedLoader;
  
  @Override protectedvoidbindIntents(){
    // 在实际项目中,这儿的某些代码应该封装到 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);
  }
}

到目前为止,这和我们在第二部分说的实现方式都一样。现在我们试着再加上下拉刷新的功能:

classHomePresenterextendsMviBasePresenter<HomeView,HomeViewState>{
  private final HomeFeedLoader feedLoader;
  
  @Override protectedvoidbindIntents(){
    // 在实际项目中,这儿的某些代码应该封装到 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(loadFirst, pullToRefresh);
    
    subscribeViewState(allIntents, HomeView::render);
  }
}

打住: feedLoader.loadNewestPage() 似乎只会返回“最新”的项,那我们已经加载的内容怎么办?在“传统的” MVP 写法中,可能会有类似 view.addNewItems(newItems) 这样的代码,但我们一开始就已经说明了这并不是很好的做法(状态问题)。现在的问题就在于,我们想要将下拉刷新获取的内容与之前获取的内容相结合。

Ladies and Gentlemen,下面掌声欢迎 状态转换机 的登场!

Model-View-Intent 构建的响应式应用(三)状态转换机

状态转换机是函数式编程中的一个概念,它将上一个状态作为输入,然后计算出一个新的状态:

publicStatereduce(State previous, Foo foo){
  State newState;
  // ... 通过前一个状态和foo,计算出新的状态
  return newState;
}

它的核心思想就是通过 reduce() 这样一个方法,将 previousfoo 结合起来。 Foo 通常表示我们想要对上一个状态做的更改的内容。在这个例子中,我们想要“转换”上一个 HomeViewState (来自于 loadFirstPageIntent 的计算), Foo 就是下拉刷新的结果。正好,RxJava 恰恰有一个为此而生的操作符, scan() 。我们来小小地重构一下代码。在这里,我们又会使用另一个类来表示状态的部分变化(所谓的 Foo ),以此计算新的状态。

classHomePresenterextendsMviBasePresenter<HomeView,HomeViewState>{
  private final HomeFeedLoader feedLoader;
  
  @Override protectedvoidbindIntents(){
    // 在实际项目中,这儿的某些代码应该封装到 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(item))
              .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);
  }
  
  privateHomeViewStateviewStateReducer(HomeViewState previousState, PartialState changes){ ... }
}

我们在这儿所做的,就是让 intent 的返回值变为 Observable<PartialState> ,而不再直接返回 Observable<HomeViewState> 了。然后,通过 Observable.merge() 合并结果,再由 Observable.scan() 更改状态。从根本上来说,用户每产生一个 intent ,这个 intent 就会生成一个 PartialState 对象,最终它就会转换为一个 HomeViewState ,由 HomeView.render(HomeViewState) 显示出来。现在唯一缺少的就是转换函数本身了。 HomeViewState 类是不可变的(上去看看该类的定义),但我们会增加一个 BuilderBuilder 模式),以一种更加方便的方式去创建新的 HomeViewState 对象。实现:

privateHomeViewStateviewStateReducer(HoomeViewState previousState, PartialState changes){
  if (changes instanceof PartialState.FirstPageLoading) {
    retunr previousState.toBuilder() // 通过已有的内容创建一个Builder
      .firstPageLoading(true) // 显示进度条
      .firstPageError(null) // 没有错误
      .build();
  }
  
  if (changes instanceof PartialState.FirstPageError) {
    return previousState.toBuilder()
      .firstPageLoading(false) // 隐藏进度条
      .firstPageError(((PartialState.FirstPageError)changes).getError()) // 错误
      .build();
  }
  
  if (changes instanceof PartialState.PullToRefreshLoading) {
    return previousState.toBuilder()
      .pullToRefreshLoading(true) // 显示下拉加载indicator
      .nextPageError(null)
      .build();
  }
  
  if (changes instanceof PartialState.PullToRefreshError) {
    return previousState.toBuilder()
      .pullToRefreshLoading(false)
      .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.toBuilder()
      .pullToRefreshLoading(false)
      .pullToRefreshError(null)
      .data(data)
      .build();
  }
  
  throw new IllegalStateException("Don't konw how to reduce the partial state " + changes);
}

我知道,这些 instanceof 不怎么优雅,但这不是重点。为什么一位技术博客的作者会写出像上面一样丑陋的代码呢?我们想要阐述某个主题,不会要求读者去掌握其他不相关的知识点,就想我们的购物软件,它并不需要多么深厚的设计模式的知识。因此,我个人认为在写技术文章的时候,应该尽量避免设计模式的使用,它虽然可以让代码更加优雅,但也让文章不那么通俗易懂。这篇文章的重点是状态转换机,而通过 instanceof 的使用,基本上每个人都能理解这个转换机所做的工作了。那你要在你的代码里使用 instanceof 吗?当然不要,你可以用上一些设计模式,比如,将 PartialState 设计为一个接口,它拥有一个 HomeViewState computeNewState(previousState) 类似的方法等等。我推荐一个库, RxSealedUnions ,对于 MVI 架构的应用,它的帮助很大。

好,我想你应该已经理解了状态转换机的工作原理,那就完成剩下的部分:分页功能,加载某个分类剩余的物品。

classHomePresenterextendsMviBasePresenter<HomeView,HomeViewState>{

  private final HomeFeedLoader feedLoader;

  @Override protectedvoidbindIntents(){
    // 在实际项目中,这儿的某些代码应该封装到 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 = ... ;
     Observable<HomeViewState> stateObservable = allIntents.scan(initialState, this::viewStateReducer)

     subscribeViewState(stateObservable, HomeView::render);
  }

  privateHomeViewStateviewStateReducer(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.NextPageLoadingError) {
       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);
  }
}

实现分页功能和实现下拉刷新十分类似,除了我们要把新加载内容放到原有内容之后。解决这个问题的方法实在是很有趣,要显示一个加载中的indicator,错误/重试按钮,我们只需要搜索 FeedItems 列表中相符的 AdditionalItemsLoadable 对象,然后只要更改那一项,去显示加载中,或者错误/重试按钮。如果我们成功地加载了某个分类的所有内容,那我们就用新加载的替换掉搜索到的 SectionHeaderAdditionalItemsLoadable 。就是这样。

总结

这篇文章就是让你看到,在一个状态转换机的帮助下,如何用更少、更易懂的代码,去完成一个复杂的功能。现在回过头想想,如果使用传统的 MVP/MVVM 模式,没有状态转换机的帮助,你会怎么实现?能够使用状态转换机的关键,我们拥有一个能够反映 statemodel 。因此,就想第一篇文章所说,深入理解 Model 非常重要。而且,只有在确保我们的数据源是唯一的情况下,我们才能使用状态转换机。因此,单向数据流也非常重要。到这里,我希望你更能理解一、二篇文章的重要性,当把这些点联系到一起之后,你能豁然开朗。如果没有,不要担心,我也花了很长时间(还有许多的练习,许多的错误,许多的推倒重来)。

你可能会疑惑,我们为什么没有在搜索界面使用状态转换机(看第二篇文章)。只有在依赖于上一状态的情况下,状态转换机才会如此重要,而在搜索界面,我们是不依赖于上一状态的。

最后,最重要的一点,不知道你有没有注意到(希望你没有钻进细节中),我们所有的数据都是不可变的(我们总是创建新的 HomeViewState ,从没调用过setter)。所以,多线程也变得相当简单。用户可以同时下拉刷新、加载下一页、加载某个分类的更多内容,因为状态转换机并不依赖于某个特定 http 的响应,就可以提供准确的状态。除此以外,我们用的是纯函数,没有副作用。这让我们代码的可测试性、复用性、理解性和高可并发性都有了提升。

当然,状态转换机并不是为了 MVI 发明的,你会在许多其他的库、框架、不同的语言中看到它的身影。单向数据流,代表状态的模型,状态转换机非常适合 MVI 的理念。

下一篇文章,我们会介绍怎样用 MVI 去构建一个可重用、响应式的组件。

原文  http://blog.saplf.top/2017/02/05/mosbys-mvi-3/
正文到此结束
Loading...