「老罈泡新菜」:SOD MVVM框架,讓Winforms煥發新春

「老罈泡新菜」:SOD MVVM框架,讓Winforms煥發新春

WinForms上MVVM技術的必要性,發現要實現MVVM框架其實並不難,關鍵在於模型(Model)和檢視(View)的雙向綁定,即模型的改變引起檢視內容的改變,而檢視的改變也能夠引起模型的改變。

最後更新 2021/11/23 下午9:13
用户1177503
預計閱讀 17 分鐘
分類
Winform
專題
Winform控制項庫
標籤
.NET Winform MVVM Winform開源專案 開源

1. 火熱的 MVVM 框架

最近幾年最熱門的技術之一就是前端技術了,各種前端框架、前端標準和前端設計風格層出不窮,而在眾多前端框架中具有 MVC、MVVM 功能的框架成為耀眼新星,例如 GitHub 關注度很高的 Vue.js,由於是國人作品,其設計風格和文件友好度對國人而言更勝一籌,因此我也將它推薦到公司採用,其中我推薦的理由就是它非常優秀的 MVVM 功能,面向資料而不是面向 DOM 細節相比 jQuery 等更加節省程式碼,更符合後端程式設計師的胃口,也更有利於 UI 設計人員與程式設計師的分工配合。

下面是 Vue.js 實現 MVVM 功能的原理圖:

前面說的 Vue.js 框架這些優點是否很眼熟?沒錯,這就是早些年流行於 WPF 的 MVVM 技術,相比 WinForms 技術,WPF 可以提供給 UI 設計人員更強大的設計能力,做出更炫更好看的介面。只不過 MS 的很多技術總是很超前,技術更新很快,WPF 新推出的時候 WinForms 還佔據桌面開發主要領域,隨後還沒有火起來,行動開發時代已經來臨,基於 Web 的前端技術大大發展,從而風頭蓋過了 WPF,但是 WPF 引入的 MVVM 思想卻在 Web 前端得到了發揚光大,現在各種基於 MVVM 的前端框架猶如雨後春筍。

2. WinForms 上的 MVVM 需求

Web 前端技術的大力發展,各種跨平台的基於 HTML5 的行動前端開發技術逐漸成熟,各種應用逐步由傳統的 C/S 轉換到 B/S、APP 模式,基於 C/S 模式的前端技術例如 WPF 的關注度逐漸下降,因此 WPF 上的 MVVM 並沒有應用得很廣,目前很多遺留的或者新的 C/S 系統仍然採用 WinForms 技術開發維護,然而 WinForms 上卻沒有良好的 MVVM 框架,WinForms 的 UI 效果和整體開發品質與開發效率沒有得到有效提高,要過渡到 WPF 開發這種不同開發風格的技術難度又比較大,所以,如果有一種能夠在 WinForms 上的 MVVM 框架,無疑是廣大後端 .NET 程式設計師的福音。

筆者一直是一個奮鬥在一線的 .NET 開發人員、架構師,對於 Web 和桌面、後端開發技術都有廣泛的涉及,深刻理解開發人員自嘲自己為「碼農」的心理,工作辛苦又沒有時間陪女朋友陪家人,所以我一直總結整理如何提高開發效率、改善開發品質的方法,經過近 10 年的時間,發展完善了一套開發框架——SOD 框架。最近研究改善 Web 前端開發的技術,Vue.js 框架的 MVVM 思想再一次讓我覺得 WinForms 上 MVVM 技術的必要性,發現要實現 MVVM 框架其實並不難,關鍵在於模型(Model)和視圖(View)的雙向繫結,即模型的改變引起視圖內容的改變,而視圖的改變也能夠引起模型的改變。

3. SOD WinForms MVVM 實現原理

要實現這種改變,對於被繫結方,必須具有屬性改變通知功能,當繫結方改變的時候,通知被繫結方讓它做相應的處理。在 .NET 中,實現這種通知功能的介面就是:

INotifyPropertyChanged

它的定義在 System.dll 中,早在 .NET 2.0 就已經支援。下面是該介面的具體定義:

namespace System.ComponentModel
{
    // 摘要:
    //     向用戶端發出某一屬性值已變更的通知。
    public interface INotifyPropertyChanged
    {
        // 摘要:
        //     在變更屬性值時發生。
        event PropertyChangedEventHandler PropertyChanged;
    }
}

SOD 框架的實體類別基底類別 EntityBase 實作了此介面:

public abstract class EntityBase : INotifyPropertyChanged, ICloneable, PWMIS.Common.IEntity
{
    /// <summary>
    /// 屬性改變事件
    /// </summary>
    public event PropertyChangedEventHandler PropertyChanged;
    /// <summary>
    /// 觸發屬性改變事件
    /// </summary>
    /// <param name="propertyFieldName">屬性改變事件物件</param>
    protected virtual void OnPropertyChanged(string propertyFieldName)
    {
        if (this.PropertyChanged != null)
        {
            string currPropName = EntityFieldsCache.Item(this.GetType()).GetPropertyName(propertyFieldName);
            this.PropertyChanged(this, new PropertyChangedEventArgs(currPropName));
        }
    }
    // 其他程式碼略… …
}

所以 SOD 框架的實體類別可以直接用來作為 MVVM 上的 Model 提供給 View 作為被繫結物件,因此我們只需要解決 WinForms 形式的 View 元素如何實作繫結操作,那麼我們的 WinForms 應用即可實現 MVVM 功能了。在 WinForms 上,控制項基本上都已經實作了繫結功能,它就是控制項的 DataBindings,向它加入繫結即可,例如下面的例子:

this.textbox1.DataBindings.Add("Text", userEntity, "Name");

這樣當文字框輸入的內容改變後,實體類別物件 userEntity.Name 屬性的值也會改變。如果 userEntity 是 SOD 實體類別,所以 userEntity.Name 改變,文字框的 Text 屬性也會同步改變。

SOD 框架的資料控制項 (WinForms、WebForms) 都實作了 IDataControl 介面,它定義了幾個重要的屬性 LinkObject、LinkProperty:

/// <summary>
/// 資料對應控制項介面
/// </summary>
public interface IDataControl
{

    /// <summary>
    /// 與資料庫資料項相關聯的資料
    /// </summary>
    string LinkProperty
    {
        get;
        set;
    }

    /// <summary>
    /// 與資料關聯的資料表名稱
    /// </summary>
    string LinkObject
    {
        get;
        set;
    }
    // 其他介面方法內容略… …

我們可以使用 LinkObject 來指定要繫結的實體類別物件,而 LinkProperty 來指定要繫結的物件的屬性,因此可以透過下面的程式碼實現 WinForms 控制項與 SOD 實體類別的雙向繫結:

public void BindDataControls(Control.ControlCollection controls)
{
    var dataControls = MyWinForm.GetIBControls(controls);
    foreach (IDataControl control in dataControls)
    {
        //control.LinkObject 這裡都是 "DataContext"
        object dataSource = GetInstanceByMemberName(control.LinkObject);
        if (control is TextBox)
        {
            ((TextBox)control).DataBindings.Add("Text", dataSource, control.LinkProperty);
        }
        if (control is Label)
        {
            ((Label)control).DataBindings.Add("Text", dataSource, control.LinkProperty);
        }
        if (control is ListBox)
        {
            ((ListBox)control).DataBindings.Add("SelectedValue", dataSource, control.LinkProperty, false, DataSourceUpdateMode.OnPropertyChanged);
        }
    }
}

另外,我們可能還需要將一些命令繫結到視圖上,而要實現此功能也比較簡單:

private Dictionary<object, CommandMethod> dictCommand;
public delegate void CommandMethod();

public void BindCommandControls(Control control,CommandMethod command)
{
    if (control is Button)
    {
        dictCommand.Add(control, command);
        ((Button)control).Click += (sender, e) => {
            dictCommand[sender]();
        };
    }
}

經過這樣的過程後,我們僅需要在表單載入事件上寫下面的幾行程式碼就行了:

SubmitedUsersViewModel DataContext{get;set;}

private void Form1_Load(object sender, EventArgs e)
{
    base.BindDataControls(this.Controls);
    base.BindCommandControls(this.button1, DataContext.SubmitCurrentUsers);
    base.BindCommandControls(this.button2, DataContext.UpdateUser);
    base.BindCommandControls(this.button3, DataContext.RemoveUser);
}

上面的程式碼中,首先定義了一個視圖模型物件 DataContext,在方法 BindDataControls 裡面作為繫結到視圖控制項上的物件,它裡面的 CurrentUser 屬性的 Name 屬性繫結到了文字框控制項上,所以 CurrentUser.Name 是作為複合屬性來繫結的,對於標籤控制項和清單方塊控制項,也是類似的過程,如下圖:

這樣,在視圖上做簡單的資料屬性設定和寫少量的 code behind 繫結程式碼,一個具有雙向繫結功能的程式就好了。

4. MVVM 範例解決方案

4.1 解決方案概覽

為了向大家展示 SOD 框架對於 MVVM 的支援,我們搭建一個簡單的解決方案,一共分為三個專案組件,分別對應 MVVM 的三大部分:

  1. WinFormMvvm: WinForm 範例程式主程式,視圖類別所在組件
  2. WinFormMvvm.Model: 模型類別組件
  3. WinFormMvvm.ViewModel: 視圖模型組件

搭建好的解決方案圖如下:

注意:此解決方案是使用 SOD Ver 5.5.5.1019 做的,因為這是目前 nuget 上 SOD 的版本,最新的 SOD 框架已經把 WinFormMvvm 專案的 MvvmForm.cs 檔案納入到框架之內了。

程式在 App.config 中指定了本次附加測試的資料庫,資料庫類型為 Access,預設的連接字串可能要求 Office 2007 以上版本支援。

下面是 App.config 的內容:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <connectionStrings>
    <add name ="default" connectionString ="Provider=Microsoft.ACE.OLEDB.12.0;Jet OLEDB:Engine Type=6;Data Source=testdb.accdb" providerName="Access"/>
  </connectionStrings>
    <startup>
        <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5" />
    </startup>
  <runtime>
    <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
      <dependentAssembly>
        <assemblyIdentity name="PWMIS.Core" publicKeyToken="17ba13a12b9fd814" culture="neutral" />
        <bindingRedirect oldVersion="0.0.0.0-5.5.5.1019" newVersion="5.5.5.1019" />
      </dependentAssembly>
    </assemblyBinding>
  </runtime>
</configuration>

如果你需要更低版本的 Access 資料庫支援,或換用其他資料庫(例如 SqlServer),請閱讀參考下列步驟提供的資訊:

  1. 開啟下面連結:

http://pwmis.codeplex.com/

  1. 看到內容章節「3,修改下 App.config 檔案的連線設定」;

  2. 點選本節下的連結「2.2.3 擴充資料存取類別設定」。

4.2 建立 MVVM 的 WinForm 視圖

這是一個簡單的 WinForm 表單,有三個 SOD「資料控制項」,包括:一個標籤控制項顯示使用者的 ID,文字框控制項顯示使用者名稱,一個清單方塊控制項顯示已經有使用者清單,三個按鈕分別用來向清單新增、修改和刪除資料。

對於資料控制項,可以在這個表單設計器介面,開啟「工具箱」,在「一般」索引標籤裡面,選擇操作功能表「選擇項目」,瀏覽到 packages\PDF.NET.SOD.WinForm.Extensions.5.5.5.1020\lib 目錄,選擇「Pwmis.Windows.dll」,即可看到 SOD 的資料控制項,然後拖曳到表單上即可。

注意我們不會直接對這三個按鈕控制項設定點選事件,而是透過命令繫結的形式。例如對應新增按鈕,我們如下繫結命令(視圖模型的一個方法):

base.BindCommandControls(this.button1, DataContext.SubmitCurrentUsers);

這會將新增使用者的按鈕控制項的點選事件,繫結到 DataContext 的 SubmitCurrentUsers 方法上。

而對於資料控制項的繫結,只需要下面的一行程式碼:

base.BindDataControls(this.Controls);

前面已經說過,該方法會走訪方法上第一個參數裡面的所有資料控制項,找到 LinkObject 和 LinkProperty 屬性,實現資料控制項和視圖模型物件的繫結,這裡繫結的是 DataContext 物件的 CurrentUser 物件的屬性。

按一下屬性瀏覽器中資料控制項的 LinkProperty 屬性旁邊的「…」按鈕,會彈出下面的「資料控制項屬性選擇器」表單:

由於這裡我們要繫結的物件是目前表單的 DataContext 物件,所以需要瀏覽選擇到主應用程式組件,這樣在屬性名稱一欄,會顯示此物件所有的屬性和子屬性。注意如果 DataContext 物件沒有出現在清單裡面,需要檢查 Form 表單是否宣告了 DataContext 物件,並且需要先編譯一次組件。最後,按一下確定,我們就設定好了資料控制項要繫結的資訊。

4.3 建立 MVVM 的模型

我們的模型很簡單,就是負責建立新使用者、載入已有使用者、新增、修改或刪除使用者,並且這些操作都是針對資料庫的,也就是我們通常的 CRUD 操作。由於是範例沒有太多邏輯,我們直接看程式碼即可:

public class UserModel
{
    private static int index = 0;
    private LocalDbContext context;
    public UserModel()
    {
        context = new LocalDbContext();
    }

    public List<UserEntity> GetAllUsers()
    {
        var list= OQL.From<UserEntity>().ToList(context.CurrentDataBase);
        int max = list.Max(p => p.ID);
        index = ++max;
        return list;
    }
    public void UpdateUser(UserEntity user)
    {
        int count= context.Update<UserEntity>(user);
    }

    public void AddUsers(IList<UserEntity> users)
    {
        int count = context.AddList(users);
    }

    public void SubmitUser(UserEntity user)
    {
        int count = context.Add(user);
    }

    public void RemoveUser(UserEntity user)
    {
        int count = context.Remove(user);
    }

    public UserEntity CreateNewUser(string userName="NoName")
    {
        return new UserEntity()
        {
              ID= ++index,
              Name =userName
        };
    }
}

使用者模型類別會使用使用者實體類別,它也很簡單,只有一個 ID 屬性和一個 Name 屬性,詳細內容如下:

public class UserEntity:EntityBase
{
    public UserEntity()
    {
        TableName = "Tb_User";
        PrimaryKeys.Add("UserID");
    }
    public int ID {
        get { return getProperty<int>("UserID"); }
        set { setProperty("UserID", value); }
    }

    public string Name
    {
        get { return getProperty<string>("UserName"); }
        set { setProperty("UserName", value); }
    }

}

該使用者實體類別雖然很簡單,卻可以直接提供給視圖作為模型繫結的元素,因為 SOD 實體類別都實作了「屬性修改通知」介面,前面已經詳細說明。

接下來就是操作此使用者實體類別的資料上下文了,使用者模型類別展示了如何使用它,但是它的定義卻很簡單:

class LocalDbContext : DbContext
{
    public LocalDbContext()
        : base("default")
    {
        //local 是連接字串名稱
    }

    protected override bool CheckAllTableExists()
    {
        //建立使用者資料表
        CheckTableExists<UserEntity>();
        return true;
    }
}

至此,一個簡單的 MVVM 模型類別的全部定義就完成了。

4.4 建立 MVVM 的視圖模型

視圖模型是對視圖的一個抽象,它封裝了主要的視圖處理邏輯,與 MVP 的 Presenter 不同,視圖模型並不會包含詳細視圖元素的抽象,例如一個抽象的清單控制項,而是對視圖可能用到的資料進行封裝,並且可能包含對後端 MVVM 的模型物件呼叫。

在本例中,我們的使用者視圖模型的功能也很簡單,就是提供視圖需要的使用者清單和回應視圖的新增、修改、刪除使用者的命令,詳細程式碼如下:

public class SubmitedUsersViewModel
{

    private UserModel model = new UserModel();
    public BindingList<UserEntity> Users { get; private set; }
    public UserEntity CurrentUser { get; private set; }


    UserEntity _selectUser;
    /// <summary>
    /// 目前選擇的使用者,如果設定,則會設定目前使用者
    /// </summary>
    public UserEntity SelectedUser {
        get { return _selectUser; }
        set {
            _selectUser = value;
            this.CurrentUser.ID = value.ID;
            this.CurrentUser.Name = value.Name;
        }

    }

    int _selectedUserID;
    public int SelectedUserID
    {
        get { return _selectedUserID; }
        set {
            _selectedUserID = value;
            var obj = this.Users.FirstOrDefault(p=>p.ID==value);
            if (obj != null)
            {
                this.CurrentUser.ID = obj.ID;
                this.CurrentUser.Name = obj.Name;
                _selectUser = this.CurrentUser;
            }

        }
    }

    public SubmitedUsersViewModel()
    {
        var data = model.GetAllUsers();
        Users = new BindingList<UserEntity>(data);
        CurrentUser = new UserEntity();

    }

    public void UpdateUser()
    {
        var obj = this.Users.FirstOrDefault(p => p.ID == this.CurrentUser.ID);
        if (obj != null)
        {
            obj.Name = this.CurrentUser.Name;
            //更新後必須呼叫 ResetBindings 方法,否則控制項上的資料會遺失一行
            this.Users.ResetBindings();

            model.UpdateUser(obj);
        }

    }
    public void UpdateUser(int id,string name)
    {
        var obj = this.Users.FirstOrDefault(p => p.ID == id);
        if (obj != null)
        {
            obj.Name = name;
            //更新後必須呼叫 ResetBindings 方法,否則控制項上的資料會遺失一行
            this.Users.ResetBindings();

            model.UpdateUser(obj);
        }
    }

    public void SubmitUsers(UserEntity user)
    {
        //UserEntity newUser = new UserEntity();
        //newUser.ID = user.ID;
        //newUser.Name = user.Name;
        //Users.Add(newUser);
        if (!Users.Contains(user))
        {
            Users.Add(user);
            model.SubmitUser(user);
        }
    }
    public void SubmitCurrentUsers()
    {
        UserEntity newUser = model.CreateNewUser(CurrentUser.Name);
        SubmitUsers(newUser);
        CurrentUser.ID = newUser.ID;
    }

    public void RemoveUser()
    {
        if (SelectedUser == null)
        {

            return;
        }
        var obj = this.Users.FirstOrDefault(p => p.ID == SelectedUser.ID);
        if (obj != null)
        {
            this.Users.Remove(obj);
            //更新後必須呼叫 ResetBindings 方法,否則控制項上的資料會遺失一行
            this.Users.ResetBindings();

            model.RemoveUser(obj);
        }
    }
}

4.5 新增 NuGet 套件參考

對於整個解決方案,我們都需要新增 PDF.NET Core 套件,但是對於我們的 WinForms 主程式,需要額外新增 2 個相關的套件,一個 SOD WinForm 擴充和一個 SOD Access 擴充,下面是解決方案安裝的全部套件示意圖:

4.6 執行解決方案

經過上面的過程,我們新增了視圖元素、設定好了視圖元素的資料繫結、建立了模型和視圖模型物件,一個簡單的 MVVM 範例程式就好了,下面是執行效果圖:

5. MVVM 模式總結

透過執行此範例,相信你已經體驗了 MVVM 的一些特點,但可能難以表述貼切,正好我跟幾個 WPF 資深專家交流後,他們總結出了 MVVM 的幾個核心特點(賣點):

  1. 檢視邏輯(視圖模型)和視圖(視圖元素、樣式)的解除耦合;
  2. 視圖和視圖模型或模型的雙向資料繫結,面向資料驅動視圖而不是視圖驅動資料;
  3. 視圖和視圖模型的分離將介面功能全部程式碼化,並提供 TDD 可能性。

6. SOD WinForms MVVM 支援

自 SOD 框架版本 5.6.0.1111 發布的這個「光棍節」版本中,您已經可以在這個版本之後獲得直接的 WinForms MVVM 支援,如果是之前的版本,那麼需要如本範例程式一樣稍微多做一點工作,但這對於您現有的 SOD 支援的解決方案來說不會造成任何影響。

本範例方案將會放到框架的開源網站 http://pwmis.codeplex.com 上提供直接的下載,並且原始碼已經全部提交,可以透過下面地址查看詳細的程式碼說明:

http://pwmis.codeplex.com/SourceControl/latest#SOD/Example/WinFormMvvm/WinFormMvvm/Readme.txt

了解更多資訊或者加入社群 QQ 群討論,或者捐助本框架,請移步框架官網:

http://www.pwmis.com/sqlmap

感謝你選擇 SOD 框架,相信它能夠為你的開發帶來很大的便利!

SOD 開發團隊

深藍醫生

2016.11.13

------------PS---------------

感謝 SOD 開發團隊的 @廣州-銀古 同學,他已經及時將 SOD 框架的 nuget 套件更新到了最新版本,沒有前面說的 nuget 套件問題了。

最後附上 SOD 庫倉庫地址及截圖資訊:

繼續探索

延伸閱讀

更多文章
同分類 / 同標籤 2024/2/29

Winform中也可以這樣做資料展示

在做winform開發的過程中,經常需要做資料展示的功能,之前一直使用的是gridcontrol控制項,今天想透過一個範例,跟大家介紹一下如何在winform blazor hybrid中使用ant design blazor中的table元件做資料展示。

繼續閱讀
同分類 / 同標籤 2024/2/29

Winform的介面也可以變好看?

前幾天跟大家介紹了在winform中使用blazor hybrid,而且還說配上blazor的UI可以讓我們的winform程式設計的更加好看,接下來我想以一個在winform blazor hybrid中繪圖的範例來進行說明,希望對你有所幫助。

繼續閱讀