展示模型
不依賴介面中使用的 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;} }
這只是一個呼叫 public 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; }
此方法非常簡單,只需將檢視的可變部分指定給簡報模型即可。載入方法稍微複雜一些。
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
的子類別,其中新增了顏色反白顯示的行為。你可以傳入一個處理行為的委派,來連結新的行為。
類別 ColorableDataGridTextBoxColumn...
public ColorableDataGridTextBoxColumn (ColorGetter getcolorRowCol, DataGridTextBoxColumn original) { _delGetColor = getcolorRowCol; copyFrom(original); } public delegate Color ColorGetter(int row); private ColorGetter _delGetColor;
建構函式會採用原始的 DataGridTextBoxColumn 以及委派。我真正想做的是使用裝飾器模式來包裝原始的,但原始的,就像 WinForms 中的許多類別一樣,都是密封的。因此,我將原始類別的所有屬性複製到我的子類別中。如果有一些重要的屬性無法複製,因為你無法讀取或寫入它們,這個方法就不會奏效。目前看來,它似乎可以正常運作。
類別 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) ; } }
幸運的是,繪製方法是虛擬的(否則我需要一個全新的資料格)。我可以使用委派來插入適當的背景顏色。
類別 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); }
為了放置這個新表格,我在表單上建立控制項後,在頁面載入中取代資料表的欄。
類別 FrmAlbums...
private void FrmAlbums_Load(object sender, System.EventArgs e){ bindData(); replaceColumnStyles(); } private void replaceColumnStyles() { ColorableDataGridTextBoxColumn.ReplaceColumnStyles(dgsAlbums, new ColorableDataGridTextBoxColumn.ColorGetter(model.RowColor)); }
類別 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)); } }
它可以運作,但我承認它比我想要的還要混亂許多。如果我真正要這麼做,我會想要尋找第三方控制項。然而,我已經在生產系統中看過這種做法,而且運作得很好。