[譯]使用MVI打造響應式APP(二):View層和Intent層

卻把清梅嗅發表於2019-03-10

原文:REACTIVE APPS WITH MODEL-VIEW-INTENT - PART2 - VIEW AND INTENT
作者:Hannes Dorfmann
譯者:卻把清梅嗅

上文 中,我們探討了對Model的定義、與 狀態 的關係以及如何在通過良好地定義Model來解決一些Android開發中常見的問題。本文將通過 Model-View-Intent ,即MVI模式,繼續我們的 響應式App 構建之旅。

如果您尚未閱讀上一小節,則應在繼續閱讀本文之前閱讀該部分。總結一下:以“傳統的”MVP為例,請避免寫出這樣的程式碼:

class PersonsPresenter extends Presenter<PersonsView> {

  public void load(){
    getView().showLoading(true); // 展示一個 ProgressBar

    backend.loadPersons(new Callback(){
      public void onSuccess(List<Person> persons){
        getView().showPersons(persons); // 展示使用者列表
      }

      public void onError(Throwable error){
        getView().showError(error); // 展示錯誤資訊
      }
    });
  }
}
複製程式碼

我們應該建立能夠反映 狀態Model,像這樣:

class PersonsModel {
  // 在真實的專案中,需要定義為私有的
  // 並且我們需要通過getter和setter來訪問它們
  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) ); // 展示一個 ProgressBar

    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層的render(personsModel)方法,Model就會被成功的渲染在螢幕上。在第一小節中我們同樣探討了 單項資料流 的重要性,同時您的業務邏輯應該驅動該Model。在正式將所有內容環環相扣連線之前,我們先簡單瞭解一下MVI的核心思想。

Model-View-Intent (MVI)

該模式最初被 andrestaltz 在他寫的JavaScript框架 cycle.js 中所提出; 從理論(還有數學)上講,我們這樣對Model-View-Intent的定義進行描述:

[譯]使用MVI打造響應式APP(二):View層和Intent層

1.intent()

此函式接受來自使用者的輸入(即UI事件,比如點選事件)並將其轉換為可傳遞給Model()函式的引數,該引數可能是一個簡單的StringModel進行賦值,也可能像是Object這樣複雜的資料結構。intent作為意圖,標誌著 我們試圖對Model進行改變

2.model()

model()函式將intent()函式的輸出作為輸入來操作Model,其函式輸出是一個新的Model(狀態發生了改變)。

不要對已存在的Model物件進行修改,我們需要的是不可變!對此,在上文中我們已經展示了一個計數器的具體案例,再次重申,不要修改已存在的Model

根據intent所描述的變化,我們建立一個新的Model,請注意,Model()函式是唯一允許對Model進行建立的途徑。然後這個新的Model作為該函式的輸出——基本上model()函式呼叫我們App的業務邏輯(可以是互動、用例、Repository......您在App中使用的任何模式/術語)並作為結果提供新的Model物件。

3.view()

該方法獲取model()函式返回的Model,並將其作為view()函式的輸入,這之後通過某種方式將Model展示出來,view()view.render(model)大體上是一致的。

4.本質

但是我們希望構建的是 響應式的App,不是嗎?那麼MVI是如何響應式的呢?響應式實際上意味著什麼?

這意味著AppUI反映了狀態的變更

因為Model反映了狀態,因此,本質上我們希望 業務邏輯能夠對輸入的事件(即intents)進行響應,並建立對應的Model作為輸出,這之後再通過呼叫View層的render(model)方法,對UI進行渲染

5.通過RxJava串聯

我們希望我們的資料流的單向性,因此RxJava閃亮登場。我們的App必須通過RxJava保持 資料的單向性響應式 來構建嗎?或者必須用MVI模式才能構建嗎?當然不,我們也可以寫 命令式程式性 的程式碼。但是,基於事件程式設計RxJava實在太優秀了,既然UI是基於事件的,因此使用RxJava也是非常有意義的。

本文我們將會構建一個簡單的虛擬線上商店App,其UI介面中展示的商品資料,都來源於我們向後臺進行的網路請求。

我們可以精確的搜尋特定的商品,並將其新增到我們的購物車中,最終App的效果如下所示:

[譯]使用MVI打造響應式APP(二):View層和Intent層

這個專案的原始碼你可以在Github上找到,我們從實現一個簡單的搜尋介面開始做起:

首先,就像上文我們描述的那樣,我們定義一個Model用於描述View層是如何被展示的—— 這個系列中,我們將用帶有 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 型別的物件,它可能是SearchViewState.Error或者其它的一個例項。這只是我個人的偏好,們也可以通過不同的方式定義,例如:

class SearchViewState {
  Throwable error;  // 非空則意味著,出現了一個錯誤
  boolean loading;  // 值為true意味著正在載入中
  List<Product> result; // 非空意味著商品列表的結果
  boolean SearchNotStartedYet; // true意味著還未開始搜尋
}
複製程式碼

再次重申,如何定義Model純屬個人喜好,如果你用Kotlin作為程式語言,那麼sealed classes是一個不錯的選擇。

將目光聚集回到業務程式碼,讓我們通過 SearchInteractor 去執行搜尋的功能,其輸出就是我們之前說過的SearchViewState物件:

public class SearchInteractor {
  final SearchEngine searchEngine; // 執行網路請求

  public Observable<SearchViewState> search(String searchString) {
    // 如果是空的字串,不進行搜尋
    if (searchString.isEmpty()) {
      return Observable.just(new SearchViewState.SearchNotStartedYet());
    }

    // 搜尋商品列表
    // 返回 Observable<List<Product>>
    return searchEngine.searchFor(searchString)
        .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()的方法簽名:我們將String型別的searchString作為 輸入 的引數,以及Observable<SearchViewState>型別的 輸出,這意味著我們期望隨著時間的推移,可以在可觀察的流上會有任意多個SearchViewState的例項被髮射。

在我們正式開始查詢搜尋之前(即SearchEngine執行網路請求),我們通過startWith()操作符發射一個SearchViewState.Loading,這將會使得View在執行搜尋時展示ProgressBar

onErrorReturn()會捕獲在執行搜尋時丟擲的所有異常,並且發射出一個SearchViewState.Error——在訂閱這個Observable時,我們為什麼不去使用onError()回撥呢?

這是一個對RxJava認知的普遍誤解,實際上,onError()的回撥意味著 整個可觀察的流進入了不可恢復的狀態,因此可觀察的流結束了,而在我們的案例中,類似“沒有網路連線”的error並非不可恢復的error:這只是我們的Model所代表的另外一個狀態。

此外,我們還有另外一個可以轉換到的狀態,即一旦網路連線可用,我們可以通過 SearchViewState.Loading 跳轉到的 載入狀態

因此,我們建立了一個可觀察的流,這是一個每當狀態發生了改變,從業務邏輯層就會發射一個發生了改變的ModelView層的流。

我們不想在網路連線錯誤時終止這個可觀察的流,因此,在error發生時,類似這種可以被處理為 狀態error(而不是終止流的那種致命的錯誤),可以反應為Model,被可觀察的流發射。

通常,在MVI中,ModelObservable永遠不會被終止(即永遠不會執行onComplete()或者onError()回撥)。

總結一下,SearchInteractor(即業務邏輯)提供了一個可觀察的流Observable<SearchViewState>,每當狀態發生了變化,就會發射一個新的SearchViewState

6.View層的職責

接下來我們來討論一下View應該是什麼樣的,View層的職責是什麼?顯然View層應該對Model進行展示,我們已經認可View層應該有類似 render(model) 這樣的函式。此外,View應該提供一個給其他層響應使用者輸入的方法,在MVI中這個方法被稱為 intents

在這個案例中,我們只有一個intent:使用者可以在輸入框中輸入一個用於檢索商品的字串進行搜尋。MVP中的好習慣是為View層定義一個介面,所以在MVI中我們也可以這樣做。

public interface SearchView {

  // 搜尋的intent
  Observable<String> searchIntent();

  // 對View層進行渲染
  void render(SearchViewState viewState);
}
複製程式碼

我們的案例中View層只提供了一個intent,但通常View擁有更多的intent;在 第一小節 中我們討論了為什麼一個單獨的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) // 感謝 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 WhartonRxBinding ,這是一個對Android UI元件提供了RxJava響應式支援的庫。

RxSearchView.queryText()建立了一個Observable<String>,每當使用者在EditText上輸入了一些文字,它就會發射一個對應的字串;我們通過filter()去保證只有當使用者輸入的字元數達到三個以上時才進行搜尋;同時,我們不希望每當使用者輸入一個字元,就去請求網路,而是當使用者輸入結束後再去請求網路(debounce()操作符會停留500毫秒以決定使用者是否輸入完成)。

現在我們知道了螢幕中的searchIntent()方法就是 輸入 ,而render()方法則是 輸出。我們如何從 輸入 獲得 輸出 呢,如下所示:

[譯]使用MVI打造響應式APP(二):View層和Intent層

7.連線View和Intent

剩下的問題就是:我們如何將Viewintent和業務邏輯進行連線呢?如果你認真觀看了上面的流程圖,你應該注意到了中間的 flatMap() 操作符,這暗示了我們還有一個尚未談及的元件: Presenter ;Presenter負責連線這些點,就和我們在MVP中使用的方式一樣。

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

  @Override protected void bindIntents() {
    Observable<SearchViewState> search =
        intent(SearchView::searchIntent)
        // 上文中我們談到了flatMap,但在這裡switchMap更為適用
            .switchMap(searchInteractor::search)
            .observeOn(AndroidSchedulers.mainThread());

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

什麼是 MviBasePresenter, intent()subscribeViewState() 又是什麼?這個類是我寫的 Mosby 庫的一部分(3.0版本後,Mosby已經支援了MVI)。本文並非為了講述Mosby,但我向簡單介紹一下MviBasePresenter是如何的便利——這其中沒有什麼黑魔法,雖然確實看起來像是那樣。

讓我們從生命週期開始:MviBasePresenter並未持有任何生命週期,它暴露出一個 bindIntent() 方法以供View層和業務邏輯進行繫結。通常,你通過flatMap()switchMap()或者concatMap()操作符將intent “轉移”到業務邏輯中,這個方法僅僅在View層第一次被附加到Presenter中時呼叫,而當View再次被附加在Presenter中時(比如,螢幕方向發生了改變),將不再被呼叫。

這聽起來有些怪,也許有人會說:

MviBasePresenter在螢幕方向發生了改變後依然能夠存活?如果是這樣,Mosby如何保證Observable的流不會發生記憶體的洩漏?

這就是 intent()subscribeViewState() 的作用所在了,intent() 在內部建立一個PublishSubject,就像是業務邏輯的“閘道器”一樣;實際上,PublishSubject訂閱了View層傳過來的intentObservable,呼叫intent(o1)實際返回了一個訂閱了o1PublishSubject

螢幕發生旋轉時,MosbyViewPresenter中分離,但是,內部的PublishSubject只是暫時和View解除了訂閱;而當View重新附著在Presenter上時,PublishSubject將會對View層的intent進行重新訂閱。

subscribeViewState()方法做的是同樣的事情,只不過將順序調換了過來(PresenterView層的通訊)。它在內部建立一個BehaviorSubject作為從業務邏輯到View層的“閘道器”。

由於它是一個BehaviorSubject,因此,即使此時Presenter沒有持有View,我們依然可以從業務邏輯中接收到Model的更新(比如View並未處於棧頂);BehaviorSubjects始終持有它最後的值,並在View重新依附後將其重新發射。

規則很簡單:使用intent()來“包裝”View層的所有intent,使用subscribeViewState()替代Observable.subscribe().

[譯]使用MVI打造響應式APP(二):View層和Intent層

8.UnbindIntents

bindIntent()相對應的是 unbindIntents() ,該方法只會執行一次,即View被永久銷燬時才會被呼叫。舉個例子,將一個Fragment放在棧中,直到Activity被銷燬之前,該View一直不會被銷燬。

由於intent()subscribeViewState()已經對訂閱進行了管理,因此您只需要實現unbindIntents()

9.其它生命週期的事件

那麼其它生命週期的事件,比如onPause()onResume()又該如何處理?我依然認為Presenter不需要生命週期的事件,然而,如果你堅持認為你需要將這些生命週期的事件視為另一種形式的intent,您的View可以提供一個pauseIntent(),它是由android生命週期觸發,而又不是按鈕點選事件這樣的由使用者互動觸發的intent——但兩者都是有效的意圖。

結語

第二小節中,我們探討了Model-View-Intent的基礎,並通過MVI淺嘗輒止實現了一個簡單的頁面。也許這個例子太簡單了,所以你尚未感受到MVI模式的優點:代表 狀態Model和與傳統MVP或者MVVM相比的 單項資料流

MVPMVVM並沒有什麼問題,我也並非是在說MVI比其它架構模式更優秀,但是,我認為MVI可以幫助我們 為複雜的問題編寫優雅的程式碼 ,這也正如我們將在本系列部落格的 下一小節(第3小節)中探討的那樣——屆時我們將針對 狀態減少 (state reducers)的問題進行探討,歡迎關注。


系列目錄

《使用MVI打造響應式APP》原文

《使用MVI打造響應式APP》譯文

《使用MVI打造響應式APP》實戰


關於我

Hello,我是卻把清梅嗅,如果您覺得文章對您有價值,歡迎 ❤️,也歡迎關注我的部落格或者Github

如果您覺得文章還差了那麼點東西,也請通過關注督促我寫出更好的文章——萬一哪天我進步了呢?

相關文章