[译] 使用MVI构建响应式 APP — 第七部分 — TIMING (SINGLELIVEEVENT 问题)

7,958 阅读7分钟

使用MVI构建响应式 APP — 第七部分 — TIMING (SINGLELIVEEVENT 问题)

在我前面系列博客中, 我们讨论了正确的状态管理的重要性,并且也阐述了为什么我认为一个像在谷歌架构组件的 github 中讨论的 SingleLiveEvent 不是一个好的主意。因为,它仅仅隐藏了真正底部的问题:状态管理。在这篇博客中,我想去讨论,SingleLiveEvent 声称能解决的问题,使用 Model-View-Intent 和正确的状态管理是如何解决的。

这个问题可以用一个常见的场景来举例说明:当一个错误发生的时候弹出一个snackbar。SnackBar 不会一直保持在一个位置,一两秒后它就会消失。这个问题是我们如何用 model 来控制错误状态和让其消失?

让我们看下下面的的视频,这样可以让你们更好的理解,我在说什么:

这个简单的 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。

请注意,这和 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前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏