如何设计MVP中的Presentation层

原文链接:http://panavtec.me/modeling-presentation-layer/

译文链接:http://blog.chengdazhi.com/index.php/115 

我发现有很多项目设计MVP架构时,分不清哪些代码属于Presenter而哪些代码属于View(UI),这就是我写这篇文章的目的。

Android view vs View vs 界面

先区分一下Android View、View、界面的区别

  • Android View: 只是继承android.view.View的Android组件。

  • View:接口,用于由presenter向View实现类通信,你可以在Android组件中实现它。有时最好直接使用Activity,Fragment或自定义View。

  • 界面:界面是面向用户的概念。比如要在手机上进行界面间切换时,我们在代码中可以通过多种方式实现,如Activity到Activity或一个Activity内部的Fragment/View进行切换。所以这个概念基于用户的视觉,包括了所有View中能看到的东西。

切换界面

界面间的切换可以是两个Fragment、两个Activity、打开对话框、启动新Activity等等。当然切换的具体实现原理不属于这篇文章的内容,而进行切换操作则是Presentation层的职责。Presenter应该知道要做什么,而它的实现类要知道怎么完成。在这个例子中,要做的就是切换界面,完成方式就是启动新的Activity。

但这样会有一个问题。Presentation层是纯java代码,所以Presenter中不应该有任何与安卓相关的代码。那怎么完成界面的切换呢?通过抽象。这里可以写一个只有一个navigate()方法的接口NavigationCommand。在需要时我们在Presenter中调用这个接口的navigate()方法,然后在Activity中实现这个接口。假设要从Activity A切到Activity B,那么流程如下:

navigation command sequence

代码长这样:

View层

ActivityA.java

public class ActivityA extends Activity {
    @OnClick(R.id.someButton)
    public void onSomeButtonClicked() {
    presenter.onSomeButtonClicked();
}

ToActivityB.java

public class ToActivityB implements NavigationCommand {
    private final Activity currentActivity;
    public ToActivityB(Activity activity) {
        currentActivity = activity;
    }
    @Override
    public void navigate() {
        currentActivity.startActivity();
    }
}

Presentation层

NavigationCommand.interface

public interface NavigationCommand {
    public void navigate();
}

PresenterA.java

public class PresenterA {
    private final NavigationCommand toBNavigation;
    public PresenterA(NavigationCommand toBNavigation) {
        this.toBNavigation = toBNavigation;
    }
    public void onSomeButtonClicked() {
        toBNavigation.navigate();
    }
}

这样我们就可以将VP两层解耦。这里将切换到一个Activity的代码提取出来,可以复用,我们可以通过注入NavigationCommand方法来测试Presenter,而且就算要跳转的页面变了,Presenter的代码也不变。这也符合Open Close原则。

另一个问题就是当一个Presenter中出现多个NavigationCommand时,构造方法就开始变得诡异了。

public class PresenterA {
    private final NavigationCommand toBNavigation;
    private final NavigationCommand toCNavigation;
    public PresenterA(NavigationCommand toBNavigation, NavigationCommand toCNavigation) {
        this.toBNavigation = toBNavigation;
        this.toCNavigation = toCNavigation;
    }
}

在这里初始化Presenter的类很难搞清楚两个NavigationCommand之间的顺序,似乎只能通过名字来辨识,这里其实可以再写一个接口继承NavigationCommand来专门管理一类特定的切换,或者如果你使用依赖注入框架的话也可以指定参数的类型。

有时需要在切换界面时传递一些参数,这时就要改动一下NavigationCommand的代码:

public interface ToScreenBNavigationCommand extends NavigationCommand {
    void setMyParameterToNavigate(String parameter);
}

这样只需要在Presenter中在调用navigate()方法之前调用设置参数的方法就行了。

这个idea归功于Pedro的项目EffectiveAndroidUI

一个界面中有多个View

Android中一个View可能由不同的组件实现,但这不影响Presenter。那一个界面中可以有多个View吗?当然可以!那如何在一个Activity中写多个View/Presenter呢?下面以Browse Spotify界面为例分析。

spotify_1

这个界面里有一个横向的滚动条显示不同的歌单,然后是一个有多重选项的菜单,在底部有一个正在播放的歌曲。当然每个人看一个界面会有不同的理解,但这不是关键,所以我们来考虑如何按上述三个组件分开View和Presenter。

spotify_2

黄色是歌单,红色是菜单,蓝色是正在播放。

不过为什么要分开呢?分开写View/Presenter与合在一起写一个有所有操作的Presenter有多大区别呢?这时要考虑到谁负责填充这些View,以及如何复用组件。这三个组件是完全不同的组件,有不同的功能、操作与逻辑代码,它们都会在其他界面被用到。

所以一个界面可以有多个View/Presenter,因为一个界面可能包括了许多组件而且可能负责许多操作,这个是设计师的事。要记住每一项责任就是一个潜在的发生改变的原因,而上述这三个View都很可能发生改变。

一个View可以有2个实现类吗?

当然!对同一个Presenter的View可以有多个实现类。再以Spotify举例,刚才的界面的下方有一个正在播放的栏,当你点击时出现下面这个界面:

spotify_3

是不是只是换了一种展示的方式呢?所以或许我们可以继续使用同样的Presenter并在另一个Android组件中实现View接口。不过这个界面似乎有更多的功能,那要不要把这些新功能加进这个Presenter呢?这个视情况而定,有多种方案:一是将Presenter整合负责不同操作,二是写两个Presenter分别负责操作和展示,三是写一个Presenter包含所有操作(在两个View相似时)。记住没有完美的解决方案,编程的过程就是让步的过程。

MVP架构

总结一下前面的内容:

  • 一个View使用一个Presenter

  • 一个界面可以有多个View/Presenter

  • 一个View可以被多次实现以使用同一个Presenter

  • 一个Android组件可以实现一个View。如果要同时实现两个View接口,或许这两个View最好一起来展示一个组件,或是你应该将View的实现分割,分别对应两个View接口。

下面来看一下其他的概念。

Presenter生命周期

下面这张截图来自Citymapper,当你点击“带我去那”按钮的时候就会打开一个让你选择开始结束位置的界面。

Citymapper_1

如何分解这个界面呢?我首先想到的事情就是:如果没有结束位置,那起始位置还有意义吗?应该没有。所以我可以写一个Presenter “PickLocation”来监听开始和结束位置是否填写。而后写一个Activity包含两个Fragment能在ViewPager中切换,这就组成了View层。两个Fragment都可以调用同一个Presenter的startLocationChanged()和endLocationChanged()方法。

如果此时设计改了,不再是两个tab了,而是一个分为两步的表单。这是需要将选择开始位置的Fragment替换为选择结束位置的Fragment。View层的代码改变了,但Presenter不变。设计可以千变万化,再比如分屏显示两个地图,但都不会改变Presenter的代码。

那么Presenter的生命周期如何呢?这取决于与Presenter对应的组件。

我们先看一下Selltag应用,这是一个二手交易应用。下面是旧版应用创建商品的截图:

Selltag_1

Selltag_2

Selltag_3

这就是一个三步表单。除了西班牙词汇外还都挺清晰地。”Siguiente”是”下一步”,”Publicar”是“发布”。

在第一步中,先给商品添加一些图片。第二步中要填写标题、描述和价格。最后点击发布按钮,商品就进入交易市场了。

在我给这个表单建立的模型中只有一个Presenter:“PublishProductPresenter”。这个Presenter代表了整个“发布商品”的概念。而这个表单在平板上该如何显示呢?或许这三步可以整合在一个界面中,毕竟屏幕变大了。但不要看到界面变了就改架构,这里只是View层变了,因为Presentation层只需要处理用户事件,代码不变。

这里有一个问题是如果只用一个Presenter的话,在不同的界面间如何传递数据呢?是在后面界面的Presenter保留前面界面Presenter的引用吗?还是创建一个Presenter让三个Activity共享?这样出了bug很难调试啊。这里也可以将这三个界面写成Fragment放在一个Activity里。

Presenter状态

当屏幕方向改变的时候,Activity和Presenter都会被销毁,所以要不要给Presenter设置状态呢?其实添加Presenter状态还不如修改一下Model层的代码。为什么这么讲,请看这个例子:

fdroid

这是F-Droid的Android版,一个只包含开源项目的开源市场。当Available栏目刷新时会发起一次网络请求。假如这时屏幕旋转,并且Presenter是没有状态的话,List就会被重新加载。如何解决这个问题呢?其实不难,可以把上次的response缓存进内存或disk中,并指定一个ttl(time to live有效期)。但不要将从网络获取的内容存进Presenter里,因为如果要重建Presenter的话,就需要重新发送一遍请求。综上,我不喜欢给我的Presenter添加状态。

回调地狱Callback Hell

Callback Hell是人们谈论Presentation层时经常讨论的问题。许多有关回调地狱的问题都是因为Presenter的任务太重。不要把Model层的任务放在Presenter里,Presentation层只应该调用Model层的方法,由Model层完成诸如同步等操作。在使用RxJava或Jdeferred等第三方库之前请思考是真的需要还是只是必须通过这些库把整个系统粘在一起。

为了描述上述问题我造了一个例子:想象一个系统,只在服务端返回true时加载并展示一个产品组成的列表。下面第一个图所展示的流程主要在错在两个地方:第一是Presenter不应该知道服务端返回的flag,这个是Model层的事。第二是presentation层因为要负责各种同步之类的事情导致代码变多。

blob.png

改进之后的流程图:

blob.png

这样一来两个问题就解决了,而且如果未来不再需要flag了就只需要修改action就行了。

结论

设计Presentation层的架构很简单,但你需要知道什么代码归Presenter什么归Model。当你有一个巨型Presenter时,想想真的是界面需要响应的事件太多还是你的Presenter干了Model的事。