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

在 MVVM WPF 应用程序中封装属性状态

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.57/5 (6投票s)

2015年5月12日

CPOL

7分钟阅读

viewsIcon

17848

downloadIcon

214

如何在 MVVM WPF 应用程序中使用 C# 属性的更自然的方式。

引言

本文将讨论传统的 C# 属性、它们的局限性,以及我们如何能够以更面向对象的方式使用属性来支持 MVVM WPF 应用程序。这包括对如何扩展 C# 的建议。它还包括一个用于创建 MVVM WPF 应用程序的基类的实现。

数据字段 - 起点

传统上,对象的状态存储在公共字段中。这允许调用者随意查看和修改对象的状态。不幸的是,这意味着我们无法进行验证以确保字段处于有效状态。当字段更改时,我们也无法生成事件。

这增加了代码的复杂性,因为每当需要使用该值时,都需要调用特殊的验证代码。

管理对象状态的唯一方法是使用“get”和“set”方法并隐藏底层字段。这很麻烦,也不自然。

结果,C# 引入了属性。

C# 属性 - 语法糖

属性是语法糖,用于封装 get 和/或 set 方法,对外部调用者可见。外部调用者现在可以以更自然的方式引用状态。同时,对象可以强制执行一致的状态。

属性的优点是:

  1. 属性更改时,我们可以引发事件。
  2. 无效状态赋值时抛出异常。
  3. 创建基于其他属性的计算属性。
  4. IDE(如 Visual Studio)可以执行引用计数。
  5. 它们是 MVVM WPF 应用程序所必需的。
private string myProperty;

public string MyPropery
{
    get
    {
        return myProperty;
    }

    set
    {
        if (!Validate(value))
        {
            // Throw some validation exception
        }
        else
        {
            // Add validation code here
            myProperty = value;
            OnStateChanged();
        }
    }
}

不幸的是,我们遇到了问题

  1. 封装被破坏。通过在类内部直接使用 myProperty,我们可以轻松绕过验证并破坏封装。
  2. 无限递归。如果您不小心,混合使用 myProperty 和 MyProperty,可能会导致一个无休止地调用另一个。
  3. 重构。由于有两个项目引用相同的逻辑状态,因此在重构代码时会引入额外的复杂性。
  4. 复杂性增加。随着属性数量的增加,维护成本也会增加。这在使用 MVVM 模型的 WPF 程序中可以看到。

因此,C# 属性并未以任何面向对象的方式封装状态。

有一个例外。

public string MyProperty { get; set; }

public string MyReadOnlyProperty { get; private set; }

使用这种结构,编译器会创建一个隐藏字段来保存状态。不幸的是,这不过是一个带有少量附加好处的字段。

C# 属性 - 真正的封装,真正的 OOP

要实现真正的 OOP 属性,我们需要能够封装状态。一种方法是使用专用关键字(C# 目前未实现)。另一种方法是创建我们自己的后备存储(参见下一节)。

专用关键字 $state

我们可以引入一个关键字来表示状态。(注意美元符号 $)

public string MyProperty
{
    get
    {
        return $state;
    }

    set
    {
        if (!$state.Equals(value))
        {
            // Validation code
            // Kickoff relevant events
            // Assign $state as necessary, or throw an exception
        }
    }
}

关键字 **$state** 仅在 get 或 set 的主体内部有效。

不幸的是,**$state** 关键字目前不存在,所以我们需要一个变通方法。

属性封装的变通方法

我遇到的 C# 属性最大的用途是在编写 MVVM WPF 应用程序时所需的数据模型或视图模型。

WPF 应用程序

在 WPF 应用程序中,属性不是简单的事情。相反,它们是 WPF 用于启用 MVVM 模式的钩子。

在 XAML 文件中,我们有

<TextBox Text="{binding FirstName, Mode=TwoWay}" />

在视图模型文件中,我们有

public class Person: INotifyPropertyChanged
{
    /// <summary>
    /// Multicast event for property change notifications.
    /// </summary>
    public event PropertyChangedEventHandler PropertyChanged;
    // ... (stuff)
    /// <summary>
    /// Gets or sets first name
    /// </summary>
    public string FirstName
    {
        get
        {
            return firstName
        }
        set
        {
            if (!this.Equals(firstName))
            {
                OnStateChanged();
                firstName = this;
            }
        }
    }
    private firstName;
    // ... (some more stuff)
    /// <summary>
    /// Notifies listeners that a property value has changed
    /// </summary>
    /// <param name="propertyName">Leave blank. Property name will auto-fill.</param>
    private void OnStateChanged([CallerMemberName] string propertyName = null)
    {
        var eventHandler = this.PropertyChanged;
        if (eventHandler != null)
        {
            eventHandler(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

这里的属性有两个功能。首先,它存储状态。其次,它在状态更改时通知 WPF。

当我开始创建 WPF 应用程序时,我决定将私有后备字段放在公共属性下方。
这是:

  1. 允许公共属性拥有 XML 文档。
  2. 使属性的伪封装成为可能,从而更容易维护类。

不幸的是,这会生成 Style Cop 错误,很麻烦,并且会混淆其他开发人员。

注意: Microsoft 在 Windows 应用商店应用程序中引入了将私有后备字段放在属性上方的标准。不幸的是,这会影响 XML 注释。

ViewModel Base

我的解决方案是创建一个视图模型基类,供我的模型和视图模型继承。

注意:我没有使用任何现有的框架,因为我的老板对使用第三方软件有顾虑。

视图模型基类的作用是:
1:封装状态
2:状态更改时通知 WPF
3:实现 Commands

首先,我们实现 INotifyPropertyChanged 接口以及引发事件的机制。

public abstract class ViewModelBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    private void OnStateChanged([CallerMemberName] string propertyName = null)
    {
        var eventHandler = this.PropertyChanged;
        if (eventHandler != null)
        {
            eventHandler(this, new PropertyChangedEventArgs(propertyName));
        }
    }

接下来,我们有存储状态的机制

    private Dictionary<string, object> propertyStore = new Dictionary<string, object>();

第三,我们设置状态

    protected bool SetState<TData>(TData value, [CallerMemberName] string propertyName = null)
    {
        if (propertyStore.ContainsKey(propertyName))
        {
            if (object.Equals(propertyStore[propertyName], value))
            {
                return false;
            }
            else
            {
                propertyStore[propertyName] = value;
                this.OnStateChanged(propertyName);

                return true;
            }
        }
        else
        {
            propertyStore[propertyName] = value;
            this.OnStateChanged(propertyName);
            return true;
        }
    }

TData 是我们正在存储的数据类型。

然后我们检索状态

    protected TData GetState<TData>(
              TData defaultValue = default(TData),
              [CallerMemberName] string propertyName = null)
    {
        if (!propertyStore.ContainsKey(propertyName))
        {
            propertyStore[propertyName] = defaultValue;
        }

        return (TData)propertyStore[propertyName];
    }

在这里,我们为用户提供了一个定义默认默认值的方法。这意味着如果我们不想在构造函数中设置它,我们就不必这样做。

注意:集合需要赋值,否则它将保持为 null。不过,这已经是标准行为且符合预期。

持久数据
为了处理我们将数据存储在持久存储中的情况,我们可以使用

    protected bool HasChanged<TData>(TData storage, TData value, string propertyName)
    {
        if (object.Equals(storage, value))
        {
            return false;
        }
        else
        {
            this.OnStateChanged(propertyName);
            return true;
        }

这对于将数据存储在外部存储(例如 App.config)中非常有用。

计算属性
接下来我们需要处理计算属性。这些属性不存储状态,而是反映其他属性的状态。因此,我们需要一种方法来通知 WPF 何时需要检查计算属性。

    protected void NotifyPropertyUpdated(string calculatedPropertyName)
    {
        this.OnStateChanged(calculatedPropertyName);
    }

Commands

最后,我们有存储和使用命令的机制。这接受来自 XAML 代码的命令参数。

    protected RelayCommand<TCommandParameter> Command<TCommandParameter>(
              Action<TCommandParameter> execute,
              Func<TCommandParameter, bool> canExecute = null,
              [CallerMemberName] string propertyName = null)
    {
        if (!propertyStore.ContainsKey(propertyName))
        {
            propertyStore[propertyName] = new RelayCommand<TCommandParameter>(execute, canExecute);
        }

        return (RelayCommand<TCommandParameter>)propertyStore[propertyName];
    }

}

TCommandParameter 代表从 XAML 文件传递的 CommandParameter 的类型。

对于不传递命令参数的情况,我们有

    protected RelayCommand Command(
              System.Action execute,
              Func<bool> canExecute = null,
              [CallerMemberName] string propertyName = null)
    {
        if (!propertyStore.ContainsKey(propertyName))
        {
            propertyStore[propertyName] = new RelayCommand(execute, canExecute);
        }

        return (RelayCommand)propertyStore[propertyName];
    }

这与中继命令相同。

Relay Command T

为了创建中继命令,我使用了 Microsoft 随其 Windows 应用商店 Visual Studio 模板一起提供的基类并对其进行了修改。

RelayCommand<TCommandParameter> 封装了 ICommand,对于公开点击命令的 WPF 控件是必需的。

首先,我们创建命令对象,然后存储执行实际工作的方法的引用。

  private readonly Action<TCommandParameter> execute;

  private readonly Func<TCommandParameter, bool> canExecute​;

  public RelayCommand(
      Action<TCommandParameter> execute,
      Func<TCommandParameter, bool> canExecute = null)
  {
    if (execute == null)
    {
       throw new ArgumentNullException("execute");
    }

    this.execute = execute;
    this.canExecute = canExecute;
  }

接下来,我们实现接口。WPF 使用 CanExecute() 和一个可选参数来检查控件是否应启用。当用户单击控件时,WPF 会调用 Execute() 和一个可选参数。

CanExecute() 和 Execute() 接受对象,因为接口需要它们。幸运的是,我们可以隐藏这种丑陋。

public class RelayCommand<TCommandParameter> : ICommand
{
   public event EventHandler CanExecuteChanged;

    public bool CanExecute(object parameter = null)
    {
        return canExecute == null ? true : canExecute(parameter);
    }

    public void Execute(object parameter = null)
    {
        execute(parameter);
    }

然后我们有 RaiseCanExecuteChanged() 来通知 WPF 何时需要更改控件的启用状态。

    public void RaiseCanExecuteChanged()
    {
        var handler = CanExecuteChanged;
        if (handler != null)
        {
            handler(this, EventArgs.Empty);
        }
    }
}

为了方便起见,我们还可以创建另一个不接受参数的中继命令。然后我们可以根据需要使用它们。

使用代码

现在是时候演示代码了。

示例程序

演示

  1. 生成解决方案并运行程序。
  2. 从下拉列表中选择一种颜色。这将启用“启用-禁用”按钮。
  3. 单击“启用-禁用”以启用或禁用测试按钮。
  4. 文本按钮根据所选颜色更改文本的颜色。

注意

  • 一个按钮使用命令参数,另一个则不使用。
  • 此外,我们还有属性绑定,支持标准场景。

XAML

首先,我们创建一个 WPF 演示应用程序并添加一些控件。

<Window x:Class="PropertyStateDemo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Property state demo" Height="350" Width="525" Background="Green" >
    <Grid VerticalAlignment="Center" >
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition Width="2*" />
            <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>

        <Label Grid.Row="0" Grid.Column="0" Margin="20" Content="Select color:" />
        <ComboBox Grid.Row="0" Grid.Column="1" Margin="20" Name="colorSelector" IsEditable="True"
                  Text="{Binding SelectedText}" ItemsSource="{Binding ColorList}" />

        <ToggleButton Grid.Row="1" Grid.Column="1" Margin="20"
                      Content="Enable-Disable" IsChecked="{Binding TestButtonIsEnabled}"
                      Command="{Binding EnableDisableButton}" />

        <Button Grid.Row="2" Grid.Column="1" Margin="20" Content="Test button" 
                Command="{Binding ChangeColor}"
                CommandParameter="{Binding ElementName=colorSelector, Path=SelectedItem }" />

        <Label Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2" Margin="20" Content="{Binding Message}"
               Foreground="{Binding MessageColor}" FontWeight="Bold" FontSize="14" />
    </Grid>
</Window>

在代码隐藏文件中,我们只需添加数据上下文。

public MainWindow()
{
    InitializeComponent();
    this.DataContext = new MainWindowViewModel();
}

ViewModel

首先,我们使用私有数据存储创建颜色列表属性。

public ObservableCollection<GroupItem> ColorList
{
    get
    {
        return this.GetState<ObservableCollection<GroupItem>>();
    }

    set
    {
        this.SetState<ObservableCollection<GroupItem>>(value);
    }
}

然后,我们创建一个新集合并在构造函数中对其进行初始化。

public MainWindowViewModel()
{
    ColorList = new ObservableCollection<GroupItem>();
    ColorList.Add(new GroupItem() { Content = string.Empty, Foreground = Brushes.White });
    ColorList.Add(new GroupItem() { Content = "Red", Foreground = Brushes.Red });
    ColorList.Add(new GroupItem() { Content = "Yellow", Foreground = Brushes.Yellow });
    ColorList.Add(new GroupItem() { Content = "Green", Foreground = Brushes.Green });
    ColorList.Add(new GroupItem() { Content = "Blue", Foreground = Brushes.Blue });
    ColorList.Add(new GroupItem() { Content = "Purple", Foreground = Brushes.Purple });
}

接下来,我们定义 SelectedText 属性。在这里,我们将用户选择存储在 Settings.Default.SelectedText 中。我们还引发事件并设置消息。

public string SelectedText
{
    get
    {
        return Settings.Default.SelectedText;
    }

    set
    {
        // Update only if text changed
        if (this.HasChanged(Settings.Default.SelectedText, value))
        {
            Settings.Default.SelectedText = value;
            Settings.Default.Save();

            if (string.IsNullOrWhiteSpace(value))
            {
                Message = "Select color from dropdown";
            }
            else
            {
                Message = "Click Enable-Disable to enable or disable text button";
            }

            EnableDisableButton.RaiseCanExecuteChanged();
        }
    }
}

Command EnableDisableButton.RaiseCanExecuteChanged() 会导致 CanExecute lambda 表达式被执行,从而启用或禁用按钮。

public RelayCommand EnableDisableButton
{
    get
    {
        return this.Command(
        () =>
        {
            // Execute command
            if (TestButtonIsEnabled)
            {
                Message = "Test button has been enabled";
            }
            else
            {
                Message = "Test button has been disabled";
            }

            ChangeColor.RaiseCanExecuteChanged();
        },
        () =>
        {
            // CanExecute command
            if (string.IsNullOrWhiteSpace(SelectedText))
            {
                TestButtonIsEnabled = false;
                ChangeColor.RaiseCanExecuteChanged();

                return false;
            }
            else
            {
                return true;
            }
        });
    }
}

按钮“Test button”使用类型为 GroupItem 的命令参数。我们使用它来设置消息颜色:MessageColor = val.Foreground

public RelayCommand<GroupItem> ChangeColor
{
    get
    {
        return this.Command<GroupItem>(
        (val) =>
        {
            // Execute command
            MessageColor = val.Foreground;
            Message = "User selected " + SelectedText;
        },
        (val) =>
        {
            // CanExecute command
            return TestButtonIsEnabled;
        });
    }
}

其他属性也很简单

关注点

我使用字典来存储状态。处理大型数据模型时,性能可能会成为问题。但是,我尚未测试这一点,因此我不知道。您需要自行决定性能和可维护性之间的权衡是否值得。

我还使用泛型,因为我喜欢类型安全。

我正在寻找实现一个更通用的版本,该版本可以与 XML 和 JSON 文档等其他数据存储一起使用。此外,我将寻求根据您期望的设置自动持久化状态。敬请关注。

历史

在此处保持您所做的任何更改或改进的实时更新。

© . All rights reserved.