原文地址:http://www.martinfowler.com/eaaDev/PresentationModel.html
呈现模型
代表的界面状态和行为,与GUI界面控件无关。
又被称为:应用模型
GUI包括含有状态的界面构件。把GUI的状态放在界面构件中使得获取这些状态变得麻烦,因为这涉及到处理界面构件的API,事实上这种做法也鼓励把呈现的行为放在视图类中。
呈现模型把状态和行为抽取出来,放在一个表现层模型中。呈现模型和领域模型交互,为视图提供了一个接口,从而使得视图中的决策行为达到最少。视图要么把状态存储在呈现模型中,要么和呈现模型进行数据同步。
呈现模型,也可能同时与几个领域对象交互,但它不仅仅是特定领域对象的外观。实际上,把呈现模型当作与特定GUI框架无关的抽象视图更为容易理解。不同视图可以使用同一呈现模型,每一个视图只需要一个呈现模型。使用组合模式时,呈现模型可以包括一个或者多个呈现模型,但每个子控件也只是用一个呈现模型。
Smalltalk用户又把呈现模型称为应用模型。
工作方式
呈现模型的精髓是使用一个完全自包含的类来表现用户界面中所有的数据、行为,但又不涉及界面呈现使用的具体控件。视图仅仅是把呈现模型的状态映射到屏幕上。
呈现呈现模型拥有视图中所有的动态数据域,不仅仅包括控件的内容,也包括它们使能状态等。通常,呈现模型并不需要包括所有的控件状态(这将太多),而是包括了哪些在和用户交互过程中可能变化的状态。因此,如果一个字段总是被激活,呈现模型中将不存储它的状态。
由于呈现模型包含视图显示需要的数据,因此视图需要和呈现模型同步。这个同步通常比和领域模型的同步要求更严格,因为屏幕的同步效率较低,你需要字段或者键值级别的同步。
为了更好地说明,我用案例中的一个部分来进行解释,其中作家字段只有在古典复选框选中时才使能。
图1 :单击古典复选框相关的类结构
图2 :单击古典复选框的响应时序图。
有人点击古典复选框时,复选框改变其状态,然后再调用视图中相应的事件处理程序。事件处理器可以把视图的状态保存到呈现模型中,然后根据呈现模型中更新自身(假设使用粗粒同步)。呈现模型包含了作家字段在复选框选中时才使能的逻辑,因此当视图随着呈现模型更新时,作家字段控件改变了它的使能状态。
在图中已经标明,呈现模型有一个明确的属性来特别标记作家字段是否使能。在本例中,这仅仅返回是否为古典属性,但是,这额外属性是重要的,因为这个属性是呈现模型对作家字段使能机制的封装。
这个小的例子说明了呈现模型的精髓-所有界面呈现需要的决策,都由呈现模型制定,从而使视图非常的简单。
[ TBD :添加一些VisualWorks如何工作的简短介绍。]
呈现模型中是最恼人的部分大概是同步呈现模型和视图。需要写的代码很简单,但我总是想尽量减少这种枯燥重复的代码。最理想的情况是某种架构可以处理它,我希望将来类似.Net的数据绑定技术可以做到。
进行呈现模型同步时,你需要做的一个特殊的决定是哪个类应该包括同步的代码。很多时候,这由测试覆盖度的要求,以及呈现模型的实现方案决定。如果你把同步放在视图中,针对呈现模型的测试不会覆盖它。如果你把它在呈现模型,你在呈现模型中加入了对视图的依赖,这意味着更多的耦合和冗余。你可以在它们之间加入映射,但也同时增加了更多的类,协调的成本增加了。选择实现方案决策时要记住重要的一点,虽然同步代码可能会发生错误,但是通常容易被发现和修复(除非你使用细粒度同步)。
一个重要的实现细节是呈现模型引用视图还是视图引用呈现模型。它们各有利弊。
呈现模型引用视图的方案一般都在呈现模型中包含同步代码。由此产生的视图是“沉默的”。视图包含设置内部属性的方法,并在用户动作时发出事件来响应。视图实现的接口,在测试呈现模型时容易实现代理。呈现模型监听视图,响应状态的改变事件,然后重新加载整个视图。因此同步代码可以方便地进行测试,而无需实际用户界面类。
在视图引用呈现模型的方案中,同步的代码通常放在视图中。因为同步代码一般很简单,错误也容易发现,因此建议测试呈现模型而不是视图。如果你是被要求进行视图的测试,那将出现视图包含了本该放在呈现模型中的代码的情况。如果您倾向于测试同步代码,那么建议使用呈现模型引用视图的方案。
(译注:但在希望使用类似数据集的通用方式来实现呈现模型时,同步代码只能放在视图中。同时,使用类似表达式、扩展应该可以做到视图也是通用的。)
什么时候使用
呈现模型是一个从视图中抽取出呈现行为的模式。因此它是Supervising Controller 和Passive View的替代办法。脱离用户界面进行测试非常有用,这可支持某些形式的多视图,也可以做到关注点分离,对于减少开发用户界面的难度很有帮助。
与Passive View以及Supervising Controller相比,呈现模型允许编写完全和实际使用的界面控件无关的呈现逻辑。你也不必依赖于视图来储存状态。缺点是你需要一个呈现模型和视图的同步机制。同步很简单,但它是必须的。分离呈现需要很少的同步,而Passive View不需要任何同步。
案例:运行实例(视图引用呈现模型)(C#)
这是运行实例的一个呈现模型的C#版本。
图3 :唱片窗口。
我将脱离领域模型开始讨论。由于领域模型和这个案例无关,因此它不被关注。模型只有一个数据集,其中只有一个表持有的唱片的数据。下面是构造几条唱片测试数据的代码。我使用了强类型数据集。
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/None.gif)
呈现模型封装了这些数据,并提供属性来访问它们。整个表只有一个唯一呈现模型实例,与窗体的实例对应。呈现模型包含数据集的各个字段,并记录当前选中的唱片。
![](/Images/OutliningIndicators/None.gif)
![](https://www.cnblogs.com/Images/dot.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/None.gif)
![](/Images/OutliningIndicators/None.gif)
![](/Images/OutliningIndicators/None.gif)
![](/Images/OutliningIndicators/None.gif)
PmodAlbum提供属性来获取整个数据集的数据。基本上我提供了窗体需要呈现的每个数据。对于仅需要直接从数据集中取出的数据,属性相当简单。
![](/Images/OutliningIndicators/None.gif)
![](https://www.cnblogs.com/Images/dot.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockEnd.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockEnd.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/None.gif)
![](/Images/OutliningIndicators/None.gif)
窗口的标题与唱片标题有关。为此,我提供了另外一个属性。
![](/Images/OutliningIndicators/None.gif)
![](https://www.cnblogs.com/Images/dot.gif)
![](/Images/OutliningIndicators/None.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/None.gif)
![](/Images/OutliningIndicators/None.gif)
我提供一个属性来确定作家字段是否使能。
![](/Images/OutliningIndicators/None.gif)
![](https://www.cnblogs.com/Images/dot.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/None.gif)
![](/Images/OutliningIndicators/None.gif)
这只是一个对公布的IsClassical属性的调用。你可能感到奇怪,为何不在窗体中直接调用,但这正是呈现模型提供的封装的本质。PmodAlbum决定该字段使能应该使用什么逻辑,作家字段的使能基于另外一个属性的细节,应该由呈现模型,而不是视图来决定。
应用以及取消按钮只有数据发生了变化时才使能。这可以通过检查数据集中该行的数据状态来提供。
![](/Images/OutliningIndicators/None.gif)
![](https://www.cnblogs.com/Images/dot.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/None.gif)
![](/Images/OutliningIndicators/None.gif)
视图的列表框显示了唱片标题列表。PmodAlbum提供这份列表。
![](/Images/OutliningIndicators/None.gif)
![](https://www.cnblogs.com/Images/dot.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockEnd.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/None.gif)
![](/Images/OutliningIndicators/None.gif)
上面覆盖了PmodAlbum提供给视图的接口。接下来我将演示如何同步视图和呈现模型。我把同步函数放在视图中,并使用粗粒度同步方法。首先,我有一个函数把视图中的状态存储呈现模型中。
![](/Images/OutliningIndicators/None.gif)
![](https://www.cnblogs.com/Images/dot.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/None.gif)
![](/Images/OutliningIndicators/None.gif)
这函数很简单,只是把视图中变化的部分反映到呈现模型中。加载函数稍微复杂一些。
![](/Images/OutliningIndicators/None.gif)
![](https://www.cnblogs.com/Images/dot.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockEnd.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/None.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/None.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockEnd.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/None.gif)
![](/Images/OutliningIndicators/None.gif)
这里的关键是避免一个无限循环,因为同步会造成窗体上控件的更新,然后再次触发同步。我通过一个标志位来防止这个问题。
有了这些同步函数后,紧接着只需要在控件的事件处理器方法中正确的调用它们。大部分情况下,这很容易,只需要在数据变化时调用SyncWithPmod。
![](/Images/OutliningIndicators/None.gif)
![](https://www.cnblogs.com/Images/dot.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/None.gif)
![](/Images/OutliningIndicators/None.gif)
有些情况复杂一些。当用户点击名单上的一个新的项目时,我们需要切换到一个新的专辑,并显示其数据。
![](/Images/OutliningIndicators/None.gif)
![](https://www.cnblogs.com/Images/dot.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockEnd.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/None.gif)
![](/Images/OutliningIndicators/None.gif)
![](https://www.cnblogs.com/Images/dot.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockEnd.gif)
![](/Images/OutliningIndicators/ExpandedSubBlockEnd.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/None.gif)
![](/Images/OutliningIndicators/None.gif)
需要注意,如果点击了列表,函数取消了前面的任何修改。这中丑陋的做法是为了简化例子,实际应用中至少应该弹出一个确认窗体来防止修改的丢失。[TBD: 考虑把这些加入到例子中]
应用和取消按钮把功能委托给呈现模型。
![](/Images/OutliningIndicators/None.gif)
![](https://www.cnblogs.com/Images/dot.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/None.gif)
![](/Images/OutliningIndicators/None.gif)
![](https://www.cnblogs.com/Images/dot.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/ExpandedBlockEnd.gif)
![](/Images/OutliningIndicators/None.gif)
![](/Images/OutliningIndicators/None.gif)
尽管我把大部分的行为放在呈现模型中,视图依然保留了一些逻辑。为了使呈现模型可测试性更好,需要把更多的逻辑放在呈现模型中。当然,你可以把同步逻辑放在呈现模型中,这样做的代价是呈现模型包含了到视图的引用。
例子:表的数据绑定例子(C#)
当我第一次考虑在.net框架中使用呈现模型时,数据绑定似乎提供了完美的技术来简化呈现模型的工作。目前版本的数据绑定存在一些局限,约束了它的作用,不过我相信它最后会得到解决。数据绑定可以很好处理的只读数据,以下就是一个例子来演示表在呈现模型设计中如何起作用。
图4 :唱片清单,其中摇滚唱片被高亮显示。
这是一个专辑的歌曲清单。另外,每一摇滚专辑行上都有连续的彩色背景。
我用的例子和其它例子使用的数据集略有不同。这是一些测试数据的代码。
{
AlbumList result = new AlbumList();
result.Albums.AddAlbumsRow(1, "HQ", "Roy Harper", "Rock");
result.Albums.AddAlbumsRow(2, "Lemonade and Buns", "Kila", "Celtic");
result.Albums.AddAlbumsRow(3, "Stormcock", "Roy Harper", "Rock");
result.Albums.AddAlbumsRow(4, "Zero Hour", "Astor Piazzola", "Tango");
result.Albums.AddAlbumsRow(5, "The Rough Dancer and Cyclical Night", "Astor Piazzola", "Tango");
result.Albums.AddAlbumsRow(6, "The Black Light", "Calexico", "Rock");
result.Albums.AddAlbumsRow(7, "Spoke", "Calexico", "Rock");
result.Albums.AddAlbumsRow(8, "Electrica", "Daniela Mercury", "Brazil");
result.Albums.AddAlbumsRow(9, "Feijao com Arroz", "Daniela Mercury", "Brazil");
result.Albums.AddAlbumsRow(10, "Sol da Libertade", "Daniela Mercury", "Brazil");
Console.WriteLine(result);
return result;
}
例子中,呈现模型把数据集直接作为一个属性公布,这样允许窗体通过数据绑定直接关联到数据集的一个格子上。
internal AlbumList DsAlbums {
get {return _dsAlbums;}
}
为了支持高亮显示,呈现模型提供了一个额外的方法进行支持:
return (Albums[row].genre.Equals("Rock")) ? Color.Cornsilk : Color.White;
}
private AlbumList.AlbumsDataTable Albums {
get {return DsAlbums.Albums;}
}
函数与一个简单案例中类似,不同的地方在于这个方法需要单元格级的同步来获取表中的数据。在这种情况下,我们所需要的是行号,但一般情况下我们可能需要行号以及列号。
在这我可以使用标准的Visual Studio的数据绑定工具。我可以把表格轻松的绑定到数据集上,也可以绑定到呈现模型中的数据上。
获取高亮颜色复杂一些。这有点偏离了案例的主题,但整个事件变得复杂的原因是标准的WinForms表格控件没有方法来实现行高亮显示。通常这个问题可以通过购买第三方控件来解决,但我太穷了买不起J。下面是我的方法(思路是从http://www.syncfusion.com/faq/winforms/ 中得到的)。我假定你已经熟悉了Winforms的方式。
本质上我从DataGridTextBoxColumn继承了一个子类来添加颜色高亮行为。你可以通过传递一个代理来处理这个行为。
![](https://www.cnblogs.com/Images/dot.gif)
public ColorableDataGridTextBoxColumn (ColorGetter getcolorRowCol, DataGridTextBoxColumn original)
{
_delGetColor = getcolorRowCol;
copyFrom(original);
}
public delegate Color ColorGetter(int row);
private ColorGetter _delGetColor;
构造函数需原DataGridTextBoxColumn以及一个代理。我很想在这里使用装饰模式,但和其它很多WinForms中的类一样,都是sealed的。所以我把所有原类中的属性拷贝到我的子类中。在有些重要属性不能读取或者修改,因此不能复制时,这样不能工作,但可以应付目前的场景。
![](https://www.cnblogs.com/Images/dot.gif)
void copyFrom (DataGridTextBoxColumn original) {
PropertyInfo[] props = original.GetType().GetProperties();
foreach (PropertyInfo p in props) {
if (p.CanWrite && p.CanRead)
p.SetValue(this, p.GetValue(original, null), null) ;
}
}
幸好paint方法是虚拟(否则,我将需要一个全新的数据网格控件),我可以重载该方法,并通过代理方法插入合适的背景颜色。
![](https://www.cnblogs.com/Images/dot.gif)
protected override void Paint(System.Drawing.Graphics g, System.Drawing.Rectangle bounds,
System.Windows.Forms.CurrencyManager source, int rowNum,
System.Drawing.Brush backBrush, System.Drawing.Brush foreBrush,
bool alignToRight)
{
base.Paint(g, bounds, source, rowNum, new SolidBrush(_delGetColor(rowNum)), foreBrush, alignToRight);
}
为了使这个新的表格控件发生作用,在控件已经在窗体上构造后,我在pageload方法中替换了数据表的列。
![](https://www.cnblogs.com/Images/dot.gif)
private void FrmAlbums_Load(object sender, System.EventArgs e){
bindData();
replaceColumnStyles();
}
private void replaceColumnStyles() {
ColorableDataGridTextBoxColumn.ReplaceColumnStyles(dgsAlbums,
new ColorableDataGridTextBoxColumn.ColorGetter(model.RowColor));
}
class ColorableDataGridTextBoxColumn
![](https://www.cnblogs.com/Images/dot.gif)
public static void ReplaceColumnStyles(DataGridTableStyle grid, ColorGetter del) {
for (int i = 0; i < grid.GridColumnStyles.Count; i++) {
DataGridTextBoxColumn old = (DataGridTextBoxColumn) grid.GridColumnStyles[0];
grid.GridColumnStyles.RemoveAt(0);
grid.GridColumnStyles.Add(new ColorableDataGridTextBoxColumn(del, old));
}
}
它可以工作,但我承认它包含了许多我不喜欢的糟糕的东西。如果在实际工作中应用,我会考虑第三方控件。不过我见过一个现实产品这么做了,工作的也很好。