[译]用MVI编写响应式APP第二部分View和Intent

在第一部分我们讨论了关于什么才是真正的Model,Model和状态的关系,并且讨论了什么样的Model才能避免安卓开发过程中的共性问题。在这篇我们通过讲Model-View-Intent模式去构建响应式安卓程序,继续我们的“响应式APP开发”探索之旅。

如果你没有阅读第一部分,你应该先读那篇然后再读这篇。我在这里先简单的回顾一下上一部分的主要内容:我们不要写类似于下面的代码(传统的MVP的例子)

class PersonsPresenter extends Presenter<PersonsView> {

  public void load(){
    getView().showLoading(true); // Displays a ProgressBar on the screen

    backend.loadPersons(new Callback(){
      public void onSuccess(List<Person> persons){
        getView().showPersons(persons); // Displays a list of Persons on the screen
      }

      public void onError(Throwable error){
        getView().showError(error); // Displays a error message on the screen
      }
    });
  }
}

我们应该创建一个反应"状态(State)"的"Model":

class PersonsModel {
  // 在正式的项目里应当为私有
  // 我们需要用get方法来获取它们的值
  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) ); //显示加载进度条

    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有一个Model,通过调用render(personsModel) 方法,将数据渲染到UI上。在上一篇文章里我们也讨论了单向数据流的重要性,并且你的业务逻辑应当驱动你的Model。在我们把所有的内容连起来之前,我们先快速的了解一下MVI的大意。

Model-View-Intent(MVI)

这个模式被 André Medeiros (Staltz) 为了他写的一个JavaScript的框架而提出的,这个框架的名字叫做 cycle.js 。从理论上(数学上)来看,我们可以用下面的表达式来描述Model-View-Intent:

  • intent() :这个函数接受用户的输入(例如,UI事件,像点击事件之类的)并把它转化成model函数的可接收的参数。这个参数可能是一个简单的String,也可能是其他复杂的结构的数据,像Object。我们可以说我们通过intent()的意图去改变Model。
  • model() :model()函数接收intent()函数的输出作为输入,去操作Model。它的输出是一个新的Model(因为状态改变)。因此我们不应该去更新已经存在的Model。因为我们需要Model具有不变性! 在第一部分,我具体用”计数APP“作为简单的例子讲了数据不变性的重要性。再次强调,我们不要去修改已经存在的Model实例。我们在model()方法里创建新的,根据intent的输出变化以后的Model。请注意,model()方法是你唯一能够创建新的Model对象的地方。基本上,我们称model()方法为我们App的业务逻辑(可以是Interactor,Usecase,Repository ...您在应用中使用的任何模式/术语)并且传递新的Model对象作为结果。
  • view() :这个方法接收model()方法的输出值。然后根据model()的输出值来渲染到UI上。view()方法大致上类似于view.render(model)

但是,我们不是去构建一个”响应式的APP“,不是么?所以,MVI是如何做到"响应式"的?"响应式"到底意味着什么?先回答最后一个问题,”响应式“就是我们的app根据状态不同而去改变UI。在MVI中,”状态“被"Model"所代表,实质上我们期望,我们的业务逻辑根据用户的输入事件(intent)产生新的"Model",然后再将新的"Model"通过调用view的render(Model)方法改变在UI。这就是MVI实现响应式的基本思路。

使用RxJava来连接不同的点(这里的点是指☞Model,View,Intent原本是相互独立的点)

我们想要让我们的数据流是单向的。RxJava在这里起到了作用。我们必须使用RxJava构建单向数据流的响应式App或MVI模式的App么?不是的,我们可以用其他的代码实现。然而,RxJava对于事件基础的编程是很好用的。既然用户界面是基于事件的,使用RxJava也就很有意义的。

在这个系列博客,我们将要开发一个简单的电商应用。我们在后台进行http请求,去加载我们需要显示商品。我们可以搜索商品和添加商品到购物车。综上所述整个App看起来想下面这个动图:

这个项目的源代码你可以在 github 上找到。我们先去实现一个简单的页面:实现搜索页面。首先,我们先定义一个最终将被View显示的Model。在这个系列博客我们采用"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 类型的。当然这种定义方法是我个人的偏好。我们也可以通过不同的方式定义,例如:

class SearchViewState {
  Throwable error; // if not null, an error has occurred
  boolean loading; // if true loading data is in progress
  List<Product> result; // if not null this is the result of the search
  boolean SearchNotStartedYet; // if true, we have the search not started yet
}

再次强调,你可以按照你的方式来定义你的Model。如果,你会使用kotlin语言的话,那么sealed classes是一个很好的选择。

下一步,让我将聚焦点重新回到业务逻辑。让我们看一下负责执行搜索的 SearchInteractor 如何去实现。先前已经说过了它的"输出"应该是一个 SearchViewState 对象。

public class SearchInteractor {
  final SearchEngine searchEngine; // 进行http请求

  public Observable<SearchViewState> search(String searchString) {
    // 空的字符串,所以没搜索
    if (searchString.isEmpty()) {
      return Observable.just(new SearchViewState.SearchNotStartedYet());
    }

    // 搜索商品
    return searchEngine.searchFor(searchString) // Observable<List<Product>>
        .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()的方法签名:我们有一个字符串类型的searchString作为输入参数,和Observable<SearchViewState> 作为输出。这已经暗示我们期望随着时间的推移在这个可观察的流上发射任意多个SearchViewState实例。startWith() 是在我们开始查询(通过http请求)之前调用的。我们在startWith这里发射SearchViewState.Loading 。目的是,当我们点击搜索按钮,会有一个进度条出现。

onErrorReturn() 捕获所有的在执行搜索的时候出现的异常,并且,发射一个SearchViewState.Error 。当我们订阅这个Observable的时候,我们为什么不只用onError的回调?这是对RxJava一个共性的误解:onError回调意味着我们整个观察流进入了一个不可恢复的状态,也就是整个观察流已经被终止了。但是,在我们这里的错误,像无网络之类的,不是不可恢复的错误。这仅仅是另一种状态(被Model代表)。此外,之后,我们可以移动到其他状态。例如,一旦我们的网络重新连接起来,那么我们可以移动到被SearchViewState.Loading 代表的“加载状态”。因此,我们建立了一个从我们的业务逻辑到View的观察流,每次发射一个改变后的Model,我们的"状态"也会随着改变。我们肯定不希望我们的观察流因为网络错误而终止。因此,这类错误被处理为一种被Model代表的状态(除去那些致命错误)。通常情况下,在MVI中可观察对象Model不会被终止(永远不会执行onComplete()或onError())。

对上面部分做个总结:SearchInteractor(业务逻辑)提供了一个观察流Observable<SearchViewState> ,并且当每次状态变化的时候,发射一个新的SearchViewState。

下一步,让我讨论View层长什么样子的。View层应该做什么?显然的,view应该去显示Model。我们已经同意,View应当有一个像render(model) 这样的方法。另外,View需要提供一个方法给其他层用来接收用户输入的事件。这些事件在MVI中被称作 intents 。在这个例子中,我们仅仅只有一个intent:用户可以通过在输入区输入字符串来搜索。在MVP中一个好的做法是我们可以为View定义接口,所以,在MVI中,我们也可以这样做。

public interface SearchView {

  /**
   * The search intent
   *
   * @return An observable emitting the search query text
   */
  Observable<String> searchIntent();

  /**
   * Renders the View
   *
   * @param viewState The current viewState state that should be displayed
   */
  void render(SearchViewState viewState);
}

在这种情况下,我们的View仅仅提供一个intent,但是,在其他业务情况下,可能需要多个intent。在第一部分我们讨论了为什么单个render()方法(译者:渲染方法)是一个好的方式,如果,你不清楚为什么我们需要单个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) // Thanks 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 Wharton’s的RxBindings 库,它使RxJava像绑定可观察对象一样绑定安卓UI控件。 RxSearchView.queryText()创建一个 Observable<String>对象,每当用户在EditText输入的一些字符,发射需要搜索的字符串。我们用filter()去保证只有当用户输入的字符数超过三个的时候,才开始搜索。并且,我们不希望每当用户输入一个新字符的时候就请求网络,而是当用户输入完成以后再去请求网络(debounce()停留500毫秒,决定用户是否输入完成)。

因此,我们知道对于这个页面而言,输入是searchIntent(),输出是render()。我们如何从“输入”到“输出”?下面的视频将这个过程可视化了:

其余的问题是谁或如何把我们的View的意图(intent)和业务逻辑联系起来?如果你已经看过了上面的视频,可以看到在中间有一个RxJava的操作符 flatMap() 。这暗示了我们需要调用额外的组件,但是,我们至今为止还没有讨论,它就是 Presenter 。Presenter将所有分离的不同点(译者:这里指Model,View,Intent这三个点)联系起来。它与MVP中的Presenter类似。

public class SearchPresenter extends MviBasePresenter<SearchView, SearchViewState> {
  private final SearchInteractor searchInteractor;

  @Override protected void bindIntents() {
    Observable<SearchViewState> search =
        intent(SearchView::searchIntent)
            .switchMap(searchInteractor::search) // 我在上面视频中用flatMap()但是 switchMap() 在这里更加适用
            .observeOn(AndroidSchedulers.mainThread());

    subscribeViewState(search, SearchView::render);
  }
}

MviBasePresenter 是什么?这个是我写的一个库叫 Mosby (Mosby3.0已经添加了MVI组件)。这篇博客不是为介绍Mosby而写的,但是,我想对MviBasePresenter做个简短的介绍。介绍一下MviBasePresenter如何让你方便使用的。这个库里面没有什么黑魔法。让我们从lifecycle(生命周期)开始说:MviBasePresenter事实上没有lifecyle(生命周期)。有一个 bindIntent() 方法将视图的意图(intent)与业务逻辑绑定。通常,你用flatMap()或switchMap 亦或concatMap(),将意图(intent)传递给业务逻辑。这个方法的调用仅仅在View第一次被附加到Presenter。当View重新附加到Presenter时,将不会被调用(例如,当屏幕方向改变)。

这听起来很奇怪,也许有人会说:“MviBasePresenter在屏幕方向变化的时候都能保持?如果是的话,Mosby是如何确保可观察流的数据在内存中,而不被丢失?”,这是intent()subscribeViewState() 的就是用来回答这个问题的。intent() 在内部创建一个PublishSubject ,并将其用作你的业务逻辑的“门户”。所以实际上这个PublishSubject订阅了View的意图(intent)可观察对象( Observable)。调用intent(o1)实际上返回一个订阅了o1的PublishSubject。

当方向改变的时候,Mosby从Presenter分离View,但是,仅仅只是暂时的取消订阅内部的PublishSubject。并且,当View重新连接到Presenter的时候,将PublishSubject重新订阅View的意图(intent)。

subscribeViewState() 用不同的方式做的是同样的事情(Presenter到View的通信)。它在内部创建一个BehaviorSubject 作为业务逻辑到View的“门户”。既然是BahaviorSubject,我们可以从业务逻辑收到“模型更新”的信息,即使是目前没有view附加(例如,View正处于返回栈)。BehaviorSubjects总是保留最后时刻的值,每当有View附加到上面的时候,它就开始重新接收,或者将它保留的值传递给View。

规则很简单:用intent()去“包装”所有View的意图(点击事件等)。用subscribeViewState()而不是Observable.subscribe(...)。

MviBasePresenter.png

和bindIntent()对应的是unbindIntents() ,这两个方法仅仅会被调用一次,当unbindIntents()调用的时候,那么View就会被永久销毁。举个例子,将fragment处于返回栈,不去永久销毁view,但是如果一个Activity结束了它的生命周期,就会永久销毁view。由于intent()和subscribeViewState()已经负责订阅管理,所以你几乎不需要实现unbindIntents()。

那么关于我们生命周期中的onPause()onResume() 是如何处理的?我认为Presenters是不需要关注生命周期 。如果,你非要在Presenter中处理生命周期,比如你将onPause()作为intent。你的View需要提供一个pauseIntent() 方法,这个方法是由生命周期触发的,而不是用户交互触发的,但两者都是有效的意图。

总结

在第二部分,我们讨论了关于Model-View-Intent的基础,并且用MVI实现了一个简单的搜索页面。让我们入门。也许这个例子太简单了。你无法看出MVI的优势,Model代表状态和单向数据流同样适用于传统的MVP或MVVM。MVP和MVVM都很优秀。MVI也许并没有它们优秀。即使如此,我认为MVI帮助我们面对复杂问题的时候写优雅的代码。我们将在这个系列博客第三部分,讨论状态减少。

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

推荐阅读更多精彩内容