[譯] 使用MVI構建響應式 APP — 第七部分 — TIMING (SINGLELIVEEVENT 問題)

使用MVI構建響應式 APP — 第七部分 — TIMING (SINGLELIVEEVENT 問題)

在我前面系列博客中, 咱們討論了正確的狀態管理的重要性,而且也闡述了爲何我認爲一個像在谷歌架構組件的 github 中討論的 SingleLiveEvent 不是一個好的主意。由於,它僅僅隱藏了真正底部的問題:狀態管理。在這篇博客中,我想去討論,SingleLiveEvent 聲稱能解決的問題,使用 Model-View-Intent 和正確的狀態管理是如何解決的。前端

這個問題能夠用一個常見的場景來舉例說明:當一個錯誤發生的時候彈出一個snackbar。SnackBar 不會一直保持在一個位置,一兩秒後它就會消失。這個問題是咱們如何用 model 來控制錯誤狀態和讓其消失?java

讓咱們看下下面的的視頻,這樣可讓大家更好的理解,我在說什麼:android

這個簡單的 app 顯示了一個國家的列表,這些國家的數據是經過 CountriesRepository 加載的。若是,咱們點擊一個國家,咱們打開了第二個 Activity ,這個 Activity 會顯示一些「細節」(國家的名字)。當咱們返回到國家列表,咱們期待看到與點擊前相同「狀態」顯示到屏幕上。到目前爲止一切都很正常,可是若是,我觸發下拉刷新時,在數據加載的時候出現了錯誤,這個錯誤會讓 Snackbar 顯示在屏幕上,用來提示錯誤信息,會發生什麼? 正如你在上面視頻中看到的那樣,不管什麼時候咱們回到國家列表,這個 SnackBar 都會再次顯示。可是,這確定不是用戶所期待的,對吧?ios

這個問題發生在這個屏幕處在「顯示錯誤」的狀態。谷歌的架構組件的例子是基於 ViewModel 和 LiveData 用一個 SingleLiveEvent 去解決這個問題。使用的方法是:不管什麼時候 view 被它的 ViewModel 從新訂閱(在從「細節」頁面返回以後),SingleLiveEvent 確保「錯誤狀態」不會被從新觸發。這防止了 Snackbar 的復現,它真正解決問題了麼?git

時機就是一切(對於 Snackbar 來講)

再次強調一下,我仍然認爲這種解決方法是不正確的方法。咱們能夠作的更好麼?我認爲正確狀態管理和單向的數據流是更好的解決方法。Model-View-Intent 是一個架構組件而且遵循必定的原則。所以,咱們在 MVI 中,如何解決上面的「Snackbar 問題」,首先,讓咱們定義 state:github

public class CountriesViewState {

  // True if progressbar should be displayed
  boolean loading;

  // List of countries (country names)  if loaded
  List<String> countries;

  // true if pull to refresh indicator should be displayed
  boolean pullToRefresh;

  // true if an error has occurred while pull to refresh -> Show Snackbar.
  boolean pullToRefreshError;
}
複製代碼

在 MVI 中的解決思路是 View 層獲得一個(不變的)CountriesViewState,而後,僅僅顯示這個狀態。所以,若是,pullToRefreshError 是 true,那麼顯示 Snackbar,其餘狀況不顯示。後端

public class CountriesActivity extends MviActivity<CountriesView, CountriesPresenter>
    implements CountriesView {

  private Snackbar snackbar;
  private ArrayAdapter<String> adapter;

  @BindView(R.id.refreshLayout) SwipeRefreshLayout refreshLayout;
  @BindView(R.id.listView) ListView listView;
  @BindView(R.id.progressBar) ProgressBar progressBar;

   ...

  @Override public void render(CountriesViewState viewState) {
    if (viewState.isLoading()) {
      progressBar.setVisibility(View.VISIBLE);
      refreshLayout.setVisibility(View.GONE);
    } else {
      // show countries
      progressBar.setVisibility(View.GONE);
      refreshLayout.setVisibility(View.VISIBLE);
      adapter.setCountries(viewState.getCountries());
      refreshLayout.setRefreshing(viewState.isPullToRefresh());

      if (viewState.isPullToRefreshError()) {
        showSnackbar();
      } else {
        dismissSnackbar();
      }
    }
  }

  private void dismissSnackbar() {
    if (snackbar != null)
      snackbar.dismiss();
  }

  private void showSnackbar() {
    snackbar = Snackbar.make(refreshLayout, "An Error has occurred", Snackbar.LENGTH_INDEFINITE);
    snackbar.show();
  }
}
複製代碼

這裏的重點是 Snackbar.Length_INDEFINITE 這就意味着 Snackbar 會一直存在,直到咱們 dismiss 它。所以,咱們不讓 android 系統來控制 SnackBar 的顯示和隱藏。此外,咱們不能讓 android 系統擾亂狀態,也不讓它引入一個不一樣於業務邏輯的 UI 狀態。取而代之,用 Snackbar.LENGTH_SHORT 來使 Snackbar 顯示兩秒,咱們寧願讓業務邏輯使 CountriesViewState.pullToRefreshError 設置爲 true 兩秒鐘,而後,將再它置爲 false。bash

咱們如何使用 RxJava 來作到這一點咧?咱們能夠用 Observable.timer()startWith() 操做符。架構

public class CountriesPresenter extends MviBasePresenter<CountriesView, CountriesViewState> {

  private final CountriesRepositroy repositroy = new CountriesRepositroy();

  @Override protected void bindIntents() {

    Observable<RepositoryState> loadingData =
        intent(CountriesView::loadCountriesIntent).switchMap(ignored -> repositroy.loadCountries());

    Observable<RepositoryState> pullToRefreshData =
        intent(CountriesView::pullToRefreshIntent).switchMap(
            ignored -> repositroy.reload().switchMap(repoState -> {
              if (repoState instanceof PullToRefreshError) {
                // Let's show Snackbar for 2 seconds and then dismiss it return Observable.timer(2, TimeUnit.SECONDS) .map(ignoredTime -> new ShowCountries()) // Show just the list .startWith(repoState); // repoState == PullToRefreshError } else { return Observable.just(repoState); } })); // 初始狀態顯示 Loading CountriesViewState initialState = CountriesViewState.showLoadingState(); Observable<CountriesViewState> viewState = Observable.merge(loadingData, pullToRefreshData) .scan(initialState, (oldState, repoState) -> repoState.reduce(oldState)) subscribeViewState(viewState, CountriesView::render); } } 複製代碼

CountriesRepositroy 有一個 reload() 方法,這個方法返回一個 Observable< RepoState>。RepoState(在這個系列的前面幾篇文章中叫作 PattialViewState) 僅僅是個 POJO 類,用來表示 repository 是否取到數據,是成功的取到數據,或者產生了錯誤(源碼)。而後,咱們使用狀態摺疊器去完成咱們 View 的狀態(scan() 操做符)。若是你讀過 MVI 前面的文章,那麼你應當很熟悉狀態摺疊器。新的東西是:app

repositroy.reload().switchMap(repoState -> {
  if (repoState instanceof PullToRefreshError) {
    //讓 Snackbar 顯示兩秒而後讓其消失
    return Observable.timer(2, TimeUnit.SECONDS)
        .map(ignoredTime -> new ShowCountries()) // Show just the list
        .startWith(repoState); // repoState == PullToRefreshError
  } else {
    return Observable.just(repoState);
  }
複製代碼

這一小段代碼作了下面這些事:若是咱們的程序跑錯了(repoState instanceof PullToRefreshError),而後,咱們觸發了這個錯誤的狀態(PullToRefreshError),這將形成狀態摺疊器去設置 CountriesViewState.pullToRefreshError =true。兩秒事後 Observable.timer() 觸發了 ShowCountries 狀態,這將形成狀態摺疊器設置CountriesViewState.pullToRefreshError = false

bingo~這就是咱們在 MVI 中如何顯示和隱藏 Snackbar。

請注意,這和 SingleLiveEvent 解決方法不同。這是一種正確的狀態管理,而且 view 僅僅顯示或「渲染」給定的狀態。所以,一旦咱們的 APP 從詳情頁返回到國家列表。他不再會看到 Snackbar 了,由於,狀態已經同時發生了改變,變成了CountriesViewState.pullToRefreshError = false 所以,Snackbar 不會再次顯示。

用戶撤銷 Snackbar

若是,咱們想要容許用戶經過輕掃手勢撤銷 Snackbar。這很是簡單。撤銷 Snackbar 也是一種改變狀態的意圖。要想在原有的代碼中添加這種功能,咱們僅僅須要確保,不管計時器或者輕掃滑動去撤銷CountriesViewState.pullToRefreshError = false 的意圖設置。你僅僅須要記住的惟一一件事情是,在你輕輕滑動以前,你的計時器已經被取消掉了。這聽起來很複雜,可是,實現起來很簡單,這要感謝 RxJava 偉大的操做符和 API:

Observable<Long> dismissPullToRefreshErrorIntent = intent(CountriesView::dismissPullToRefreshErrorIntent)

...

repositroy.reload().switchMap(repoState -> {
  if (repoState instanceof PullToRefreshError) {
    //讓 Snackbar 顯示兩秒而後讓其消失
    return Observable.timer(2, TimeUnit.SECONDS)
        .mergeWith(dismissPullToRefreshErrorIntent) // 合併定時器並解除意圖
        .take(1) // 僅僅取先觸發的那個(解除意圖或計時器)
        .map(ignoredTime -> new ShowCountries()) // Show just the list
        .startWith(repoState); // repoState == PullToRefreshError
  } else {
    return Observable.just(repoState);
  }
複製代碼

經過使用 mergeWith() 操做符,咱們能夠將 timer 和撤銷意圖聯合起來到一個可觀察對象,而後,第一個發射 take(1) 。若是輕掃撤銷觸發在 timer 以前,那麼,take(1) 取消 timer,反之亦然:若是 timer 先觸發,則不要觸發退出意圖。.

總結

所以,讓咱們嘗試能不能把 UI 搞亂吧。讓咱們作下拉刷新的動做,退出 Snackbar 而且,讓 timer 計時:

正如你在視頻上所看到的,不管我多努力的嘗試,view 都可以在 UI 上正確顯示,由於,單項數據流和業務邏輯驅使狀態(view 層是無狀態的,view 是從底層獲得狀態的,而且,僅僅起到顯示做用)。例如:咱們歷來咱們歷來沒有見過加載指示器和 Snackbar 同時顯示(除去 Snackbar 退出過程當中,一個小的疊加狀況)。

固然,Snackbar 例子十分簡單,可是,我認爲它向咱們展現了可以嚴格進行狀態管理像 Model-View-Intent 這類模式的力量。不難想象,這種模式用在複雜的頁面和用戶需求上也會很棒。

dome app 的源代碼已經在 Github 上了。

這篇博客是 "用 MVI 開發響應式App"中的一篇博客。下面是內容表:

這是中文翻譯:


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索