[译]使用MVI打造响应式APP(一):Model到底是什么

4,348 阅读16分钟

原文:《REACTIVE APPS WITH MODEL-VIEW-INTENT - PART1 - MODEL》
作者:Hannes Dorfmann
译者:却把清梅嗅

有朝一日,我突然发现我对于Model层的定义 全部是错误的,更新了认知后,我发现曾经我在Android平台上主题讨论中的那些困惑或者头痛都消失了。

从结果上来说,最终我选择使用 RxJavaModel-View-Intent(MVI) 构建 响应式的APP,这是我从未有过的尝试——尽管在这之前我开发的APP也是响应式的,但 响应式编程 的体现与这次实践相比,完全无法相提并论,在接下来我将要讲述的一系列文章中,你也会感受到这些。但作为系列文章的开始,我想先阐述一个观点:

所谓的Model层到底是什么,我之前对Model层的定义出现了什么问题?

我为什么说 我对Model层有着错误的理解和使用方式 呢?当然,现在有很多架构模式将View层和Model层进行了分离,至少在Android开发的领域,最著名的当属Model-View-Controller (MVC)Model-View-Presenter (MVP)Model-View-ViewModel (MVVM)——你注意到了吗?这些架构模式中,Model都是不可或缺的一环,但我意识到 在绝大数情况下,我根本没有Model

举例来说,一个简单的从后端拉取Person列表情况下,传统的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到底是指什么呢?是指后台的网络请求吗?不,那只是业务逻辑。是指请求结果的用户列表吗?不,它和ProgressBar、错误信息的展示一样,仅仅只代表了View层所能展示内容的一小部分而已。

那么,Model层究竟是指什么呢?

从我个人理解来说,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层持有了一个Model,并且能够借助它对屏幕上的控件进行rendered(渲染)。这并非什么新鲜的概念,Trygve Reenskaug在1979年时,其对最初版本的MVC定义中具有相似的概念:View观察Model的变化

然而,MVC这个术语被用来描述太多种不同的模式,这些模式与Reenskaug在1979年制定的模式并不完全相同。比如后端开发人员使用MVC框架,iOS有ViewController,到了Android领域MVC又被如何定义了呢?ActivityController吗? 那这样的话ClickListener又算什么呢?如今,MVC这个术语变成了一个很大的误区,它错误地理解和使用了Reenskaug最初制定的内容——这个话题到此为止,再继续下去整个文章就会失控了。

言归正传,Model的持有将会解决许多我们在Android开发中经常遇到的问题:

  • 1.状态问题
  • 2.屏幕方向的改变
  • 3.在页面堆栈中导航
  • 4.进程终止
  • 5.单向数据流的不变性
  • 6.可调试和可重现的状态
  • 7.可测试性

要讨论这些关键的问题,我们先来看看“传统”的MVPMVVM的实现代码中如何处理它们,然后再谈Model如何跳过这些常见的陷阱。

1.状态问题

响应式App,这是最近非常流行的话题,不是吗?所谓的 响应式App 就是 应用会根据状态的改变作出UI的响应,这句话里有一个非常好的单词:状态。什么是状态呢?大多数时间里,我们将 状态 描述为我们在屏幕中看到的东西,例如当界面展示ProgressBar时的loading state

很关键的一点是,我们前端开发人员倾向专注于UI。这不一定是坏事,因为一个好的UI体验决定了用户是否会用你的产品,从而决定了产品能否获得成功。但是看看上述的MVP示例代码(不是使用了PersonModel的那个例子),这里UI的状态由Presenter进行协调,Presenter负责告诉View层如何进行展示。

MVVM亦然,我想在本文中对MVVM的两种实现方式进行区分:第一种依赖DataBinding库,第二种则依赖RxJava;对于依赖DataBinding的前者,其状态被直接定义于ViewModel中:

class PersonsViewModel {
  ObservableBoolean loading;
  // 省略...

  public void load(){

    loading.set(true);

    backend.loadPersons(new Callback(){
      public void onSuccess(List<Person> persons){
      loading.set(false);
      // 省略其它代码,比如对persons进行渲染
      }

      public void onError(Throwable error){
        loading.set(false);
        // 省略其它代码,比如展示错误信息
      }
    });
  }
}

使用RxJava实现MVVM的方式中,其并不依赖DataBinding引擎,而是将Observable和UI的控件进行绑定,例如:

class RxPersonsViewModel {
  private PublishSubject<Boolean> loading;
  private PublishSubject<List<Person> persons;
  private PublishSubject loadPersonsCommand;

  public RxPersonsViewModel(){
    loadPersonsCommand.flatMap(ignored -> backend.loadPersons())
      .doOnSubscribe(ignored -> loading.onNext(true))
      .doOnTerminate(ignored -> loading.onNext(false))
      .subscribe(persons)
      // 实现方式并不惟一
  }

  // 在View层订阅它 (比如 Activity / Fragment)
  public Observable<Boolean> loading(){
    return loading;
  }

  // 在View层订阅它 (比如 Activity / Fragment)
  public Observable<List<Person>> persons(){
    return persons;
  }

  // 每当触发此操作 (即调用 onNext()) ,加载Persons数据
  public PublishSubject loadPersonsCommand(){
    return loadPersonsCommand;
  }
}

当然,这些代码并非完美,您的实现方式可能截然不同;我想说明的是,通常在MVP或者MVVM中,状态 是由ViewModel或者Presenter进行驱动的。

这导致下述情况的发生:

  • 1.业务逻辑本身也拥有了状态,Presenter(或者ViewModel)本身也拥有了状态(并且,你还需要通过代码去同步它们的状态使其保持一致),同时,View可能也有自己的状态(比方说,调用ViewsetVisibility()方法设置其可见性,或者Android系统在重新创建时从bundle恢复状态)。

  • 2.Presenter(或ViewModel)有任意多个输入(View层触发行为并交给Presenter处理),这是ok的,但同时Presenter也有很多输出(或MVP中的输出通道,如view.showLoading()view.showError();在MVVM中,ViewModel的实现中也提供了多个Observable,这最终导致了View层,Presenter层和业务逻辑中状态的冲突,在处理多线程的时候,这种情况更明显。

在好的情况下,这只会导致视觉上的错误,例如同时显示加载指示符(“加载状态”)和错误指示符(“错误状态”),如下所示:

在最糟糕的情况下,您从崩溃报告工具(如Crashlytics)接收到了一个严重的错误报告,但您无法重现这个错误,因此也几乎无从着手去修复它。

如果从 底层 (业务逻辑层)到 顶层 (UI视图层),有且仅有一个真实描述状态的源,会怎么样呢?事实上,我们已经在文章的开头谈论Model的时候,就已经通过案例,把相似的概念展示了出来:

class PersonsModel {
  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;
  }
}

你猜怎么了? Model映射了状态,当我想通了这点,许多状态相关的问题迎刃而解(甚至在编码之前就已经被避免了);现在Presenter层变得只有一个输出了:

getView().render(PersonsModel)

它对应了一个数学上简单的函数,比如f(x) = y,对于多个输入的函数,对应的则是f(a,b,c),但也是一个输出。

并非对所有人来说数学都是香茗,就好像数学家并不清楚bug是什么——但软件工程师需要去品尝它。

了解Model到底是什么以及如何建立对应的Model非常重要,因为最终Model可以解决 状态问题

2.屏幕方向的改变

译者注:针对 屏幕旋转后的状态回溯 这个问题,已经可以通过Google官方发布的ViewModel组件进行处理,开发者不再需要为此烦恼,但本章节仍值得一读。

Android设备上的 屏幕旋转 是一个有足够挑战性的问题;忽视它是一个最简单的解决方案,即 每次屏幕旋转,都对数据重新进行加载 。这确实行之有效,大多数情况下,您的APP也在离线状态下工作,其数据来源于数据库或者其它本地缓存,这意味着屏幕旋转后的数据加载速度是很快的。

但是,个人而言我不喜欢看到加载框,哪怕加载速度是毫秒级别的,因为我认为这并非完美的用户体验,因此大家(包括我)开始使用MVP,这其中包括了 保留性的Presenter——这样就可以 在屏幕旋转时分离和销毁View层,而Presenter则会保存在内存中不会被销毁,然后View层会再次连接到Presenter

使用RxJavaMVVM也可以实现相同的概念,但请牢记,一旦ViewViewModel取消了订阅,可观察的流就会被销毁,这个问题你可以用Subject解决;对于DataBinding构建的MVVM来讲,ViewModelDataBinding直接绑定到View层,为了避免内存泄露,需要我们在屏幕旋转时及时销毁ViewModel

对于 保留性的Presenter 或者 ViewModel 的问题是: 我们如何将View的状态在屏幕旋转之后回溯,保证ViewPresenter再次回到之前相同的状态?我编写了一个名为 Mosby 的MVP库,其包含一个名为ViewState的功能,它基本上将业务逻辑的状态与View同步。 Moxy,另一个MVP库,提出了一个非常有趣的解决方案——通过使用commands在屏幕方向更改后重现View的状态:

针对View层状态的问题,我很确定还有其他的解决方案。让我们退后一步,归纳一下这些库试图解决的问题:那就是我们已经讨论过的 状态问题

再次重申,我们通过一个 能反映当前状态的Model 和一个渲染Model的方法 解决了这个问题,就像调用getView().render(PersonsModel)一样简单。

3.在页面堆栈中导航

View不再使用时,是否还有保留Presenter(或ViewModel)的必要?比如,用户跳转到了另外一个界面,这导致Fragment(View)被另外的Fragmentreplace了,因此Presenter已经不在被任何View持有。

如果没有View层和Presenter进行关联,Presenter自然也无法根据业务逻辑,将最新的数据反映在View上。但如果用户又回来了怎么办(比如按下后退按钮),是 重新加载数据 还是 重用现有的Presenter?——这看起来像是一个哲学问题。

通常用户一旦回到之前的界面,他会期望回到之前的界面继续操作。这仍然像是第二小节关于View状态恢复 的问题,解决方案简明扼要:当用户返回时,我们得到 代表状态的Model ,然后只需调用 getView().render(PersonsModel)View层进行渲染。

4.进程终止

进程终止是一件坏事,并且我们需要依赖一些库以帮助我们在进程终止后对状态进行恢复——我认为这是Android开发中常见的一种误解。

首先,进程终止的原因只有一个,并且有足够充分的理由——Android操作系统需要更多资源用于其他应用程序或节省电池。如果你的APP处于前台并且正在被用户主动使用时,这种情况永远不会发生,因此,遵纪守法,不要与平台作斗争了(就是不要执拗于所谓的进程保活了)。如果你真的需要在后台进行一些长时间的工作,请使用Service,这也是向操作系统发出信号,告知您的App仍处于“主动使用状态”的 唯一方式

如果进程终止了,Android会提供一些回调以供 保存状态,比如onSaveInstanceState()——没错,又是 状态 。我们应该将View的信息保存在Bundle中吗?我们是否也应该把Presenter中的状态保存到Bundle中?那么业务逻辑的状态呢?又是老生常谈的问题,就和上面三个小节谈到的一样。

我们只需要一个代表整个状态的Model类,我们很容易将Model保存在Bundle中并在之后对它进行恢复。但是,我个人认为大部分情况下最好不保存状态,而是 重新加载整个界面,就像我们第一次启动App一样。 想想显示新闻列表的 NewsReader App。 当App被杀掉,我们保存了状态,6小时后用户重新打开App并恢复了状态,我们的App可能会显示过时的内容。因此,这种情况下,也许不存储Model和状态、而对数据重新加载才是更好的策略。

5.单向数据流的不变性

在这里我不打算讨论不变性(immutabiliy)的优势,因为有很多资源讨论这个问题。我们想要一个不可变的Model(代表状态)。为什么?因为我们想要唯一的状态源,在传递Model时,我们不希望App中的其他组件可以改变我们的Model或者State

让我们假设编写一个简单的计数器App,它具有递增和递减的功能按钮,并在TextView中显示当前计数器值。 如果我们的Model(在这种情况下只是计数器值,即一个整数)是不可变的,那么我们如何更改计数器?

我很高兴被问到这个问题,按钮被点击时,我们并非直接操作TextView。我的建议是:

  • 1.我们的View层应该有一个类似view.render(...)的方法;
  • 2.我们的Model是不可变的,因此不可直接修改Model;
  • 3.View的渲染有且只有一个来源:即业务逻辑。

我们将点击事件 下沉 到业务逻辑层。业务逻辑知道当前的Model(例如,持有一个私有的成员Model,它代表着当前的状态), 这之后根据旧的Model,创建一个新的带有增量/减量值的Model

这样我们建立了一个 单向数据流,业务逻辑作为单一源用于创建不可变的Model实例,但对于一个计数器来讲未免有点小题大做,不是吗?诚然,是的,计数器只是一个简单的应用程序。大多数应用程序都是以简单的应用程序开始,但复杂性增长很快——从我的角度来看,单向数据流和不可变模型是必要的,这会使简单的应用程序,在复杂性递增的同时,依然保持着简单(对开发者而言)。

6.可调试和可重现的状态

此外,单向数据流保证了我们的应用程序易于调试。下次我们从Crashlytics获得崩溃报告时,我们可以轻松地重现并修复此崩溃,因为所有必需的信息都已附加到崩溃报告中了。

什么叫做必需的信息?那就是当前的Model和用户用户在崩溃发生时想要执行的操作(比如,点击减量按钮)。这就是我们重现这次崩溃所需的全部信息,这些信息非常容易收集并附加在崩溃报告中。

如果没有单项数据流(比如,对EventBus的滥用,或者将CounterModels的私有域暴露出来),或者没有不变性(这会导致我们不知道谁实际更改了Model),那么bug的复现就没那么容易了。

7.可测试性

“传统”的MVPMVVM提高了应用程序的可测试性。MVC也是可测试的:没有人说我们必须将所有业务逻辑放入Activity中。使用表示状态的Model,我们可以简化单元测试的代码,因为我们可以简单地检查assertEqual(expectedModel,model)。这使我们避免了许多必须要Mock的对象。

此外,这也减少了很多验证的测试,即某些方法是否被调用(比如Mockito.verify(view, times(1)).showFoo()),最终,这使得我们的单元测试代码更具可读性,易于理解并且易于维护,因为我们不必处理很多实际代码的实现细节。

总结

在这个博客文章系列的第一部分中,我们谈了很多关于理论的东西。我们真的需要关于专门讨论Model的博客吗?

我认为初步地理解Model的确很重要,这也有助于我们避免一些会遇到的问题。Model并不意味着业务逻辑,它是生成Model的业务逻辑(比如,一次交互,一个用例,一个仓库或者你在APP中调用的任何东西)。

在接下来的第二部分中,当我们最终使用Model-View-Intent构建一个响应式App 时,我们将看到Model的实际应用。演示的APP是一个虚构的在线商店的应用程序,敬请关注。


系列目录

《使用MVI打造响应式APP》原文

《使用MVI打造响应式APP》译文

《使用MVI打造响应式APP》实战


关于我

Hello,我是却把清梅嗅,如果您觉得文章对您有价值,欢迎 ❤️,也欢迎关注我的博客或者Github

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