65.9K
CodeProject 正在变化。 阅读更多。
Home

LOB WPF 应用程序的优秀方法

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (37投票s)

2009年2月23日

CPOL

13分钟阅读

viewsIcon

123001

downloadIcon

2143

在 WPF LOB 应用程序中实现 MVVM 模式 + CommandModel。

引言

我一直想写一篇自己的文章很久了,并且在业余时间我一直在做一个 WPF LOB 应用程序,一个 Silverlight 团队管理系统。我有一些关于我实现方式的疑问,就去问了 CodeProject WPF 作者 Sacha Barber 该怎么办,你知道吗?他竟然恭喜了我,因为我做得很好。他的赞扬让我既震惊又高兴,然后我想,“如果他觉得它不错,也许我可以写一篇关于它的文章……”,于是就有了这篇。我的第一篇文章是关于在 WPF LOB 应用程序中使用 MVVM + Command Model 的一种方法。

所以,我想感谢 Sacha 在我追求更好应用程序的道路上的帮助,也感谢像他一样以及我将在本文中引用的其他一些伟大开发者。

从哪里开始

让我们从定义我们的目标开始,以一个简单的待办事项列表为例。

有些人可能会说:“嘿,我知道怎么做。我不需要一篇文章来学习它。” 我请求您耐心等待,因为这并非关于应用程序本身,而是关于它是如何构建的。

定义 Model

Model,或者有些人称之为 DataModel,负责以视图可以消费的方式公开数据。对我来说,DataModel 不是数据本身,它不是数据库、XML 或包含数据的任何东西,而是一个包装器,以便我们可以将其公开供应用程序使用。我知道这对大多数读者来说是常识,但由于我在工作单位曾与其他不知晓这种方法的程序员有过一些问题,我认为我需要解释一下。让我们分析以下场景:

我们有一个使用 SQL Server 2005 Enterprise 的应用程序,我们有一个新客户,但该客户拥有 Oracle 10i 的许可证。您会礼貌地要求您的新客户购买 SQL Server 2005 Enterprise 许可证吗?还是您会重写您的应用程序,因为您对 Model 的概念包含了数据本身?都不是。因此,Model 只包含数据的映射,例如 LINQ to SQL 的实体,或者 Lightspeed Model 的表。使用这两个例子,我们可以更好地理解。LINQ to SQL 有其实体,所以在我们的例子中,我们可能有这样的内容:

[Table(Name="Sales.Customer")]
public partial class Customer : INotifyPropertyChanging, INotifyPropertyChanged
{
    private static PropertyChangingEventArgs emptyChangingEventArgs = 
                   new PropertyChangingEventArgs(String.Empty);
    
    private int _CustomerID;
    
    private System.Nullable<int> _TerritoryID;
    
    private string _AccountNumber;
    
    private char _CustomerType;
    
    private System.Guid _rowguid;
    
    private System.DateTime _ModifiedDate;
    
    private EntitySet<customeraddress> _CustomerAddresses;
    
    private EntityRef<individual> _Individual;
    
    private EntityRef<salesterritory> _SalesTerritory;
    
    #region Extensibility Method Definitions
    partial void OnLoaded();
    partial void OnValidate(System.Data.Linq.ChangeAction action);
    partial void OnCreated();
    partial void OnCustomerIDChanging(int value);
    partial void OnCustomerIDChanged();
    partial void OnTerritoryIDChanging(System.Nullable<int> value);
    partial void OnTerritoryIDChanged();
    partial void OnAccountNumberChanging(string value);
    partial void OnAccountNumberChanged();
    partial void OnCustomerTypeChanging(char value);
    partial void OnCustomerTypeChanged();
    partial void OnrowguidChanging(System.Guid value);
    partial void OnrowguidChanged();
    partial void OnModifiedDateChanging(System.DateTime value);
    partial void OnModifiedDateChanged();
    #endregion
    
    public Customer()
    {
        this._CustomerAddresses = new EntitySet<customeraddress>(
          new Action<customeraddress>(this.attach_CustomerAddresses), 
          new Action<customeraddress>(this.detach_CustomerAddresses));
        this._Individual = default(EntityRef<individual>);
        this._SalesTerritory = default(EntityRef<salesterritory>);
        OnCreated();
    }
    
    [Column(Storage="_CustomerID", AutoSync=AutoSync.OnInsert, 
      DbType="Int NOT NULL IDENTITY", 
      IsPrimaryKey=true, IsDbGenerated=true)]
    public int CustomerID
    {
        get
        {
            return this._CustomerID;
        }
        set
        {
            if ((this._CustomerID != value))
            {
                this.OnCustomerIDChanging(value);
                this.SendPropertyChanging();
                this._CustomerID = value;
                this.SendPropertyChanged("CustomerID");
                this.OnCustomerIDChanged();
            }
        }
    }
    
    [Column(Storage="_TerritoryID", DbType="Int")]
    public System.Nullable<int> TerritoryID
    {
        get
        {
            return this._TerritoryID;
        }
        set
        {
            if ((this._TerritoryID != value))
            {
                if (this._SalesTerritory.HasLoadedOrAssignedValue)
                {
                    throw new System.Data.Linq.ForeignKeyReferenceAlreadyHasValueException();
                }
                this.OnTerritoryIDChanging(value);
                this.SendPropertyChanging();
                this._TerritoryID = value;
                this.SendPropertyChanged("TerritoryID");
                this.OnTerritoryIDChanged();
            }
        }
    }
    
    [Column(Storage="_AccountNumber", AutoSync=AutoSync.Always, 
      DbType="VarChar(10) NOT NULL", CanBeNull=false, 
      IsDbGenerated=true, UpdateCheck=UpdateCheck.Never)]
    public string AccountNumber
    {
        get
        {
            return this._AccountNumber;
        }
        set
        {
            if ((this._AccountNumber != value))
            {
                this.OnAccountNumberChanging(value);
                this.SendPropertyChanging();
                this._AccountNumber = value;
                this.SendPropertyChanged("AccountNumber");
                this.OnAccountNumberChanged();
            }
        }
    }
    
    [Column(Storage="_CustomerType", DbType="NChar(1) NOT NULL")]
    public char CustomerType
    {
        get
        {
            return this._CustomerType;
        }
        set
        {
            if ((this._CustomerType != value))
            {
                this.OnCustomerTypeChanging(value);
                this.SendPropertyChanging();
                this._CustomerType = value;
                this.SendPropertyChanged("CustomerType");
                this.OnCustomerTypeChanged();
            }
        }
    }
    
    [Column(Storage="_rowguid", DbType="UniqueIdentifier NOT NULL")]
    public System.Guid rowguid
    {
        get
        {
            return this._rowguid;
        }
        set
        {
            if ((this._rowguid != value))
            {
                this.OnrowguidChanging(value);
                this.SendPropertyChanging();
                this._rowguid = value;
                this.SendPropertyChanged("rowguid");
                this.OnrowguidChanged();
            }
        }
    }
    
    [Column(Storage="_ModifiedDate", DbType="DateTime NOT NULL")]
    public System.DateTime ModifiedDate
    {
        get
        {
            return this._ModifiedDate;
        }
        set
        {
            if ((this._ModifiedDate != value))
            {
                this.OnModifiedDateChanging(value);
                this.SendPropertyChanging();
                this._ModifiedDate = value;
                this.SendPropertyChanged("ModifiedDate");
                this.OnModifiedDateChanged();
            }
        }
    }
    
    [Association(Name="Customer_CustomerAddress", 
      Storage="_CustomerAddresses", 
      ThisKey="CustomerID", OtherKey="CustomerID")]
    public EntitySet<customeraddress> CustomerAddresses
    {
        get
        {
            return this._CustomerAddresses;
        }
        set
        {
            this._CustomerAddresses.Assign(value);
        }
    }
    
    [Association(Name="Customer_Individual", Storage="_Individual", 
      ThisKey="CustomerID", 
      OtherKey="CustomerID", IsUnique=true, IsForeignKey=false)]
    public Individual Individual
    {
        get
        {
            return this._Individual.Entity;
        }
        set
        {
            Individual previousValue = this._Individual.Entity;
            if (((previousValue != value) || 
                (this._Individual.HasLoadedOrAssignedValue == false)))
            {
                this.SendPropertyChanging();
                if ((previousValue != null))
                {
                    this._Individual.Entity = null;
                    previousValue.Customer = null;
                }
                this._Individual.Entity = value;
                if ((value != null))
                {
                    value.Customer = this;
                }
                this.SendPropertyChanged("Individual");
            }
        }
    }
    
    [Association(Name="SalesTerritory_Customer", Storage="_SalesTerritory", 
       ThisKey="TerritoryID", OtherKey="TerritoryID", IsForeignKey=true)]
    public SalesTerritory SalesTerritory
    {
        get
        {
            return this._SalesTerritory.Entity;
        }
        set
        {
            SalesTerritory previousValue = this._SalesTerritory.Entity;
            if (((previousValue != value) || 
                (this._SalesTerritory.HasLoadedOrAssignedValue == false)))
            {
                this.SendPropertyChanging();
                if ((previousValue != null))
                {
                    this._SalesTerritory.Entity = null;
                    previousValue.Customers.Remove(this);
                }
                this._SalesTerritory.Entity = value;
                if ((value != null))
                {
                    value.Customers.Add(this);
                    this._TerritoryID = value.TerritoryID;
                }
                else
                {
                    this._TerritoryID = default(Nullable<int>);
                }
                this.SendPropertyChanged("SalesTerritory");
            }
        }
    }
    
    public event PropertyChangingEventHandler PropertyChanging;
    
    public event PropertyChangedEventHandler PropertyChanged;
    
    protected virtual void SendPropertyChanging()
    {
        if ((this.PropertyChanging != null))
        {
            this.PropertyChanging(this, emptyChangingEventArgs);
        }
    }
    
    protected virtual void SendPropertyChanged(String propertyName)
    {
        if ((this.PropertyChanged != null))
        {
            this.PropertyChanged(this, 
                 new PropertyChangedEventArgs(propertyName));
        }
    }
    
    private void attach_CustomerAddresses(CustomerAddress entity)
    {
        this.SendPropertyChanging();
        entity.Customer = this;
    }
    
    private void detach_CustomerAddresses(CustomerAddress entity)
    {
        this.SendPropertyChanging();
        entity.Customer = null;
    }
}

这是一个非常常见的映射,自 LINQ to SQL 生成代码以来没有任何改变。来自 Mindscape 的 Lightspeed Model 可能看起来像这样:

[Serializable]
[System.CodeDom.Compiler.GeneratedCode("LightSpeedModelGenerator", "1.0.0.0")]
[Table(IdColumnName="CustomerID", Schema="Sales")]
public partial class Customer : Entity<int>
{
    #region Fields

    [ValidatePresence]
    [ValidateLength(0, 10)]
    private string _accountNumber;
    [ValidatePresence]
    [ValidateLength(0, 1)]
    private string _customerType;
    private System.DateTime _modifiedDate;
    private System.Guid _rowguid;
    private System.Nullable<int> _territoryId;

    #endregion
    
    #region Field attribute names
    
    public const string AccountNumberField = "AccountNumber";
    public const string CustomerTypeField = "CustomerType";
    public const string ModifiedDateField = "ModifiedDate";
    public const string RowguidField = "Rowguid";
    public const string TerritoryIdField = "TerritoryId";

    #endregion
    
    #region Relationships

    private readonly EntityCollection<customeraddress> 
            _customerAddresses = new EntityCollection<customeraddress>();
    [ReverseAssociation("Customers")]
    private readonly EntityHolder<salesterritory> 
            _territory = new EntityHolder<salesterritory>();

    #endregion
    
    #region Properties

    public EntityCollection<customeraddress> CustomerAddresses
    {
      get { return Get(_customerAddresses); }
    }

    public SalesTerritory Territory
    {
      get { return Get(_territory); }
      set { Set(_territory, value); }
    }

    public string AccountNumber
    {
      get { return Get(ref _accountNumber); }
      set { Set(ref _accountNumber, value, "AccountNumber"); }
    }

    public string CustomerType
    {
      get { return Get(ref _customerType); }
      set { Set(ref _customerType, value, "CustomerType"); }
    }

    public System.DateTime ModifiedDate
    {
      get { return Get(ref _modifiedDate); }
      set { Set(ref _modifiedDate, value, "ModifiedDate"); }
    }

    public System.Guid Rowguid
    {
      get { return Get(ref _rowguid); }
      set { Set(ref _rowguid, value, "Rowguid"); }
    }

    public System.Nullable<int> TerritoryId
    {
      get { return Get(ref _territoryId); }
      set { Set(ref _territoryId, value, "TerritoryId"); }
    }

    #endregion
}

如您所见,这是 CLR 属性集的映射,因此使用 LINQ to SQL 或 Lightspeed 创建它的新实例的方式是相同的。

Customer c = new Customer();

并且设置 Customer 对象中的特定属性也会是相同的,例如:

c.ModifiedDate = System.DateTime.Now;

通过实现 INotifyPropertyChangedINotifyPropertyChanging 接口,可以跟踪对实体的任何更改,这允许视图进行双向绑定。因此,要拥有一个无需为每个数据存储方案重建的 Model,我们只需要为每个实体创建一个 BAL 类,例如 CustomerBAL 类,它可能看起来像这样:

对于 LINQ to SQL

public class CustomerBAL
{
    public CustomerBAL() { }

    [MethodImpl(MethodImplOptions.Synchronized)]
    public List<salesterritory> GetSalesTerritories()
    {
        AdventureWorksDataContext db = AdventureWorksDataContext.Instance;

        return db.SalesTerritories.ToList();
    }

    [MethodImpl(MethodImplOptions.Synchronized)]
    public List<individual> GetAllCustomers()
    {
        AdventureWorksDataContext db = AdventureWorksDataContext.Instance;

        return db.Individuals.ToList();
    }

    [MethodImpl(MethodImplOptions.Synchronized)]
    public List<individual> GetCustomersByTerritory(int TerritoryId)
    {
        AdventureWorksDataContext db = AdventureWorksDataContext.Instance;

        return (from s in db.Individuals where 
                s.Customer.TerritoryID == TerritoryId select s).ToList();
    }

    [MethodImpl(MethodImplOptions.Synchronized)]
    public Individual GetCustomerById(int CustomerId)
    {
        AdventureWorksDataContext db = AdventureWorksDataContext.Instance;

        return db.Individuals.Single(c => c.CustomerID == CustomerId);
    }

    [MethodImpl(MethodImplOptions.Synchronized)]
    public bool InsertCustomer(Individual c)
    {
        AdventureWorksDataContext db = AdventureWorksDataContext.Instance;

        if (c.Contact.PasswordHash == null)
            c.Contact.PasswordHash = "NOTHING YET"; 
        if (c.Contact.PasswordSalt == null)
            c.Contact.PasswordSalt = "NOTHING";
        
        c.ModifiedDate = DateTime.Now;
        c.Contact.ModifiedDate = DateTime.Now;
        c.Customer.ModifiedDate = DateTime.Now;
        db.Individuals.InsertOnSubmit(c);

        return this.SubmitChanges();
    }

    [MethodImpl(MethodImplOptions.Synchronized)]
    public bool UpdateCustomer(Individual c)
    {
        c.ModifiedDate = DateTime.Now;
        c.Contact.ModifiedDate = DateTime.Now;
        c.Customer.ModifiedDate = DateTime.Now;
        return this.SubmitChanges();
    }

    [MethodImpl(MethodImplOptions.Synchronized)]
    public bool DeleteCustomer(Individual c)
    {
        AdventureWorksDataContext db = AdventureWorksDataContext.Instance;

        db.Individuals.DeleteOnSubmit(c);
        
        return this.SubmitChanges();
    }

    [MethodImpl(MethodImplOptions.Synchronized)]
    public bool DeleteCustomer(int id)
    {
        AdventureWorksDataContext db = AdventureWorksDataContext.Instance;

        return DeleteCustomer(db.Individuals.Single(c => c.CustomerID == id));
    }

    [MethodImpl(MethodImplOptions.Synchronized)]
    private bool SubmitChanges()
    {
        AdventureWorksDataContext db = AdventureWorksDataContext.Instance;

        db.SubmitChanges(System.Data.Linq.ConflictMode.FailOnFirstConflict);
        return true;
    }
}

对于 Mindscape Lightspeed

public class CustomerBAL
{
    public CustomerBAL() { }

    [MethodImpl(MethodImplOptions.Synchronized)]
    public List<customer> GetAllCustomers()
    {
        AdventureWorksUnitOfWork db = Repository.Instance;

        return db.Customers.ToList();
    }

    [MethodImpl(MethodImplOptions.Synchronized)]
    public List<customer> GetCustomersByTerritory(int TerritoryId)
    {
        AdventureWorksUnitOfWork db = Repository.Instance;

        return (from s in db.Customers where 
                s.TerritoryId == TerritoryId select s).ToList();
    }

    [MethodImpl(MethodImplOptions.Synchronized)]
    public Customer GetCustomerById(int CustomerId)
    {
        AdventureWorksUnitOfWork db = Repository.Instance;

        return db.Customers.Single(c => c.Id == CustomerId);
    }

    [MethodImpl(MethodImplOptions.Synchronized)]
    public bool InsertCustomee(Customer c)
    {
        AdventureWorksUnitOfWork db = Repository.Instance;

        db.Add(c);

        return this.SubmitChanges();
    }

    [MethodImpl(MethodImplOptions.Synchronized)]
    public bool UpdateCustomer(Customer c)
    {
        return this.SubmitChanges();
    }

    [MethodImpl(MethodImplOptions.Synchronized)]
    public bool DeleteCustomer(Customer c)
    {
        AdventureWorksUnitOfWork db = Repository.Instance;

        db.Remove(c);

        return this.SubmitChanges();
    }

    [MethodImpl(MethodImplOptions.Synchronized)]
    public bool DeleteCustomer(int id)
    {
        AdventureWorksUnitOfWork db = Repository.Instance;

        return DeleteCustomer(db.Customers.Single(c => c.Id == id));
    }

    [MethodImpl(MethodImplOptions.Synchronized)]
    private bool SubmitChanges()
    {
        AdventureWorksUnitOfWork db = Repository.Instance;

        db.SaveChanges();
        return true;
    }
}

如您所见,它们非常相似,只有一些小的更改,并且这些更改只会发生在 BAL 类中,因为应用程序的其余部分将保持不变。

为了本文的目的,我们将继续使用 LINQ to SQL,尽管我喜欢 Lightspeed 的 TraceLogger。我将在本文中不使用第三方组件。

下一步?

此时,由于数据模型已准备就绪,我们将团队分开。如果您知道客户有电话号码,例如,那么您可以构建 UI,或者您可以处理 ViewModel。我通常选择 ViewModel,因为它们将拥有窗口中使用的命令,这些命令不仅可以授权用户提交的操作,还可以检查执行给定操作所需的权限。例如,用户可以注册客户,但不能更新客户的信用额度数据,因此他不能执行此操作。

由于我在这里说了算,我们将选择 ViewModel。

ViewModel

啊……ViewModel,这是为开发者设计的最好也最棘手的东西之一。让我们从定义它开始。ViewModel 是 View 的抽象。ViewModel 是您定义应用程序将执行的行为的地方,因此您希望应用程序执行的所有操作都将在 ViewModel 中定义。在此应用程序中,预期的行为是我们可以对 AdventureWorks 数据库的 Customer 表执行 CRUD 操作。为了让我们理解 ViewModel 的行为,我们分步进行:

ViewModel:抽象

在阅读 Josh SmithMSDN Magazine 文章之前,我总是习惯将 ViewModel 用作 Model 对象中方法的代理类,但这篇文章使我大开眼界。ViewModel 有很多我甚至没有想过的事情可以做。因此,非常感谢 Josh Smith 的精彩工作。

我现在做的第一件事是查看我的 Model 并找出需要什么( duh... 每个人都这样做),所以让我们从 Customer 开始。我们需要对我们的客户做两件事。

  1. 列出已注册的客户。
  2. 保存客户和删除客户。我通常不会将取消操作列入此列表,因为当数据更改时,它随处可见。保存客户操作可以是插入或更新。

这是我们的类图:

如您所见,我们有四个抽象类,一个 ViewModelBase 类,并在此基础上扩展出一个 BaseWorkspaceViewModel(我们将在此处理编辑和插入操作),一个 BaseCollectionViewModel,我们将用它来公开现有对象列表并调用 BaseWorkspaceViewModel,以便我们可以编辑对象的一个实例(更准确地说,一个单独的客户),一个 CommandModel 类(稍后详述),以及一个 CustomerViewModel,它将用作窗口的 DataContext。

既然我们已经定义了基类型,现在是时候开始真正的乐趣了,让我们让这个宝贝为我们工作。

ViewModel:真身

本文的目的是拥有一个 TabControl,其 ItemsSource 绑定到一个 ViewModels 的 Collection(这将代表 Customer 列表),并且该集合必须有一个 CollectionViewModel 和一个 WorkspaceViewModel。为此,我们将一个 ObservableCollection of ViewModelBase 放入 CustomerViewModel 中。稍后会对此进行更详细的解释。现在,我们希望将操作集中在 CustomerViewModel 上,所以我们将所有命令以及我们将用于执行命令所需的操作的方法放在这个类中,并为 Workspace 和 Collection ViewModel 添加对 CustomerViewModel 的引用。

为了获得更准确的数据输入验证,我们再次遵循 Josh Smith 的脚步。如果您还没有阅读过他关于有意义的验证错误消息的文章,我强烈建议您阅读。链接在此:http://joshsmithonwpf.wordpress.com/2008/11/14/using-a-viewmodel-to-provide-meaningful-validation-error-messages/

为此,我们将此放入我们的 WorkspaceViewModel 中:

public override string Error
{
    get
    {
        return
        CanSave ?
            null :
            "This form contains errors and must be fixed before recording.";
    }
}

public override string this[string ColumnName]
{
    get
    {
        string result = null;

        if (_error == null)
        _error = new string[6];

        switch (ColumnName)
        {
         case "Territory":
            if (Territory == null)
            result = "A Sales Territory must be selected.";
            _error[0] = result;
            break;
         case "EmailAddress":
            result = 
             (DataContext as Mainardi.Model.ObjectMapping.Individual).Contact[ColumnName];
            if (result == null)
            if (!Validations.Validator.isEmail(EmailAddress))
                result = "This is not a valid email address.";
            _error[1] = result;
            break;
         case "FirstName":
            result = 
             (DataContext as Mainardi.Model.ObjectMapping.Individual).Contact[ColumnName];
            _error[2] = result;
            break;
         case "LastName":
            result = 
             (DataContext as Mainardi.Model.ObjectMapping.Individual).Contact[ColumnName];
            _error[3] = result;
            break;
         case "Phone":
            result = 
             (DataContext as Mainardi.Model.ObjectMapping.Individual).Contact[ColumnName];
            _error[4] = result;
            break;
         case "CustomerType":
            int i = CustomerType;
            if (i < 0 || i > 1)
            result = "Customer type must be selected";
            _error[5] = result;
            break;
        }

        CanSave = Validations.Validator.ValidateFields(_error);

        return result;
    }
}

此验证是通过使用 IDataErrorInfo 接口实现的。在这种情况下,我们使用上面看到的验证规则来验证 Customer 对象。有关更好的电子邮件验证想法,请查看 Effective Email Address Validation 一文,作者是 Vasudevan Deepak Kumar,或者任何其他过于复杂而无法包含在 Model 中的验证。

正如我之前所说,我们将命令和操作集中在 CustomerViewModel 中。为此,我们有这些属性:

private CommandModel _CancelCommand,
    _EditCommand,
    _NewCommand,
    _SaveCommand,
    _DeleteCommand;

public CommandModel NewCommand { get { return _NewCommand; } }

public CommandModel EditCommand { get { return _EditCommand; } }

public CommandModel CancelCommand { get { return _CancelCommand; } }

public CommandModel SaveCommand { get { return _SaveCommand; } }

public CommandModel DeleteCommand { get { return _DeleteCommand; } }

这些属性只有 get 方法,所以我们必须在构造函数中添加每个 CommandModel 的新实例创建,如下所示:

public CustomerViewModel()
{
    ... REMOVED FOR CLARITY ...

    _CancelCommand = new CustomerCancelCommand(this);
    _DeleteCommand = new CustomerDeleteCommand(this);
    _EditCommand = new CustomerEditCommand(this);
    _NewCommand = new CustomerNewCommand(this);
    _SaveCommand = new CustomerSaveCommand(this);
}

以及将从 Command.Executed 方法调用的方法:

internal void New()
{
    ... REMOVED FOR CLARITY ...
}

internal void Delete(object p)
{
    ... REMOVED FOR CLARITY ...
}

internal void Save(Mainardi.ViewModels.CustomerViewModels.
              InternalViewModels.CustomerWorkspaceViewModel cvm)
{
    ... REMOVED FOR CLARITY ...
}

internal void Cancel(Mainardi.ViewModels.CustomerViewModels.
              InternalViewModels.CustomerWorkspaceViewModel cvm)
{
    ... REMOVED FOR CLARITY ...
}

internal void Edit(Mainardi.Model.ObjectMapping.Individual c)
{
    ... REMOVED FOR CLARITY ...
}

对于那些细心看了代码的人来说,那里有一些我们还没有解释过的类。这引出了下一个主题,也是一个非常重要的话题:Command Models。

Command Models

Dan Crevier,另一位天才,实现了一个很好的方法来封装和使用 ViewModel 中的 Commands,这样您就不需要在视图中实际编写任何 CanExecuteExecuted 方法了。这对于将 View 与逻辑分离很重要,并使应用程序设计师更容易做他们的事情,让您的应用程序看起来更漂亮,而不会干扰控制逻辑操作的代码。您可以在他的博客上阅读更多关于此的信息:Dan Crevier’s Blog

由于 Dan 在他的博客上对此进行了非常好的解释,我将只解释基本内容。

CommandModel 类

这是我们实际实现命令的地方。它是一个抽象类,它还有一个 RoutedCommand CLR 属性和两个方法 - 一个是虚拟方法,因为我们不想每次都实现 CanExecute,因为有些情况 e.CanExecutee 是一个 CanExecuteRoutedEventArgs)的值始终为 true

整个类在这里展示:

namespace Mainardi.ViewModels.CommandBase
{
    public abstract class CommandModel
    {
        private RoutedCommand _routedCommand;

        public CommandModel()
        {
            _routedCommand = new RoutedCommand();
        }

        public RoutedCommand Command
        {
            get { return _routedCommand; }
        }

        [DebuggerStepThrough]
        public virtual void CanExecute(object sender, 
                            CanExecuteRoutedEventArgs e)
        {
            e.CanExecute = true;
            e.Handled = true;
        }

        public abstract void Executed(object sender, 
                             ExecutedRoutedEventArgs e);
    }
}

很简单,对吧?仅供记录,[DebuggerStepThrough] 属性是为了允许调试器在每次调用 CanExecute 方法时不会停止。

CreateCommandBinding.Command AttachedProperty

这是非常棒的一部分,我特别喜欢它。为了使用 CommandBinding 而无需实际在我们的 View 中声明它,我们有这个很棒的技巧:这个附加属性有一个 PropertyChangedCallback,它调用一个方法:

private static void OnCommandInvalidated(DependencyObject dependencyObject, 
                    DependencyPropertyChangedEventArgs e)
{
    UIElement element = (UIElement)dependencyObject;
    element.CommandBindings.Clear();

    CommandModel commandModel = e.NewValue as CommandModel;
    if (commandModel != null)
    {
        element.CommandBindings.Add(
            new CommandBinding(commandModel.Command,
                commandModel.Executed,
                commandModel.CanExecute));
    }

    CommandManager.InvalidateRequerySuggested();
}

这个简单的方法会清除 UIElementCommandBinding,并在 CommandBindings 对象中添加一个新的 CommandBinding(它有一个 Command(RoutedCommand)、Executed 方法(CommandModel 类中的抽象方法)和 CanExecute 方法(虚拟方法))。

这允许我们将命令绑定到 UIElement.CommandBindings 并公开它,以便 View 可以使用它。我们所要做的就是将 CreateCommandBinding.Command 添加到我们想要使用命令的元素上,例如 ViewModels:CreateCommandBinding.Command="{Binding NewCommand}”。然后,我们将 UIElementCommandProperty 设置为:Command="{Binding NewCommand.Command}",如果需要,我们还可以传递 CommandParameterProperty

因此,使用此技术将导致一个 Button 的 XAML 声明如下:

<Button Content="New" Margin="3" Command="{Binding NewCommand.Command}" 
  ViewModels:CreateCommandBinding.Command="{Binding NewCommand}"/>

现在我们知道了它是如何工作的,我们必须创建将绑定到我们窗口的操作:

public class CustomerNewCommand : CommandBase.CommandModel
{
    ... REMOVED FOR CLARITY ...
}
public class CustomerEditCommand : CommandBase.CommandModel
{
    ... REMOVED FOR CLARITY ...
}
public class CustomerSaveCommand : CommandBase.CommandModel
{
    ... REMOVED FOR CLARITY ...
}
public class CustomerDeleteCommand : CommandBase.CommandModel
{
    ... REMOVED FOR CLARITY ...
}
public class CustomerCancelCommand : CommandBase.CommandModel
{
    ... REMOVED FOR CLARITY ...
}

这一系列操作将涵盖我们在此示例应用程序中需要的所有内容。当然,根据您的场景,可能会有很多其他命令。

让我们回顾一下我们到目前为止所做的工作,以便我们能够跟上进度:

  • 我们已经构建了 Model;
  • 为 View 制作了 ViewModel;
  • 为 Workspace 制作了 ViewModel;
  • 为 Collections 制作了 ViewModel;
  • 为 ViewModel 制作了所需的 Commands。

是的,我们已经涵盖了客户的所有内容,除了 View。让我们转到那里。

视图

终于到了 View。我们所有先前的工作现在将得到回报,因为我们将创建一个 Window 和一组 UserControls,它们将使用绑定来绑定到我们的 ViewModels(它们毕竟是 View 的抽象)。我不知道您怎么想,但我的设计人员喜欢 Bindings,因为他们不必处理太多的代码隐藏。

View 实际上非常简单:

对于 CustomerCollectionViewModel(代表 Customer 列表),我们有以下 UserControl:

Image003-CollectionView.jpg

<UserControl 
    x:Class="MVVMArticle.Views.UserControls.CustomerListView"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:ViewModels="http://schemas.mainardi.com/WPF/MVVMArticle/PresentationLogic"
    >
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <StackPanel Orientation="Horizontal" VerticalAlignment="Center">
            <Button Content="New" Margin="3" 
              Command="{Binding NewCommand.Command}" 
              ViewModels:CreateCommandBinding.Command="{Binding NewCommand}"/>
            <Button Content="Edit" Margin="3" 
              Command="{Binding EditCommand.Command}" 
              CommandParameter="{Binding ElementName=List, Path=SelectedItem, Mode=TwoWay}" 
              ViewModels:CreateCommandBinding.Command="{Binding EditCommand}"/>
            <Button Content="Delete" Margin="3" 
              Command="{Binding DeleteCommand.Command}" 
              CommandParameter="{Binding ElementName=List, Path=SelectedItem, Mode=TwoWay}" 
              ViewModels:CreateCommandBinding.Command="{Binding DeleteCommand}"/>
        </StackPanel>
        <ListView Grid.Row="1" ItemsSource="{Binding List}" x:Name="List">
            <ListView.View>
                <GridView>
                    <GridViewColumn Header="Last Name" 
                        DisplayMemberBinding="{Binding Contact.LastName}"/>
                    <GridViewColumn Header="First Name" 
                        DisplayMemberBinding="{Binding Contact.FirstName}"/>
                    <GridViewColumn Header="E-mail" 
                        DisplayMemberBinding="{Binding Contact.EmailAddress}"/>
                </GridView>
            </ListView.View>
        </ListView>
    </Grid>
</UserControl>

以及以下代码隐藏:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;

namespace MVVMArticle.Views.UserControls
{
    /// <summary>
    /// Interaction logic for CustomerListView.xaml
    /// </summary>
    public partial class CustomerListView : UserControl
    {
        public CustomerListView()
        {
            InitializeComponent();
        }
    }
}

正如您所见,我们有一个干净的代码隐藏,并且由于这是我们实现 MVVM + CommandModel 的目标,我们已经做了所有工作使其正常工作。

但让我们继续为 CustomerWorkspaceViewModel,它显示单个 Customer 并带有用于 CRUD 操作的按钮。我们有以下 UserControl:

Image004-WorkspaceView.jpg

<UserControl 
    x:Class="MVVMArticle.Views.UserControls.CustomerWorkspaceView"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:ViewModel="http://schemas.mainardi.com/WPF/MVVMArticle/PresentationLogic"         
    >
    <Grid>
        ...
        Removed for Clarity
        ...
            <TextBlock 
                Grid.Column="0" Grid.Row="0" 
                Text="Title :" VerticalAlignment="Center" 
                HorizontalAlignment="Right" Foreground="#FFFFFFFF"/>
                <TextBox 
                Grid.Column="1" Grid.Row="0" 
                Margin="2" HorizontalAlignment="Left" 
                Width="60" 
                Text="{Binding Path=Title, Mode=TwoWay, 
                      UpdateSourceTrigger=PropertyChanged, 
                      ValidatesOnDataErrors=True}" 
                Foreground="#FF000000" 
                VerticalContentAlignment="Center"/>
                <TextBlock 
                Grid.Column="0" Grid.Row="1" 
                Text="First Name :" 
                VerticalAlignment="Center" 
                HorizontalAlignment="Right" 
                Foreground="#FFFFFFFF"/>
                <TextBox 
                Grid.Column="1" Grid.Row="1" 
                Margin="2" 
                Text="{Binding Path=FirstName, Mode=TwoWay, 
                      UpdateSourceTrigger=PropertyChanged, 
                      ValidatesOnDataErrors=True}" 
                Foreground="#FF000000" 
                VerticalContentAlignment="Center"/>
                <TextBlock 
                Grid.Column="0" Grid.Row="2" Text="Middle Name :" 
                VerticalAlignment="Center" 
                HorizontalAlignment="Right" Foreground="#FFFFFFFF"/>
                <TextBox 
                Grid.Column="1" Grid.Row="2" Margin="2" 
                Text="{Binding Path=MiddleName, Mode=TwoWay}" 
                Foreground="#FF000000" VerticalContentAlignment="Center"/>
                <TextBlock 
                Grid.Column="0" Grid.Row="3" Text="Last Name :" 
                VerticalAlignment="Center" HorizontalAlignment="Right" 
                Foreground="#FFFFFFFF"/>
                <TextBox 
                Grid.Column="1" Grid.Row="3" Margin="2" 
                Text="{Binding Path=LastName, Mode=TwoWay, 
                      UpdateSourceTrigger=PropertyChanged, 
                      ValidatesOnDataErrors=True}" 
                Foreground="#FF000000" VerticalContentAlignment="Center"/>
                <TextBlock 
                Grid.Column="0" 
                Grid.Row="4" Text="Customer Type :" 
                VerticalAlignment="Center" HorizontalAlignment="Right" 
                Foreground="#FFFFFFFF"/>
                <ComboBox 
                Grid.Column="1" Grid.Row="4" Margin="2" 
                SelectedIndex="{Binding Path=CustomerType, 
                               Mode=TwoWay, ValidatesOnDataErrors=True}" 
                Foreground="#FF000000">
                    <ComboBoxItem 
                    Content="Individual"/>
                    <ComboBoxItem 
                    Content="Store"/>
                </ComboBox>
                <TextBlock 
                Grid.Column="0" Grid.Row="5" Text="Sales Territory :" 
                VerticalAlignment="Center" HorizontalAlignment="Right" 
                Foreground="#FFFFFFFF"/>
                <ComboBox 
                Grid.Column="1" Grid.Row="5" Margin="2" 
                SelectedItem="{Binding Path=Territory, Mode=TwoWay, 
                              UpdateSourceTrigger=PropertyChanged, 
                              ValidatesOnDataErrors=True}" 
                ItemsSource="{Binding Path=Territories}" Foreground="#FF000000">
                    <ComboBox.ItemTemplate>
                        <DataTemplate>
                            <TextBlock Text="{Binding Path=Name}"/>
                        </DataTemplate>
                    </ComboBox.ItemTemplate>
                </ComboBox>
                <TextBlock 
                Grid.Column="0" Grid.Row="6" Text="Phone number :"
                VerticalAlignment="Center" HorizontalAlignment="Right" 
                Foreground="#FFFFFFFF"/>
                <TextBox
                 Grid.Column="1" Grid.Row="6" 
                 Margin="2" HorizontalAlignment="Left" 
                Width="120" 
                Text="{Binding Path=Phone, Mode=TwoWay, 
                      UpdateSourceTrigger=PropertyChanged, 
                      ValidatesOnDataErrors=True}" 
                Foreground="#FF000000" VerticalContentAlignment="Center"/>
                <TextBlock 
                 Grid.Column="0" Grid.Row="7" 
                 Text="E-mail :" VerticalAlignment="Center" 
                 HorizontalAlignment="Right" Foreground="#FFFFFFFF"/>
                <TextBox 
                Grid.Column="1" Grid.Row="7" Margin="2"
                Text="{Binding Path=EmailAddress, Mode=TwoWay, 
                      UpdateSourceTrigger=PropertyChanged, ValidatesOnDataErrors=True}" 
                Foreground="#FF000000" VerticalContentAlignment="Center"/>
            </Grid>
        </ScrollViewer>
        <StackPanel Grid.Row="1" Orientation="Horizontal">
        <Button Content="Save" Margin="3" 
          Command="{Binding Path=SaveCommand.Command}" 
          CommandParameter="{Binding}" 
          ViewModel:CreateCommandBinding.Command="{Binding Path=SaveCommand}" 
          Foreground="#FFFFFFFF" Width="60"/>
        <Button Content="Cancel" Margin="3" 
          Command="{Binding Path=CancelCommand.Command}" 
          CommandParameter="{Binding}" 
          ViewModel:CreateCommandBinding.Command="{Binding Path=CancelCommand}"
          Foreground="#FFFFFFFF" Width="60"/>
        <Button Content="Delete" Margin="3" 
          Command="{Binding Path=DeleteCommand.Command}" 
          CommandParameter="{Binding Path=DataContext}" 
          ViewModel:CreateCommandBinding.Command="{Binding Path=DeleteCommand}"
          Foreground="#FFFFFFFF" Width="60"/>
        </StackPanel>
    </Grid>
</UserControl>

与其他用户控件一样,它的代码隐藏文件除了构造函数和其中对 InitializeComponent 方法的调用之外,没有其他内容。

文章奖励:使用 DataTemplateSelectors 的动态子元素的 TabControl

还记得我曾说过稍后会回到绑定到 TabControlItemsSource 吗?就是这里。

在我第一次尝试构建 Window 时,我使用 Google 来查找可关闭的 TabItem。我确实找到了一个,看起来不错,工作正常,但在 TabItems 需要在 TabControl 中动态创建时却无效。我为此问题奋斗了两天,……没有找到解决方案。在此之后,我尝试查看 http://msdn.microsoft.com/en-us/library/system.windows.controls.tabcontrol.aspx 上的 TabControl 类,并发现了这个:

Image005-TabControlProperties.jpg

好的。这正是我需要的。而且,我记得之前读过一篇关于 DataTemplateSelector 的文章。在谷歌搜索之后,我在 Dr.WPF 博客上找到了这篇精彩的文章。不幸的是,他当时使用的是列表框,但他的文章中有我可以用到的东西。DataTemplateSelector 被解释得很清楚,所以非常感谢 Dr.WPF。在稍微了解了 TabControl 之后,我了解到它与其他控件的工作方式略有不同,因为它有两个部分:页眉和内容。我们必须为页眉和内容都定义选择器。由于选择器的逻辑对两者都相同,所以我们使用相同的。选择器的代码如下:

public class DTSelector : DataTemplateSelector
{
    private DataTemplate _CollectionTemplate, _EditableTemplate;

    // NOT CLOSABLE HEADER AND HAS A CustomerListView AS CHILD
    public DataTemplate CollectionTemplate
    {
        get { return _CollectionTemplate; }
        set { _CollectionTemplate = value; }
    }

    // CLOSABLE HEADER AND HAS A CustomerWorkspaceView AS CHILD
    public DataTemplate EditableTemplate
    {
        get { return _EditableTemplate; }
        set { _EditableTemplate = value; }
    }

    public override DataTemplate SelectTemplate(object item, 
                    DependencyObject container)
    {
        if (item != null && item is Mainardi.ViewModels.VMBase.ViewModelBase)
        {
            if (item is Mainardi.ViewModels.VMBase.BaseCollectionViewModel)
            {
                return CollectionTemplate;
            }
            else if (item is Mainardi.ViewModels.VMBase.BaseWorkspaceViewModel)
            {
                return EditableTemplate;
            }
        }
        throw new
            NullReferenceException("Object is not an valid " + 
                                   "ViewModel for this implementation.");
    }
}

因此,我们需要一个不同的 ViewModel 来处理每种类型的 TabItem 模板,所以我创建了 CollectionViewModelWorkspaceViewModel。我们可以为 TabControl 内的项目声明模板,让 DataTemplateSelector 决定 View 中需要什么来正确可视化所需的功能。为了实现这一点,我们所要做的就是创建两个 DataTemplateSelectors,如下所示:

...
Removed for Clarity
...

<DataTemplate x:Key="CollectionHeaderTemplate">
    <TextBlock Text="{Binding DisplayName}"/>
</DataTemplate>

<DataTemplate x:Key="WorkspaceHeaderTemplate">
    ...
    Removed for Clarity
    ...
</DataTemplate>

<DataTemplate x:Key="CollectionTemplate">
    <MVVMArticle:CustomerListView />
</DataTemplate>

<DataTemplate x:Key="WorkspaceTemplate">
    <MVVMArticle:CustomerWorkspaceView />
</DataTemplate>

<ViewModel:DTSelector x:Key="HeaderDataTemplateSelector" 
   CollectionTemplate="{StaticResource CollectionHeaderTemplate}"
   EditableTemplate="{StaticResource WorkspaceHeaderTemplate}"/>

<ViewModel:DTSelector x:Key="ContentDataTemplateSelector" 
  CollectionTemplate="{StaticResource CollectionTemplate}" 
  EditableTemplate="{StaticResource WorkspaceTemplate}"/>

...
Removed for Clarity
...

<TabControl ItemsSource="{Binding Path=Collection}" 
  ItemTemplateSelector="{StaticResource HeaderDataTemplateSelector}" 
  ContentTemplateSelector="{DynamicResource ContentDataTemplateSelector}"/> 

同样,代码隐藏文件除了构造函数和 InitializeComponent 方法之外,什么都没有,这真是太酷了。

应用程序运行时看起来是这样的。这是 TabControl 中的 CollectionViewModel

Image007-FinalCollectionView.jpg

请注意“编辑”和“删除”按钮是禁用的。

按下“新建”后,将调用带有 NewCommand 的新 TabItem

Image008-NewExecuted.jpg

显示一个新的 TabItem,如下所示:

Image009-NewCommandFinished.jpg

由于其默认值,表单无效。感谢出色的 IDataErrorInfo,红色装饰器会显示表单中哪些字段无效,并且由于这些无效字段,表单无法保存。

Image004-WorkspaceView.jpg

选择客户后,“删除”和“编辑”按钮将被启用:

Image010-EditDeleteCanExecuteTrue.jpg

点击“编辑”后,它会以编辑模式显示客户:

Image011-EditCurrentClicked.jpg

这是编辑模式下的样子:

Image012-InEditMode.jpg

我还想特别感谢 Rudi Grobler,感谢他在此应用程序中使用的 GlassEffect AttachedProperty 技巧。Rudi 的作品可以在 这里找到。

关注点

正如您所见,ViewModel 可以完全将逻辑与可视化分离,因此设计团队和开发团队可以更有效地协同工作。

好了,这次就到这里。希望大家喜欢这篇内容,并能将其付诸实践。

© . All rights reserved.