WPF 的 MVVM 測試


甚麼是WPF

WPF(Windows Presentation Foundation) 簡單的說是一種把UI跟程式碼拆開來做的windows應用程式,早期UI的設計與動作都會和程式碼扯上關聯(因為程式碼寫在UI裡面),導致做美編的人員要動到畫面設計時,還得小心考慮到每個物件裡面的程式碼。而微軟在2006年的.NET Framework 3.0以後,重新去定義Windows APP設計的方向與概念而訂製了 WPF 架構。

我想有在設計android程式的人大概見怪不怪了,畢竟android程式的設計就是如此的,UI跟程式碼本來就是分開在設計的。當然習慣於舊式Coding的程式設計師還是會有點難以適應的,因為它不是『直覺』去設計程式的,而是透過『分析』→『模組化』→『開發設計』這種方式去進行開發的。

雖然WPF也可以用比較傳統方式去開發(把各種程式碼寫在UI的片段下)。但由於『模組化』才是WPF的精隨,所以一開始就栽進 M-V-VM 的環境,對於將來開發會比較有幫助的。

網路上也有不少資料可以參考,我這邊就以我自己的想法去解釋,如一開始的圖片。

  • Model主要負責資料結構(Data Model:Data Structure)的建立或與資料庫連結的存取(DAL;Data Access Layer)的各類模組化都是由這一層負責處理。
  • View主要負責畫面的設計與成型,它是一個 .xaml 的 XML 描述檔案,這個描述檔案描述了畫面個元件如何擺放與屬性等,通常它會搭配一個 .cs 的程式碼檔案,雖然可以把程式碼寫在 .cs 裡面,但為了把運算邏輯分離,所以盡可能不要寫入任何與畫面無關的程式碼,甚至根輸入有關的互動運算邏輯也不應該寫在這裡。
  • ViewModel這個才是View的邏輯核心,通常一個View會搭配一個ViewModel,而這個ViewModel負責 View 的資料保存(輸入或是顯示用)與畫面元件的互動或是指令,例如按鈕的行為、輸入資料後的自動動作等等,都是在這個模組去完成。透過 View 與 ViewModel 進行結繫(Biding)的方式,也可以讓 View 接收 ViewModel 傳回來的事件要求。

基本型:

假如我需要一個可以輸入產品名稱、數量、成本 與計算小計的功能。
所以我建立一個WPF專案,名稱叫 metoolook。

一、首先,定義一個資料類別(資料結構) ProductModel.cs


namespace metoolook.Models
{
    public class ProductModel
    {
        public string Name { get; set; }
        public int Amount { get; set; }
        public int Cost { get; set; }
        public int Total { get; set; }
    }
}


這裡要注意的是我把這個類別放在 Models 資料夾下面,所以它的 namespace 是 metoolook.Models 。



所以接下來,所有使用到這個 Class 的程式碼都必須使用

using metoolook.Models;


二、再來開啟主畫面 MainWindows.xaml ,並設計一個 4個格位的格窗與輸入TextBox 和一個計算用的按鈕。


XAML 內容部分(只有 Grid 部分):

<Grid Margin="0,0,0,439">
        <Grid x:Name="Grid1" HorizontalAlignment="Left" Height="108" Margin="56,69,0,-148" VerticalAlignment="Top" Width="641">
            <Grid.RowDefinitions>
                <RowDefinition Height="30"></RowDefinition>
                <RowDefinition Height="30"></RowDefinition>
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition></ColumnDefinition>
                <ColumnDefinition></ColumnDefinition>
                <ColumnDefinition></ColumnDefinition>
                <ColumnDefinition></ColumnDefinition>
            </Grid.ColumnDefinitions>
            <TextBlock Text="產品" Grid.Row="0" Grid.Column="0" TextAlignment="Center" FontSize="24" Background="Aqua"></TextBlock>
            <TextBlock Text="數量" Grid.Row="0" Grid.Column="1" TextAlignment="Center" FontSize="24" Background="Aqua"></TextBlock>
            <TextBlock Text="單價" Grid.Row="0" Grid.Column="2" TextAlignment="Center" FontSize="24" Background="Aqua"></TextBlock>
            <TextBlock Text="小計" Grid.Row="0" Grid.Column="3" TextAlignment="Center" FontSize="24" Background="Aqua"></TextBlock>
            <TextBox Text="" Grid.Row="1" Grid.Column="0" FontSize="20"></TextBox>
            <TextBox Text="" Grid.Row="1" Grid.Column="1" TextAlignment="Right" FontSize="20"></TextBox>
            <TextBox Text="" Grid.Row="1" Grid.Column="2" TextAlignment="Right" FontSize="20"></TextBox>
            <TextBox Text="" Grid.Row="1" Grid.Column="3" TextAlignment="Right" FontSize="20"></TextBox>
        </Grid>
        <Button x:Name="button" Content="計算" HorizontalAlignment="Left" Height="30" Margin="702,93,0,-123" VerticalAlignment="Top" Width="75"/>
    </Grid>


此時,這個 View 還沒有 ViewModel 可以結繫(Biding),接下來我們就要做一個 ViewModel 來給這個 View 使用。

三、設計 ViewModel ,設計這個 ViewModel 前要先瞭解二個介面 :

  • INotifyPropertyChanged 
  • ICommand


INotifyPropertyChanged 這個介面是用來通知結繫端物件有屬性內容發生變更的,當結繫端收到此通知便會進行畫面更新的動作。


既然,每個 ViewModel 基本上都會用到 INotifyPropertyChanged ,所以就乾脆寫一個基本類別,然後再給 各別 ViewModel 繼承,這樣就不用每個 ViewModel 再寫一次 INotifyPropertyChanged 的實做功能了。

實做 INotifyPropertyChanged  基底 ViewModel 類別 :ViewModelBase.cs
(注意這個 Class 放在 ViewModels 資料夾下,所以 namespace 會不一樣)


using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace metoolook.ViewModels
{
    public class ViewModelBase : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;
     
        protected void OnPropertyChanged(
            [CallerMemberName] string PropertyName = null)
        {
            if (PropertyName != null)
            {
                PropertyChanged(this,
                    new PropertyChangedEventArgs(PropertyName));
            }
        }
    }
}


這個裡面有個 [CallerMemberName] ,這個用法是會帶入呼叫端的名稱(可以是Property、Member、Function等的名稱 ),例如下面的呼叫範例:


public void FunctionA()
{
    //呼叫FunctionB
    FunctionB("MyApp");
    FunctionB();
}
public void FunctionB([CallerMemberName] string PropertyName = null)
{
    Console.WriteLine(PropertyName)
}


## 得到的結果:

MyApp    ← 這是第一次的結果
FunctionA    ←這是第二次的結果(當沒有參數時會帶入[CallerMemberName])


認識一下 ICommand 介面,這個介面是用來接收 XAML(也就是View) 結繫的觸發使用,通常會用在 Button 物件上,它跟 INotifyPropertyChanged 不一樣的是,它沒辦法預先實做在基底(Base),由於它必須實做在對應屬性上,而每個按鈕可能會對應到不同屬性。



假如,你的 View 上面有 3個按鈕分別是 ButtonAdd、ButtonDelete、ButtonUpdate,那這樣你的 ViewModel 上就可能會做出 3種屬性對應


public ICommand AddData {get;}
public ICommand DeleteData {get;}
public ICommand UpdateData {get;}


但是,很奇怪那個屬性到底要回傳(get)甚麼呢,它其實是 回傳一個 具有 ICommand 介面樣式的物件,而這個物件要能夠接受並處理 ICommand 要求的 Action 和 Func<bool> 兩個參數,


  • Action:指定要執行的 Function 或是 Method,其時就像 Callback動作。
  • Func<bool>:指定要執行的 CallBack 並一定要回傳一個 bool 值,用來判斷 Action 是否可以執行,感覺就像Enabled/Disabled。


所以上面的 get 內容我門就要去實做一個 ICommand 類別讓它回傳,最標準的 ICommand 類是網路常見的 RelayCommand.cs


using System;
using System.Diagnostics;
using System.Windows.Input;

namespace metoolook
{
    public class RelayCommand :ICommand
    {
        readonly Func<bool> _canExecute;
        readonly Action _execute;
        // 建構子(多型)
        public RelayCommand(Action execute) :
            this(execute, null)
        {
        }
        // 建構子(傳入參數)
        public RelayCommand(Action execute, Func<bool> canExecute)
        {
            // 簡化寫法 if(execute == null) throw new ArgumentNullException("execute");
            _execute = execute ?? throw new ArgumentNullException("execute");
            _canExecute = canExecute;
        }

        // 當_canExecute發生變更時,加入或是移除Action觸發事件
        public event EventHandler CanExecuteChanged
        {
            add
            {
                if (_canExecute != null) CommandManager.RequerySuggested += value;
            }
            remove
            {
                if (_canExecute != null) CommandManager.RequerySuggested += value;
            }
        }

        // 下面兩個方法是提供給 View 使用的
        [DebuggerStepThrough]
        public bool CanExecute(object parameter)
        {
            return _canExecute == null ? true : _canExecute();
        }

        public void Execute(object parameter)
        {
            _execute();
        }
    }
}


※這個標準的 RelayCommand 是不具備接收參數能力的,如果要接收參數就要做成 DelegateCommand 泛型版本,可以參考這裡

完成 之後,上面3個按鈕範例就會改成如下:


public ICommand AddData {get{return new RelayCommand(ToAdd , CanDo); }}
public ICommand DeleteData {get{return new RelayCommand(ToDelete , CanDo); }}
public ICommand UpdateData {get{return new RelayCommand(ToUpdate , CanDo); }}

public void ToAdd()
{
    // 新增資料動作...
}
public void ToDelete()
{
    // 刪除資料動作...
}
public void ToUpdate()
{
    // 修改資料動作...
}
public bool CanDo()
{
    // 檢查是否enabled...
    return true;
}



好了,有了上面幾項東西後,

接下來開始設計我們需要的 ViewModel : ProductViewModel.cs


using metoolook.Models;
using System.Windows.Input;

namespace metoolook.ViewModels
{
    public class ProductViewModel : ViewModelBase
    {
        // 使用的資料來源
        private ProductModel product;
        // 建構子
        public ProductViewModel()
        {
            // 建立資料模型 (這裡沒有對資料初始化)
            product = new ProductModel();
        }
        // 對應屬性:ICommand 類型,提供給 View 上面的 button 使用,所以沒有 set
        public ICommand UpdateTotal
        {
            get { return new RelayCommand(CalcTotal, CanExecute); }
        }
        // 對應屬性:輸入欄位:產品
        public string ProductName
        {
            get { return product.Name; }
            set
            {
                product.Name = value;
                OnPropertyChanged();
            }
        }
        // 對應屬性:輸入欄位:數量
        public int ProductAmount
        {
            get { return product.Amount; }
            set
            {
                product.Amount = value;
                OnPropertyChanged();
            }
        }
        // 對應屬性:輸入欄位:單價
        public int ProductCost
        {
            get { return product.Cost; }
            set
            {
                product.Cost = value;
                OnPropertyChanged();
            }
        }
        // 對應屬性:輸入欄位:小計
        public int ProductTotal
        {
            get { return product.Total; }
            set
            {
                product.Total = value;
                OnPropertyChanged();
            }
        }
        // 計算小計(給UpdateTotal使用)
        public void CalcTotal()
        {
            ProductTotal = ProductCost * ProductAmount;
        }
        // 回應是否可執行(給UpdateTotal使用)
        public bool CanExecute()
        {
            return true;
        }
    }
}


所以你看,ViewModel 上的屬性欄位都會與 xaml 相關的屬性來相應,到這裡只是初步完成屬性建置,但是 View 和 ViewModel 還沒有做結繫(Biding) ,因此View 上面的任何動作都不會與 ViewModel 產生互動,因此接下來,我們準備做結繫。

四、建置專案

接下來就要做 Biding 動作了,在此之前一定要做一次建置,不然的話,在 Biding 時,設計界面會一直告訴你找不到對應的 ViewModel 喔!!!


五、結繫(Biding)

回到我們的 MainWindows.xaml 裡面,先設定  DataContext,這裡要注意的一點是我們的 ProductViewModel.cs 是放在 metoolook.ViewModels 下面,所以必須把 namespace 先加進來


然後,我們再把 TextBox 和 Button 去和 ProductViewModel 的屬性去做個結繫


六、執行


按下計算




進階型:

如果我們希望讓它能夠自動計算,而不需要按『計算』按鈕,那要如何做呢?

  • 把自動計算放在 Model 裡面,雖然可以做,其實個人不太建議,因為按照邏輯層此處不應該做邏輯處理,而且也沒辦法儲存其值。但是為了瞭解其運作背景,所以還是把它實做一次。
  • 把自動計算放在 ViewModel裡面,這樣只需專注於 View 和 ViewModel 之間的關係處理。


一、把自動計算放在 Model 裡面的話就是改 Model 的 Total 屬性讓它自動計算

修改 ProductModel.cs


namespace metoolook.Models
{
    public class ProductModel
    {
        public string Name { get; set; }
        public int Amount { get; set; }
        public int Cost { get; set; }
        public int Total { get { return Amount * Cost; } }
    }
}


修改 ProductViewModel.cs


using metoolook.Models;
using System.Windows.Input;

namespace metoolook.ViewModels
{
    public class ProductViewModel : ViewModelBase
    {
        // 使用的資料來源
        private ProductModel product;
        // 建構子
        public ProductViewModel()
        {
            // 建立資料模型 (這裡沒有對資料初始化)
            product = new ProductModel();
        }
        // 對應屬性:輸入欄位:產品
        public string ProductName
        {
            get { return product.Name; }
            set
            {
                product.Name = value;
                OnPropertyChanged();
            }
        }
        // 對應屬性:輸入欄位:數量
        public int ProductAmount
        {
            get { return product.Amount; }
            set
            {
                product.Amount = value;
                OnPropertyChanged("ProductTotal");
            }
        }
        // 對應屬性:輸入欄位:單價
        public int ProductCost
        {
            get { return product.Cost; }
            set
            {
                product.Cost = value;
                OnPropertyChanged("ProductTotal");
            }
        }
        // 對應屬性:輸入欄位:小計
        public int ProductTotal
        {
            get { return product.Total; }
        }
    }
}


由於不需要 button 動作,所以把 ICommand UpdateTotal 、 CalcTotal、CanExecute 屬性拿掉,並修改 ProductTotal 只能 get (對照 ProductModel 屬性)。

接下來很重要的,是當你輸入數量、單價後要觸發 ProductTotal 去更新畫面欄位,所以原本的 

OnPropertyChanged(); 

要改成 

OnPropertyChanged("ProductTotal");

強迫小計欄位去做 get 動作,當觸發 get 動作,就可以觸發 Total 裡面的計算公式。

※這裡有個盲點,若當資料來自 Model 時就會發生數量、單價這兩個欄位不會發生更新狀況了,除非在數量、單價屬性再做一次 OnPropertyChanged()。

然後,既然 ProductTotal 被改成唯獨狀態,所以 xaml 裡面那個結繫 ProductTotal 的 TextBox 就必須改成唯讀的 TextBlock 喔!!!



測試結果



二、把自動計算放在 ViewModel裡面(UI邏輯層)

維持 ProductModel.cs 原本樣式:


namespace metoolook.Models
{
    public class ProductModel
    {
        public string Name { get; set; }
        public int Amount { get; set; }
        public int Cost { get; set; }
        public int Total { get; set; }
    }
}


然後 ProductViewModel.cs 裡面在 ProductAmount、ProductCost 變更資料時去呼叫 CalcTotal 就可以了:


using metoolook.Models;
using System.Windows.Input;

namespace metoolook.ViewModels
{
    public class ProductViewModel : ViewModelBase
    {
        // 使用的資料來源
        private ProductModel product;
        // 建構子
        public ProductViewModel()
        {
            // 建立資料模型 (這裡沒有對資料初始化)
            product = new ProductModel();
        }
        // 對應屬性:輸入欄位:產品
        public string ProductName
        {
            get { return product.Name; }
            set
            {
                product.Name = value;
                OnPropertyChanged();
            }
        }
        // 對應屬性:輸入欄位:數量
        public int ProductAmount
        {
            get { return product.Amount; }
            set
            {
                product.Amount = value;
                CalcTotal();
                OnPropertyChanged();
            }
        }
        // 對應屬性:輸入欄位:單價
        public int ProductCost
        {
            get { return product.Cost; }
            set
            {
                product.Cost = value;
                CalcTotal();
                OnPropertyChanged();
            }
        }
        // 對應屬性:輸入欄位:小計
        public int ProductTotal
        {
            get { return product.Total; }
            set
            {
                product.Total = value;
                OnPropertyChanged();
            }
        }
        // 計算小計(給UpdateTotal使用)
        public void CalcTotal()
        {
            ProductTotal = ProductCost * ProductAmount;
        }
    }
}


測試結果:



結果是一樣的,只是關於以後的維護,還是分門別類放清楚比較好。








參考:
一篇关于C# WPF MVVM 实战与总结
天空的垃圾場 -WPF – MVVM (一)
天空的垃圾場 -WPF – MVVM (二)
天空的垃圾場 -WPF – MVVM (三)
高級打字工!!! -WPF MVVM 實作
程式學習紀錄 -WPF MVVM架構
實現Icommand介面- 掃文資訊
Marcus的奇幻旅程 -[WPF]MVVM開發架構(二)
Marcus的奇幻旅程 -[WPF]實作DelegateCommand

留言

匿名表示…
public ICommand AddData {get;}
public ICommand DeleteData {get;}
public ICommand UpdateData {get;}

請問以上是被宣告在哪個cs裡面呢?
匿名表示…
網誌管理員已經移除這則留言。
WILDOX寫道…
Re: 匿名 <634822378833878716>

這個沒有cs喔..因為這是個概念描述,
在我這篇文章範例中只使用一個『按鈕』,就是『計算』這個功能,

這個『計算』就是對應到文中 ProductViewModel.cs 內的 public ICommand UpdateTotal,其實你把它當作 public ICommand UpdateData {get;} 方法來看就是了

這個網誌中的熱門文章

【研究】列印的條碼為什麼很難刷(掃描)

C# 使用 Process.Start 執行外部程式

統一發票列印小程式