[譯]用MVI編寫響應式APP第二部分View和Intent

pcdack發表於2018-01-19

在第一部分我們討論了關於什麼才是真正的Model,Model和狀態的關係,並且討論了什麼樣的Model才能避免安卓開發過程中的共性問題。在這篇我們通過講Model-View-Intent模式去構建響應式安卓程式,繼續我們的“響應式APP開發”探索之旅。

如果你沒有閱讀第一部分,你應該先讀那篇然後再讀這篇。我在這裡先簡單的回顧一下上一部分的主要內容:我們不要寫類似於下面的程式碼(傳統的MVP的例子)

class PersonsPresenter extends Presenter<PersonsView> {

  public void load(){
    getView().showLoading(true); // Displays a ProgressBar on the screen

    backend.loadPersons(new Callback(){
      public void onSuccess(List<Person> persons){
        getView().showPersons(persons); // Displays a list of Persons on the screen
      }

      public void onError(Throwable error){
        getView().showError(error); // Displays a error message on the screen
      }
    });
  }
}
複製程式碼

我們應該建立一個反應"狀態(State)"的"Model":

class PersonsModel {
  // 在正式的專案裡應當為私有
  // 我們需要用get方法來獲取它們的值
  final boolean loading;
  final List<Person> persons;
  final Throwable error;

  public(boolean loading, List<Person> persons, Throwable error){
    this.loading = loading;
    this.persons = persons;
    this.error = error;
  }
}
複製程式碼

然後Presenter的實現類似於下面這樣:

class PersonsPresenter extends Presenter<PersonsView> {

  public void load(){
    getView().render( new PersonsModel(true, null, null) ); //顯示載入進度條

    backend.loadPersons(new Callback(){
      public void onSuccess(List<Person> persons){
        getView().render( new PersonsModel(false, persons, null) ); // 顯示人列表
      }

      public void onError(Throwable error){
          getView().render( new PersonsModel(false, null, error) ); // 顯示錯誤資訊
      }
    });
  }
}
複製程式碼

現在View有一個Model,通過呼叫render(personsModel) 方法,將資料渲染到UI上。在上一篇文章裡我們也討論了單向資料流的重要性,並且你的業務邏輯應當驅動你的Model。在我們把所有的內容連起來之前,我們先快速的瞭解一下MVI的大意。

Model-View-Intent(MVI)

這個模式被 André Medeiros (Staltz) 為了他寫的一個JavaScript的框架而提出的,這個框架的名字叫做 cycle.js 。從理論上(數學上)來看,我們可以用下面的表示式來描述Model-View-Intent:

[譯]用MVI編寫響應式APP第二部分View和Intent

  • intent() :這個函式接受使用者的輸入(例如,UI事件,像點選事件之類的)並把它轉化成model函式的可接收的引數。這個引數可能是一個簡單的String,也可能是其他複雜的結構的資料,像Object。我們可以說我們通過intent()的意圖去改變Model。
  • model() :model()函式接收intent()函式的輸出作為輸入,去操作Model。它的輸出是一個新的Model(因為狀態改變)。因此我們不應該去更新已經存在的Model。因為我們需要Model具有不變性! 在第一部分,我具體用”計數APP“作為簡單的例子講了資料不變性的重要性。再次強調,我們不要去修改已經存在的Model例項。我們在model()方法裡建立新的,根據intent的輸出變化以後的Model。請注意,model()方法是你唯一能夠建立新的Model物件的地方。基本上,我們稱model()方法為我們App的業務邏輯(可以是Interactor,Usecase,Repository ...您在應用中使用的任何模式/術語)並且傳遞新的Model物件作為結果。
  • view() :這個方法接收model()方法的輸出值。然後根據model()的輸出值來渲染到UI上。view()方法大致上類似於view.render(model)

但是,我們不是去構建一個”響應式的APP“,不是麼?所以,MVI是如何做到"響應式"的?"響應式"到底意味著什麼?先回答最後一個問題,”響應式“就是我們的app根據狀態不同而去改變UI。在MVI中,”狀態“被"Model"所代表,實質上我們期望,我們的業務邏輯根據使用者的輸入事件(intent)產生新的"Model",然後再將新的"Model"通過呼叫view的render(Model)方法改變在UI。這就是MVI實現響應式的基本思路。

使用RxJava來連線不同的點(這裡的點是指☞Model,View,Intent原本是相互獨立的點)

我們想要讓我們的資料流是單向的。RxJava在這裡起到了作用。我們必須使用RxJava構建單向資料流的響應式App或MVI模式的App麼?不是的,我們可以用其他的程式碼實現。然而,RxJava對於事件基礎的程式設計是很好用的。既然使用者介面是基於事件的,使用RxJava也就很有意義的。

在這個系列部落格,我們將要開發一個簡單的電商應用。我們在後臺進行http請求,去載入我們需要顯示商品。我們可以搜尋商品和新增商品到購物車。綜上所述整個App看起來想下面這個動圖:

[譯]用MVI編寫響應式APP第二部分View和Intent
這個專案的原始碼你可以在 github 上找到。我們先去實現一個簡單的頁面:實現搜尋頁面。首先,我們先定義一個最終將被View顯示的Model。在這個系列部落格我們採用"ViewState"標示來標示Model ,例如:我們的搜尋頁面的Model類叫做SearchViewState ,因為Model代表狀態(State)。至於為什麼不使用SearchModel這樣的名字,是因為怕與MVVM的類似於SearchViewModel的命名混淆。命名真的很難。

public interface SearchViewState {

  /**
   *搜尋還沒有開始
   */
  final class SearchNotStartedYet implements SearchViewState {
  }

  /**
   * 載入: 等待載入
   */
  final class Loading implements SearchViewState {
  }

  /**
   *標識返回一個空結果
   */
  final class EmptyResult implements SearchViewState {
    private final String searchQueryText;

    public EmptyResult(String searchQueryText) {
      this.searchQueryText = searchQueryText;
    }

    public String getSearchQueryText() {
      return searchQueryText;
    }
  }

  /**
   * 驗證搜尋結果. 包含符合搜尋條件的專案列表。
   */
  final class SearchResult implements SearchViewState {
    private final String searchQueryText;
    private final List<Product> result;

    public SearchResult(String searchQueryText, List<Product> result) {
      this.searchQueryText = searchQueryText;
      this.result = result;
    }

    public String getSearchQueryText() {
      return searchQueryText;
    }

    public List<Product> getResult() {
      return result;
    }
  }

  /**
   *標識搜尋出現的錯誤狀態
   */
  final class Error implements SearchViewState {
    private final String searchQueryText;
    private final Throwable error;

    public Error(String searchQueryText, Throwable error) {
      this.searchQueryText = searchQueryText;
      this.error = error;
    }

    public String getSearchQueryText() {
      return searchQueryText;
    }

    public Throwable getError() {
      return error;
    }
  }
}
複製程式碼

Java是個強型別的語言,我們需要為我們的Model選擇一個安全的型別。我們的業務邏輯返回的是 SearchViewState 型別的。當然這種定義方法是我個人的偏好。我們也可以通過不同的方式定義,例如:

class SearchViewState {
  Throwable error; // if not null, an error has occurred
  boolean loading; // if true loading data is in progress
  List<Product> result; // if not null this is the result of the search
  boolean SearchNotStartedYet; // if true, we have the search not started yet
}
複製程式碼

再次強調,你可以按照你的方式來定義你的Model。如果,你會使用kotlin語言的話,那麼sealed classes是一個很好的選擇。 下一步,讓我將聚焦點重新回到業務邏輯。讓我們看一下負責執行搜尋的 SearchInteractor 如何去實現。先前已經說過了它的"輸出"應該是一個 SearchViewState 物件。

public class SearchInteractor {
  final SearchEngine searchEngine; // 進行http請求

  public Observable<SearchViewState> search(String searchString) {
    // 空的字串,所以沒搜尋
    if (searchString.isEmpty()) {
      return Observable.just(new SearchViewState.SearchNotStartedYet());
    }

    // 搜尋商品
    return searchEngine.searchFor(searchString) // Observable<List<Product>>
        .map(products -> {
          if (products.isEmpty()) {
            return new SearchViewState.EmptyResult(searchString);
          } else {
            return new SearchViewState.SearchResult(searchString, products);
          }
        })
        .startWith(new SearchViewState.Loading())
        .onErrorReturn(error -> new SearchViewState.Error(searchString, error));
  }
}
複製程式碼

讓我們看一下SearchInteractor.search()的方法簽名:我們有一個字串型別的searchString作為輸入引數,和Observable 作為輸出。這已經暗示我們期望隨著時間的推移在這個可觀察的流上發射任意多個SearchViewState例項。startWith() 是在我們開始查詢(通過http請求)之前呼叫的。我們在startWith這裡發射SearchViewState.Loading 。目的是,當我們點選搜尋按鈕,會有一個進度條出現。

onErrorReturn() 捕獲所有的在執行搜尋的時候出現的異常,並且,發射一個SearchViewState.Error 。當我們訂閱這個Observable的時候,我們為什麼不只用onError的回撥?這是對RxJava一個共性的誤解:onError回撥意味著我們整個觀察流進入了一個不可恢復的狀態,也就是整個觀察流已經被終止了。但是,在我們這裡的錯誤,像無網路之類的,不是不可恢復的錯誤。這僅僅是另一種狀態(被Model代表)。此外,之後,我們可以移動到其他狀態。例如,一旦我們的網路重新連線起來,那麼我們可以移動到被SearchViewState.Loading 代表的“載入狀態”。因此,我們建立了一個從我們的業務邏輯到View的觀察流,每次發射一個改變後的Model,我們的"狀態"也會隨著改變。我們肯定不希望我們的觀察流因為網路錯誤而終止。因此,這類錯誤被處理為一種被Model代表的狀態(除去那些致命錯誤)。通常情況下,在MVI中可觀察物件Model不會被終止(永遠不會執行onComplete()或onError())。

對上面部分做個總結:SearchInteractor(業務邏輯)提供了一個觀察流Observable ,並且當每次狀態變化的時候,發射一個新的SearchViewState。

下一步,讓我討論View層長什麼樣子的。View層應該做什麼?顯然的,view應該去顯示Model。我們已經同意,View應當有一個像render(model) 這樣的方法。另外,View需要提供一個方法給其他層用來接收使用者輸入的事件。這些事件在MVI中被稱作 intents 。在這個例子中,我們僅僅只有一個intent:使用者可以通過在輸入區輸入字串來搜尋。在MVP中一個好的做法是我們可以為View定義介面,所以,在MVI中,我們也可以這樣做。

public interface SearchView {

  /**
   * The search intent
   *
   * @return An observable emitting the search query text
   */
  Observable<String> searchIntent();

  /**
   * Renders the View
   *
   * @param viewState The current viewState state that should be displayed
   */
  void render(SearchViewState viewState);
}
複製程式碼

在這種情況下,我們的View僅僅提供一個intent,但是,在其他業務情況下,可能需要多個intent。在第一部分我們討論了為什麼單個render()方法(譯者:渲染方法)是一個好的方式,如果,你不清楚為什麼我們需要單個render(),你可以先去閱讀第一部分。在我們具體實現View層之前,我們先看一下最後搜尋頁面是什麼樣的

[譯]用MVI編寫響應式APP第二部分View和Intent

public class SearchFragment extends Fragment implements SearchView {

  @BindView(R.id.searchView) android.widget.SearchView searchView;
  @BindView(R.id.container) ViewGroup container;
  @BindView(R.id.loadingView) View loadingView;
  @BindView(R.id.errorView) TextView errorView;
  @BindView(R.id.recyclerView) RecyclerView recyclerView;
  @BindView(R.id.emptyView) View emptyView;
  private SearchAdapter adapter;

  @Override public Observable<String> searchIntent() {
    return RxSearchView.queryTextChanges(searchView) // Thanks Jake Wharton :)
        .filter(queryString -> queryString.length() > 3 || queryString.length() == 0)
        .debounce(500, TimeUnit.MILLISECONDS);
  }

  @Override public void render(SearchViewState viewState) {
    if (viewState instanceof SearchViewState.SearchNotStartedYet) {
      renderSearchNotStarted();
    } else if (viewState instanceof SearchViewState.Loading) {
      renderLoading();
    } else if (viewState instanceof SearchViewState.SearchResult) {
      renderResult(((SearchViewState.SearchResult) viewState).getResult());
    } else if (viewState instanceof SearchViewState.EmptyResult) {
      renderEmptyResult();
    } else if (viewState instanceof SearchViewState.Error) {
      renderError();
    } else {
      throw new IllegalArgumentException("Don't know how to render viewState " + viewState);
    }
  }

  private void renderResult(List<Product> result) {
    TransitionManager.beginDelayedTransition(container);
    recyclerView.setVisibility(View.VISIBLE);
    loadingView.setVisibility(View.GONE);
    emptyView.setVisibility(View.GONE);
    errorView.setVisibility(View.GONE);
    adapter.setProducts(result);
    adapter.notifyDataSetChanged();
  }

  private void renderSearchNotStarted() {
    TransitionManager.beginDelayedTransition(container);
    recyclerView.setVisibility(View.GONE);
    loadingView.setVisibility(View.GONE);
    errorView.setVisibility(View.GONE);
    emptyView.setVisibility(View.GONE);
  }

  private void renderLoading() {
    TransitionManager.beginDelayedTransition(container);
    recyclerView.setVisibility(View.GONE);
    loadingView.setVisibility(View.VISIBLE);
    errorView.setVisibility(View.GONE);
    emptyView.setVisibility(View.GONE);
  }

  private void renderError() {
    TransitionManager.beginDelayedTransition(container);
    recyclerView.setVisibility(View.GONE);
    loadingView.setVisibility(View.GONE);
    errorView.setVisibility(View.VISIBLE);
    emptyView.setVisibility(View.GONE);
  }

  private void renderEmptyResult() {
    TransitionManager.beginDelayedTransition(container);
    recyclerView.setVisibility(View.GONE);
    loadingView.setVisibility(View.GONE);
    errorView.setVisibility(View.GONE);
    emptyView.setVisibility(View.VISIBLE);
  }
}
複製程式碼

render(SearchViewState) 這個方法,我們通過看,就知道它是幹什麼的。在 searchIntent() 方法中我們用到了Jake Wharton’s的RxBindings 庫,它使RxJava像繫結可觀察物件一樣繫結安卓UI控制元件。 RxSearchView.queryText()建立一個 Observable物件,每當使用者在EditText輸入的一些字元,發射需要搜尋的字串。我們用filter()去保證只有當使用者輸入的字元數超過三個的時候,才開始搜尋。並且,我們不希望每當使用者輸入一個新字元的時候就請求網路,而是當使用者輸入完成以後再去請求網路(debounce()停留500毫秒,決定使用者是否輸入完成)。

因此,我們知道對於這個頁面而言,輸入是searchIntent(),輸出是render()。我們如何從“輸入”到“輸出”?下面的視訊將這個過程視覺化了:

[譯]用MVI編寫響應式APP第二部分View和Intent
其餘的問題是誰或如何把我們的View的意圖(intent)和業務邏輯聯絡起來?如果你已經看過了上面的視訊,可以看到在中間有一個RxJava的操作符 flatMap() 。這暗示了我們需要呼叫額外的元件,但是,我們至今為止還沒有討論,它就是 Presenter 。Presenter將所有分離的不同點(譯者:這裡指Model,View,Intent這三個點)聯絡起來。它與MVP中的Presenter類似。

public class SearchPresenter extends MviBasePresenter<SearchView, SearchViewState> {
  private final SearchInteractor searchInteractor;

  @Override protected void bindIntents() {
    Observable<SearchViewState> search =
        intent(SearchView::searchIntent)
            .switchMap(searchInteractor::search) // 我在上面視訊中用flatMap()但是 switchMap() 在這裡更加適用
            .observeOn(AndroidSchedulers.mainThread());

    subscribeViewState(search, SearchView::render);
  }
}
複製程式碼

MviBasePresenter 是什麼?這個是我寫的一個庫叫 Mosby (Mosby3.0已經新增了MVI元件)。這篇部落格不是為介紹Mosby而寫的,但是,我想對MviBasePresenter做個簡短的介紹。介紹一下MviBasePresenter如何讓你方便使用的。這個庫裡面沒有什麼黑魔法。讓我們從lifecycle(生命週期)開始說:MviBasePresenter事實上沒有lifecyle(生命週期)。有一個 bindIntent() 方法將檢視的意圖(intent)與業務邏輯繫結。通常,你用flatMap()或switchMap 亦或concatMap(),將意圖(intent)傳遞給業務邏輯。這個方法的呼叫僅僅在View第一次被附加到Presenter。當View重新附加到Presenter時,將不會被呼叫(例如,當螢幕方向改變)。

這聽起來很奇怪,也許有人會說:“MviBasePresenter在螢幕方向變化的時候都能保持?如果是的話,Mosby是如何確保可觀察流的資料在記憶體中,而不被丟失?”,這是intent()subscribeViewState() 的就是用來回答這個問題的。intent() 在內部建立一個PublishSubject ,並將其用作你的業務邏輯的“門戶”。所以實際上這個PublishSubject訂閱了View的意圖(intent)可觀察物件( Observable)。呼叫intent(o1)實際上返回一個訂閱了o1的PublishSubject。

當方向改變的時候,Mosby從Presenter分離View,但是,僅僅只是暫時的取消訂閱內部的PublishSubject。並且,當View重新連線到Presenter的時候,將PublishSubject重新訂閱View的意圖(intent)。

subscribeViewState() 用不同的方式做的是同樣的事情(Presenter到View的通訊)。它在內部建立一個BehaviorSubject 作為業務邏輯到View的“門戶”。既然是BahaviorSubject,我們可以從業務邏輯收到“模型更新”的資訊,即使是目前沒有view附加(例如,View正處於返回棧)。BehaviorSubjects總是保留最後時刻的值,每當有View附加到上面的時候,它就開始重新接收,或者將它保留的值傳遞給View。

規則很簡單:用intent()去“包裝”所有View的意圖(點選事件等)。用subscribeViewState()而不是Observable.subscribe(...)。

[譯]用MVI編寫響應式APP第二部分View和Intent

和bindIntent()對應的是unbindIntents() ,這兩個方法僅僅會被呼叫一次,當unbindIntents()呼叫的時候,那麼View就會被永久銷燬。舉個例子,將fragment處於返回棧,不去永久銷燬view,但是如果一個Activity結束了它的生命週期,就會永久銷燬view。由於intent()和subscribeViewState()已經負責訂閱管理,所以你幾乎不需要實現unbindIntents()。

那麼關於我們生命週期中的onPause()onResume() 是如何處理的?我認為Presenters是不需要關注生命週期 。如果,你非要在Presenter中處理生命週期,比如你將onPause()作為intent。你的View需要提供一個pauseIntent() 方法,這個方法是由生命週期觸發的,而不是使用者互動觸發的,但兩者都是有效的意圖。

總結

在第二部分,我們討論了關於Model-View-Intent的基礎,並且用MVI實現了一個簡單的搜尋頁面。讓我們入門。也許這個例子太簡單了。你無法看出MVI的優勢,Model代表狀態和單向資料流同樣適用於傳統的MVP或MVVM。MVP和MVVM都很優秀。MVI也許並沒有它們優秀。即使如此,我認為MVI幫助我們面對複雜問題的時候寫優雅的程式碼。我們將在這個系列部落格第三部分,討論狀態減少。

相關文章