浅析MVP模式中V-P交互问题及案例分享
在差不多两年的时间内,我们项目组几十来号人都扑在一个项目上面。这是一个基于微软scsf(smart client software factory)的项目,客户端是墨尔本一家事业单位。前两周,我奉命负责对某个模块进行code review工作,在此期间,发现了一些问题,也有了一些想法。不过,有些想法可能还不是很成熟,不能完全保证其正确性,有机会写出来讨论一下。今天来说说关于mvp的一些想法。
一、简单讲讲mvp是什么玩意儿
如果从层次关系来讲,mvp属于presentation层的设计模式。对于一个ui模块来说,它的所有功能被分割为三个部分,分别通过model、view和presenter来承载。model、view和presenter相互协作,完成对最初数据的呈现和对用户操作的响应,它们具有各自的职责划分。model可以看成是模块的业务逻辑和数据的提供者;view专门负责数据可视化的呈现,和用户交互事件的相对应。一般地,view会实现一个相应的接口;presenter是一般充当model和view的纽带。
mvp具有很多的变体,其中最为常用的一种变体成为passive view(被动视图)。对于passive view,model、view和presenter之间的关系如下图所示。view和modell之间不能直接交互,view通过presenter与model打交道。presenter接受view的ui请求,完成简单的ui处理逻辑,并调用model进行业务处理,并调用view将相应的结果反映出来。view直接依赖presenter,但是presenter间接依赖view,它直接依赖的是view实现的接口。关于mvp和passive view基本的常识性东西,不是本篇文章论述的重点,对此不清楚的读者相信可以google出很多相关的资料来,所以在这里就再多做介绍了。
二、passive view模式的基本特征总结
passive view,顾名思义,view是被动的。那么主动是谁呢?答案是presenter。对于presenter的主动性,我个人是这么理解的:
•presenter是整个mvp体系的控制中心,而不是单纯的处理view请求的人;
•view仅仅是用户交互请求的汇报者,对于响应用户交互相关的逻辑和流程,view不参与决策,真正的决策者是presenter;
•view向presenter发送用户交互请求应该采用这样的口吻:“我现在将用户交互请求发送给你,你看着办,需要我的时候我会协助你”,不应该是这样:“我现在处理用户交互请求了,我知道该怎么办,但是我需要你的支持,因为实现业务逻辑的model只信任你”;
•对于绑定到view上的数据,不应该是view从presenter上“拉”回来的,应该是presenter主动“推”给view的;
•view尽可能不维护数据状态,因为其本身仅仅实现单纯的、独立的ui操作;presenter才是整个体系的协调者,它根据处理用于交互的逻辑给view和model安排工作。
三、理想与现实的距离
上面对passive view mvp特征的罗列,我觉得是一种理想状态。是在大型项目中,尤其是项目的开发者自身并不完全理解mvp原理的情况下,要整体实现这样的一种理想状态是一件很难的事情。有人可能会说,在开发人员不了解mvp的情况下要求他们用好mvp,你这不是扯淡吗?实际上,在这里并不是说开发人员完全没有mvp关于关注点分离的概念,只是对mvp中的三元角色并没有非常清晰的界定(实际上也没有一个明确的规范对model、view和presenter具体的职责范围进行明确的划分),在开发的时候,会不自觉地受传统编程习惯的影响,将presenter单纯地当成是view调用model的中介。我经常这么说:如果以view为中心,将presenter当成是view和model的中间人,这也叫mvp模式,不过这里的p不是presenter,而是proxy,是model在view的代理而已。
从passive view中model、view和presenter三者之间的依赖关系来看,这个模型充分地给了开发者犯这样错误的机会。注意上面的图中view到presenter的箭头表明view是可以任意的调用presenter的。开发人员完全有可能将大部分ui处理逻辑写在view中,而presenter仅仅对model响应操作的简单调用。因为在我review的各种所谓的mvp编程方式中,有不少是这么写的。在很多情况下,甚至不用认真去分析具体的代码,从view和presenter中代码的行数就可以看出来,因为view的代码和presenter的代码都不在一个数量级。
我现在的一个目的是提出一种编程模式,杜绝开发人员将程序写成基于proxy的mvp,在我看来,唯一的办法就是尽量弱化(不可能剔除)view对presenter的依赖。实际上,对于mvp来说,view仅仅向presenter递交用户交互请求,仅此而已。如果我们将view对presenter的这点依赖关系实现在框架层次中,最终开发人员的编程来说就不需要这种依赖了。那么我就可以通过一定的编程技巧使view根本无法访问presenter,从而避免presenter成为proxy的可能的。
那么,如果在不能获得presenter的情况下,使view能够正常将请求递交给presenter呢?很简单,通过事件订阅机制就可以了,虽然view不可以获取到presenter,但是presenter却可以获取到view,让presenter订阅view的相关事件就可以的。
四、让view不再依赖presenter的编程模型
现在,我们就来如果通过一种简单的编程模式就能够让view对presenter的依赖完全地从中最终开发者的源代码中移除。为此,我们需要定义一系列的基类,首先我为所有的view创建基类viewbase,在这里我们直接用form作为view,而在scsf中view一般是通过usercontrol来表示的。viewbase定义如下,为了使view中不能调用presenter,我将其定义成私有字段。那么,如何让view和presenter之间建立起关联呢?在这里通过虚方法createpresenter,具体的view必须重写该方法,不然会抛出一个notimplementedexception异常。在构造函数中,调用该方法比用返回值为presenter赋值。
using system;
using system.componentmodell;
using system.windows.forms;
namespace mvpdemo
{
public class viewbase: form
{
private object _presenter;
public viewbase()
{
_presenter = this.createpresenter();
}
protected virtual object createpresenter()
{
if (licensemanager.currentcontext.usagemodel == licenseusagemodel.designtime)
{
return null;
}
else
{
throw new notimplementedexception(string.format("{0} must override the createpresenter method.", this.gettype().fullname));
}
}
}
}
然后,我们也为所有的presenter创建基类presenter<iview>,泛型类型iview表示具体view实现的接口。表示view的同名只读属性在构造函数中赋值,赋值完成之后调用调用虚方法onviewset。具体的presenter可以重写该方法进行对view进行事件注册工作。但是需要注意的是,presenter的创建是在viewbase的构造函数中通过调用createpresenter方法实现,所以执行onviewset的时候,view本身还没有完全初始化,所以在此不能对view的控件进行操作。
namespace mvpdemo
{
public class presenter<iview>
{
public iview view { get; private set; }
public presenter(iview view)
{
this.view = view;
this.onviewset();
}
protected virtual void onviewset()
{ }
}
}
由于,presenter是通过接口的方式与view进行交互的。在这里,由于view通过form的形式体现,有时候我们要通过这个接口访问form的一些属性、方法和事件,需要将相应的成员定义在接口上面,比较麻烦。此时,我们可以选择将这些成员定义在一个接口中,具体view的接口继承该接口就可以了。在这里,我们相当是为所有的view接口创建了“基接口”。作为演示,我现在了form的三个事件成员定义在街口iviewbase中。
using system;
using system.componentmodell;
namespace mvpdemo
{
public interface iviewbase
{
event eventhandler load;
event eventhandler closed;
event canceleventhandler closing;
}
}
五、实例演示
上面我通过定义基类和接口为整个编程模型搭建了一个框架,现在我们通过一个具体的例子来介绍该编程模型的应用。我们采用的是一个简单的windows forms应用,模拟管理客户信息的场景,逻辑很简单:程序启动的时候显示出所有的客户端列表;用户选择某一客户端,将响应的信息显示在textbox中以供编辑;对客户端信息进行相应修改之后,点击ok按钮进行保存。整个操作界面如下图所示:
首先,我们创建实体类customer,简单起见,仅仅包含四个属性:id、firstname、lastname和address:
using system;
namespace mvpdemo
{
public class customer: icloneable
{
public string id
{ get; set; }
public string firstname
{ get; set; }
public string lastname
{ get; set; }
public string address
{ get; set; }
object icloneable.clone()
{
return this.clone();
}
public customer clone()
{
return new customer {
id = this.id,
firstname = this.firstname,
lastname = this.lastname,
address = this.address
};
}
}
}
然后,为了真实模拟mvp三种角色,特意创建一个customermodel类型,实际上在真实的应用中,并没有单独一个类型来表示model。customermodel维护客户列表,体统相关的查询和更新操作。customermodel定义如下:
using system.collections.generic;
using system.linq;
namespace mvpdemo
{
public class customermodel
{
private ilist<customer> _customers = new list<customer>{
new customer{ id = "001", firstname = "san", lastname = "zhang", address="su zhou"},
new customer{ id = "002", firstname = "si", lastname = "li", address="shang hai"}
};
public void updatecustomer(customer customer)
{
for (int i = 0; i < _customers.count; i++)
{
if (_customers[i].id == customer.id)
{
_customers[i] = customer;
break;
}
}
}
public customer getcustomerbyid(string id)
{
var customers = from customer in _customers
where customer.id == id
select customer.clone();
return customers.toarray<customer>()[0];
}
public customer[] getallcustomers()
{
var customers = from customer in _customers
select customer.clone();
return customers.toarray<customer>();
}
}
}
接着,我们定义view的接口icustomerview。icustomerview定义了两个事件,customerselected在用户从gird中选择了某个条客户记录是触发,而customersaving则在用户完成编辑点击ok按钮视图提交修改时触发。icustomerview还定义了view必须完成的三个基本操作:绑定客户列表(listallcustomers);显示单个客户信息到textbox(displaycustomerinfo);保存后清空可编辑控件(clear)。
using system;
namespace mvpdemo
{
public interface icustomerview : iviewbase
{
event eventhandler<customereventargs> customerselected;
event eventhandler<customereventargs> customersaving;
void listallcustomers(customer[] customers);
void displaycustomerinfo(customer customer);
void clear();
}
}
事件参数的类型customereventargs定义如下,两个属性customerid和customer分别代表客户id和具体的客户,它们分别用于上面提到的customerselected和customersaving事件。
using system;
namespace mvpdemo
{
public class customereventargs : eventargs
{
public string customerid
{ get; set; }
public customer customer
{ get; set; }
}
}
而具体的presenter定义在如下的customerpresenter类型中。在重写的onviewset方法中注册view的三个事件:load事件中调用model获取所有客户列表,并显示在view的grid上;customerselected事件中通过事件参数传递的客户id调用model获取相应的客户信息,显示在view的可编辑控件上;customersaving则通过事件参数传递的被更新过的客户信息,调用model提交更新。
using system.windows.forms;
namespace mvpdemo
{
public class customerpresenter: presenter<icustomerview>
{
public customermodel model
{ get; private set; }
public customerpresenter(icustomerview view)
: base(view)
{
this.model = new customermodel();
}
protected override void onviewset()
{
this.view.load += (sender, args) =>
{
customer[] customers = this.model.getallcustomers();
this.view.listallcustomers(customers);
this.view.clear();
};
this.view.customerselected += (sender, args) =>
{
customer customer = this.model.getcustomerbyid(args.customerid);
this.view.displaycustomerinfo(customer);
};
this.view.customersaving += (sender, args) =>
{
this.model.updatecustomer(args.customer);
customer[] customers = this.model.getallcustomers();
this.view.listallcustomers(customers);
this.view.clear();
messagebox.show("the customer has been successfully updated!", "successfully update", messageboxbuttons.ok, messageboxicon.information);
};
}
}
}
对于具体的view来说,仅仅需要实现icustomerview,并处理响应控件事件即可(主要是用户从grid中选择某个记录触发的rowheadermouseclick事件,以及点击ok的事件)。实际上不需要view亲自处理这些事件,而仅仅需要触发相应的事件,让事件订阅者(presenter)来处理就可以了。此外还需要重写createpresenter方法完成对customerpresenter的创建。customerview定义如下:
using system;
using system.windows.forms;
namespace mvpdemo
{
public partial class customerview : viewbase, icustomerview
{
public customerview()
{
initializecomponent();
}
protected override object createpresenter()
{
return new customerpresenter(this);
}
#region icustomerview members
public event eventhandler<customereventargs> customerselected;
public event eventhandler<customereventargs> customersaving;
public void listallcustomers(customer[] customers)
{
this.datagridviewcustomers.datasource = customers;
}
public void displaycustomerinfo(customer customer)
{
this.buttonok.enabled = true;
this.textboxid.text = customer.id;
this.textbox1stname.text = customer.firstname;
this.textboxlastname.text = customer.lastname;
this.textboxaddress.text = customer.address;
}
public void clear()
{
this.buttonok.enabled = false;
this.textbox1stname.text = string.empty;
this.textboxlastname.text = string.empty;
this.textboxaddress.text = string.empty;
this.textboxid.text = string.empty;
}
#endregion
protected virtual void oncustomerselected(string customerid)
{
var previousid = this.textboxid.text.trim();
if (customerid == previousid)
{
return;
}
if(null != this.customerselected)
{
this.customerselected(this, new customereventargs{ customerid = customerid});
}
}
protected virtual void oncustomersaving(customer customer)
{
if(null != this.customersaving)
{
this.customersaving(this, new customereventargs{ customer = customer});
}
}
private void datagridviewcustomers_rowheadermouseclick(object sender, datagridviewcellmouseeventargs e)
{
var currentrow = this.datagridviewcustomers.rows[e.rowindex];
var customerid = currentrow.cells[0].value.tostring();
this.oncustomerselected(customerid);
}
private void buttonok_click(object sender, eventargs e)
{
var customer = new customer();
customer.id = this.textboxid.text.trim();
customer.firstname = this.textbox1stname.text.trim();
customer.lastname = this.textboxlastname.text.trim();
customer.address = this.textboxaddress.text.trim();
this.oncustomersaving(customer);
}
}
}
推荐阅读