[译]使用MVI打造响应式APP(二):View层和Intent层

原文: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的定义进行描述:

[站外图片上传中...(image-fc6580-1552228859520)]

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的效果如下所示:

这个项目的源码你可以在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层进行具体的实现之前,我们先看看最终界面的展示效果:

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()方法则是 输出。我们如何从 输入 获得 输出 呢,如下所示:

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().

[站外图片上传中...(image-1db2b2-1552228859520)]

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

如果您觉得文章还差了那么点东西,也请通过关注督促我写出更好的文章——万一哪天我进步了呢?

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容