为什么要写这篇文章
笔者当前正在负责研究所中一个项目,这个项目基于.NET平台,初步拟采用C/S部署体系,所以选择了Windows Forms作为其UI。经过几此迭代,我们发现了一个问题:虽然业务逻辑已经封装到Services层中,但诸多的UI逻辑仍然弥漫在各个事件Listener中,使得UI显得臃肿不堪,并且存在诸多重复性代码。另外,需求提供方说,根据实际需要,不排除将部署结构改为B/S的可能性,甚至可能会要求此系统同时支持C/S和B/S两种部署方式。那么,如果保持目前将UI逻辑编码到Windows Forms中的方式,到时这些UI逻辑将无法复用,修改部署方式的代价很大。
为了解决以上两个问题,笔者和相关人员商量后,决定引入既有成熟模式,重新设计表示层的架构方式,并重构既有代码。
提到表示层(Presentation Layer)的模式,我想大家脑海中第一个闪过的很可能是经典的MVC(Model-View-Controller)。我最初也准备使用MVC,但经过分析和实验后,我发现MVC并不适合目前的情况,因为MVC的结构相对复杂,Model和View之间要实现一个Observer模式,并实现双向通信。这样重构起来Services层也必须修改。我并不想修改Services层,而且我想将View和Model彻底隔离,因为我个人并不喜欢View和Model直接通信的架构方式。最终,我选择了MVP(Model-View-Presenter)模式。
经过两天的重构和验证,目前已经将MVP正式引入项目的表示层,并且解决了上文提到的两个问题。在这期间,积累了少许关于在.NET平台上实践MVP的经验,在这里汇集成此文,和朋友们共享。
UI与P Logic
首先,我想先明确一下UI和P Logic的概念。
表示层可以拆分为两个部分:User Interface(简称UI)和Presentation Logic(简称P Logic)。
UI是系统与用户交互的界面性概念,它的职责有两个——接受用户的输入和向用户展示输出。UI应该是一个纯静态的概念,本身不应包含任何逻辑,而单纯是一个接受输入和展示输出的“外壳”。例如,一个不包含逻辑的Windows Form,一张不包含逻辑的页面,一个不包含逻辑的Flex界面,都属于UI。
P Logic是表示层应有的逻辑性内容。例如,某个文本内容不能为空,当某个事件发生时获取界面上哪些内容,这都属于P Logic。应该指出,P Logic应该是抽象于具体UI的,它的本质是逻辑,可以复用到任何与此逻辑相符的UI。
UI与P Logic之间的联系是事件,UI可以根据用户的动作触发各种事件,P Logic响应事件并执行相应的逻辑。P Logic对UI存在约束作用,P Logic规定一套UI契约,UI要根据契约实现,才能被相应的P Logic调用。
下图展示了UI与P Logic的结构及交互原理。
图1、UI与P Logic
Model-View-Presenter模式
MVP模式最早由Taligent的Mike Potel在《MVP: Model-View-Presenter The Taligent Programming Model for C++ and Java》(点击这里下载)一文中提出。MVP的提出主要是为了解决MVC模式中结构过于复杂和模型-视图耦合性过高的问题。MVP的核心思想是将UI分离成View,将P Logic分离成Presenter,而业务逻辑和领域相关逻辑都分离到Model中。View和Model完全解除耦合,不再像MVC中实现一个Observer模式,两者的通信则依靠Presenter进行。Presenter响应View接获的用户动作,并调用Model中的业务逻辑,最后将用户需要的信息返回给View。
下图直观表示了MVP模式:
图2、MVP模式
图2清楚地展示了MVP模式的几个特点:
1、View和Model完全解耦,两者不发生直接关联,通过Presenter进行通信。
2、Presenter并不是与具体的View耦合,而是和一个抽象的View Interface耦合,View Interface相当于一个契约,抽象出了对应View应实现的方法。只要实现了这个接口,任何View都可以与指定Presenter兼容,从而实现了P Logic的复用性和视图的无缝替换。
3、View在MVP里应该是一个“极瘦”的概念,最多也只能包含维护自身状态的逻辑,而其它逻辑都应实现在Presenter中。
总的来说,使用MVP模式可以得到以下两个收益:
1、将UI和P Logic两个关注点分离,得到更干净和单一的代码结构。
2、实现了P Logic的复用以及View的无缝替换。
在.NET平台上实现MVP模式
这一节通过一个示例程序展示在.NET平台上实现MVP的一种实践方法。本来想通过我目前负责的实际项目中的代码片段作为Demo,但这样做存在两个问题:一是这样做可能会违反学校的保密守则,二是这个项目应用了许多其他框架和模式,如通过Unity实现依赖注入,通过PostSharp实现AOP来负责异常处理和事务管理等,通过NHibernate实现的ORM等等,这样如果读者不了解系统整体架构就很难完全读懂代码片段,MVP模式不够突出。因此,我专门为这篇文章实现了一个Demo,其中的MVP实践方式与实际项目中是一致的,而且Demo规模小,排除了其他干扰,使得读者更容易理解其中的MVP实现方式。
这个简单的Demo运行效果如下:
图3、Demo界面
这个Demo的功能如下:这是一个简单的点餐软件。系统中存有餐厅所有菜品的信息,客户只需在界面右侧输入菜品名称和数量,单击“添加”按钮,菜品就会被添加到左侧点餐列表,并显示此菜品详细信息。如果所点菜品不存在则软件会给出提示。另外,在左侧已点餐品列表中右键单击某个条目,在弹出菜单中点击“删除”,则可将此菜品从列表删除。
下面分步骤介绍应用了MVP模式的实现方式。
第一步,解决方法及工程结构
这个Demo共有三个工程,MVPSimple.Model为Mock方式实现的Services,作为Model;MVPSimple.Presenters为Presenter工程,其中包括Presenter和View Interface;MVPSimple.WinUI为View的Windows Forms实现。
第二步,构建Mock方式的Services
因为重点在于表示层,所以这里的Services使用了Mock方式,并没有包含真正的业务领域逻辑。其中MVPSimple.Model工程里两个文件的代码如下:
FoodDto.cs:
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
42
43
44
45
|
using System; namespace MVPSimple.Model { /// <summary> /// 表示菜品类别的枚举类型 /// </summary> public enum FoodType { 主菜 = 1, 汤 = 2, 甜品 = 3, } /// <summary> /// 菜品的Data Transfer Object /// </summary> public class FoodDto { /// <summary> /// ID,标识字段 /// </summary> public Int32 ID { get ; set ; } /// <summary> /// 菜品名称 /// </summary> public String Name { get ; set ; } /// <summary> /// 菜品类型 /// </summary> public FoodType Type { get ; set ; } /// <summary> /// 菜品价格 /// </summary> public Double Price { get ; set ; } /// <summary> /// 点菜数量 /// </summary> public Int32 Amount { get ; set ; } } } |
FoodServices.cs:
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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
|
using System; using System.Collections.Generic; namespace MVPSimple.Model { /// <summary> /// 菜品Services的Mock实现 /// </summary> public class FoodServices { private IList<FoodDto> foodList = new List<FoodDto>(); /// <summary> /// 默认构造函数,初始化各个菜品 /// </summary> public FoodServices() { this .foodList.Add( new FoodDto() { ID = 1, Name = "牛排" , Price = 60.00, Type = FoodType.主菜, } ); this .foodList.Add( new FoodDto() { ID = 2, Name = "法式蜗牛" , Price = 120.00, Type = FoodType.主菜, } ); this .foodList.Add( new FoodDto() { ID = 3, Name = "水果沙拉" , Price = 58.00, Type = FoodType.甜品, } ); this .foodList.Add( new FoodDto() { ID = 4, Name = "奶油红菜汤" , Price = 15.00, Type = FoodType.汤, } ); this .foodList.Add( new FoodDto() { ID = 5, Name = "杂拌汤" , Price = 20.00, Type = FoodType.汤, } ); } /// <summary> /// 按照菜品名称获取菜品详细信息 /// </summary> /// <param name="foodName">菜品名称</param> /// <returns>含有指定菜品信息的DTO</returns> public FoodDto GetFoodDetailByName(String foodName) { foreach (FoodDto f in this .foodList) { if (f.Name.Equals(foodName)) { return f; } } return new FoodDto() { ID = 0 }; } } } |
第三步,通过View Interface规定View契约
如果想实现Presenter和View的交互和无缝替换,必须在它们之间规定一个契约。一般来说,每一张界面(注意是界面不是视图)都应该对应一个View接口,不过由于Demo只有一个页面,所以也只有一个View接口。
这里需要特别强调,View接口必须抽象于任何具体视图而服务于Presenter,所以,View接口中绝不能出现任何与具体视图相关的元素。例如,我们的Demo中是使用Windows Forms作为视图实现,但View接口中绝不可出现与Windows Forms相耦合的元素,如返回一个Winform的TextBox。因为如果这样做的话,使用其他技术实现的View就无法实现这个接口了,如使用Web Forms实现,而Web Forms是不可能返回一个Winform的TextBox的。
下面给出视图接口的代码。
IMainView.cs:
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
42
43
44
45
46
47
48
49
50
51
52
53
54
|
using System; using System.Collections.Generic; using MVPSimple.Model; namespace MVPSimple.Presenters { /// <summary> /// MainView的接口,所有MainView必须实现此接口,此接口暴露给Presenter /// </summary> public interface IMainView { /// <summary> /// View上的菜品名称 /// </summary> String foodName { get ; set ; } /// <summary> /// View上点菜数量 /// </summary> Int32 Amount { get ; set ; } /// <summary> /// 判断某一菜品是否已经存在于点菜列表中 /// </summary> /// <param name="foodName">菜品名称</param> /// <returns>结果</returns> bool IsExistInList(String foodName); /// <summary> /// 将某一菜品加入点菜列表 /// </summary> /// <param name="food">菜品DTO</param> void AddFoodToList(FoodDto food); /// <summary> /// 将某一已点菜品从列表中移除 /// </summary> /// <param name="foodName">欲移除的菜品名称</param> void RemoveFoodFromList(String foodName); /// <summary> /// View显示提示信息给用户 /// </summary> /// <param name="message">信息内容</param> void ShowMessage(String message); /// <summary> /// View显示确认信息并返回结果 /// </summary> /// <param name="message">信息内容</param> /// <returns>用户回答是确定还是取消。True - 确定,False - 取消</returns> bool ShowConfirm(String message); } } |
可以看到,IMainView抽象了如图3所示的界面,但又不包含任何与Windows Forms相耦合的元素,因此如果需要,以后完全可以使用Web Forms、WPF或SL等技术实现这个接口。
第四步,实现Presenter
上文说过,一个界面应该对应一个Presenter,这个Demo里只有一个界面,所以只有一个Presenter。Presenter仅于视图接口耦合,而并不和具体视图耦合,最好证据就是Presenter工程根本没有引用WinUI工程!代码如下:
MainPresenter.cs:
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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
|
using System; using System.Collections.Generic; using MVPSimple.Model; namespace MVPSimple.Presenters { /// <summary> /// MainView的Presenter /// </summary> public class MainPresenter { /// <summary> /// 当前关联View /// </summary> public IMainView View { get ; set ; } /// <summary> /// 默认构造函数,初始化View /// </summary> /// <param name="view">MainView对象</param> public MainPresenter(IMainView view) { View = view; } #region Acitons /// <summary> /// Action:将所点菜品增加到点菜列表 /// </summary> public void AddFoodAction() { if (String.IsNullOrEmpty(View.foodName)) { View.ShowMessage( "请选输入菜品名称" ); return ; } if (View.Amount <= 0) { View.ShowMessage( "点菜的份数至少要是一份" ); return ; } if (View.IsExistInList(View.foodName)) { View.ShowMessage(String.Format( "菜品【{0}】已经在您的菜单中" , View.foodName)); return ; } FoodServices foodServ = new FoodServices(); FoodDto food = foodServ.GetFoodDetailByName(View.foodName); if (food.ID == 0) { View.ShowMessage(String.Format( "抱歉,本餐厅没有菜品【{0}】" ,View.foodName)); return ; } View.AddFoodToList(food); } /// <summary> /// Action:从点菜列表移除某一菜品 /// </summary> /// <param name="foodName">被移除菜品的名称</param> public void RemoveFoodAction(String foodName) { if (View.ShowConfirm( "确定要删除吗?" )) { View.RemoveFoodFromList(foodName); } } #endregion } } |
第五步,实现View
这里我们使用Windows Forms实现View。如果朋友们有兴趣,完全可以自己试着用Web或WPF实现以下视图,同时可以验证P Logic的可复用性和视图无缝替换,亲身体验一下MVP模式的威力。Winform的View代码如下。
frmMain.cs:
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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
|
using System; using System.Windows.Forms; using MVPSimple.Model; using MVPSimple.Presenters; namespace MVPSimple.WinUI { /// <summary> /// MainView的Windows Forms实现 /// </summary> public partial class frmMain : Form, IMainView { /// <summary> /// 相关联的Presenter /// </summary> private MainPresenter presenter; /// <summary> /// 默认构造函数,初始化Presenter /// </summary> public frmMain() { InitializeComponent(); this .presenter = new MainPresenter( this ); } #region IMainView Members /// <summary> /// View上的菜品名称 /// </summary> public String foodName { get { return this .tbFoodName.Text; } set { this .tbFoodName.Text = value; } } /// <summary> /// View上点菜数量 /// </summary> public Int32 Amount { get { return (Int32) this .tbAmount.Value; } set { this .tbAmount.Value = (Decimal)value; } } /// <summary> /// 判断某一菜品是否已经存在于点菜列表中 /// </summary> /// <param name="foodName">菜品名称</param> /// <returns>结果</returns> public bool IsExistInList(String foodName) { foreach (ListViewItem i in this .lvFoods.Items) { if (i.Text == foodName) { return true ; } } return false ; } /// <summary> /// 将某一菜品加入点菜列表 /// </summary> /// <param name="food">菜品DTO</param> public void AddFoodToList(FoodDto food) { ListViewItem item = new ListViewItem(); Double price = food.Price * (Double) this .tbAmount.Value; item.Text = food.Name; item.SubItems.Add(food.Type.ToString()); item.SubItems.Add( this .tbAmount.Value.ToString()); item.SubItems.Add(price.ToString()); this .lvFoods.Items.Add(item); } /// <summary> /// 将某一已点菜品从列表中移除 /// </summary> /// <param name="foodName">欲移除的菜品名称</param> public void RemoveFoodFromList(String foodName) { foreach (ListViewItem i in this .lvFoods.Items) { if (i.Text == foodName) { this .lvFoods.Items.Remove(i); } } } /// <summary> /// View显示提示信息给用户 /// </summary> /// <param name="message">信息内容</param> public void ShowMessage(String message) { MessageBox.Show(message, "信息" , MessageBoxButtons.OK, MessageBoxIcon.Warning); } /// <summary> /// View显示确认信息并返回结果 /// </summary> /// <param name="message">信息内容</param> /// <returns>用户回答是确定还是取消。True - 确定,False - 取消</returns> public bool ShowConfirm(String message) { DialogResult result = MessageBox.Show(message, "确认" , MessageBoxButtons.OKCancel, MessageBoxIcon.Question); return DialogResult.OK == result; } #endregion #region Event Listeners private void btnAdd_Click( object sender, EventArgs e) { this .presenter.AddFoodAction(); } private void miDeleteFood_Click( object sender, EventArgs e) { if ( this .lvFoods.SelectedItems.Count != 0) { String foodName = this .lvFoods.SelectedItems[0].Text; this .presenter.RemoveFoodAction(foodName); } } #endregion } } |
可以看到,使用了MVP后,View的代码变的非常干净整洁,以前充斥着厚重表示逻辑的事件Listener方法变得“瘦”了许多。
完成以上几步后,就可以运行这个Demo看效果了。
总结
这篇文章首先讨论表示层的组成,说明User Interface和Presentation Logic是表示层的两个重要组成部分,并分别说明了两者的作用及交互方式。接着讨论了MVP模式。最后,通过一个Demo展示了在.NET平台上实现MVP的一种实践方式。应该说,MVP很类似简化了MVC,MVP不但可以分离关注、使得代码变得干净整洁、并实现P Logic的复用,而且实现起来比MVC在结构上要简单很多。MVP是一种模式,本身有诸多实现方式,本文只是介绍了笔者使用的一种实践,朋友们也可以在此基础上摸索自己的实践。
本文基于署名-非商业性使用 3.0许可协议发布,欢迎转载,演绎,但是必须保留本文的署名张洋(包含链接),且不得用于商业目的。如您有任何疑问或者授权方面的协商,请与我联系。
出处:http://www.cnblogs.com/leoo2sk/archive/2010/01/28/mvp-in-practice-based-on-dot-net.html