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

pcdack發表於2018-04-04

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

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

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

讓我們看下下面的的視訊,這樣可以讓你們更好的理解,我在說什麼:

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

這個簡單的 app 顯示了一個國家的列表,這些國家的資料是通過 CountriesRepository 載入的。如果,我們點選一個國家,我們開啟了第二個 Activity ,這個 Activity 會顯示一些「細節」(國家的名字)。當我們返回到國家列表,我們期待看到與點選前相同「狀態」顯示到螢幕上。到目前為止一切都很正常,但是如果,我觸發下拉重新整理時,在資料載入的時候出現了錯誤,這個錯誤會讓 Snackbar 顯示在螢幕上,用來提示錯誤資訊,會發生什麼? 正如你在上面視訊中看到的那樣,無論何時我們回到國家列表,這個 SnackBar 都會再次顯示。但是,這肯定不是使用者所期待的,對吧?

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

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

再次強調一下,我仍然認為這種解決方法是不正確的方法。我們可以做的更好麼?我認為正確狀態管理和單向的資料流是更好的解決方法。Model-View-Intent 是一個架構元件並且遵循一定的原則。因此,我們在 MVI 中,如何解決上面的「Snackbar 問題」,首先,讓我們定義 state:

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。

我們如何使用 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 前面的文章,那麼你應當很熟悉狀態摺疊器。新的東西是:

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。

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

請注意,這和 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);
  }
複製程式碼

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

通過使用 mergeWith() 操作符,我們可以將 timer 和撤銷意圖聯合起來到一個可觀察物件,然後,第一個發射 take(1) 。如果輕掃撤銷觸發在 timer 之前,那麼,take(1) 取消 timer,反之亦然:如果 timer 先觸發,則不要觸發退出意圖。.

總結

因此,讓我們嘗試能不能把 UI 搞亂吧。讓我們做下拉重新整理的動作,退出 Snackbar 並且,讓 timer 計時:

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

正如你在視訊上所看到的,無論我多努力的嘗試,view 都能夠在 UI 上正確顯示,因為,單項資料流和業務邏輯驅使狀態(view 層是無狀態的,view 是從底層得到狀態的,並且,僅僅起到顯示作用)。例如:我們從來我們從來沒有見過載入指示器和 Snackbar 同時顯示(除去 Snackbar 退出過程中,一個小的疊加情況)。

當然,Snackbar 例子十分簡單,但是,我認為它向我們展示了能夠嚴格進行狀態管理像 Model-View-Intent 這類模式的力量。不難想象,這種模式用在複雜的頁面和使用者需求上也會很棒。

dome app 的原始碼已經在 Github 上了。

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

這是中文翻譯:


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

相關文章