Mosby翻译(三) : MVP原理

英文原文

本文主要介绍 Model-View-Presenter (MVP)的原理,以及如何使用Mosby创建基于MVP的应用程序。

  • model 是将在视图(用户界面)中显示的数据。
  • view 是显示数据(model)并将用户命令(事件)传递到 Presenter 以对该数据执行操作的界面。view 通常对其Presenter有一个引用。
  • Presenter 是“中间人”(就像MVC中的controller),并具有view和model的引用。请注意,“Model”一词是误导性的。它应该是检索或操纵模型的业务逻辑 例如:如果你有一个在数据库表中存储User的数据库,并且你的View想要显示一个User列表,那么Presenter会引用数据库中的业务逻辑层(比如DAO)从而查询到一个User列表。

查询和显示来自数据库的用户列表的具体工作流程:

上面显示的工作流程图应该是很容易理解的。不过这里有一些额外的想法:

  • Presenter 并不是OnClickListenerView负责处理用户输入并调用Presenter 的相应方法。为什么不通过将Presenter变成OnClickListener从而消除这种”转移”的过程呢?如果这样做,Presenter需要了解有关视图内部的知识。例如,如果一个View有两个按钮,并且这个view在这两个按钮上都把Presenter注册成OnClickListener,那么Presenter如何区分哪个按钮被点击了(在不知道view按钮引用等内部构造的情况下)? Model,View和Presenter应该分离。而且,如果让Presenter实现OnClickListener,Presenter就被绑定到了android平台。从理论上说,Presenter和业务逻辑应该能够在桌面程序或其他java程序间共享的普通java代码。

  • 就像在步骤1和步骤2中看到的,View只做Presenter告诉View 需要做的那些操作:用户点击了“load user button”(第1步)之后,view不会直接显示加载动画。而是在步骤2由Presenter明确地告诉view去显示加载动画。Model-View-Presenter的这种变体被称为被动视图(Passive View)。view应该尽可能愚蠢。让Presenter以抽象的方式控制view。例如:Presenter调用view.showLoading(),而不是控制view中特定的东西,如动画。所以,Presenter不应该调用view.startAnimation()这种方法。

  • 通过实现MVP被动视图,处理并发和多线程更容易。就像您在步骤3中看到的那样,数据库查询异步运行,Presenter是一个监听器Listener/观察者Observer,并在数据准备好显示时得到通知。

Android上的MVP

到现在为止还挺好。但是如何在自己的Android应用上应用MVP?第一个问题是,我们应该在哪里应用MVP模式?在Activity上,Fragment上,还是在像RelativeLayout这样的ViewGroup上?让我们来看看Gmail Android平板应用程序:

在我们看来,在上图所示的屏幕上有四个独立的可使用MVP的地方。“可以使用MVP的地方”是指屏幕上显示的、在逻辑上属于一个整体的UI元素。因此这些地方也可以称为是可以运用MVP的一个单独的UI单元。

这样看起来MVP似乎适合运用到Activity,特别是Fragment上。通常一个Fragment负责显示一个像ListView一样的内容。例如上图中被使用MailProvider获取Mails列表的InboxPresenter控制的InboxView。但是,MVP不限于Fragment 和 Activity。你也可以在ViewGroups上应用这个设计模式,如上图所示的SearchView。在许多app中都在Fragment上使用MVP。然而,这都取决于你想要把MVP运用到什么地方。只要确保view是独立的,以便一个Presenter可以控制这个view,而不会与另一个Presenter发生冲突。

我们为什么要实现MVP?

思考一下,如果不使用MVP,你将如何在Fragment中实现收件箱view,来显示从本地sql数据库和IMAP邮件服务器两个数据源得到的邮件列表。你的Fragment代码会是什么样子?或许,你将启动两个AsyncTasks并且必须实现一个“等待机制”(等到两个任务都完成),然后将两个任务得到的邮件列表合并成一个邮件列表。你还需要注意,在加载时显示加载动画(ProgressBar),之后用ListView替换它。你会把所有的代码放入Fragment吗?如果加载时出现了错误怎么办?如果屏幕方向改变了呢?谁负责取消AsyncTasks?这一系列的问题都可以用MVP来解决。让我们向1000+行、大杂烩似的Activity和Fragment代码说再见吧。

但是在我们深入了解如何在Android上实现MVP之前,我们必须澄清一下,Activity和Fragment到底是View还是Presenter。Activity和Fragment似乎既是View又是Presenter,因为他们都有onCreate()onDestroy()这种生命周期回调,也有像从一个UI控件切换到另一个UI控件(例如,加载时显示一个ProgressBar,然后显示一个带有数据的ListView)的View职责。你可以说这些听起来Activity和Fragment更像是一个Controller。然而,我们得出的结论是Activity和Fragment应该被视为(愚蠢的)View,而不是Presenter。后面你会看到原因。

有了这个说法,我们想要介绍Mosby,这是一个在android上创建基于MVP的应用程序的库。

Mosby

你可能已经发现,如果你试图去解释MVP是MVC(Model-View-Controller)的变种或改进,那么就很难理解什么是Presenter。尤其是iOS开发人员,他们很难理解Controller和Presenter的区别, because they “grew up” with the fixed idea and definition of an iOS alike UIViewController。在我们来看,MVP并不是MVC的变种或改进,因为这意味着Presenter取代了Controller。我们认为,MVP包装了MVC。看看你使用MVC开发的app。通常你有你的View和Controller(即Android中的Fragment或iOS的UIViewController)处理点击事件,绑定数据和观察ListView(或在iOS上为UITableView实现一个UITableViewDelegate)等等。现在退一步,想象一下,controller就是view的一部分,而不是直接连接到你的model(业务逻辑)。而Presenter位于controller 和model的中间,如下所示:

让我们来看一个具体的例子:示例程序显示从数据库中查询的用户列表。当用户点击“加载按钮”时开始执行。查询数据库(异步)时ProgressBar显示,然后 ListView显示出查询结果。

我们认为Presenter不会取代Controller。而是Presenter协调并监督Presenter所属的View。Controller是处理点击事件并调用相应的Presenter方法的组件。Controller是负责控制动画的组件,如隐藏ProgressBar并显示ListView。Controller监听ListView上的滚动事件,即在滚动ListView时进行一些item动画或显示隐藏toolbar。因此,所有与UI相关的东西仍然受Controller而不是Presenter控制(即Presenter不应该是一个OnClickListener)。Presenter负责协调view层(由UI控件和Controller组成)的整体状态。因此,Presenter的工作是告诉view层现在应该显示加载动画,然后在数据准备好后,显示ListView。

MvpView和MvpPresenter

所有view的基类是MvpView。本质上它只是一个空的interface。该接口为Presenter提供了一个公共API来调用View相关的方法。Presenter的基类是MvpPresenter

1
2
3
4
5
6
7
8
9
public interface MvpView { }


public interface MvpPresenter<V extends MvpView> {

public void attachView(V view);

public void detachView(boolean retainInstance);
}

这一理念是MvpView(即Fragment or Activity)会去关联和取消关联一个MvpPresenter。这样一来Mosby获取到Activity和Fragment的生命周期(更多内容可以查看下面“委托”部分的内容)。因此,初始化和清理东西(如取消异步运行任务)的操作应该在presenter.attachView()presenter.detachView()中执行。

Mosby提供了Presenter的另一种实现MvpBasePresenter,它使用WeakReference来保存对view(Fragment or Activity)的引用,以避免内存泄漏。因此,当你的Presenter想要调用view的方法时,您必须通过调用isViewAttached()来检查这个view是否被关联到你的Presenter,并通过使用
getView()来或者view的引用。

另外,你可以为你的MvpView使用实现了空对象模式的MvpNullObjectBasePresenter。所以无论什么时候MvpNullObjectBasePresenter.onDetach()被调用,view都不会被设置为null(像MvpBasePresenter这样),而是通过使用反射来动态创建一个空view,并将其作为view关联到Presenter中。这就避免了在方法调用时检查view != null

MvpActivity和MvpFragment

如前所述,我们将Activity 和 Fragment当作View。如果你只是想要一个由Presenter控制的Activity 或 Fragment,你可以在你的程序中使用实现了MvpViewMvpActivityMvpFragment用作基类。为了确保类型安全,建议这样使用:MvpActivity<V extends MvpView, P extends MvpPresenter>MvpFragment<V extends MvpView, P extends MvpPresenter>

加载内容错误(LCE)

通常你会发现自己在应用程序中一遍又一遍地写同样的东西:在后台加载数据,在加载时显示加载视图(即ProgressBar),显示加载的数据或加载错误时显示错误消息。由于SwipeRefreshLayout成为Android的支持库的一部分,现在支持下拉刷新是很容易的。为了不重复实施这个工作流程Mosby提供了MvpLceView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* @param <M> The type of the data displayed in this view
*/
public interface MvpLceView<M> extends MvpView {

/**
* Display a loading view while loading data in background.
* <b>The loading view must have the id = R.id.loadingView</b>
*
* @param pullToRefresh true, if pull-to-refresh has been invoked loading.
*/
public void showLoading(boolean pullToRefresh);

/**
* Show the content view.
*
* <b>The content view must have the id = R.id.contentView</b>
*/
public void showContent();

/**
* Show the error view.
* <b>The error view must be a TextView with the id = R.id.errorView</b>
*
* @param e The Throwable that has caused this error
* @param pullToRefresh true, if the exception was thrown during pull-to-refresh, otherwise
* false.
*/
public void showError(Throwable e, boolean pullToRefresh);

/**
* The data that should be displayed with {@link #showContent()}
*/
public void setData(M data);
}

上面说的那种view,你可以使用MvpLceActivity implements MvpLceViewMvpLceFragment implements MvpLceView来实现。这两个都假设XML布局中包含了含有R.id.loadingViewR.id.contentViewR.id.errorView的view。

示例

在下面的示例中(托管在Github上),我们通过使用CountriesAsyncLoader加载Country列表并在Fragment的RecyclerView中显示。

我们首先定义视图界面CountriesView

1
2
public interface CountriesView extends MvpLceView<List<Country>> {
}

为什么我需要为View定义接口?

  1. 由于它是一个接口,你可以改变view的实现。我们可以简单的将代码从继承Activity的实现中拷贝到继承Fragment的实现中。

  2. 模块化:您可以将整个业务逻辑,Presenter和View Interface移动到独立的库中。然后,把这个包含了Presenter的库应用到各种app中。

  3. 您可以轻松编写单元测试,因为您可以通过实现view interface来模拟视图。还有一个更简单的方法就是在presenter中引入java接口并模拟presenter对象来编写单元测试。

  4. 为视图定义一个接口的另一个好处是,你不需要直接从Presenter中调用activity / fragment的方法。因为在实现Presenter的时候,你在IDE的自动完成提示中只能看到view interface的那些方法。根据我们的个人经验,我们可以说,这是非常有用的,特别是如果你在一个团队中工作。

请注意,我们也可以使用MvpLceView<List<Country>>,而不是定义一个(空的,因为继承方法)接口CountriesView。但是有一个专用的接口CountriesView可以提高代码的可读性,而且我们可以在将来更灵活地定义更多的与View有关的方法。

接下来我们用所需的id来定义我们view的xml布局文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
>

<!-- Loading View -->
<ProgressBar
android:id="@+id/loadingView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
/>

<!-- Content View -->
<android.support.v4.widget.SwipeRefreshLayout
android:id="@+id/contentView"
android:layout_width="match_parent"
android:layout_height="match_parent"
>

<android.support.v7.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>

</android.support.v4.widget.SwipeRefreshLayout>


<!-- Error view -->
<TextView
android:id="@+id/errorView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
/>

</FrameLayout>

CountriesPresenter控制CountriesView并启动CountriesAsyncLoader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class CountriesPresenter extends MvpBasePresenter<CountriesView> {

@Override
public void loadCountries(final boolean pullToRefresh) {

getView().showLoading(pullToRefresh);


CountriesAsyncLoader countriesLoader = new CountriesAsyncLoader(
new CountriesAsyncLoader.CountriesLoaderListener() {

@Override public void onSuccess(List<Country> countries) {

if (isViewAttached()) {
getView().setData(countries);
getView().showContent();
}
}

@Override public void onError(Exception e) {

if (isViewAttached()) {
getView().showError(e, pullToRefresh);
}
}
});

countriesLoader.execute();
}
}

实现CountriesViewCountriesFragment如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class CountriesFragment
extends MvpLceFragment<SwipeRefreshLayout, List<Country>, CountriesView, CountriesPresenter>
implements CountriesView, SwipeRefreshLayout.OnRefreshListener {

@Bind(R.id.recyclerView) RecyclerView recyclerView;
CountriesAdapter adapter;

@Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
return inflater.inflate(R.layout.countries_list, container, false);
}

@Override public void onViewCreated(View view, @Nullable Bundle savedInstance) {
super.onViewCreated(view, savedInstance);

// Setup contentView == SwipeRefreshView
contentView.setOnRefreshListener(this);

// Setup recycler view
adapter = new CountriesAdapter(getActivity());
recyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));
recyclerView.setAdapter(adapter);
loadData(false);
}

public void loadData(boolean pullToRefresh) {
presenter.loadCountries(pullToRefresh);
}

@Override protected CountriesPresenter createPresenter() {
return new SimpleCountriesPresenter();
}

@Override public void setData(List<Country> data) {
adapter.setCountries(data);
adapter.notifyDataSetChanged();
}

@Override public void onRefresh() {
loadData(true);
}
}

没有太多的代码要写,对吧?这是因为基类MvpLceFragment已经帮我们实现了从加载视图切换到内容视图或者错误视图。乍一看你可能会被MvpLceFragment那一串泛型参数列表吓到。让我解释一下:第一个泛型参数是content view 的类型(从android.view.View延伸的东西)。第二个是fragment要显示的Model。第三个是View接口,最后一个是Presenter的类型。总结:MvpLceFragment<AndroidView, Model, View, Presenter>

ViewGroup

如果你想避免使用Fragment,你可以做到这一点。Mosby为ViewGroups提供了与Activities and Fragments相同的MVP脚手架。API与Activity和Fragment的相同。一些默认的实现像MvpFrameLayoutMvpLinearLayoutMvpRelativeLayout已经提供使用了。

Delegation委托

您可能想知道,Mosby如果不使用代码复制(复制和粘贴相同的代码),是如何为所有类型的view(Activity,Fragment和ViewGroup)提供相同的API的。答案是delegation委托。委托的方法已被命名为与Activity或Fragments生命周期的方法名称(受appcompat支持库中最新的AppCompatDelegate的启发)相匹配的名称,以更好地理解应从哪个Activity或Fragment生命周期方法调用哪个委托方法:

  • MvpDelegateCallback:是每个Mosby中的MvpView 都必须实现的接口。基本上它只是提供了一些MVP相关的方法像createPresenter()等。这个方法在内部被ActivityMvpDelegateFragmentMvpDelegate调用。

  • ActivityMvpDelegate:这是一个接口。通常你使用ActivityMvpDelegateImpl这个默认的实现。要想在你自己的Activity中引入Mosby MVP,你需要做的是,从Activity的onCreate()onPause()onDestroy()等生命周期方法中调用相应的委托方法,并实现MvpDelegateCallback

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public abstract class MyActivity extends Activity implements MvpDelegateCallback<> {

protected ActivityMvpDelegate mvpDelegate = new ActivityMvpDelegateImpl(this);

@Override protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mvpDelegate.onCreate(savedInstanceState);
}

@Override protected void onDestroy() {
super.onDestroy();
mvpDelegate.onDestroy();
}

@Override protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
mvpDelegate.onSaveInstanceState(outState);
}

... // other lifecycle methods
}
  • FragmentMvpDelegate:同ActivityMvpDelegate一样。要在你的Fragment中引入Mosby MVP的支持,您所要做的就和上面在Activity中引入一样:创建一个FragmentMvpDelegate,并从Fragment的生命周期方法中调用相应的委托方法,你的Fragment同样也必须实现MvpDelegateCallback。通常你可以使用默认的委托实现FragmentMvpDelegateImpl

  • ViewGroupMvpDelegate:这个委托是给ViewGroup用的。在你的ViewGroup中引入Mosby MVP,生命周期方法要比Fragment的更简单:onAttachedToWindow()onDetachedFromWindow()。默认的实现是ViewGroupMvpDelegateImpl

委托的另一个优点是可以将Mosby整合到其他任何一个第三方库或框架中。只需实现MvpDelegateCallback并实例化一个委托,并在生命周期事件中调用相应的委托方法。

演示模型

在理想世界中,我们通过最佳的方式得到在我们的GUI(View)中显示的数据。很多时候,我们通过公共API检索后端数据,这些公共API无法为了适应UI的需求而更改。实际上,后端根据你的用户界面提供一个API并不是一个好主意,因为如果你改变你的用户界面,你也可能需要改变后端。因此,您必须将model转换,从而使你的GUI可以轻松的显示。一个典型的例子是从一个REST json API中加载一个Items列表,比方说一个用户列表,并将它们显示在一个ListView中。使用MVP,在真实环境中这个工作是这样的:

这里没有新东西。List<User>被加载并且GUI 通过使用 UserAdapterListView中显示用户。我敢肯定,你之前已经千万次的使用了ListViewAdapter,但你可曾想过背后的想法Adapter?Adapter通过android UI控件使你的model可以显示出来。这就是适配器设计模式adapter design pattern。如果我们想要支持手机和平板电脑,还都以不同的方式显示item呢?我们是实现两个适配器:PhoneUserAdapterTabletUserAdapter,然后在运行时选择合适的适配器么。

如果那样做,就真是“理想情况”了。如果我们必须对用户列表进行排序或者显示一些必须通过复杂(和CPU密集型)方式进行计算的事情呢?我们不能在UserAdapter中那样做,因为在主UI线程上做那些繁重的工作会导致listview滚动性能问题。因此,我们放到一个单独的线程中去做。随之而来的有两个问题:第一个是我们如何转换数据?我们拿我们的用户类,并添加一些额外的属性么?我们是否覆盖用户类的值?

1
2
3
4
public class User {
String firstname;
String lastname;
}

我们假设我们UserView想要显示全名,并计算一个排名使列表排序:

1
2
3
4
5
6
7
8
9
public class User {
String firstname;
String lastname;
int ranking;

public String getFullname(){
return firstname +" "+lastname;
}
}

虽然引入方法getFullname()是可以的,但添加ranking字段可能会导致问题,想象一下我们从后端检索得到的User可能并没有ranking。所以首先,如果你看看你的json api提要,并将它与我们的User类进行比较,最后但不是最不重要的ranking 将设为默认值零,因为我们还没有计算出排名。如果我们使用了一个对象而不是一个整数,那么默认值就是null,并且很可能会遇到NullPointerException。

解决方案是引入一个 Presentation Model。这个模型只是为我们的GUI优化的一个类:

1
2
3
4
5
6
public class UserPresentationModel {
String fullname;
int ranking;

public UserPresentationModel(String fullname, int ranking) { ... }
}

通过这样做,我们确定ranking始终被设置为一个具体值,并且在滚动ListView时不会计算fullname(PresentationModel在独立线程中实例化)。UserView现在显示List<UserPresentationModel>而不是List<User>

第二个问题是:在哪里做异步转换?View, Model 还是 Presenter? 很明显,View进行这种转换操作,因为View知道如何在屏幕上显示事物。

PresentationModelTransformer是接受List<User>并将其“转换”到List<UserPresentationModel>的组件(适配器模式,所以我们有两个adapter:一个转换为表示模型,另一个是在ListView中显示它们的UserAdapter)。在view中整合PresentationModelTransformer的优势在于,view知道如何显示内容,并且可以在内部轻松切换 手机和平​​板电脑优化了的演示模型(可能平板电脑的用户界面跟手机比还有其他需求)。但是,最大的缺点是现在view必须控制异步线程和视图状态(在进行转换时显示ProgressBar?!?),这显然是Presenter的工作。因此,让转换成为view的一部分并不是一个好主意。在Presenter中包括转换是将要做的:

正如我们前面已经讨论的那样,Presenter负责协调View,因此Presenter告诉view在UserPresentationModel转换完成后显示ListView 。此外,Presenter可以控制所有异步线程(转换的异步线程),并在必要时取消它们。顺便说一下:使用RxJava,你可以使用类似map()或者flatMap()操作符进行转换。如果我们想要支持手机和平板电脑,我们可以定义两个实现了不同PresentationModelTransformer的Presenter PhoneUserPresenterTabletUserPresenter。在Mosby,View创建Presenter。由于在运行时View知道是手机还是平板电脑,因此可以在运行时选择不同的Presenter实例化(PhoneUserPresenter或TabletUserPresenter)。或者,你可以为手机和平板电脑使用同一个UserPresenter,仅通过使用依赖注入替换PresentationModelTransformer的实现。

坚持原创技术分享,您的支持将鼓励我继续创作!