数据绑定上下文中的 MVC 模式声明式编程






4.88/5 (13投票s)
2004年6月1日
10分钟阅读

89922

311
探索MVC模式。
引言
模型-视图-控制器 (MVC) 设计模式是一种强大的方式,用于分离用户界面、数据模型和两者的状态管理。事件用于实现这种解耦。模型和视图都非常适合声明式编程。模型管理数据,它是具有已知类型的字段集合,视图是用户界面通过控件集合表示的。控制器最好用代码构建,因为它包含响应视图触发的事件和调整模型状态的逻辑。
在经典的MVC模式中,视图不直接改变模型的状态(例如,模型管理的字段值)。相反,它向控制器触发事件。控制器包含决定模型状态如何改变的逻辑,并通过直接函数调用通知模型适当的状态改变。模型在处理状态改变后,会触发一个事件,该事件被视图接收。视图调整UI的状态以反映模型的状态改变。
当采用数据绑定时,模型和视图之间有一个直接的通信路径,由底层框架而不是应用程序管理。如果视图改变,模型中字段的值会自动更新。反之,如果模型中字段的值改变,视图会自动更新。除非处理了Binding
类的Parse
事件,否则控制器不会干预。但是,此事件旨在提供显示值的自定义非格式化,而不是状态管理,因此它不是此事件的适当用途。
一个工作示例
我将使用MyXaml作为声明式语法来演示MVC模式。一个简单的例子是日期范围选择器,这是一个足够常见的对话框,用于选择报告的日期范围。
声明式创建模型
此UI的模型管理以下字段:
- 开始日期
- 结束日期
- 选定的默认日期范围选项
- 按索引分组
这通过在标记中构建一个容器来声明式地表达。
<MxContainer Name="DateSelection">
<MxProperties>
<MxProperty Name="StartDate" Type="DateTime"/>
<MxProperty Name="EndDate" Type="DateTime"/>
<MxProperty Name="Option" Type="int"/>
<MxProperty Name="GroupBy" Type="int"/>
</MxProperties>
</MxContainer>
现在,我们遇到了一个问题。数据绑定只能通过相应的属性“get”和“set”方法与字段一起工作。我们无法通过数据绑定来抽象这个概念,因此,我们必须在运行时为表示此容器的类生成代码并实例化它。MxContainer
类在被MyXaml解析器实例化并完成所有类解析后,正是这样做的。以下代码是自动生成的:
// MyXaml Runtime Generated Code
using System;
using System.Collections;
using MyXaml.Extensions;
namespace MyXaml.App.Container
{
public class DateSelection : MxDataContainer
{
protected DateTime startDate;
protected DateTime endDate;
protected int option;
protected int groupBy;
public DateTime StartDate
{
get {return startDate;}
set {startDate=value;}
}
public DateTime EndDate
{
get {return endDate;}
set {endDate=value;}
}
public int Option
{
get {return option;}
set {option=value;}
}
public int GroupBy
{
get {return groupBy;}
set {groupBy=value;}
}
}
}
如果未在MxProperty
元素中定义,则还必须建立初始值,否则对于DataTime
等对象,数据绑定将失败。这也在MxContainer
类中处理。DateSelection
类的结果实例被放入解析器的对象池中。稍后,我将讨论为什么该类派生自MxDataContainer
。
声明式创建用户界面
用户界面是用典型的声明式语法生成的。
<Style def:Name="RangeStyle">
<StyleProperties>
<PropertyStyle Class="MxRadioButton" Size="80, 20"/>
</StyleProperties>
</Style>
<Controls>
<Label Location="10, 10" Size="220, 15" Text="Select A Date Range"
TextAlign="MiddleCenter"/>
<Label Location="10, 42" Size="70, 15" Text="Start Date:"/>
<Label Location="10, 67" Size="70, 15" Text="End Date:"/>
<DateTimePicker Location="80, 40" Size="100, 20" Format="Short">
<DataBindings>
<MxDataBinding PropertyName="Value" DataSource="DateSelection"
DataMember="StartDate"/>
</DataBindings>
</DateTimePicker>
<DateTimePicker Location="80, 65" Size="100, 20" Format="Short">
<DataBindings>
<MxDataBinding PropertyName="Value" DataSource="DateSelection"
DataMember="EndDate"/>
</DataBindings>
</DateTimePicker>
<GroupBox Location="10, 95" Size="220, 110" Text="Default Options:">
<Controls>
<MxRadioButton Location="10, 20" Size="80, 15" Text="Today">
<DataBindings>
<MxDataBinding PropertyName="Index" DataSource="DateSelection"
DataMember="Option"/>
</DataBindings>
</MxRadioButton>
<MxRadioButton Location="10,40" Text="This week" MxStyle="{RangeStyle}"/>
<MxRadioButton Location="10,60" Text="This month" MxStyle="{RangeStyle}"/>
<MxRadioButton Location="10,80" Text="This year" MxStyle="{RangeStyle}"/>
<MxRadioButton Location="120,20" Text="Yesterday" MxStyle="{RangeStyle}"/>
<MxRadioButton Location="120,40" Text="Last week" MxStyle="{RangeStyle}"/>
<MxRadioButton Location="120,60" Text="Last month" MxStyle="{RangeStyle}"/>
<MxRadioButton Location="120,80" Text="Last year" MxStyle="{RangeStyle}"/>
</Controls>
</GroupBox>
<GroupBox Location="235, 95" Size="100, 110" Text="Group By:">
<Controls>
<MxRadioButton Location="10, 20" Size="70, 20" Text="Hour">
<DataBindings>
<MxDataBinding PropertyName="Index" DataSource="DateSelection"
DataMember="GroupBy"/>
</DataBindings>
</MxRadioButton>
<MxRadioButton Location="10, 40" Size="70, 20" Text="Day"/>
<MxRadioButton Location="10, 60" Size="70, 20" Text="Month"/>
<MxRadioButton Location="10, 80" Size="70, 20" Text="Year"/>
</Controls>
</GroupBox>
<Button def:Name="OKButton" Location="250, 10" Size="80, 25" Text="OK"
FlatStyle="System" Click="OnDlgOK"/>
<Button def:Name="CancelButton" Location="250, 35" Size="80, 25"
Text="Cancel" FlatStyle="System" Click="OnDlgCancel"/>
</Controls>
数据绑定问题
不幸的是,数据绑定也带来了一系列问题,我在此处说明其中一些。
第一个也是迄今为止最令人恼火的问题是当将数据绑定与单选按钮一起使用时。单选按钮是独特的,因为作为程序员,我们几乎总是对组中哪个按钮被选中感兴趣。数据绑定只允许我们绑定到每个独立按钮的每个属性。这意味着我们需要在容器中有单独的属性,这会破坏单选按钮的类索引特性。为了解决这个问题,MxRadioButton
是一个通过实现Index
属性来扩展RadioButton
功能的类。通过绑定到此属性,MxRadioButton
类智能地设置组中相应单选按钮的Checked
状态。
其次,当RadioButton
上的Click
事件触发时,数据绑定更新尚未发生!这意味着我们必须直接检查控件的值,而不是由容器管理的属性值。同样,为了解决这个问题,容器的基类MxDataContainer
,在我们在容器中分别设置和获取属性值时,强制将数据在控件之间传输。
好消息是,一旦这些修复到位,它们就可以在我们的所有应用程序中反复使用。
容器问题
容器是在运行时生成的,因此您的编译时代码对其一无所知。这意味着您必须使用单独的载体来访问容器中的属性。因此,容器的基类MxDataContainer
。这是一个在应用程序编译时已知的类,可用于访问运行时编译容器的属性。虽然笨拙,但它也有一个优点——容器的数据表示与控制器解耦,允许两者独立变化,这有一些优点。
控制器
在此UI的控制器中,我们想做两件事:
- 当选择默认日期范围时,自动调整开始和结束日期。
- 当用户更改开始或结束日期时,“取消选中”默认范围组中的所有单选按钮。
处理用户手势事件
有多种方法可以做到这一点,但为了与MVC模式保持一致,我们希望视图与控制器解耦。换句话说,视图不调用控制器中的方法。理想情况下,视图应该消费任何Control
事件,并向控制器提供“已净化”版本的事件。如果控制器直接消费Control
的事件,那么就会失去一层解耦。如果视图发生变化,并且新Control
的事件具有不同的EventArgs
签名,这将变得显而易见。这将破坏控制器的实现。
我在前一篇文章中写到的事件池是管理此需求的一种很好的方式。它还有几个优点:
- 它记录了事件的生产者和消费者;
- 它检测事件;
- 它提供了一种安全机制来调用事件,无论是否有消费者。
问题仍然存在,视图是否将任何数据传递给控制器?我们可以很容易地将选择信息放入单选按钮的Tag
属性中。但是我们已经在容器中有一个属性可以用来获取当前选择,所以使用容器似乎更合乎逻辑(也更简单)。这样做的一个缺点是,它假设容器中的属性总是会有一个数据绑定到控件,无论它是如何实现的。这个决定将不得不留给程序员,根据他/她对未来可能遇到的视图的预判。但是,由于所有控件都派生自Control
类,并且包含DataBindings
集合,因此可以说“始终将数据绑定与容器中的属性一起使用”是合理的。也就是说,我们可能永远不必担心视图是否需要将参数传递给控制器——所有相关信息都应该在容器模型中。
映射控件事件
鉴于对处理Control
事件所做的决定,如果我们可以将Control
事件映射到由EventPool
管理的适当用户手势事件,那将是很好的。是的,这在这样一个微不足道的例子中看起来是不必要的步骤,但它的美妙之处在于,一旦由EventPool
处理,事件就会被检测!这意味着我们现在可以自动跟踪QA人员所做的用户操作,当被问到“你是怎么弄坏的?”时,他回答“我不知道”。这个单一功能在我参与的项目中将QA验收时间缩短了三分之二。所以,即使对于看起来不必要的复杂性,也有巨大的好处。
因为每个单选按钮都代表我们想要在模型中设置的不同状态,所以为RadioButton
控件的每个Click
事件创建单独的事件处理程序是有意义的。因此,我们不需要检查容器的Option
属性,并且避免了
语句。switch
由于事件池实现了多播委托,因此池管理的每个事件可以有多个订阅者。同样,我们可以使用XML声明式地建立视图中Control
事件、相应的EventPool
事件以及订阅该事件的处理程序之间的连接。ControlEvents
集合处理控件事件到相应EventPool
事件的映射。Subscribers
集合管理EventPool
事件到一个或多个订阅者的映射。
<ev:EventPool def:Name="DateRangeEventPool"> <ev:ControlEvents> <ev:ControlEvent Control="{rbToday}" Event="Click" MapTo="SelectToday"/> <ev:ControlEvent Control="{rbWeek}" Event="Click" MapTo="SelectThisWeek"/> <ev:ControlEvent Control="{rbMonth}" Event="Click" MapTo="SelectThisMonth"/> <ev:ControlEvent Control="{rbYear}" Event="Click" MapTo="SelectThisYear"/> <ev:ControlEvent Control="{rbYesterday}" Event="Click" MapTo="SelectYesterday"/> <ev:ControlEvent Control="{rbLastWeek}" Event="Click" MapTo="SelectLastWeek"/> <ev:ControlEvent Control="{rbLastMonth}" Event="Click" MapTo="SelectLastMonth"/> <ev:ControlEvent Control="{rbLastYear}" Event="Click" MapTo="SelectLastYear"/> <ev:ControlEvent Control="{StartDate}" Event="ValueChanged" MapTo="CustomDate"/> <ev:ControlEvent Control="{EndDate}" Event="ValueChanged" MapTo="CustomDate"/> </ev:ControlEvents> <ev:Subscribers> <ev:Subscriber Event="SelectToday" Instance="{DateRangeController}" Handler="OnSelectToday"/> <ev:Subscriber Event="SelectThisWeek" Instance="{DateRangeController}" Handler="OnSelectThisWeek"/> <ev:Subscriber Event="SelectThisMonth" Instance="{DateRangeController}" Handler="OnSelectThisMonth"/> <ev:Subscriber Event="SelectThisYear" Instance="{DateRangeController}" Handler="OnSelectThisYear"/> <ev:Subscriber Event="SelectYesterday" Instance="{DateRangeController}" Handler="OnSelectYesterday"/> <ev:Subscriber Event="SelectLastWeek" Instance="{DateRangeController}" Handler="OnSelectLastWeek"/> <ev:Subscriber Event="SelectLastMonth" Instance="{DateRangeController}" Handler="OnSelectLastMonth"/> <ev:Subscriber Event="SelectLastYear" Instance="{DateRangeController}" Handler="OnSelectLastYear"/> <ev:Subscriber Event="CustomDate" Instance="{DateRangeController}" Handler="OnCustomDate"/> </ev:Subscribers> </ev:EventPool>
为了使这一切正常工作,我们再次依赖运行时代码生成来创建适当的桥接类。生成的代码如下所示:
// MyXaml Runtime Generated Code
using System;
using System.Collections;
using MyXaml.EventManagement;
using MyXaml.Extensions;
namespace MyXaml.App.ControlEvents
{
public class DateRangeEventPool : IEventPool
{
protected EventPool eventPool;
public EventPool EventPool
{
get {return eventPool;}
set {eventPool=value;}
}
public void Click_SelectToday(object sender, EventArgs e)
{
eventPool.FireEvent("SelectToday");
}
public void Click_SelectThisWeek(object sender, EventArgs e)
{
eventPool.FireEvent("SelectThisWeek");
}
public void Click_SelectThisMonth(object sender, EventArgs e)
{
eventPool.FireEvent("SelectThisMonth");
}
public void Click_SelectThisYear(object sender, EventArgs e)
{
eventPool.FireEvent("SelectThisYear");
}
public void Click_SelectYesterday(object sender, EventArgs e)
{
eventPool.FireEvent("SelectYesterday");
}
public void Click_SelectLastWeek(object sender, EventArgs e)
{
eventPool.FireEvent("SelectLastWeek");
}
public void Click_SelectLastMonth(object sender, EventArgs e)
{
eventPool.FireEvent("SelectLastMonth");
}
public void Click_SelectLastYear(object sender, EventArgs e)
{
eventPool.FireEvent("SelectLastYear");
}
public void ValueChanged_CustomDate(object sender, EventArgs e)
{
eventPool.FireEvent("CustomDate");
}
}
}
在这一点上,你可能认为Marc这次真的疯了!他竟然创建了一堆耗时的运行时生成的代码,来替代.NET中将事件连接到处理程序的非常简单的机制!
从某种意义上说,你是对的。
然而,让我们看看这一切做了什么:
- 我消除了在代码中连接事件的需要。好的,MyXaml已经做到了。
- 所有视图事件现在都已检测。太棒了!我可以跟踪我做了什么,以及程序为什么崩溃!
- 在.NET中,事件的连接需要代码同时知道
Control
类和处理事件的实例(控制器)。我消除了这种耦合。啊哈!现在,MyXaml已经做到了,但事件签名绑定到了Control
类型。这更抽象。 - 它创建了一个架构模式,帮助我编写更好的代码。控制器现在代表一组管理模型状态的方法。典型的实现将模型状态管理嵌入到子类化的Form中,至少如果你遵循Visual Studio生成事件处理程序的方式。这种“微软方式”使得实现MVC模式变得不可能,导致视图更改时昂贵的代码重写,并且通常会降低应用程序的灵活性。
控制器的实现很简单,我们将它作为内联代码添加到标记中,这样我们就可以得到一个完整的独立包(是的,我首先将这段代码写成一个程序集,调试它,然后复制到标记中)。
<def:Code language="C#">
<reference assembly="System.Drawing.dll"/>
<reference assembly="System.Windows.Forms.dll"/>
<reference assembly="myxaml.dll"/>
<![CDATA[
using System;
using System.ComponentModel;
using System.Drawing;
using System.Windows.Forms;
using MyXaml;
using MyXaml.Extensions;
public class DateRangeController
{
protected Parser parser;
protected MxDataContainer dateSelection;
protected TimeSpan oneDay;
public DateRangeController()
{
parser=Parser.CurrentInstance;
parser.AddReference("DateRangeController", this);
oneDay=new TimeSpan(1, 0, 0, 0);
}
public void DateSelectionLoaded(object sender, EventArgs e)
{
dateSelection=(MxDataContainer)parser.GetReference("DateSelection");
}
public void OnSelectToday(object sender, EventArgs e)
{
dateSelection.SetValue("StartDate", DateTime.Now);
dateSelection.SetValue("EndDate", DateTime.Now);
}
public void OnSelectThisWeek(object sender, EventArgs e)
{
DateTime dt=DateTime.Now;
dateSelection.SetValue("EndDate", dt);
while (dt.DayOfWeek != 0)
{
dt-=oneDay;
}
dateSelection.SetValue("StartDate", dt);
}
public void OnSelectThisMonth(object sender, EventArgs e)
{
DateTime dt=DateTime.Now;
dateSelection.SetValue("EndDate", dt);
dt=new DateTime(dt.Year, dt.Month, 1, dt.Hour, dt.Minute, dt.Second);
dateSelection.SetValue("StartDate", dt);
}
public void OnSelectThisYear(object sender, EventArgs e)
{
DateTime dt=DateTime.Now;
dateSelection.SetValue("EndDate", dt);
dt=new DateTime(dt.Year, 1, 1, dt.Hour, dt.Minute, dt.Second);
dateSelection.SetValue("StartDate", dt);
}
public void OnSelectYesterday(object sender, EventArgs e)
{
DateTime dt=DateTime.Now;
dt=dt - oneDay;
dateSelection.SetValue("StartDate", dt);
dateSelection.SetValue("EndDate", dt);
}
public void OnSelectLastWeek(object sender, EventArgs e)
{
DateTime dt=DateTime.Now;
while (dt.DayOfWeek != 0)
{
dt-=oneDay;
}
dateSelection.SetValue("EndDate", dt);
dt-=new TimeSpan(7, 0, 0, 0);
dateSelection.SetValue("StartDate", dt);
}
public void OnSelectLastMonth(object sender, EventArgs e)
{
DateTime dt=DateTime.Now;
dt=new DateTime(dt.Year, dt.Month, 1, dt.Hour, dt.Minute, dt.Second);
dt=dt.AddMonths(-1);
dateSelection.SetValue("StartDate", dt);
dt=new DateTime(dt.Year, dt.Month, DateTime.DaysInMonth(dt.Year, dt.Month),
dt.Hour, dt.Minute, dt.Second);
dateSelection.SetValue("EndDate", dt);
}
public void OnSelectLastYear(object sender, EventArgs e)
{
DateTime dt=DateTime.Now;
dt=new DateTime(dt.Year, 1, 1, dt.Hour, dt.Minute, dt.Second);
dt=dt.AddYears(-1);
dateSelection.SetValue("StartDate", dt);
dt=new DateTime(dt.Year, 12, 31, dt.Hour, dt.Minute, dt.Second);
dateSelection.SetValue("EndDate", dt);
}
public void OnCustomDate(object sender, EventArgs e)
{
// if the container data is different from the control data, then the
// user is changing the control data.
string dtStart1=
((DateTime)dateSelection.GetContainerValue("StartDate")).ToShortDateString();
string dtStart2=
((DateTime)dateSelection.GetValue("StartDate")).ToShortDateString();
string dtEnd1=
((DateTime)dateSelection.GetContainerValue("EndDate")).ToShortDateString();
string dtEnd2=
((DateTime)dateSelection.GetValue("EndDate")).ToShortDateString();
if ( (dtStart1 != dtStart2) || (dtEnd1 != dtEnd2) )
{
dateSelection.SetValue("Option", -1);
}
}
}
]]>
</def:Code>
不同的视图
现在,如果我没有演示在不影响模型或控制器的情况下更改视图,那么所有这些都没有多大价值。所以,在这个UI中:
我已经用一个ComboBox
替换了单选按钮。现在,这引出了一个问题——当用户做出选择时,我们应该把判断采取哪个动作的逻辑放在哪里?这应该放在视图还是控制器中?并没有一个很好的答案——在这个特定情况下,我决定同时演示这两种方法。然而,我觉得既然组合框是一个改变UI状态的活动选择,那么视图应该负责。视图管理列表并赋予列表内容意义,而且它也不需要改变控制器代码。我在下载中演示了这两种方法。
将视图与模型/控制器分离的优雅之处在下一个示例中可见。替换
<ComboBox def:Name="cbOption" Location="10, 20" Size="200, 20" SelectionChangeCommitted="OnSelectChangeCommitted"> <Items> <Item>Today</Item> <Item>This Week</Item> <Item>This Month</Item> <Item>This Year</Item> <Item>Yesterday</Item> <Item>Last Week</Item> <Item>Last Month</Item> <Item>Last Year</Item> </Items> <DataBindings> <MxDataBinding PropertyName="SelectedIndex" DataSource="DateSelection" DataMember="Option"/> </DataBindings> </ComboBox>
用
<ListBox def:Name="cbOption" Location="10, 20" Size="200, 80"
SelectedIndexChanged="OnSelectedIndexChanged">
etc...
我们已经更改了视图,独立于控制器。
结论
MVC模式非常强大。通过使用声明式编程,可以消除创建类和连接事件的许多繁琐工作。我在这里说明的例子有些微不足道。然而,在复杂的UI中,一个Form
可能有多个正在管理的视图,或者一个视图中的选择会影响另一个视图中的状态,这种技术就变得非常有用。另请注意,在我提供的示例中,我将模型、视图和控制器代码放在与标记相同的文件中。这在三者之间创建了物理绑定,但在现实生活中您可能不会这样做。最后,这在很大程度上是一个概念性作品。我正在将这种技术用于我自己的应用程序开发,我怀疑随着时间的推移,它会进一步成熟。