[譯]使用MVI打造響應(yīng)式APP[七]:掌握時(shí)機(jī)(SingleLiveEvent問(wèn)題)

原文:REACTIVE APPS WITH MODEL-VIEW-INTENT - PART7 - TIMING (SINGLELIVEEVENT PROBLEM)
作者:Hannes Dorfmann
譯者:卻把清梅嗅

在之前的文章中,我們探討了正確狀態(tài)管理的重要性,以及我為什么認(rèn)為使用類(lèi)似 Github上Google架構(gòu)組件的這個(gè)repo 中的 SingleLiveEvent 并不是一個(gè)好主意——這種解決方案只是隱藏了真正的潛在問(wèn)題,那就是狀態(tài)管理。本文我將會(huì)闡述SingleLiveEvent聲稱(chēng)能解決的問(wèn)題,在Model-View-Intent中如何通過(guò)狀態(tài)管理正確地解決。

譯者注:關(guān)于SingleLiveEvent的這個(gè)issue 從17年討論到19年至今還未close,各方大佬(還有g(shù)oogle的巨佬)針對(duì)SingleLiveEvent進(jìn)行了激烈的討論,堪稱(chēng)Android論壇的一場(chǎng)神仙大戰(zhàn),非常值得一看。

當(dāng)error發(fā)生時(shí),Snackbar 將被展示—— 這是一個(gè)常見(jiàn)的場(chǎng)景可以用來(lái)描述這個(gè)問(wèn)題。Snackbar并非一直展示,當(dāng)錯(cuò)誤信息被展示幾秒鐘之后它將消失,問(wèn)題在于,我們?nèi)绾文M這種錯(cuò)誤狀態(tài)并控制Snackbar的消失呢?

通過(guò)下面的視頻,你就能了解我在說(shuō)什么:

image

這個(gè)示例顯示了如何從 CountriesRepository 中加載國(guó)家的列表,當(dāng)我們點(diǎn)擊某個(gè)國(guó)家的條目時(shí),程序?qū)?huì)跳轉(zhuǎn)到第二個(gè)界面去展示“詳情”(僅僅是國(guó)家的名稱(chēng))。當(dāng)我們返回到國(guó)家列表的界面時(shí),我們希望和之前點(diǎn)擊國(guó)家條目時(shí)的狀態(tài)一致。

目前為止一切正常,但是當(dāng)我們執(zhí)行下拉刷新操作時(shí),一個(gè)異常出現(xiàn)了,并通過(guò)在屏幕中展示一個(gè)Snackbar來(lái)展示錯(cuò)誤信息。正如您在視頻中看到的,當(dāng)我們?cè)俅螐膰?guó)家詳情返回國(guó)家列表時(shí),Snackbar和相關(guān)的錯(cuò)誤信息再次展示了出來(lái),這和用戶(hù)預(yù)期的并不一致,不是嗎?

問(wèn)題的根源是展示了錯(cuò)誤的狀態(tài)。Google基于ViewModelLiveData的架構(gòu)組件示例中使用了 SingleLiveEvent 來(lái)解決這個(gè)問(wèn)題。其解決思路是:當(dāng)View重新訂閱了其ViewModel(當(dāng)從“國(guó)家詳情”界面返回時(shí)),SingleLiveEvent確保錯(cuò)誤的狀態(tài)不會(huì)再次被發(fā)射,這確實(shí)預(yù)防了SnakeBar的再次顯示,但是這真的解決問(wèn)題了嗎?

時(shí)機(jī)就是一切(也適用于SnakeBar

重申,我依然認(rèn)為這種解決方案并不恰當(dāng),我們能做的更好嗎?我認(rèn)為合理的運(yùn)用 狀態(tài)管理單向的數(shù)據(jù)流 是更好的答案,而Model-View-Intent架構(gòu)模式遵循了這些規(guī)則。因此在MVI中我們?nèi)绾谓鉀QSnakeBar的問(wèn)題呢?首先,我們對(duì)Model狀態(tài)進(jìn)行定義:

public class CountriesViewState {

  // true意味著Progress將被展示
  boolean loading;

  // 被加載的國(guó)家列表
  List<String> countries;

  // true意味著`SwipeRefreshLayout`將被展示
  boolean pullToRefresh;

  // true意味著下拉刷新出現(xiàn)了error,SnakeBar將被展示
  boolean pullToRefreshError;
}

MVI的思想是View層同時(shí)只會(huì)展示一個(gè)不可變的CountriesViewState,因此當(dāng)pullToRefreshErrortrue時(shí)SnakeBar將被展示,反之則會(huì)消失。

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 {
      // 展示國(guó)家列表
      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();
  }
}

關(guān)鍵點(diǎn)在于 Snackbar.LENGTH_INDEFINITE,這意味著Snackbar將會(huì)一直顯示直到我們主動(dòng)控制其消失——我們不需要讓Android系統(tǒng)控制它顯示與否。

我們不會(huì)讓Android系統(tǒng)將狀態(tài)搞亂,也不會(huì)讓系統(tǒng)為UI引入一個(gè)與業(yè)務(wù)邏輯狀態(tài)不一致的狀態(tài)。我們寧愿讓業(yè)務(wù)邏輯將CountriesViewState.pullToRefreshError設(shè)置為true兩秒鐘,然后將其設(shè)置為false,而不愿使用Snackbar.LENGTH_SHORT來(lái)顯示Snackbar兩秒鐘。

RxJava中我們?cè)趺醋瞿??我們可以使?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) {
                // 展示snakebar2秒中,然后dismiss它
                return Observable.timer(2, TimeUnit.SECONDS)
                    .map(ignoredTime -> new ShowCountries()) // 僅僅展示列表
                    .startWith(repoState); // repoState == PullToRefreshError
              } else {
                return Observable.just(repoState);
              }
            }));

    // 作為初始狀態(tài),展示加載中
    CountriesViewState initialState = CountriesViewState.showLoadingState();

    Observable<CountriesViewState> viewState = Observable.merge(loadingData, pullToRefreshData)
        .scan(initialState, (oldState, repoState) -> repoState.reduce(oldState))

    subscribeViewState(viewState, CountriesView::render);
  }

CountriesRepositroyreload() 方法返回了一個(gè) Observable<RepoState>。RepoState(前文中叫做PattialViewState) 僅僅是個(gè)POJO類(lèi),用來(lái)表示repository是否取到數(shù)據(jù),是成功的取到數(shù)據(jù),或者產(chǎn)生了錯(cuò)誤(點(diǎn)擊查看源碼)。

這之后,我們使用狀態(tài)折疊器去計(jì)算View的狀態(tài)(通過(guò)scan()操作符),如果您已閱讀我之前的MVI系列文章,那么這應(yīng)該此曾相識(shí), “新”的東西是:

repositroy.reload().switchMap(repoState -> {
  if (repoState instanceof PullToRefreshError) {
    // 展示snakebar2秒中,然后dismiss它
    return Observable.timer(2, TimeUnit.SECONDS)
        .map(ignoredTime -> new ShowCountries()) // 僅僅展示列表
        .startWith(repoState); // repoState == PullToRefreshError
  } else {
    return Observable.just(repoState);
  }

這段代碼執(zhí)行以下操作:如果我們得到了一個(gè)error(repoState instanceof PullToRefreshError),我們會(huì)發(fā)射一個(gè)錯(cuò)誤的狀態(tài)(PullToRefreshError),這使得狀態(tài)折疊器設(shè)置 CountriesViewState.pullToRefreshError = true。2秒后,Observable.timer()將發(fā)射ShowCountries狀態(tài),狀態(tài)折疊器會(huì)設(shè)置 CountriesViewState.pullToRefreshError = false

OK, 現(xiàn)在你可以看到MVI中我們?nèi)绾物@示和隱藏SnakeBar

image

請(qǐng)記住這并非像是SingleLiveEvent這樣的解決方案。這是正確的狀態(tài)管理,View只是顯示或“渲染”給定的狀態(tài)。因此用戶(hù)如果再次從“國(guó)家詳情”中返回,Snackbar不再會(huì)被展示,因?yàn)闋顟B(tài)在 CountriesViewState.pullToRefreshError = false 時(shí)已經(jīng)發(fā)生了改變。

用戶(hù)消除Snakebar

如果我們希望用戶(hù)能夠通過(guò)滑動(dòng)操作主動(dòng)消除Snakebar呢。這非常簡(jiǎn)單,消除Snakebar 本身也是改變狀態(tài)的一種intent,要將它添加到目前的代碼中,我們只需要確保定時(shí)器或者滑動(dòng)的意圖能夠設(shè)置CountriesViewState.pullToRefreshError = false

我們唯一需要處理的是,如果在計(jì)時(shí)結(jié)束之前出發(fā)了滑動(dòng)解除的intent,我們必須結(jié)束定時(shí)器的計(jì)時(shí)行為,這聽(tīng)起來(lái)很復(fù)雜,但得益于RxJava的優(yōu)秀api和操作符,這輕而易舉:

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

...

repositroy.reload().switchMap(repoState -> {
  if (repoState instanceof PullToRefreshError) {
    // 展示Snakebar,并在2秒后dismiss它
    return Observable.timer(2, TimeUnit.SECONDS)
        .mergeWith(dismissPullToRefreshErrorIntent) // 合并計(jì)時(shí)器和滑動(dòng)dismiss的intent
        .take(1) // 二者只會(huì)觸發(fā)其中一個(gè)
        .map(ignoredTime -> new ShowCountries()) // 展示列表
        .startWith(repoState); // repoState == PullToRefreshError
  } else {
    return Observable.just(repoState);
  }
image

使用mergeWith(),我們將計(jì)時(shí)器和滑動(dòng)消失的intent組合成一個(gè)observable,然后使用take(1)僅將它們中的第一個(gè)事件進(jìn)行發(fā)射。如果在計(jì)時(shí)器計(jì)時(shí)結(jié)束之前滑動(dòng)Snakebar,則取消計(jì)時(shí)器,反之則取消滑動(dòng)消失的intent

結(jié)語(yǔ)

現(xiàn)在讓我們來(lái)嘗試將UI搞亂,我們嘗試下拉刷新、并在計(jì)時(shí)過(guò)程中手動(dòng)取消Snakebar

image

如你所見(jiàn),無(wú)論我們?nèi)绾螄L試,都沒(méi)有問(wèn)題發(fā)生,由于 單向數(shù)據(jù)流業(yè)務(wù)邏輯驅(qū)動(dòng)的狀態(tài),View可以正確顯示UI小部件(View層是無(wú)狀態(tài)的,它從底層獲取狀態(tài)并只能對(duì)它進(jìn)行展示)。比如,我們從未看到下拉刷新指示器和Snakebar同時(shí)顯示(除了Snackbar退出過(guò)程中,兩者的疊加情況)。

當(dāng)然,Snackbar這個(gè)示例非常簡(jiǎn)單,但我認(rèn)為它證明了像Model-View-Intent這樣 嚴(yán)格規(guī)范下對(duì)狀態(tài)進(jìn)行管理 的架構(gòu)模式的強(qiáng)大。不難想象這種模式對(duì)于更復(fù)雜的屏幕和使用場(chǎng)景同樣也會(huì)非常棒。

本文的示例源碼你可以從這里獲取。


系列目錄

《使用MVI打造響應(yīng)式APP》原文

《使用MVI打造響應(yīng)式APP》譯文

《使用MVI打造響應(yīng)式APP》實(shí)戰(zhàn)


關(guān)于我

Hello,我是卻把清梅嗅,如果您覺(jué)得文章對(duì)您有價(jià)值,歡迎 ??,也歡迎關(guān)注我的博客或者Github

如果您覺(jué)得文章還差了那么點(diǎn)東西,也請(qǐng)通過(guò)關(guān)注督促我寫(xiě)出更好的文章——萬(wàn)一哪天我進(jìn)步了呢?

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀(guān)點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容