演示模型

独立于界面中使用的 GUI 控件,表示演示文稿的状态和行为

也称为:应用程序模型

2004 年 7 月 19 日

这是我在 2000 年代中期进行的 进一步的企业应用程序架构开发 写作的一部分。遗憾的是,此后太多其他事情吸引了我的注意力,所以我没有时间进一步研究它们,而且在可预见的未来我也没有看到太多时间。因此,这些材料非常草稿形式,我不会进行任何更正或更新,直到我能找到时间再次处理它。

GUI 由包含 GUI 屏幕状态的小部件组成。将 GUI 的状态保留在小部件中会使获取此状态变得更加困难,因为这涉及操作小部件 API,并且还会鼓励在视图类中放置演示行为。

演示模型 将视图的状态和行为提取到一个模型类中,该模型类是演示文稿的一部分。 演示模型 与域层协调,并为视图提供一个接口,最大限度地减少视图中的决策。视图要么将其所有状态存储在 演示模型 中,要么经常与其状态同步 演示模型

演示模型 可能会与多个域对象交互,但 演示模型 不是特定域对象的 GUI 友好外观。相反,更容易将 演示模型 视为不依赖于特定 GUI 框架的视图的抽象。虽然多个视图可以使用相同的 演示模型,但每个视图都应该只需要一个 演示模型。在组合的情况下, 演示模型 可能包含一个或多个子 演示模型 实例,但每个子控件也只有一个 演示模型

演示模型 被 Visual Works Smalltalk 的用户称为 应用程序模型

工作原理

演示模型 的本质是一个完全自包含的类,它表示 UI 窗口的所有数据和行为,但没有任何用于在屏幕上呈现该 UI 的控件。然后,视图只需将演示模型的状态投影到玻璃上。

为此, 演示模型 将为视图的所有动态信息提供数据字段。这不仅包括控件的内容,还包括诸如它们是否启用的内容。通常, 演示模型 不需要保存所有这些控制状态(这将是很多),而是保存用户交互期间可能发生更改的任何状态。因此,如果一个字段始终处于启用状态,则 演示模型 中不会有额外的状态数据。

由于 演示模型 包含视图需要显示控件的数据,因此您需要将 演示模型 与视图同步。这种同步通常需要比与域的同步更紧密 - 屏幕同步是不够的,您需要字段或键同步。

为了更好地说明问题,我将使用 运行示例 的方面,其中作曲家字段仅在选中古典复选框时才启用。

图 1:显示与单击古典复选框相关的结构的类

图 2:对象如何对单击古典复选框做出反应。

当有人单击古典复选框时,复选框会更改其状态,然后调用视图中的相应事件处理程序。此事件处理程序将视图的状态保存到 演示模型 中,然后从 演示模型 中更新自身(我在这里假设粗粒度同步。) 演示模型 包含逻辑,该逻辑指出作曲家字段仅在选中复选框时才启用,因此当视图从 演示模型 中更新自身时,作曲家字段控件会更改其启用状态。我在图表上指示了 演示模型 通常会具有一个专门用于标记是否应启用作曲家字段的属性。当然,这只会返回 isClassical 属性的值 - 但单独的属性很重要,因为该属性封装了 演示模型 如何确定是否启用作曲家字段 - 清楚地表明该决定是 演示模型 的责任。

这个小例子说明了 演示模型 思想的本质 - 所有用于演示显示所需的决策都由 演示模型 做出,使视图变得非常简单。

可能 演示模型 最烦人的部分是 演示模型 和视图之间的同步。编写代码很简单,但我总是喜欢最大限度地减少这种无聊的重复代码。理想情况下,某种框架可以处理这个问题,我希望有一天像 .NET 的数据绑定这样的技术能够实现这一点。

演示模型 中同步时,您必须做出一个特定的决定,即哪个类应该包含同步代码。通常,此决定很大程度上取决于所需的测试覆盖范围和所选的 演示模型 实现。如果您将同步放在视图中,它不会被 演示模型 上的测试捕获。如果您将其放在 演示模型 中,您会在 演示模型 中添加对视图的依赖项,这意味着更多的耦合和存根。您可以添加一个它们之间的映射器,但这会添加更多类来协调。在决定使用哪种实现时,务必记住,尽管同步代码中确实会发生故障,但它们通常很容易发现和修复(除非您使用细粒度同步)。

演示模型 的一个重要的实现细节是视图是否应该引用 演示模型 或者 演示模型 是否应该引用视图。两种实现都提供了优缺点。

引用视图的 演示模型 通常在 演示模型 中维护同步代码。生成的视图非常愚蠢。视图包含任何动态状态的设置器,并响应用户操作引发事件。视图实现接口,允许在测试 演示模型 时轻松存根。 演示模型 将观察视图并响应事件,通过更改任何适当的状态并重新加载整个视图来做出响应。因此,同步代码可以轻松测试,而无需实际的 UI 类。

由视图引用的 演示模型 通常在视图中维护同步代码。由于同步代码通常很容易编写并且很容易发现错误,因此建议在 演示模型 上而不是在视图上进行测试。如果您被迫为视图编写测试,这应该是一个线索,表明视图包含应该属于 演示模型 的代码。如果您更喜欢测试同步,建议使用引用视图实现的 演示模型

何时使用它

演示模型 是一种从视图中提取演示行为的模式。因此,它是 监督控制器被动视图 的替代方案。它对于允许您在没有 UI 的情况下进行测试、支持某种形式的多视图以及关注点分离很有用,这可能会使开发用户界面变得更容易。

被动视图监督控制器 相比, 演示模型 允许您编写完全独立于用于显示的视图的逻辑。您也不需要依赖视图来存储状态。缺点是您需要在演示模型和视图之间建立同步机制。这种同步可以非常简单,但它是必需的。 分离的演示 需要更少的同步,而 被动视图 根本不需要任何同步。

示例:运行示例(视图引用 PM)(C#)

这是 运行示例 的版本,使用 C# 和 演示模型 开发。

图 3:专辑窗口。

我将从域模型开始讨论基本布局。由于域不是本例的重点,因此它非常无趣。它本质上只是一个数据集,其中包含一个表,其中包含专辑的数据。以下是设置一些测试专辑的代码。我正在使用强类型数据集。

public static DsAlbum AlbumDataSet() {
  DsAlbum result = new DsAlbum();
  result.Albums.AddAlbumsRow(1, "HQ", "Roy Harper", false, null);
  result.Albums.AddAlbumsRow(2, "The Rough Dancer and Cyclical Night", "Astor Piazzola", false, null);
  result.Albums.AddAlbumsRow(3, "The Black Light", "Calexico", false, null);
  result.Albums.AddAlbumsRow(4, "Symphony No.5", "CBSO", true, "Sibelius" );
  result.AcceptChanges();
  return result;
}

演示模型 包装了这个数据集,并提供属性来获取数据。整个表只有一个 演示模型 实例,对应于窗口的单个实例。 演示模型 具有数据集的字段,还跟踪当前选定的专辑。

class PmodAlbum...

  public PmodAlbum(DsAlbum albums) {
    this._data = albums; 
    _selectedAlbumNumber = 0;
  }
  private DsAlbum _data;
  private int _selectedAlbumNumber;

PmodAlbum 提供属性来获取数据集中的数据。本质上,我为表单需要显示的每条信息提供了一个属性。对于直接从数据集中提取的值,此属性非常简单。

class PmodAlbum...

  public String Title {
    get {return SelectedAlbum.Title;}
    set {SelectedAlbum.Title = value;}
  }
  public String Artist {
    get {return SelectedAlbum.Artist;}
    set {SelectedAlbum.Artist = value;}     
  }
  public bool IsClassical {
    get {return SelectedAlbum.IsClassical;}
    set {SelectedAlbum.IsClassical = value;}            
  }
  public String Composer {
    get {
      return (SelectedAlbum.IsComposerNull()) ? "" : SelectedAlbum.Composer;
    }
    set {
      if (IsClassical) SelectedAlbum.Composer = value;
    }                 
  }
  public DsAlbum.AlbumsRow SelectedAlbum {
    get {return Data.Albums[SelectedAlbumNumber];}
  }

窗口的标题基于专辑标题。我通过另一个属性提供它。

class PmodAlbum...

  public String FormTitle 
  {
    get {return "Album: " + Title;}
  }

我有一个属性来查看是否应启用作曲家字段。

class PmodAlbum...

  public bool IsComposerFieldEnabled {
    get {return IsClassical;}
  }

这只是对公共 IsClassical 属性的调用。您可能想知道为什么表单不直接调用它 - 但这是 演示模型 提供的封装的本质。PmodAlbum 决定启用该字段的逻辑是什么,它仅仅基于一个属性的事实是 演示模型 知道的,而不是视图知道的。

应用和取消按钮应仅在数据发生更改时才启用。我可以通过检查数据集该行的状态来提供它,因为数据集会记录此信息。

class PmodAlbum...

  public bool IsApplyEnabled {
    get {return HasRowChanged;}
  }
  public bool IsCancelEnabled {
    get {return HasRowChanged;}
  }
  public bool HasRowChanged {
    get {return SelectedAlbum.RowState == DataRowState.Modified;}
  }

视图中的列表框显示专辑标题列表。PmodAlbum 提供了此列表。

class PmodAlbum...

  public String[] AlbumList {
    get {
      String[] result = new String[Data.Albums.Rows.Count];
      for (int i = 0; i < result.Length; i++)
        result[i] = Data.Albums[i].Title;
      return result;
    }
  }

因此,涵盖了 PmodAlbum 向视图提供的接口。接下来,我将看看如何进行视图和 演示模型 之间的同步。我将同步方法放在视图中,并使用粗粒度同步。首先,我有一个方法将视图的状态推送到 演示模型 中。

class FrmAlbum...

  private void SaveToPmod() {
    model.Artist = txtArtist.Text;
    model.Title = txtTitle.Text;
    model.IsClassical = chkClassical.Checked;
    model.Composer = txtComposer.Text;
  }

此方法非常简单,只需将视图的可变部分分配给 演示模型。load 方法稍微复杂一些。

class FrmAlbum...

  private void LoadFromPmod() {
    if (NotLoadingView) {
      _isLoadingView = true;
      lstAlbums.DataSource = model.AlbumList;
      lstAlbums.SelectedIndex = model.SelectedAlbumNumber;
      txtArtist.Text = model.Artist;
      txtTitle.Text = model.Title;
      this.Text = model.FormTitle;
      chkClassical.Checked = model.IsClassical;
      txtComposer.Enabled = model.IsComposerFieldEnabled;
      txtComposer.Text = model.Composer;
      btnApply.Enabled = model.IsApplyEnabled;
      btnCancel.Enabled = model.IsCancelEnabled;
      _isLoadingView = false;
    }
  }
  private bool _isLoadingView = false;
  private bool NotLoadingView {
    get {return !_isLoadingView;}
  }
private void SyncWithPmod() {
  if (NotLoadingView) {
    SaveToPmod();
    LoadFromPmod();
  }
}

这里的问题是避免无限递归,因为同步会导致表单上的字段更新,从而触发同步......我使用一个标志来防止这种情况。

有了这些同步方法,下一步就是仅在控件的事件处理程序中调用正确的同步部分。大多数情况下这很简单,只需在数据更改时调用 SyncWithPmod 即可。

class FrmAlbum...

  private void txtTitle_TextChanged(object sender, System.EventArgs e){
    SyncWithPmod();
  }

有些情况更复杂。当用户单击列表中的新项目时,我们需要导航到新的专辑并显示其数据。

class FrmAlbum...

  private void lstAlbums_SelectedIndexChanged(object sender, System.EventArgs e){
    if (NotLoadingView) {
      model.SelectedAlbumNumber = lstAlbums.SelectedIndex;  
      LoadFromPmod();
    }
  }

class PmodAlbum...

  public int SelectedAlbumNumber {
    get {return _selectedAlbumNumber;}
    set {
      if (_selectedAlbumNumber != value) {
        Cancel();
        _selectedAlbumNumber = value;
      }
    }
  }

请注意,如果您点击列表,此方法将放弃任何更改。为了使示例简单,我做了这个糟糕的可用性设计,表单实际上应该至少弹出一个确认框以避免丢失更改。

应用和取消按钮将要执行的操作委托给 演示模型

class FrmAlbum...

  private void btnApply_Click(object sender, System.EventArgs e)    {
    model.Apply();
    LoadFromPmod();
  }
  private void btnCancel_Click(object sender, System.EventArgs e){
    model.Cancel();
    LoadFromPmod();
  }

class PmodAlbum...

  public void Apply ()    {
    SelectedAlbum.AcceptChanges();
  }
  public void Cancel() {
    SelectedAlbum.RejectChanges();
  }

因此,虽然我可以将大部分行为移到 演示模型 中,但视图仍然保留了一些智能。为了使 演示模型 的测试方面更好地工作,将更多内容移到 演示模型 中会很好。当然,您可以通过将同步逻辑移到 演示模型 中来将更多内容移到 演示模型 中,但代价是 演示模型 对视图了解得更多。

示例:数据绑定表格示例(C#)

当我第一次在 .NET 框架中查看 演示模型 时,似乎数据绑定提供了出色的技术,使 演示模型 能够简单地工作。到目前为止,数据绑定当前版本的局限性阻碍了它进入我确信它最终会去的领域。数据绑定可以很好地工作的一个领域是只读数据,因此这里有一个示例展示了这一点,以及表格如何与 演示模型 设计相适应。

图 4:专辑列表,其中摇滚专辑突出显示。

这只是一个专辑列表。额外的行为是,每个摇滚专辑的行都应该用玉米色着色。

我使用的数据集与其他示例略有不同。以下是部分测试数据的代码。

public static AlbumList AlbumGridDataSet() 
{
  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;
}

在这种情况下,演示模型将其内部数据集作为属性公开。这允许表单直接绑定到数据集中的单元格。

private AlbumList _dsAlbums;
internal AlbumList DsAlbums {
  get {return _dsAlbums;}
}

为了支持突出显示,演示模型提供了一个额外的索引到表格的方法。

internal Color RowColor(int row) {
  return (Albums[row].genre.Equals("Rock")) ? Color.Cornsilk : Color.White;
}
private AlbumList.AlbumsDataTable Albums {
  get {return DsAlbums.Albums;}
}

此方法类似于简单示例中的方法,区别在于表格数据上的方法需要单元格坐标来挑选表格的一部分。在这种情况下,我们只需要行号,但一般情况下,我们可能需要行号和列号。

从这里开始,我可以使用 Visual Studio 附带的标准数据绑定功能。我可以轻松地将表格单元格绑定到数据集中的数据,以及绑定到 演示模型 上的数据。

让颜色生效稍微复杂一些。这有点偏离了示例的主要内容,但整个事情变得复杂,因为没有办法在标准 WinForms 表格控件上进行逐行突出显示。通常,解决此问题的答案是购买第三方控件,但我太便宜了,不想这样做。所以对于好奇的人,以下是我所做的(这个想法主要来自 http://www.syncfusion.com/FAQ/WinForms/)。从现在开始,我将假设您熟悉 WinForms 的内部结构。

本质上,我创建了一个 DataGridTextBoxColumn 的子类,它添加了颜色突出显示行为。您可以通过传入处理该行为的委托来链接新的行为。

class ColorableDataGridTextBoxColumn...

  public ColorableDataGridTextBoxColumn (ColorGetter getcolorRowCol, DataGridTextBoxColumn original)
  {
    _delGetColor = getcolorRowCol;
    copyFrom(original);
  }
  public delegate Color ColorGetter(int row);
  private ColorGetter _delGetColor;

构造函数接受原始 DataGridTextBoxColumn 以及委托。我真正想在这里做的是使用装饰器模式来包装原始对象,但原始对象,就像 WinForms 中的许多类一样,都被密封了。因此,我将原始对象的所有属性复制到我的子类中。如果存在无法复制的重要属性,因为您无法读取或写入它们,这将不起作用。目前似乎在这里有效。

class ColorableDataGridTextBoxColumn...

  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 方法是虚拟的(否则我需要一个全新的数据网格)。我可以使用它使用委托插入适当的背景颜色。

class ColorableDataGridTextBoxColumn...

  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);
  }

为了将这个新表格放到位,我在页面加载后替换了数据表格的列,此时控件已在表单上构建。

class FrmAlbums...

  private void FrmAlbums_Load(object sender, System.EventArgs e){
    bindData();
    replaceColumnStyles();
  }
  private void replaceColumnStyles() {
    ColorableDataGridTextBoxColumn.ReplaceColumnStyles(dgsAlbums, 
      new ColorableDataGridTextBoxColumn.ColorGetter(model.RowColor));
  }

class ColorableDataGridTextBoxColumn...

  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));
    }
  }

它有效,但我承认它比我想象的要乱得多。如果我真要这样做,我会考虑使用第三方控件。但是我已经在生产系统中看到过这种做法,而且它运行良好。