[译]为什么使用MVI模式(MVI编写响应式安卓APP入门系列第一部分MODEL)

2,976 阅读15分钟

我曾经有一个瞬间觉的我的Model定义全都是错的。经过在各种安卓开发论坛也好主题也罢的讨论和头疼的研究。无论如何,最终我选择使用rxjava和Model-View-Intent(MVI)的方式构建响应式的安卓应用程序,就像这种组合我以前是没有尝试过一样,我创建是十分被动的。当然,你也会,但是,你会比我好很多,因为,我将写一系列文章来介绍这个模式和用法。在第一节,也就是这篇文章,我们来说说我们的Model出现了什么问题?

我为什么说我以前定义的Model全都是错的咧?诚然,有很多模式将"View"和"Model"分离。在安卓开发领域,最出名的当属Model-View-Controller(MVC),Model-View_Presenter(MVP)和Model-View-ViewModel(MVVM)。你可以从名字看出什么东西么?他们都有Model。但是,我发现大多数时间,我根本没有用Model。

例子:仅仅是在后台加载一个persons的列表,一个传统的MVP模式的代码是这样的:

class PersonsPresenter extends Presenter<PersonsView> {

  public void load(){
    getView().showLoading(true); // 显示一个加载进度条

    backend.loadPersons(new Callback(){
      public void onSuccess(List<Person> persons){
        getView().showPersons(persons); // 显示人列表
      }

      public void onError(Throwable error){
        getView().showError(error); // 显示错误信息
      }
    });
  }
}
 

但是到底什么是"Model"?后台请求是Model?不是,Model应当是业务逻辑。它是作为结果的列表?不是,它仅仅只做一件事情,就是我们View显示所需要的东西,像加载指示器或错误信息。因此,真正的Model“长”什么样的?

如果按照我对View的理解,那么,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) ); // 显示一个加载进度条

    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。这个概念其实不是什么新概念。最开始的被Trygve Reenskaug在1979年定义的MVC模式的Model的定义几乎一致:View观察Model的变化。不幸的是,MVC这个术语被滥用来描述太多不同的模式,它们与最原始的MVC定义有了出入。例如,后端工程师使用MVC框架,iOS工程师有ViewController,在安卓开发中MVC的真正含义是什么?Activities是Controller?那么ClickListener意味着什么?现在MVC与最初被Reenskaug定义的MVC来讲,这个术语被误解,滥用和错误使用。关于MVC的讨论就此打住,在讨论下去文章就要失控翻车了。

让我们回到我刚开始说的地方。Model需要解决我们在安卓开发中经常遇到的问题:

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

让我们讨论的上面这些点,并研究传统的MVP和MVVM如何处理这些内容,最后,在探究到底什么样的Model可以帮助避免共性的陷阱。

1.状态问题

响应式App,可以说最近非常流行。难道不是么?所谓的响应式App应该就是会根据应用的状态改变,来改变UI。这里还有一个单词:"State(上文译为状态)"。什么是"State(上文译为状态)"?大多数时间我们描述“State(上文译为状态)”,就是我们从屏幕上看到的东西,比如说在屏幕上显示一个ProgressBar 就是“加载状态”。最关键的地方:我们的前端开发者趋向于关注UI。这明显不是一件坏事,因为一个好的UI决定了用户会不会用你们家的产品,从而决定了产品能不能成功。但是,我们看一下上面最基本的MVP示例代码(不是用PersonsModel的例子,是最上面的例子)。Ui的状态被Presenter协调,Presenter决定了View应该显示什么内容。MVVM也是同样的。在这篇博客中我简单区分两种MVVM实现:第一种是用到了Android的data binding,第二种是用到RxJava。在用data binding实现的MVVM这种方式下,状态直接被定义到了ViewModel里面:

class PersonsViewModel {
  ObservableBoolean loading;
  // ... Other fields left out for better readability

  public void load(){

    loading.set(true);

    backend.loadPersons(new Callback(){
      public void onSuccess(List<Person> persons){
      loading.set(false);
      // ... other stuff like set list of persons
      }

      public void onError(Throwable error){
        loading.set(false);
        // ... other stuff like set error message
      }
    });
  }
}

在使用RxJava实现的MVVM中,我们不需要使用data binding引擎,而是将Observable绑定到View中的UI Widget,例如:

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)
      // Could also be implemented entirely different
  }

  // Subscribed to in View (i.e. Activity / Fragment)
  public Observable<Boolean> loading(){
    return loading;
  }

  // Subscribed to in View (i.e. Activity / Fragment)
  public Observable<List<Person>> persons(){
    return persons;
  }

  // Whenever this action is triggered (calling onNext() ) we load persons
  public PublishSubject loadPersonsCommand(){
    return loadPersonsCommand;
  }
}

当然,这只是一个代码片段不是一个完整的代码,你实现的可能看起来完全不一样。重点是通常在MVP和MVVM中,状态由Presenter或ViewModel驱动。

这导致了下面几个问题:

  1. 业务逻辑有了自己的状态,Presenter(或ViewModel)有了自己的状态(你需要同步你的业务逻辑状态,和你的Presenter的状态,两者需要保持一致)并且View可能也有自己的状态(举个栗子,您直接在视图中设置可见性,或者Android本身在从bundle中恢复状态)
  2. Presenter(或者ViewModel)有任意多的输入(View的触发,被Presenter处理),这是可以理解的,但是Presenter也有很多的输出(或输出一些像 view.showLoading()view.showError() 在MVP或ViewModel都提供观察)那么这种情况会导致View,Presenter和业务逻辑的状态冲突,这种现象在多线程下尤为突出。

在最好的情况下,这只会导致可见的错误,例如像这样同时显示加载指示符(“加载状态”)和错误指示符(“错误状态”)

plaid app.gif

在最坏的情况下,你有一个像Crashlytics(理解成bugly)这样的崩溃报告工具报告给你的严重的错误,你无法重现,因此几乎不可能修复。

如果,我们从底层(业务逻辑)到顶层(VIew)有且仅有一个状态源。其实,我们最开始展示的第二个例子就是一个很接近这个概念的例子。

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也就只有一个明确的输出:getView().render(PersonsModel) .这反应了一个简单的数学函数像f(x)=y (也可以有多个输入,例如f(a,b,c),但只有一个输出)。数学并不是所有的人都擅长,但是,数学家不知道什么是Bug。软件工程师咧。

理解什么是"Model",并且知道model如何正确的定义,是十分重要的,因为到最后Model将解决"状态问题"。

2.屏幕方向改变

安卓屏幕方向改变是一个有挑战性性的问题。最简单的方法是直接忽略这个问题。当屏幕方向改变的时候,重新加载所有的东西。这是完全有效的解决方法。大多数时间,你的App在离线状态下工作,数据是存储在你的本地数据库或者其他的本地缓存。因此,当屏幕的方向发生改变,加载数据是很快的、然而,我个人不喜欢看到loading指示器(大神都是有点各种小脾气的),尽管它可能只出现几微秒的时间(这里应该用了夸张的修辞手法),因为在我看来这不是一个无缝的用户体验。因此,很多人(包括我)开始使用带有“固定的presenter”的MVP。因此View可以在屏幕方向旋转的时候被分离(被销毁),而presenter将被保留在内存中,随后,我们的View和Presenter将会被重新连接。在用RxJava实现的MVVM中有相同的概念,但是,我们需要记在心中的是一旦View被它的ViewModel退订那么观察流就被破坏。例如,你可以用Subjects来解决这个问题。在使用data binding实现的MVVM中ViewModel是通过data binding 引擎直接绑定在View上的。去避免当我们改变屏幕方向而导致的内存泄露。

但是固定的Presenter(或者ViewModel)有一个问题是:当屏幕旋转的时候,我们如何将View的状态退回旋转前的状态,也就是说,我们的View和Presenter是否处在相同的状态?我写了一个MVP库叫做Mosby 带有一个功能叫做ViewState ,用来同步业务逻辑和View的状态。Moxy ,另一个MVP库,用了一种有趣的方式解决了这个问题,解决的方法就是用到了"命令(原文为commands)"去在屏幕旋转以后,重建View的状态:

moxy.gif

我可以十分确定的是,肯定有其他方法来解决这个问题。让我们退一步来说,我们总结一下上面说到的库的解决方法:他们试图解决我们一直在讨论的状态问题。

所以,再次强调,当有一个能够反应确切的"状态"的"Model",肯定只有一个方法去"渲染(原文为render)"这个"Model"解决这个问题,并且是通过一种简单的如调用 getView().render(PersonsModel) 一样。

3.在页面堆栈中导航

Presenter(或者ViewModel)需要去维护什么时候View不使用么?举个栗子,如果,Fragment(View)将被另一个Fragment替换掉,因为用户导航到另外的页面,那么这个将没有View附属到Presenter里。如果没有View没有Presenter显然不可能用最新的从业务逻辑里出来的数据去更新View。如果用户返回(例如,用户按了返回按钮)?去重新加载数据或复用已经存在的Presenter?这是一道哲学题。通常的一旦用户返回先前的页面,他期望回到他原来阅读的地方。这是个最基本的“重置View状态的问题”,我们刚刚在2中也讨论了这个问题。所以富有策略的解决方案:当"Model"代表一种状态,我们仅仅需要当用户返回时,调用getView().render(PersonModel) 去渲染视图就可以了。

4.进程死亡

我认为这是个安卓开发普遍错误的理解,就是进程死亡是意见坏的事情,并且在进程死亡以后,我们需要库去帮助我们重启状态(例如Presenters或者ViewModels)。第一,一个进程死亡发生的原因是:安卓操作系统需要更多的资源去给其他的App或者为了省电。但是,如果你的应用程序处于前台,正在被你的用户使用是决定不可能出现进程死亡的。因此,做个好市民,不要在和平台对战了(这里的意思是不要再瞎折腾进程包活了)。如果你真的需要在后台长时间运行的一些工作,请用 Service ,在安卓操作系统中,这是唯一的一种方式向系统发出你的应用程序仍然被使用的信号。如果一个进程死亡发生,安卓提供了一些回调像onSaveInstanceState() 去保存状态。State又出现了。我们应该保存我们的View信息到Bundle里么?我们的Presenter的状态是不是也要存储到Bundle里?那么业务逻辑的状态要不要存?我们先前也一直讨论这个问题:刚才1.2.3点都在讨论这个问题。我们仅仅需要一个Model类,这个Model类代表了整个状态。那么存储到Bundle里,就变得很简单了。然而,我个人意见认为大多数时间我们不存储状态数据,而选择像我们启动App时候重新加载整个屏幕,似乎更好。考虑一下新闻阅读软件显示新闻列表,当我们App六小时以前被杀死,我们存储了的新闻状态,当用户重新打开我们的App的时候,我们六小时前存储的状态被重新显示出来,很显然新闻已经过期了。也许在这种场景下,不去存储状态(Model/State),而去重新加载数据是更好的选择。

5. 单向不可变的数据流

我这里不去讨论不变性(immutabiliy)的先进性,因为有很多资源讨论这个问题。我们需要一个不变的“Model”(代表状态)。为啥?因为我们想要唯一的来源。当我们传递Model对象的时候,我们不想要在我们应用中其他组件去改变我们的Model/状态(State)。让我想象一下我们正在写一个简单的“计数器”的安卓应用程序,这个有一个增量和一个减量按钮,并且在一个TextView中显示当前技术的值。如果我们的Model(就是计数的值,一个Integer)是不可变的,我们如何去更改计数器?我要告诉你,我们不直接通过按钮点击来控制TextView。一些建议:第一,我们的View应该有一个view.render(...).第二,我们的Model是不可变的,因此不可能直接修改Model。第三,有且只有一个来源:业务逻辑。我们让点击事件“下沉”到业务逻辑层。业务逻辑知道了当前的Model(例如,当前Model有一个私有域)并且将根据旧的Model,创建一个新的带有增量/减量值的Model。

counter

通过这样做,我们确信有单向的数据流,并将业务逻辑作为创建不可变模型实例的单一的来源。对于一个计数器来讲有点太小题大做。难道不是么?是的,一个计数器是一个非常简单的程序。大多数的App都是从一个简单的程序变的复杂起来。我认为,一个单向的数据流和一个不变的Model是十分必要的,当我们工程变复杂的时候,开发将依然是简单的。

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

此外,单向数据流保证了我们的APP的调试非常简单。下次如果有新的crash报告从Crashlytics(感觉类似与Bugly)传过来,我们可以很快速的修复Crash。因为所有需要的信息都会在crash报告里面。什么是“需要的信息”?就是我们需要的当前Model和用户执行了什么样的操作而导致的八阿哥(例子:点击减量按钮)。这就是我们需要的信息,而且这些信息很显然,是十分容易附加到Crash报告中的。如果,数据流不是单向的,那么实现起来就有点困难。(例如:一些人乱用EventBus,并且将CounterModels暴露出来。译者:EventBus没用过,所以,这里可能看起来怪怪的原话是someone misuses an EventBus and fires CounterModels out into the wild )或不具有不变性(这样会导致我们不能确定谁改变了Model)。

7. 可测试

"传统"MVP或者MVVM改善了应用程序的可测试性。MVC也是可测试的:没人告诉我们业务逻辑一定要放在activity里。当Model代表状态,我们可以简化我们的集成测试代码,例如,我们可以简单的检查assertEquals(expectedModel, model) 。这让我们除了Model以外的所有对象都不用mock。另外,这可以消除了方法的许多验证测试,例如 Mockito.verify(view, times(1)).showFoo() 。最后,它可以让我们的测试代码可读性更好,更容易理解,更好的可维护性,我们不需要纠结于如何实现在代码中实现一些细节。

##总结

作为这个系列的第一篇博客,我们讨论了很多关于理论的东西。我们真的需要花4千多字介绍Model么?我认为理解Model的实现是十分重要的基础,有助于防止一些问题,否则容易翻车。Model不意味着业务逻辑,它是生成Model的业务逻辑(例如,一个交互,一个用例,一个仓库或者你在APP中调用的任何东西(原文:Model doesn’t mean business logic. It’s the business logic (i.e. an Interactor, a Usecase, a Repositor or whatever you call it in your app) that produces a Model.)。在第二部分,我们将要将我们学到的Model理论,用到Model-View-Intent上,来构建响应式应用程序。下面展示的,简单的在线商城软件将是我们以后去实现的一个例子。你可以期望在第二部分了。 敬请关注。

shop dome