数据绑定(Data Binding)是 WPF "数据驱动 UI"的基础。上章研究了绑定的源(Source)与路径(Path)等数据源,本章将探讨绑定的目标(Target)对象及其数据流向。
7.1 属性(Property)的来龙去脉
程序的本质是数据和算法的结合,通过算法处理数据得到结果。数据以变量形式存在,算法以函数形式存在。面向对象编程虽然引入了类的概念,但基本原理不变------类只是将变量(称为字段,表示状态)和函数(称为方法,表示功能)进行封装和访问控制。这种最基本的面向对象封装尚未包含事件、属性等后续概念。
通过 private、public 等修饰符可控制字段或方法的可访问性。使用 static 关键字则决定其为类级别还是实例级别的成员。以 Human 类为例,Weight 字段对个体有意义,而 Amount 字段对整个类有意义。方法同理,Speak 是实例方法,而 Populate 是类方法。在 C# 中,类级别的成员用 static 修饰并称为静态成员,需通过类名访问;实例级别的成员则不用 static 修饰,称为实例成员。
虽然语义上静态成员与非静态成员显示对称,但在内存结构上并非如此。静态字段只有一个内存拷贝,而非静态字段每个实例都有一份。所有方法(无论静态与否)在内存中都只有一份,区别在于通过类名还是实例名访问。
让我们来看属性的演变。字段在实例中的访问权限可分为公开(非 private)和私有(private),如图 7-1 所示。
直接暴露数据给外界存在安全隐患,容易导致错误值的写入。如果在每次写入时都进行值的有效性判断,不仅会产生冗余代码,还会违反高内聚原则。因此,程序员通常将字段设为 private,并用一对非 private 的方法对其进行包装:一个 Set 方法用于验证并写入数据,一个 Get 方法用于读取数据。如图 7-2 所示。
虽然使用 Get/Set 方法包装字段能够实现目标,但这种方式导致代码分散且使用不便。为解决这个问题,当 .NET Framework 引入了属性(Property)的概念,将 Get/Set 方法进行了整合。属性不仅保持了字段使用的简洁性,还兼具了方法的安全性。使用属性后,Human 类可以改写成这样:
c#
class Human
{
private int _age;
public int Age
{
get { return this._age; }
set
{
if (value >= 0 && value <= 100)
{
this._age = value;
}
else
{
throw new OverflowException("Age Overflow");
}
}
}
}
这种属性在 .NET Framework 中被称为 CLR 属性,本质上是对私有字段的安全访问包装。如图 7-3 所示,该模式展示了这种结构。
7.2 依赖属性(Dependency Property)
WPF 中的依赖属性(Dependency Property)是对传统属性的扩展。它可以没有自身的值,而是通过 Binding 从其他数据源获取值。包含依赖属性的对象称为依赖对象。相比传统 CLR 属性,依赖属性具有以下特点:
- 节省实例对内存的开销。
- 属性值可以通过 Binding 依赖在其他对象上。
7.2.1 依赖属性对内存的使用方式
依赖属性较之 CLR 属性在内存使用方面迥然不同。前面已经说过,实例的每个CLR属性都包装着一个非静态的字段(或者说由一个非静态的字段在后台支持)。
在传统 .NET 中,对象实例化时会固定分配内存空间。而 WPF 的依赖对象(Dependency Object)创建时无需预先分配数据存储空间,而是通过依赖属性(Dependency Property)实现按需获取默认值、借用其他对象数据或动态分配空间。在 WPF 中,依赖对象作为依赖属性的宿主,两者结合形成可被数据驱动的 Binding 目标。
在 WPF 系统中,依赖对象的概念由 DependencyObject 类实现,依赖属性的概念则由 DependencyProperty 类实现。DependencyObject 提供了 GetValue 和 SetValue 两个方法:
c#
public class DependencyObject: DispatcherObject
{
public object GetValue(DependencyProperty dp)
{
// ...
}
public void SetValue(DependencyProperty dp, object value)
{
// ...
}
}
这两个方法通过 DependencyProperty 对象来读写数据,从而将 DependencyObject 和 DependencyProperty 联系起来。
DependencyObject 是 WPF 系统中相当底层的一个基类,如图7-5所示。
继承树显示所有 WPF UI 控件都是依赖对象,其大部分属性都采用了依赖属性的设计。
7.2.2 声明和使用依赖属性
本小节我们使用一个简单的实例来说明依赖属性的使用方法。
先准备好一个界面:
xml
<Window x:Class="WpfLearn.DependencyPropertyWindow"
xmlns="<http://schemas.microsoft.com/winfx/2006/xaml/presentation>"
xmlns:x="<http://schemas.microsoft.com/winfx/2006/xaml>"
xmlns:mc="<http://schemas.openxmlformats.org/markup-compatibility/2006>"
xmlns:d="<http://schemas.microsoft.com/expression/blend/2008>"
xmlns:local="clr-namespace:WpfLearn"
mc:Ignorable="d"
Title="DependencyPropertyWindow" Height="135" Width="260">
<StackPanel>
<TextBox x:Name="TextBox1" BorderBrush="Black" Margin="5"/>
<TextBox x:Name="TextBox2" BorderBrush="Black" Margin="5"/>
<Button Content="OK" Margin="5" Click="ButtonBase_OnClick"></Button>
</StackPanel>
</Window>
DependencyProperty 必须依附于 DependencyObject 宿主类,并通过 SetValue 和 GetValue 方法进行访问。因此在自定义 DependencyProperty 时,其宿主类必须继承自 DependencyObject。DependencyProperty 的声明遵循固定格式:使用 public static readonly 修饰符,并通过 DependencyProperty.Register 方法创建实例。代码如下:
c#
public class Student: DependencyObject
{
public static readonly DependencyProperty NameProperty =
DependencyProperty.Register("Name", typeof(string), typeof(Student));
}
让我们详细分析这个 DependencyProperty 的最简示例代码:
首先,DependencyProperty 必须在 DependencyObject 类中使用,因此 Student 类需要派生自 DependencyObject。
其次,DependencyProperty 声明时必须同时使用 public、static 和 readonly 三个修饰符。根据命名约定,成员变量需要加上 Property 后缀以表明它是依赖属性。这个依赖属性用于存储学生姓名,所以命名为 NameProperty。
最后,这个成员变量的实例并非通过 new 运算符创建,而是通过 DependencyProperty.Register 方法注册获得。我们使用了该方法最基础的重载版本,它需要 3 个参数。让我们来分析这些参数:
- 第一个参数(字符串类型)指定依赖属性对应的 CLR 属性名称,此处为 "Name"。
- 第二个参数指定属性值的类型,此处为 typeof(string)。
- 第三个参数指定依赖属性的宿主类型,此处为 typeof(Student),表示该属性将注册到 Student 类中。
注意
- 依赖属性的包装器是 CLR 属性,但实际的依赖属性是由 public static readonly 修饰的 DependencyProperty 实例,它的存在不依赖于包装器。
- 包装器的作用是将依赖属性以实例属性的形式暴露,让它能够作为数据源的 Path。
- 依赖属性注册时的第二个参数指定数据类型,这也是包装器的类型。虽然我们通常说"依赖属性的类型",但严格来说依赖属性的类型是 DependencyProperty。
了解了依赖属性的声明和创建方法后,让我们看看如何使用它。我们将展示依赖属性的基本存取操作。
下面是 UI 中 OK 按钮的 Click 事件处理器代码:
c#
private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
{
Student stu = new Student();
stu.SetValue(Student.NameProperty, this.TextBox1.Text);
this.TextBox2.Text = (string)stu.GetValue(Student.NameProperty);
}
这些示例可能会引发几个问题:依赖属性作为静态对象,如何为每个 Student 实例存储不同的值?SetValue 和 GetValue 方法的工作原理是什么?为什么 readonly 修饰的变量可以用于写入值?这些都是涉及依赖属性核心机制的问题,我们将在下节详细讨论。现在让我们先专注于依赖属性的使用方法。
介绍完依赖属性的基本功能后,我们来探讨它的"依赖"特性。通过一个简单示例,我们将演示如何使用 Binding 建立数据连接。虽然数据通常从业务层流向 UI 控件,但为了演示目的,我们将反其道而行之:使用 textBox1 作为数据源,让 Student 实例依赖于它。需要注意的是,这种用法仅作示例,在实际开发中较少使用。
下面是窗口类的后台代码:
c#
public partial class DependencyPropertyWindow : Window
{
private Student _stu;
public DependencyPropertyWindow()
{
InitializeComponent();
_stu = new Student();
Binding binding = new Binding("Text") { Source = TextBox1 };
BindingOperations.SetBinding(_stu, Student.NameProperty, binding);
}
private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
{
MessageBox.Show(_stu.GetValue(Student.NameProperty).ToString());
}
}
构造器的最后两行是核心代码:第一行创建一个 Binding 实例,将 TextBox1 设为数据源并指定获取其 Text 属性值;第二行通过 BindingOperations.SetBinding 方法,将 stu 对象依赖绑定到 TextBox1 上。
代码的进化并没有结束。如果我想把 textBox1 和 textBox2 关联起来,代码应该是这样:
c#
// ...
Binding binding = new Binding("Text") { Source = TextBox1 };
this.TextBox2.SetBinding(TextBox.TextProperty, binding);
使用 TextBox2 的 SetBinding 方法比 BindingOperations.SetBinding 更简洁直观。需要注意的是,Student 类虽然继承自 DependencyObject,但并没有 SetBinding 方法。这个方法来自更高层的 FrameworkElement 类(位于 UIElement 之上),这反映了微软将 UI 元素作为数据绑定目标的设计理念。事实上,FrameworkElement 的 SetBinding 方法仅仅是对 BindingOperations.SetBinding 的一层简单封装,代码如下:
c#
public class FrameworkElement: UIElement // ...
{
// ...
public BindingExpressionBase SetBindng(DependencyProperty dp, BindingBase binding)
{
return BindingOperations.SetBinding(dp, binding);
}
// ...
}
从这些例子可以看出依赖属性的基本用法。虽然我们可以通过 SetValue 和 GetValue 方法访问依赖属性,但这种方式需要进行类型转换。为了简化使用,我们通常会为依赖属性添加 CLR 属性包装器:
c#
public class Student: DependencyObject
{
// Dependency Property
public static readonly DependencyProperty NameProperty =
DependencyProperty.Register(nameof(Name), typeof(string), typeof(Student));
// CLR Property Wrapper
public string Name
{
get => (string)GetValue(NameProperty);
set => SetValue(NameProperty, value);
}
// Wrap SetBinding
public BindingExpressionBase SetBinding(DependencyProperty dp, BindingBase binding)
{
return BindingOperations.SetBinding(this, dp, binding);
}
}
有了这个 CLR属性包装我们就可以这样访问依赖属性了:
c#
private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
{
Student stu = new Student();
stu.Name = this.TextBox1.Text;
this.TextBox2.Text = stu.Name;
}
如果不关心底层的实现,下游程序员在使用依赖属性时与使用单纯的CLR属性感觉别无二致。
接下来,我们通过 Binding 将 Student 对象绑定到 textBox1,然后将 textBox2 绑定到 Student 对象,从而形成绑定链。代码如下:
c#
public partial class DependencyPropertyWindow : Window
{
private Student _stu;
public DependencyPropertyWindow()
{
InitializeComponent();
_stu = new Student();
_stu.SetBinding(Student.NameProperty, new Binding("Text") { Source = TextBox1 });
TextBox2.SetBinding(TextBox.TextProperty, new Binding("Name") { Source = _stu });
}
}
7.2.3 依赖属性值存取的秘密
(底层原理,用到的时候再补充)
7.3 附加属性(Attached Properties)
附加属性是在依赖属性基础上的扩展概念。它指的是一个对象在特定环境下被赋予的额外属性,这些属性原本并不属于该对象,而是由环境动态添加的。回想一下学习布局时遇到的例子。如果在Grid里对一个TextBox定位,代码会是这样:
xml
<Grid ShowGridLines="True">
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<TextBox Background="Lime" Grid.Column="1" Grid.Row="1" />
</Grid>
TextBox 控件的设计者无法预知控件将被放置在哪种布局容器中,因此不能预先定义位置相关的属性。相应地,各种布局容器可以为 TextBox 附加其所需的定位属性:Grid 附加 Column 和 Row 属性,Canvas 附加 Top 和 Left 属性,DockPanel 附加 Dock 属性。这种附加属性机制使属性与宿主类型得以解耦,提高了设计的灵活性。
让我们来探讨附加属性的声明、注册和使用方法。附加属性本质上与依赖属性相同,区别仅在于它们的注册方式和包装器实现。Visual Studio 为我们提供了两个代码片段:propdp 用于创建依赖属性,propa 用于创建附加属性。下面我们将通过学生的年级和班级这个例子来演示附加属性的创建过程。
c#
public class School: DependencyObject
{
public static readonly DependencyProperty GradeProperty =
DependencyProperty.RegisterAttached("Grade", typeof(int), typeof(School), new UIPropertyMetadata(0));
public static int GetGrade(DependencyObject obj)
{
return (int)obj.GetValue(GradeProperty);
}
public static void SetGrade(DependencyObject obj, int value)
{
obj.SetValue(GradeProperty, value);
}
}
GradeProperty 是一个 DependencyProperty 类型的静态只读成员变量。它通过 RegisterAttached 方法而非 Register 方法注册,但参数保持一致。与依赖属性的 CLR 属性包装方式不同,附加属性使用独立的 Get 和 Set 方法作为包装器,这样能提供更直观的语法。
如何使用 School 的 GradeProperty?首先,我们需要创建一个从 DependencyObject 派生的 Human 类:
c#
public class Human: DependencyObject
{
}
在 UI 上准备一个 Button 并把下面的代码作为其 Click 事件的处理器:
c#
private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
{
Human human = new Human();
School.SetGrade(human, 6);
int grade = School.GetGrade(human);
MessageBox.Show(grade.ToString());
}
通过分析 .NET Framework 源码可知,附加属性的存储机制与依赖属性完全相同。值都存储在 Human 实例的 EffectiveValueEntry 数组中,区别仅在于属性的定义位于 School 类而不是 Human 类。CLR 属性名和宿主类型名只用于生成 hash code 和 GlobalIndex。
现在我们已经了解如何在 XAML 和 C# 代码中直接为附加属性赋值。不过要记住,附加属性本质上就是依赖属性------这意味着它同样可以通过 Binding 依赖于其他对象的数据。