WPF 和属性依赖关系 - 第 I 部分






4.76/5 (21投票s)
对 WPF 数据绑定和属性依赖关系的初步介绍
引言
在从用户需求创建应用程序时,最困难的方面之一是捕获与控件行为相关的信息。控件与数据以及其他控件的状态之间存在许多依赖关系的情况通常会导致“意大利面条式代码”。在本文中,我将探讨捕获这些需求并将其转化为可管理的代码块(XAML 和 C#)的一些方法。
数据绑定的审视
要将控件的数据保存到某处,我们可以使用多种方法。例如,我们可以响应 `textbox` 中的 `Changed` 事件……
<TextBox Name="textBox1" TextChanged="textBox1_TextChanged"/>
……然后手动存储值
private void textBox1_TextChanged(object sender, TextChangedEventArgs e)
{
myString = textBox1.Text;
}
这种方法相当简单,但缺点太多。特别是,如果我们处理所有控件的所有事件,我们会得到很多函数。如果这些函数有任何形式的依赖关系,它们将变得难以管理。
一种更好的方法是使用数据绑定。数据绑定支持一种模型,其中控件中的属性(例如 `TextBox` 的 `Text` 属性)映射到代码隐藏类中的变量。当一个改变时,另一个也随之改变。以下是它的工作原理。首先,我们需要给包含的 `window` 一个 `Name` 属性来标识它。
<Window x:Class="WpfDataBinding.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Window1" Height="300" Width="300" Name="myWindow">
现在我们有了 `window` 名称,我们将其作为 `DataContext` 属性提供给包含我们的 `textbox` 的 `grid`。
<Grid>
<Grid.DataContext>
<Binding ElementName="myWindow" Path="."/>
</Grid.DataContext>
<TextBox Height="23" Name="textBox1" VerticalAlignment="Bottom"/>
</Grid>
`DataContext` 定义建立了一个数据源(在本例中为 `myWindow`)实际工作的范围。在我们的 `grid` 的数据上下文中,我们将数据源简单地定义为 `myWindow`,路径为“.”。这里需要指出的是,在处理数据绑定时,路径通常遵循 XPath 约定,这使得导航 XAML 树更加容易。
总之,我们有了数据上下文。现在,如果我们想将 `textbox` 绑定到一个变量,我们需要为 `textbox` 的 `Text` 属性指定一个 `Binding` 元素。
<TextBox Height="23" Name="textBox1" VerticalAlignment="Bottom">
<TextBox.Text>
<Binding Path="MyString"/>
</TextBox.Text>
</TextBox>
剩下的就是让代码隐藏中有一个属性来存储值。
private string MyString { get; set; }
这里有两点需要注意。首先,当您在 `textbox` 中键入新字符时,`MyString` 的值**不会**更新。只有当控件失去焦点时它才会更新。为了在每次文本更改时更新变量,我们需要更改绑定的 `UpdateSourceTrigger` 属性。此属性决定了值同步的条件。为了达到我们想要的效果,绑定 XAML 定义需要更改为以下内容。
<TextBox Height="23" Name="textBox1" VerticalAlignment="Bottom">
<TextBox.Text>
<Binding Path="MyString" UpdateSourceTrigger="PropertyChanged"/>
</TextBox.Text>
</TextBox>
第二个问题是,当您在代码中更改 `MyString` 的值时,`textbox` 不会更新(尽管绑定是双向的)。原因是属性必须告知控件其值已更改。最直接的方法是使用 `INotifyPropertyChanged` 接口,该接口可供类用于通知其他组件其内部状态已更改。因此,为了使此功能正常工作,我们的 `window` 类必须实现 `INotifyPropertyChanged`。
public partial class Window1 : Window, INotifyPropertyChanged
如果您让 Visual Studio 为您生成实际的 `event`,您将在 `window` 类中得到以下代码。
#region INotifyPropertyChanged Members
public event PropertyChangedEventHandler PropertyChanged;
#endregion
现在您有一个 `event`,但是有一个函数来实际检查订阅者并在某些内容更改时触发 `event` 是很有用的。一个简单的实现如下所示。
private void NotifyPropertyChanged(string propertyName)
{
if (PropertyChanged != null)
{
PropertyChanged(this,
new PropertyChangedEventArgs(propertyName));
}
}
现在,使用这些自动属性不再是可选项,因为修改器(属性 `set`)必须调用我们新创建的函数。已修改的 `MyString` 现在必须有一个后备字段。整个装置如下所示。
private string myString = string.Empty;
public string MyString {
get { return myString; }
set
{
myString = value;
NotifyPropertyChanged("MyString");
}
}
现在,如果您更改 `MyString`,`textBox1` 的 `Text` 属性将相应地更改。
封装
到目前为止的代码可能可以作为学术示例,但它仍然离实际使用很远。它不切实际的一个原因是因为,开发人员经常将他们的属性包装在专门设计的结构中,这些结构封装了值,以及一些额外的附加信息。例如,开发人员可能需要保留相同的值并使用不同的度量系统(例如公制与英制)输出它,或者他们可能需要一个先前值的堆栈来实现撤销/重做或命令模式。显然,我们现有的功能不适合此任务,因此让我们尝试对其进行一些调整。
首先,我将定义一个简单的包装类,名为 `Property`,它封装了我们要绑定的值。
public class Property<T>
{
private T value;
public Property() { }
public Property(T value)
{
this.value = value;
}
public T Value
{
get
{
return value;
}
set
{
this.value = value;
}
}
}
如您所见,该类只是一个简单的包装。在实际生活中,您会添加额外的功能使其更有用。现在,让我们更改 `window` 类中 `MyString` 属性的定义。
private readonly Property<string> myString = new Property<string>(string.Empty);
public string MyString
{
get
{
return myString.Value;
}
set
{
myString.Value = value;
NotifyPropertyChanged("MyString");
}
}
上述解决方案并不智能。考虑一下如果我们想在 `Value` 内部更改时得到通知会发生什么。例如,如果我们想根据属性是否已更改来改变控件的边框颜色,我们将陷入真正的困境(无双关之意):我们不仅需要在 `Property` 类内部,还需要在 `window` 类内部创建一个新的 `bool` 属性。而这只是太多的工作。
为了解决这个问题,让我们尝试换一种方式。我将更改我们的 `Property` 类,以便包含这个 `Changed` 属性。我还将在 `Property` 类内部实现 `INotifyPropertyChanged`。
public class Property<T> : INotifyPropertyChanged
{
private T value;
private bool changed = false;
public Property() { }
public Property(T value)
{
this.value = value;
}
public T Value
{
get
{
return value;
}
set
{
this.value = value;
NotifyPropertyChanged("Value");
Changed = true;
}
}
public bool Changed
{
get
{
return changed;
}
set
{
changed = value;
NotifyPropertyChanged("Changed");
}
}
public void Commit() { Changed = false; }
private void NotifyPropertyChanged(string propertyName)
{
if (PropertyChanged != null)
{
PropertyChanged(this,
new PropertyChangedEventArgs(propertyName));
}
}
public event PropertyChangedEventHandler PropertyChanged;
}
我们现在有一个相当长的类,它自己进行通知。为了测试目的,我还添加了一个 `Commit` 方法,以便我们可以重置其 `Changed` 状态。该属性现在知道其值以及它是否已更改。但是,它无法告诉我们边框颜色是普通的还是红色的。我们现在可以编写以下内容……
<TextBox Height="23" Name="textBox1" VerticalAlignment="Bottom">
<TextBox.Text>
<Binding Path="MyString.Value" UpdateSourceTrigger="PropertyChanged"/>
</TextBox.Text>
<TextBox.BorderBrush>
<Binding Path="MyString.Changed"/>
</TextBox.BorderBrush>
</TextBox>
……但是 `brush` 选项将毫无意义,因为,您猜怎么着,您不能简单地将一个 `bool` 转换为一个 `brush`。有三种方法可以解决这个问题。一种方法是从 `Property` 类公开一个 `BorderBrush` 属性,但这没有意义,因为并非所有类型的控件都需要此功能(而且 `Property`,您可能已经猜到了,是为了可重用性)。更重要的是,它没有意义,因为 `Property` 与 UI 方面无关——我们需要保持数据和 UI 的分离。第二种选择是从 `window` 公开某种 `MyStringBrush` 属性,每次都进行转换。这个选项更糟糕,因为如果我们这样做,`window` 必须订阅 `Property` 的通知机制,然后……好吧,您可以想象会变得多么混乱。我们剩下第三种选择——创建一个自定义 `converter`,它将 `bool` 转换为 `border`。
首先,我们定义一个实际执行转换的类。
[ValueConversion(typeof(bool), typeof(Brush))]
public class BoolToBrushConverter : IValueConverter
{
#region IValueConverter Members
public object Convert(object value, Type targetType,
object parameter, System.Globalization.CultureInfo culture)
{
return (bool)value ? Brushes.Red : Brushes.Gray;
}
public object ConvertBack(object value, Type targetType,
object parameter, System.Globalization.CultureInfo culture)
{
return false;
}
#endregion
}
根据 MSDN,在定义我们代码的 `namespace` 之前,`converter` 将无法工作。为此,我们将以下行添加到 `Window` 标记中。
xmlns:custom="clr-namespace:WpfDataBinding"
在上面的行中,我们定义了自己的 `namespace` 称为 `custom`,并将其与我们的程序集 `namespace` 相关联。不需要指定程序集。现在我们将我们的自定义 `converter` 添加到 `grid` 资源中,以便能够在 `grid` 的元素中使用它。
<Grid.Resources>
<custom:BoolToBrushConverter x:Key="btbc"/>
</Grid.Resources>
最后,我们可以指定我们值的 `converter`。
<TextBox.BorderBrush>
<Binding Path="MyString.Changed" Converter="{StaticResource btbc}"/>
</TextBox.BorderBrush>
这就是我们需要做的全部。我们刚刚通过 `converter` 将一个 `brush` 绑定到了一个 `bool` 值。反向转换不起作用(我们只返回 `false`),因为我们假设用户不会操作 `brush`。
总而言之,到目前为止我们学到的东西是:
- 属性使用 `INotifyPropertyChanged` 通知其观察者。
- 属性经常被包装,并且到它们的绑定会变得有些复杂(例如,`MyString.Value` 而不是简单的 `MyString`)。
- 绑定可以通过指定 `Converter` 属性使用自定义转换。
代码中的绑定
这些绑定定义存在一个相当恼人的问题:它们将 UI 设计师和业务逻辑设计师绑定在一起。在理想的世界里,这两种角色可以同步,但在大规模开发中,到处都有 `<Binding>` 标签会变得有些不切实际。我的意思是,假设导航逻辑发生变化,控件开始表现不同:我们不希望修改 XAML 元素,如果这不是我们的主要工作。所以,让我们删除 `MyString` 绑定,并在代码中进行设置。
首先,我们为 `converter` 创建一个 `private` 字段。
private IValueConverter boolConv = new BoolToBrushConverter();
严格来说,我们应该有一个单例(因为实例化 `converter` 是没有意义的),但这可以留给您作为家庭作业。现在我们有了 `converter`,我们只需在构造函数中添加以下内容。
Binding strBinding = new Binding();
strBinding.Path = new PropertyPath("MyString.Value");
strBinding.UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged;
textBox1.SetBinding(TextBox.TextProperty, strBinding);
Binding boolBinding = new Binding();
boolBinding.Path = new PropertyPath("MyString.Changed");
boolBinding.Converter = boolConv;
textBox1.SetBinding(TextBox.BorderBrushProperty, boolBinding);
一切完成!最终结果与基于 XAML 的数据绑定完全相同。我在这里展示这段代码的原因是,当您有大量数据相互依赖(即将到来!)时,手工编写所有这些 XAML 是不切实际的,而有效地生成它意味着对 XAML 代码进行 XSLT 或 XQuery 转换,这相当复杂,而且可能不具成本效益。
依赖项
在之前的示例中,我们依赖 `INotifyPropertyChanged`,就好像它是某种改变通知的“圣杯”一样。遗憾的是,它不是,这里有一个例子说明了原因。
考虑一个情况,您有一个编辑框,一个人可以在其中输入她的年龄。在此之下,有两个单选按钮,允许该人投票,但前提是他们必须年满 16 岁。

想法很简单——如果你未满 16 岁,你就不能投票。现在,就像上一个例子一样,我们将年龄字段绑定到 `int` 类型的 `Property` 的 `Value`。
private readonly Property<int> age = new Property<int>();
public Property<int> Age
{
get
{
return age;
}
}
我们现在可以在 `window` 类中定义一个 `CanVote` 属性来确定一个人是否可以投票。
public bool CanVote
{
get
{
return age.Value >= 16;
}
}
现在我们遇到了一个问题:`CanVote` 如何知道 `age.Value` 已经改变?问题在于,它不知道。这些属性位于不同的类中,即使理论上 `CanVote` 可以注册以监听 `age.Value` 的 `NotifyPropertyChanged`,但在实践中,这种方法无法扩展且不切实际。如果它们在同一个类中,`Age` 可以在其值更改时调用 `NotifyPropertyChanged("CanVote")`,但当然,这也无法很好地扩展。
让我们稍微概括一下问题:属性 `SomeClass.A` 如何在不实际注册其他类的 `NotifyPropertyChanged` 的情况下,得到 `OtherClass.B` 已更改的通知?我的猜测是,它不能;但是,如果我们开始**大规模**进行这种操作,它将极大地污染我们的代码,最终我们会得到“意大利面条式代码”。一定有更好的解决方案。
识别依赖关系
在本节中,我们将通过查看生成的 IL 来研究属性依赖关系如何被识别和显式化。您可能还记得,在我们示例中 `CanVote` 属性令人讨厌的一点是,它无法主动通知其观察者自身的变化(这些变化是 `age.Value` 变化的结果)。它知道是因为它使用了该属性。通过 IL 反汇编器看到的 `get_CanVote` 函数的主体如下所示。
.method public hidebysig specialname instance bool
get_CanVote() cil managed
{
// Code size 24 (0x18)
.maxstack 2
.locals init ([0] bool CS$1$0000)
IL_0000: nop
IL_0001: ldarg.0
IL_0002: ldfld class WpfDataBinding.Property`1<int32> WpfDataBinding.Window1::age
IL_0007: callvirt instance !0 class WpfDataBinding.Property`1<int32>::get_Value()
IL_000c: ldc.i4.s 16
IL_000e: clt
IL_0010: ldc.i4.0
IL_0011: ceq
IL_0013: stloc.0
IL_0014: br.s IL_0016
IL_0016: ldloc.0
IL_0017: ret
} // end of method Window1::get_CanVote
如果我们取 IL_2 和 IL_7 行,去掉双冒号 `::` 以及左边的所有内容,我们会得到 `age` 和 `get_Value()`,从这些信息可以安全地推断出 `CanVote` 至少**使用** `age.Value`。这并不一定意味着它依赖于它,但它在这里的事实必然意味着什么。
好的,让我们暂时假设我们有一个神奇的工具,可以告诉我们某个特定属性受哪些属性的影响。在我们的示例中,我们将如何根据这些信息采取行动?好吧,例如,我们的窗口类可以执行以下操作。
-
注册以监听 `Age` 的变化。
age.PropertyChanged += new PropertyChangedEventHandler(age_PropertyChanged);
-
在生成的函数中,调用 `window` 类。
void age_PropertyChanged(object sender, PropertyChangedEventArgs e) { NotifyPropertyChanged("CanVote"); }
处理这些依赖关系有两种方法。一种是简单地将它们记录下来。我的意思是,您只需在一个配置文件中记录 `A` 影响 `B`,然后生成一些按照我下面描述的方式操作的代码:捕获变化并进行额外的通知。另一种更令人感兴趣的方法是分析代码并提取依赖关系,方法是分析 IL 或 C# 源代码。在本系列文章的第二部分,我们将考虑这些选项,并推导出一个可以一劳永逸地解决属性依赖性问题的解决方案。
历史
- 2008年2月7日:文章发布