[譯]使用 MVI 開發響應式 APP — 第三部分 — 狀態摺疊器(state reducer)

pcdack發表於2018-02-27

使用 MVI 開發響應式 APP — 第三部分 — 狀態摺疊器(state reducer)

前面的系列裡 我們已經討論瞭如何用 Model-View-Intent 模式和單向資料流去實現一個簡單的頁面。在這篇部落格裡我們將要實現更加複雜頁面,這個頁面將有助於我們理解狀態摺疊器(state reducer)。

如果你沒讀第二部分,你應該先去讀一下第二部分,然後再讀這篇部落格, 因為第二部分部落格描述我們如何將業務邏輯通過 Presenter 與 View 進行溝通,如果讓資料進行單向流動。

現在我們構建一個更加複雜的場景,像下面演示的內容:

[譯]使用 MVI 開發響應式 APP — 第三部分 — 狀態摺疊器(state reducer)

正如你所見,上面的演示內容,就是根據不同的型別顯示商品列表。這個 APP 中每個型別只顯示三個項,使用者可以點選載入更多,來載入更多的商品(http請求)。另外,使用者可以使用下拉重新整理去更新不同型別下的商品,並且,當使用者載入到最底端的時候,可以載入更多型別的商品(載入下一頁的商品)。當然,當出現異常的時候,所有的這些動作執行過程與正常載入時候類似,只不過顯示的內容不同(例如:顯示網路錯誤)。

讓我們一步一步實現這個頁面。第一步定義View的介面。

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:

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

相關文章