转:安卓中的Model-View-Presenter模式介绍

英文原文:Introduction to Model-View-Presenter on Android

翻译原文:http://jcodecraeer.com/a/anzhuokaifa/androidkaifa/2015/0425/2782.html

这是一篇观点比较激进的文章,完全否定了MVC模式在安卓开发的中的意义,认为其是没有任何用处的。这篇文章因为使用了静态变量来定义Presenter,因此在原文的评论部分也受到一些争议。不过我觉得还是从中学到了一些思想。我甚至同意MVC模式在安卓开发的中毫无意义的说法。没有完美的文章。另外关于MVP,还看到了一篇思路更清晰的文章,也准备翻译出来。

这是一篇安卓中MVP模式的详细教程,从最简单的例子到最佳实践。本文还介绍了一个让在安卓中使用MVP模式变得非常简单的library。

它是不是很简单,我们如何才能从中获益?

什么是MVP

.View是指显示数据并且和用户交互的层。在安卓中,它们可以是一个Activity,一个Fragment,一个android.view.View或者是一个Dialog。

.Model是数据源层。比如数据库接口或者远程服务器的api。

.Presenter是从Model中获取数据并提供给View的层,Presenter还负责处理后台任务。

MVP是一个将后台任务和activities/views/fragment分离的方法,让它们独立于绝大多数跟生命周期相关的事件。这样应用就会变得更简单,整个应用的稳定性提高10倍以上,代码也变得更短,可维护性增强,程序员也不会过劳死了~~。

为什么要在安卓上使用MVP

原因之一: 尽量简单

如果你还没有阅读过这篇文章,阅读它:Kiss原则。- kiss是Keep It Stupid Simple或者Keep It Simple, Stupid的缩写。

.绝大多数的安卓程序都只使用了View-Model架构。

.程序员被绞尽了复杂的界面开发中,而不是解决事务逻辑。

在应用中使用Model-View的坏处是“每个东西之间都是相互关联的”如下图:

如果上面的图解看起来还不够复杂,那么想想这些情况:每个view可能在任意的时间出现或者消失,view数据需要保存与恢复,在临时的view上挂载一个后台任务。

而与“每个东西之间都是相互关联的”的相反选择是使用一个万能对象(god object)。注:god object是指一个对象/例程在系统中做了太多的事情,或者说是有太多不怎么相关的事情放在一个对象/例程里面来完成。

god object过于复杂,他的不同部分无法重用、测试,无法轻易的debug和重构。

使用MVP

.复杂的任务被分割成简单的任务。

.更小的对象,更少的bug。

.更好测试

MVP的view层变得如此简单,在请求数据的时候甚至不需要使用回调。view的逻辑变得非常直接。

原因之二: 后台任务

当你需要写一个Activity,Fragment或者一个自定义View的时候,你可以将所有和后台任务相关的方法放在一个外部的或者静态的类中。这样你的后台任务就不会再与Activity相关联,不会在泄漏内存同时也不会依赖于Activity的重建。我们称这样的一个类为“Presenter”。注:要理解此话的含义最好先看懂第一个MVP示例的代码。

虽然有一些方法可以解决后台任务的问题,但是没有一种和MVP一样可靠。

为什么这是可行的

下面的图解显示了在configuration改变或者发生out-of-memory事件的情况下应用的不同部分所发生的事情。每一个开发者都应该知道这些数据,但是这些数据并不好发现。

|Case1|Case2|Case3

|A configuration|Anactivity|A process

|change|restart|restart

----------------------------------------|-------------|------------|------------

Dialog|reset|reset|reset

Activity,View,Fragment|save/restore|save/restore|save/restore

FragmentwithsetRetainInstance(true)|nochange|save/restore|save/restore

Staticvariablesandthreads|nochange|nochange|reset

情景1:configuration的改变通常发生在旋转屏幕,修改语言设置,链接外部的模拟器等情况下。要知道更多的configuration change事件请阅读:configChanges

情景2:Activity的重启发生在当用户在开发者选项中选中了“Don't keep activities”(“中文下为 不保留活动”)的复选框,然后另一个Activity在最顶上的时候。

情景3:进程的重启发生在应用运行在后台,但是这个时候内存不够的情况下。

结论

现在你可以发现,一个拥有setRetainInstance(true)的Fragment并没有带来帮助 - 我们还是要保存和/恢复这种fragment的状态。因此我们可以去掉可保持Fragment的情景,把问题简单化。Occam's razor.

|A configuration|

|change,|

|Anactivity|A process

|restart|restart

----------------------------------------|-------------|-------------

Activity,View,Fragment,DialogFragment|save/restore|save/restore

Staticvariablesandthreads|nochange|reset

现在看起来就好多了。我们只需要写两部分代码来实现任意情况下完全恢复应用的状态:

.保存/恢复Activity, View, Fragment, DialogFragment;

.在进程重启的情况下重新开启后台请求。

第一部分我们可以通过常规的Android API方式来实现,第二部分就是Presenter的工作了。Presenter可以记住哪个请求应该被执行,并且在执行期间如果进程重启,Presenter可以重新执行这些请求。

一个简单的例子 (未使用MVP)

这个例子将从远程服务器中加载与显示一些item元素(就是显示在ListView中的意思)。如果遇到错误会显示一个toast提示。

我推荐使用RxJava来建立presenter,因为这个库可以让数据流的控制更简单。

我还要感谢那个创立了一个简单api的小伙伴,我的例子中用到了它:The Internet Chuck Norris Database。作者的远程数据就是来自于这个api。貌似是一个提供笑话内容的api。

不使用 MVP示例 00:

publicclassMainActivityextendsActivity{

publicstaticfinalStringDEFAULT_NAME="Chuck Norris";

privateArrayAdapteradapter;

privateSubscriptionsubscription;

@Override

publicvoidonCreate(BundlesavedInstanceState){

super.onCreate(savedInstanceState);

setContentView(R.layout.activity_main);

ListViewlistView=(ListView)findViewById(R.id.listView);

listView.setAdapter(adapter=newArrayAdapter<>(this,R.layout.item));

requestItems(DEFAULT_NAME);

}

@Override

protectedvoidonDestroy(){

super.onDestroy();

unsubscribe();

}

publicvoidrequestItems(Stringname){

unsubscribe();

subscription=App.getServerAPI()

.getItems(name.split("\\s+")[0],name.split("\\s+")[1])

.delay(1,TimeUnit.SECONDS)

.observeOn(AndroidSchedulers.mainThread())

.subscribe(newAction1(){

@Override

publicvoidcall(ServerAPI.Responseresponse){

onItemsNext(response.items);

}

},newAction1(){

@Override

publicvoidcall(Throwableerror){

onItemsError(error);

}

});

}

publicvoidonItemsNext(ServerAPI.Item[]items){

adapter.clear();

adapter.addAll(items);

}

publicvoidonItemsError(Throwablethrowable){

Toast.makeText(this,throwable.getMessage(),Toast.LENGTH_LONG).show();

}

privatevoidunsubscribe(){

if(subscription!=null){

subscription.unsubscribe();

subscription=null;

}

}

}

注:别被RxJava吓到,你就当成一般的异步请求就行了。

一个有经验的开发者应该注意到这个简单的例子存在很严重的问题:

.每次用户翻转屏幕的时候都会开始请求 - app做了多余实际需要的请求,并且用户在旋转屏幕之后会观察到一段时间的空白屏幕。

.如果用户翻转屏幕的此时很频繁会导致内存泄漏 - 每次回调都会保存一个对MainActivity的引用,在请求运行的时候这个引用将保存在内存中。这几乎会必然导致应用因为out-of-memory错误或者运行缓慢而崩溃。

译者注:为什么平时我们没有发现这样的问题?因为我们完全不去考虑用户频繁旋转屏幕的情况,我们认为用户这样用手机是找虐,还有,绝大多数的中文应用都禁止屏幕旋转,只有竖屏,因此就避免了这种问题的发生。

使用MVP示例 01:

publicclassMainPresenter{

publicstaticfinalStringDEFAULT_NAME="Chuck Norris";

privateServerAPI.Item[]items;

privateThrowableerror;

privateMainActivityview;

publicMainPresenter(){

App.getServerAPI()

.getItems(DEFAULT_NAME.split("\\s+")[0],DEFAULT_NAME.split("\\s+")[1])

.delay(1,TimeUnit.SECONDS)

.observeOn(AndroidSchedulers.mainThread())

.subscribe(newAction1(){

@Override

publicvoidcall(ServerAPI.Responseresponse){

items=response.items;

publish();

}

},newAction1(){

@Override

publicvoidcall(Throwablethrowable){

error=throwable;

publish();

}

});

}

publicvoidonTakeView(MainActivityview){

this.view=view;

publish();

}

privatevoidpublish(){

if(view!=null){

if(items!=null)

view.onItemsNext(items);

elseif(error!=null)

view.onItemsError(error);

}

}

}

严格意义上来说MainPresenter有三个事件:onNext, onError, onTakeView(onNext指代view.onItemsNext,同理onError指代view.onItemsError)。这三个事件在publish()方法中结合到了一起。onNext和onError的值被发布给了onTakeView()方法提供的MainActivity的实例。

publicclassMainActivityextendsActivity{

privateArrayAdapteradapter;

privatestaticMainPresenterpresenter;

@Override

publicvoidonCreate(BundlesavedInstanceState){

super.onCreate(savedInstanceState);

setContentView(R.layout.activity_main);

ListViewlistView=(ListView)findViewById(R.id.listView);

listView.setAdapter(adapter=newArrayAdapter<>(this,R.layout.item));

if(presenter==null)

presenter=newMainPresenter();

presenter.onTakeView(this);

}

@Override

protectedvoidonDestroy(){

super.onDestroy();

presenter.onTakeView(null);

if(isFinishing())

presenter=null;

}

publicvoidonItemsNext(ServerAPI.Item[]items){

adapter.clear();

adapter.addAll(items);

}

publicvoidonItemsError(Throwablethrowable){

Toast.makeText(this,throwable.getMessage(),Toast.LENGTH_LONG).show();

}

}

MainActivity创建MainPresenter,并让它在onCreate/onDestroy的周期之外。MainActivity用静态变量来引用MainPresenter,因此每次进程因为out-of-memory事件重启的时候,MainActivity都会检查presenter是否还在,如果必要再新建一个。是的,使用静态变量看起来会觉得让人不舒服,但是稍后我们会告诉你如何好看些:

主要的考虑是:

示例程序不会在每次切换屏幕的时候都开始一个新的请求。

如果进程重启,示例程序会重新加载数据。

在MainActivity销毁(destroyed)的时候MainPresenter不会再持有对MainActivity的引用,因此不会在切换屏幕的时候发生内存泄漏,而且没必要去unsubscribe请求。

Nucleus

Nucleus是我从Mortar库和Keep It Stupid Simple这篇文章得到的灵感而建立的库。

下面列出其特点:

1.支持在View、Fragment或者Activity的Bundle中保存与恢复Presenter的状态。Presenter可以将请求参数保存在这个bundle中,在稍后重启请求。

2.只需一行代码就能将请求的结果与错误信息交给view,你不需要写什么!= null之类的检查代码。

3.presenter允许拥有多个View的实例。不过你不能在用Dagger实例化的presenter中这样使用。

4.支持只用一行代码将presenter和view绑定。

5.提供一些现成的基类:NucleusView, NucleusFragment, NucleusSupportFragment, NucleusActivity。你可以将他们的代码拷贝出来改造出一个自己的类以利用Nucleus的presenter。

6.支持在进程重启的时候自动重启一个请求,以及在销毁(onDestroy)期间自动取消RxJava的订阅。

7.最后,它非常简单,任何一个开发者都能理解。只有Presenter的驱动只有180行代码,而对于RxJava的支持只有230行代码。

Nucleus的例子example 02

publicclassMainPresenterextendsRxPresenter{

publicstaticfinalStringDEFAULT_NAME="Chuck Norris";

@Override

protectedvoidonCreate(BundlesavedState){

super.onCreate(savedState);

App.getServerAPI()

.getItems(DEFAULT_NAME.split("\\s+")[0],DEFAULT_NAME.split("\\s+")[1])

.delay(1,TimeUnit.SECONDS)

.observeOn(AndroidSchedulers.mainThread())

.compose(this.deliverLatestCache())

.subscribe(newAction1(){

@Override

publicvoidcall(ServerAPI.Responseresponse){

getView().onItemsNext(response.items);

}

},newAction1(){

@Override

publicvoidcall(Throwablethrowable){

getView().onItemsError(throwable);

}

});

}

}

@RequiresPresenter(MainPresenter.class)

publicclassMainActivityextendsNucleusActivity{

privateArrayAdapteradapter;

@Override

publicvoidonCreate(BundlesavedInstanceState){

super.onCreate(savedInstanceState);

setContentView(R.layout.activity_main);

ListViewlistView=(ListView)findViewById(R.id.listView);

listView.setAdapter(adapter=newArrayAdapter<>(this,R.layout.item));

}

publicvoidonItemsNext(ServerAPI.Item[]items){

adapter.clear();

adapter.addAll(items);

}

publicvoidonItemsError(Throwablethrowable){

Toast.makeText(this,throwable.getMessage(),Toast.LENGTH_LONG).show();

}

}

就如你看到的那样,这个例子比前面的例子要简短多了。Nucleus可以创建/销毁/保存 presenter,附加或者解除和一个view的关系,并且自动向附加的view发送请求。

MainPresenter的代码变短是因为我们使用了deliverLatestCache()操作将数据源发出的所有数据与错误信息延迟到了view可用之后。它还能将数据缓存到内存中,因此可以在onfiguration change的时候重用。

警告!这里有一个注解!在安卓的世界里,如果你使用了注解,最好检查一下它是否会影响性能。

MainActivity的代码变简单了是因为presenter的创建是由NucleusActivity管理的。你只需要写上@RequiresPresenter(MainPresenter.class) 就能绑定presenter。我在Galaxy S(2010年的设备)上的检测结果显示,注解在这里

只花费了不到0.3ms。只在实例化view的时候才会发生,因此注解在这里对性能的影响可以忽略。

更多示例

带有保持请求参数的拓展示例在这里:Nucleus Example.

带有单元测试的例子:Nucleus Example With Tests

deliverLatestCache() 方法

这个RxPresenter的工具方法有三个变种:

deliver() will just delay all onNext, onError and onComplete emissions until a View becomes available. Use it for cases when you're doing a one-time request, like logging in to a web service.Javadoc

deliverLatest() will drop the older onNext value if a new onNext value is available. If you have an updatable source of data this will allow you to not accumulate data that is not necessary.Javadoc

deliverLatestCache() is the same as deliverLatest() but in addition it will keep the latest result in memory and will re-deliver it when another instance of a view becomes available (i.e. on configuration change). If you don't want to organize save/restore of a request result in your view (in case if a result is big or it can not be easily saved into Bundle) this method will allow you to make user experience better.Javadoc

Presenter的生命周期

Presenter的生命周期要比安卓组建的生命周期简短得多

void onCreate(Bundle savedState) - 在Presenter创建的时候调用Javadoc

void onDestroy() - 在用户离开一个view的时候调用Javadoc

void onSave(Bundle state) - 在View的onSaveInstanceState同时也是Presenter的状态保持的时候被调用Javadoc

void onTakeView(ViewType view) -  在Activity或者Fragment的Resume()或者android.view.View#onAttachedToWindow()的时候调用.Javadoc

void onDropView() - 在Activity或者Fragment的onPause()或者android.view.View#onDetachedFromWindow()的时候调用.Javadoc

View的生命周期与view栈

通常来说你的view(比如fragment或者自定义的view)在用户的交互过程中挂载与解挂(attached and detached)都是随机发生的。 这倒是不让presenter在view每次解挂(detached)的时候都销毁的一个启发。你可以在任何时候挂载与解挂view,但是presenter可以在这些行为中幸存下来,继续后台的工作。

关于view的周期有一个问题:fragment会因为configuration change或者从栈中去掉而不知道自己是否被解挂(detached)。

Nucleus view默认:只有在activity结束的时候,在view的onDetachedFromWindow()/onDestroy()期间才会销毁presenter。

因此,如果你要在Activity正常的生命期间销毁一个view,你必须向view发出presenter也必须销毁的信号。通过公共方法NucleusLayout.destroyPresenter()和NucleusFragment.destroyPresenter()来做这个事情。

比如,下面是fragment manager的pop()操作在我的一个项目里是如何工作的:

fragment=fragmentManager.findFragmentById(R.id.fragmentStackContainer);

fragmentManager.popBackStackImmediate();

if(fragmentinstanceofNucleusFragment)

((NucleusFragment)fragment).destroyPresenter();

同样在fragment的replace操作中也要做相同的事情,在最底部的fragment销毁的时候也要如此。

你可能会决定在每次view从Activity解挂的时候都销毁presenter来避免这样的问题,但是如果这样的话,在view销毁的时候你无法继续后台任务。所以这一节的 "view recycling"完全留你你自己考虑,也许有一天我会找到更好的解决办法,如果你有一个办法,请告诉我。

最佳实践

在Presenter中保存你的请求参数

规则很简单:presenter的主要职能是管理请求。因此view不应该去处理或者开始请求。从view的角度来看,后台任务是永不消失的,总是会返回一个结果或者错误信息的,不需要任何回调的。

publicclassMainPresenterextendsRxPresenter{

privateStringname=DEFAULT_NAME;

@Override

protectedvoidonCreate(BundlesavedState){

super.onCreate(savedState);

if(savedState!=null)

name=savedState.getString(NAME_KEY);

...

@Override

protectedvoidonSave(@NonNullBundlestate){

super.onSave(state);

state.putString(NAME_KEY,name);

}

我推荐你使用酷爆了的Icepick库。在不使用运行时注解的前提下,它减少了代码量并且简化了app的逻辑,所有的事情都在编译过程中就完成了,是ButterKnife的好伴侣。

publicclassMainPresenterextendsRxPresenter{

@IcicleStringname=DEFAULT_NAME;

@Override

protectedvoidonCreate(BundlesavedState){

super.onCreate(savedState);

Icepick.restoreInstanceState(this,savedState);

...

@Override

protectedvoidonSave(@NonNullBundlestate){

super.onSave(state);

Icepick.saveInstanceState(this,state);

}

如果你有多个请求参数,这个库可以帮助你节省不少时间。你可以创建一个BasePresenter,然后将Icepick放到里面,所有的子类将自动保存被@Icicle注释的变量,你再也不需要实现onSave了。这对于Activity和Fragment或者View也同样适用。

Execute instant queries on the main thread in onTakeViewJavadoc

有时候我们的数据查询量并不大,比如从数据库中读取少量的数据。虽然使用Nucleus创建一个可重启的请求非常简单,但是你不需要每次都用。如果你在fragment创建的时候初始化一个后台请求,即使只有几毫秒,用户也会看到一会儿的空白屏。因此为了代码的简洁,也为了用户的感受,使用主线程来初始化。

不要让Presenter控制View

这种情况不好工作 - application的逻辑因为使用了不自然的方式变得非常复杂。

最自然的方式是用户的操作流从view,到presenter到model最后到数据。这样用户才是控制应用的源头。对应用的控制应该来源于用户,而不是应用的内部结构。从view,到presenter到model是很直接的形式,这样的代码也很好写,操作流是这样的user -> view -> presenter -> model -> data;但是像这样的操作流:user -> view -> presenter -> view -> presenter -> model -> data,是违背了KISS原则的。

什么?Fragment?不好意思它是违背了这种自然操作流程的。他们太复杂。这里有一篇关于看待fragment的好文章:不提倡 Android Fragment。但是fragment的替代者Flow并没有简化多少东西。

MVC

如果你熟悉MVC(Model-View-Controller)- 别那样做。Model-View-Controller和MVP完全不同,也并没有解决用户界面开发上的任何问题。

什么是MVC?

Modelstands here for internal application state. It can or can not be connected with a storage.

Viewis the only thing that is partially common with MVP - it is a part of an application that rendersModelto the screen.

Controllerrepresents an input device, such as keyboard, mouse or joystick.

MVC在过去以键盘为驱动的应用中(比如游戏),是比较好的模式。没有窗口和图形用户界面的交互-程序接受输入(Controller),维护状态(Model),以及显示输出(View)。数据与操作类似于:controller -> model -> view.但是这种模式在安卓中完全无用。MVC有太多的困扰。人们认为他们在使用MVC,其实使用的的MVP(web开发者)。许多安卓开发者认为Controller应该是控制view的东西,因此他们将view的逻辑从view中分离,创建一个轻量级的被代理Controller控制的view。我个人是没有看出这种架构的好处。

在数据复杂的项目中使用固定的数据结构

AutoValue是做这件事的一个优秀的库,在他的描述中有其优点的列表,建议阅读。有安卓的接口AutoParcel。使用固定数据对象的主要原因是你可以四处传递,而不用关心是否在程序的某个地方被修改了。而且它们是线程安全的。

结论

试试mvp吧,并告诉你的朋友。

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

推荐阅读更多精彩内容