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






3.57/5 (6投票s)
如何在 MVVM WPF 应用程序中使用 C# 属性的更自然的方式。
引言
本文将讨论传统的 C# 属性、它们的局限性,以及我们如何能够以更面向对象的方式使用属性来支持 MVVM WPF 应用程序。这包括对如何扩展 C# 的建议。它还包括一个用于创建 MVVM WPF 应用程序的基类的实现。
数据字段 - 起点
传统上,对象的状态存储在公共字段中。这允许调用者随意查看和修改对象的状态。不幸的是,这意味着我们无法进行验证以确保字段处于有效状态。当字段更改时,我们也无法生成事件。
这增加了代码的复杂性,因为每当需要使用该值时,都需要调用特殊的验证代码。
管理对象状态的唯一方法是使用“get”和“set”方法并隐藏底层字段。这很麻烦,也不自然。
结果,C# 引入了属性。
C# 属性 - 语法糖
属性是语法糖,用于封装 get 和/或 set 方法,对外部调用者可见。外部调用者现在可以以更自然的方式引用状态。同时,对象可以强制执行一致的状态。
属性的优点是:
- 属性更改时,我们可以引发事件。
- 无效状态赋值时抛出异常。
- 创建基于其他属性的计算属性。
- IDE(如 Visual Studio)可以执行引用计数。
- 它们是 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();
}
}
}
不幸的是,我们遇到了问题
- 封装被破坏。通过在类内部直接使用 myProperty,我们可以轻松绕过验证并破坏封装。
- 无限递归。如果您不小心,混合使用 myProperty 和 MyProperty,可能会导致一个无休止地调用另一个。
- 重构。由于有两个项目引用相同的逻辑状态,因此在重构代码时会引入额外的复杂性。
- 复杂性增加。随着属性数量的增加,维护成本也会增加。这在使用 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 应用程序时,我决定将私有后备字段放在公共属性下方。
这是:
- 允许公共属性拥有 XML 文档。
- 使属性的伪封装成为可能,从而更容易维护类。
不幸的是,这会生成 Style Cop 错误,很麻烦,并且会混淆其他开发人员。
注意: Microsoft 在 Windows 应用商店应用程序中引入了将私有后备字段放在属性上方的标准。不幸的是,这会影响 XML 注释。
ViewModel Base
我的解决方案是创建一个视图模型基类,供我的模型和视图模型继承。
注意:我没有使用任何现有的框架,因为我的老板对使用第三方软件有顾虑。
首先,我们实现 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);
}
}
}
为了方便起见,我们还可以创建另一个不接受参数的中继命令。然后我们可以根据需要使用它们。
使用代码
现在是时候演示代码了。
![]() |
演示
注意
|
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 文档等其他数据存储一起使用。此外,我将寻求根据您期望的设置自动持久化状态。敬请关注。
历史
在此处保持您所做的任何更改或改进的实时更新。