[譯]使用 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前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索