数据和 WPF
使用数据绑定和 WPF 自定义数据显示
Josh Smith
当 Windows® Presentation Foundation (WPF) 首次出现在 .NET 雷达上时,大多数文章和演示应用程序都对其华丽的渲染引擎和 3D 性能大加宣扬。这些示例虽然读起来引人入胜、玩起来趣味横生,但却无法证明 WPF 在现实世界中的强大功能。那些在单击后会突然放出烟火的三维旋转视频固然很酷,但我们当中的大多数人都不会用它创建应用程序。创建软件来显示和编辑大量复杂的业务或科学数据才是我们的衣食父母。
让人振奋的是,WPF 为管理显示和编辑复杂数据提供了良好的支持。在 2007 年 12 月刊的《MSDN® 杂志上,John Papa 撰写了“WPF 中的数据绑定”一文 (msdn.microsoft.com/magazine/cc163299),其中对 WPF 数据绑定的重要概念做了出色的介绍。在此,我将以 John 在上述数据点专栏中讲到的内容为基础,探讨一些更高级的数据绑定方案。研究过这些方案后,您将了解到在大多数行业应用程序中达到常用数据绑定要求的各种方法。 在代码中绑定
WPF 为桌面应用程序开发人员带来的最大变化之一就是广泛地使用和支持声明性编程。WPF 用户界面和资源可通过使用基于 XML 的标准标记语言 - 可扩展应用程序标记语言 (XAML) 来声明。大多数 WPF 数据绑定的说明只展示了如何在 XAML 中使用绑定。由于在 XAML 中执行的所有操作都可在代码中实现,因此专业 WPF 开发人员学习如何以编程方式及声明方式来使用数据绑定就显得尤为重要。
许多情况下,在 XAML 中声明绑定更为便利。随着系统变得更加复杂和更加动态,有时在代码中使用绑定更为适宜。在继续深入之前,让我们首先回顾一下编程式数据绑定中涉及到的一些常用的类和方法。
WPF 元素从 FrameworkElement 或实体框架 ContentElement 那里同时继承了 SetBinding 和 GetBinding 表达式方法。它们恰好是在 BindingOperations 实用程序类中可调用相同名称方法的便捷方法。下列代码说明了如何使用 BindingOperations 类将某个文本框的 Text 属性绑定到另一个对象的属性上:
您可以使用此处显示的代码轻松地取消绑定某个属性:
通过清除绑定,您还可以移除目标属性的绑定值。
在 XAML 中声明数据绑定会隐藏某些基本细节。一旦您开始在代码中使用绑定,这些细节就会显现出来。其中之一就是绑定源与目标之间的关系实际上是由 BindingExpression 类(而不是 Binding 自身)维持的。Binding 类中包含多个 BindingExpressions 可共享的高级信息,但两个绑定属性之间的联系是由基础表达式决定的。下列代码说明了您如何使用 BindingExpression 以编程方式检查某个文本框的 Text 属性是否经过了验证:
由于 BindingExpression 不知道其是否已验证,您需要询问其父项绑定。稍后我将分析一下输入验证技术。
使用模板
如用户界面富有成效,它提供的原始数据可以使用户直观地从中发现有意义的信息。这就是数据可视化的本质。数据绑定就像一道数据可视化难题。几乎所有最繁琐的 WPF 程序都需要一种更有效的数据提供方式,而不是简单地将某个控件属性绑定到数据对象的某个属性上。真实的数据对象具有多个相关值,这些不同的值应合并为一个组织严密的直观表示。这就是 WPF 使用数据模板的原因。
System.Windows.DataTemplate 类就是 WPF 中的一种模板。通常,模板就像 WPF 框架使用的一种“俗套”,它创建的可视元素协助呈现那些没有固有直观表示的对象。如某个元素尝试显示一个没有固有直观表示的对象(如自定义业务对象),您可以通过赋予 DataTemplate 来告知元素如何呈现该对象。
DataTemplate 会根据需要尽可能多地产生可视元素来显示该数据对象。这些元素使用数据绑定显示该数据对象的属性值。如果某个元素不知道如何显示通知其呈现的对象,就会对其调用 ToString 方法并在 TextBlock 中显示结果。
假定您有一个名为 FullName 的简单类,用于存储人名。您想显示一个姓名列表,并使每个人的姓氏突出显示。要执行此操作,您需要创建一个描述如何呈现 FullName 对象的 DataTemplate。图 1 中所列代码显示了 FullName 类以及将显示姓名列表的窗口的源代码。
图 1 使用 DataTemplate 显示 FullNames
如图 2 所示,窗口的 XAML 文件中含有一个 ItemsControl。它创建了一个用户无法选择或移除的项目列表。ItemsControl 向其 ItemTemplate 属性分配一个 DataTemplate,利用它来呈现在窗口构造函数中创建的各个 FullName 实例。您应该注意 DataTemplate 中的大部分 TextBlock 元素是如何将其 Text 属性绑定到了它们所代表的 FullName 对象的属性上的。
图 2 使用 DataTemplate 显示 FullName 对象
当运行此演示应用程序时,其外观类似于图 3 所示。通过使用 DataTemplate 来呈现名称可以很容易地突出显示每个人的姓氏,因为相应的 TextBlock 的 FontWeight 均为粗体。这个简单的示例说明了 WPF 数据绑定与模板之间的基本关系。进一步探讨该主题时,我将会把这些功能组合成更强大的复杂对象可视化方法。
图 3 由 DataTemplate 呈现的 FullNames 使用继承的 DataContext
默认情况下,所有绑定均隐式绑定在某个元素的 DataContext 属性上。因此,可以说是元素的 DataContext 引用了其数据源。在 DataContext 的工作方式上,需要了解一些特殊的内容。在您了解了 DataContext 这个微妙的方面后,将极大地简化复杂数据绑定用户界面的设计。
并非必须设置元素的 DataContext 属性才能引用数据源对象。如果元素树(从技术上讲是逻辑树)中某个祖先元素的 DataContext 被赋值,则该值将自动被用户界面中的每个后代元素所继承。换言之,如果窗口的 DataContext 设置为引用 Foo 对象,则默认情况下,窗口中每个元素的 DataContext 都将引用同一 Foo 对象。您可以轻松地为窗口中的任何元素分配一个不同的 DataContext 值,这会使该元素的所有后代元素都将继承新的 DataContext 值。这与 Windows 窗体中的环境属性很相似。
在上一部分中,我讨论了如何使用 DataTemplates 创建可视化数据对象。图 2 中由模板创建的元素将其属性绑定到某个 FullName 对象的属性。这些元素隐式地绑定到其 DataContext 上。由 DataTemplate 创建的元素的 DataContext 引用模板所使用的数据对象,如 FullName 对象。
DataContext 属性的值继承中没有任何神奇之处。它只是利用了对 WPF 中内置继承依赖关系属性的支持。任何依赖关系属性都可以是继承属性,在向 WPF 的依赖关系属性系统注册该属性时,只需在提供的元数据中指定标记即可。
继承依赖关系属性的另一个示例是 FontSize,它含有所有元素。如果您在窗口中设置了 FontSize 依赖关系属性,则默认情况下,该窗口中的所有元素都将以该大小显示其文本。沿元素树向下传播 FontSize 值所使用的基础结构与传播 DataContext 的基础结构相同。
在面向对象环境中的“继承”表示子类继承其父类的成员,而这里使用的术语“继承”有所不同。属性值继承仅指值在运行时沿着元素树向下的传播。在面向对象含义中,类当然可以继承那些支持值继承的依赖关系属性。 使用集合视图
当 WPF 控件绑定到数据集合上时,它们不会直接绑定在集合本身上。而是隐式地绑定在自动封装该集合的视图上。该视图可实现 ICollectionView 界面,可以是若干具体实现之一,如 ListCollectionView。
一个集合视图有多项职责。它可跟踪集合中的当前项,该项通常会转换为列表控件中的活动/选定项。集合视图还提供在列表内排序、筛选和归组项目的一般方法。可以围绕集合将多个控件绑定到同一视图上,以便它们形成彼此并列的关系。以下代码显示了 ICollectionView 的一些功能:
所有列表控件(如列表框、组合框和列表视图)必须将它们的 IsSynchronizedWithCurrentItem 属性设置为 true,以与集合视图的 CurrentItem 属性保持同步。抽象的 Selector 类定义了该属性。如果未设置为 true,选择列表控件中的某项将不会更新该集合视图的 CurrentItem,向 CurrentItem 分配新值也不会在列表控件中有所反映。 使用分层数据
现实生活中到处都是分层数据。客户下达多重订单、分子由很多个原子组成、部门由多名员工组成,太阳系包含一系列的天体。您肯定对这种常见的主从复合排列非常熟悉。
WPF 提供了多种使用分层数据结构的方法,分别适用于不同的情形。从本质上看,这是在选择使用多个控件显示数据,还是在一个控件中显示多层数据。接下来,我就要讲述这两种方法。 使用多个控件显示 XML 数据
一种极为常见的分层数据处理方法就是利用单独的控件显示各个层级。例如,假设我们有一个表示客户、订单和订单详细信息的系统。在此情况下,我们可能需要一个组合框来显示客户,用列表框显示所有选定的客户订单,然后用 ItemsControl 显示所选订单的相关详细信息。这是一种显示分层数据的不错的方法,极易在 WPF 中实现。
根据我之前描述的场景,图 4 显示了某个数据的简化示例,它封装在 WPF XmlDataProvider 组件中,可供应用程序处理。一个类似于图 5 的用户界面将显示该数据。注意客户和订单是可选的,而订单的详细信息是存在于只读列表中的。这一点非常有意义,因为可视对象应仅在影响应用程序的状态时或处于可编辑状态时才可供选择。
图 4 客户、订单和订单详细信息 XML 层次结构
图 5 显示 XML 数据的一种方法 图 6 中的 XAML 介绍了如何使用这些不同的控件来显示刚才所示的分层数据。此窗口不需要任何代码,它完全存在于 XAML 中。
图 6 将分层 XML 数据绑定到 UI 上的 XAML
注意,在这里广泛使用了短 XPath 查询通知 WPF 在哪里获取绑定值。绑定类向您提供 XPath 属性,您可以为其分配 XmlNode.SelectNodes 方法支持的任何 XPath 查询。实质上,WPF 是使用该方法来执行 XPath 查询的。遗憾的是,这意味着由于 XmlNode.SelectNodes 当前不支持使用 XPath 函数,WPF 数据绑定也不支持它们。
客户组合框以及订单列表框都绑定到 Xpath 查询(由根网格的 DataContext 绑定执行)的合成节点集上。列表框的 DataContext 将自动返回集合视图的 CurrentItem,该集合视图封装为网格的 DataContext 生成的 XmlNodes 集合。换言之,列表框的 DataContext 是当前选定的 Customer。由于该列表框的 ItemsSource 隐式地绑定到其自己的 DataContext(因为没有指定任何其他源)上,且其 ItemsSource 绑定会执行 XPath 查询以从 DataContext 中获取 <order> 元素,因此 ItemsSource 将有效地绑定到选定客户的订单列表上。
请记住,在绑定到 XML 数据时,您实际上是绑定到由对 XmlNode.SelectNodes 的调用创建的对象上。如果不仔细,您就会最终将多个控件绑定到逻辑上等效但实际不同的多组 XmlNodes 上。这是由于每次调用 XmlNode.SelectNodes 都会生成一组新的 XmlNodes,即使您每次都是将相同的 XPath 查询传递给同一 XmlNode 也是如此。这是需要对 XML 数据绑定特别注意的一点,在绑定业务对象时,可将其忽略。 使用多个控件显示业务对象
假设您现在要绑定到上一示例的同一数据中,但数据以业务对象而不是 XML 的形式存在。这会对您绑定数据结构各个层次的方法有何影响?使用的技术会有何相同或不同之处?
图 7 中的代码显示了一个简单类,它用于创建业务对象,其中存储着我们将要绑定的数据。这些类构成的逻辑架构与前一部分中 XML 数据所使用的架构相同。
图 7 用于创建业务对象层次结构的类
图 8 所示为这些对象显示窗口的 XAML。它与图 6 中看到的 XAML 很相似,但其中一些重要的差异需要注意。XAML 中没有显示出是该窗口的构造函数创建了数据对象并设置了 DataContext,而不是 XAML 将其作为资源进行引用。请注意,其中没有任何控件显式设置了 DataContext。它们全部都继承了同一个 DataContext,该 DataContext 是一个 List<Customer> 实例。
图 8 将分层业务对象绑定到 UI 中的 XAML
绑定到业务对象(而非 XML)时,另一个明显的区别是,承载订单详细信息的 ItemsControl 不需要绑定到订单列表框的 SelectedItem。该方法在 XML 绑定中是必要的,因为对于列表项来自本地 XPath 查询的列表,没有任何通用的方式可以用来引用它的当前项。
在绑定到业务对象(而不是 XML)时,通常是按照所选项的嵌套级别来绑定的。ItemsControl 的 ItemsSource 绑定利用了这个便捷的特点,在绑定路径中两次指定 CurrentItem: 一次是针对所选客户,一次是针对所选订单。如前文所述,CurrentItem 属性是封装数据源的基础 ICollectionView 的成员。
就 XML 和业务对象之间在工作方式上的差异而言,还有一点需要注意。由于 XML 示例绑定到 XmlElements,因此必须提供 DataTemplates 以解释如何呈现客户和订单。在绑定到自定义业务对象时,只需覆盖 Customer 类和 Order 类的 ToString 方法,并允许 WPF 为这些对象显示该方法的输出即可避免这种开销。这一窍门仅够处理具有简单文本表示的对象。在处理复杂的数据对象时,使用这种便捷的技术可能不适用。 一个用于显示整个层次结构的控件
到现在为止,您只了解了通过在不同的控件中显示层次结构的每个层级来展示分层数据的方法。但通常需要在同一控件中显示分层数据的所有层级,这对数据处理是有帮助的,也是必要的。此方法的典型例子就是 TreeView 控件,该控件支持显示和浏览任意层级的嵌套数据。
您可通过以下两种方式用各项来填充 WPF TreeView。一种方法以代码或 XAML 方式手动添加项,另一种方法是通过数据绑定创建这些项。
下面的 XAML 表明了如何通过 XAML 将一些 TreeViewItem 手动添加到 TreeView:
如控件始终都显示小型静态项组,在 TreeView 中手动创建项的方法是很实用的。如果需要显示大量会随时间变化的数据,就必须使用更具动态性的方法。此时,您有两种方法可供选择。您可以编写这样的代码:从头至尾检查整个数据结构,根据找到的数据对象创建 TreeViewItem,然后将这些项添加到 TreeView。或者利用分层数据模板,让 WPF 代您完成所有工作。 使用分层数据模板
您可以用声明的方式解释 WPF 应如何通过分层数据模板呈现分层数据。利用 HierarchicalDataTemplate 类这一工具可以弥补复杂数据结构与该数据的直观表示之间的缺口。它与常用 DataTemplate 非常相似,但还允许您指定数据对象子项的来源。您还可以为 HierarchicalDataTemplate 提供一个用于呈现这些子项的模板。
假定您现在要在一个 TreeView 控件中显示图 7 中展现的数据。该 TreeView 控件看上去可能有些类似于图 9。实现此控件需要使用两个 HierarchicalDataTemplate 和一个 DataTemplate。
图 9 在 TreeView 中显示整个数据层次结构 这两个分层模板可显示 Customer 对象和 Order 对象。由于 OrderDetail 对象没有任何子项,您可以通过非分层 DataTemplate 呈现这些对象。TreeView 的 ItemTemplate 属性会将该模板用于 Customer 类型的对象,因为 Customer 是包含在 TreeView 根层级下的数据对象。图 10 中所列的 XAML 表明了如何将所有这些方式有机地结合在一起。
图 10 支持 TreeView 显示的 XAML
我为 Grid(包含 TreeView )的 DataContext 分配了一个 Customer 对象集合。这在 XAML 中通过使用 ObjectDataProvider 即可实现,它是从 XAML 调用方法的一种便捷方式。由于 DataContext 是在元素树中自上而下地继承,因此 TreeView 的 DataContext 会引用这组 Customer 对象。这就是我们可以为其 ItemsSource 属性提供一个 "{Binding Path=.}" 绑定的原因,通过这种方式可以表明 ItemsSource 属性被绑定到 TreeView 的 DataContext。
如果没有分配 TreeView 的 ItemTemplate 属性,则 TreeView 将仅显示顶层的 Customer 对象。由于 WPF 不知道如何呈现 Customer,因此它会对每个 Customer 都调用 ToString,并为每项都显示该文本。它无法确定每个 Customer 都有一个与其关联的 Order 对象列表,且每个 Order 都有一个 OrderDetail 对象列表。由于 WPF 无法理解您的数据架构,因此您必须向 WPF 解释架构,使它能正确呈现数据结构。
向 WPF 解释数据的结构和外观是 HierarchicalDataTemplates 的份内工作。此演示中所使用的模板包含的可视元素树非常简单,就是其中带有少量文本的 TextBlocks。在更复杂的应用程序中,模板可能包含交互式的旋转 3D 模型、图像、矢量绘图、复杂的 UserControl 或任何其他可视化基础数据对象的 WPF 内容。
需要特别注意声明模板的顺序。必须先声明一个模板后才能通过 StaticResource 扩展对其进行引用。这是由 XAML 阅读器规定的要求,该要求适用于所有资源,而不仅仅是模板。
或者可通过使用 DynamicResource 扩展来引用模板,在这种情况下,模板声明的词汇顺序无关紧要。但使用 DynamicResource 引用(而不是 StaticResource 引用)总会带来一些运行时开销,因为它们要监控资源系统的更改。由于我们不会在运行时替换模板,因此这笔开销是多余的,最好使用 StaticResource 引用并恰当地安排模板声明的顺序。 使用用户输入
对大多数程序而言,显示数据仅仅是这场较量的一半。另一个巨大的挑战是分析、接受和拒绝用户输入的数据。理想状态下,所有用户都始终输入符合逻辑且准确的数据,那么这会是一项简单的任务。但在现实生活中,却根本不是这么回事。现实中的用户会出现打字错误、忘记输入所需的值、在错误的位置输入值、删除不应删除的记录、添加本不该有的记录,人都会犯错误。
作为开发人员和架构师,我们的工作就是与无法避免的错误和恶意用户输入作斗争。WPF 绑定基础结构支持输入验证。在本文接下来的几节中,我将讲述如何充分利用 WPF 对验证的支持,以及如何向用户显示验证错误消息。 通过 ValidationRules 验证输入
WPF 的第一个版本包含在 Microsoft® .NET Framework 3.0 之内,只能有限地支持输入验证。Binding 类具有 ValidationRules 属性,它可存储任意数量的 ValidationRule 派生类。这些规则中的每一个都包含一些可通过测试查看绑定值是否有效的逻辑。
那时,WPF 仅随一个被称为 ExceptionValidationRule 的 ValidationRule 子类出现。开发人员可将该规则添加到绑定的 ValidationRules 中,它将捕捉在数据源更新过程中抛出的异常,从而允许 UI 显示异常的错误消息。这种输入验证方法的有效性极富争议,人们认为良好用户体验的基础是避免不必要地向用户泄露技术细节。对于大多数用户而言,数据分析异常的错误消息通常过于专业,对不起,这个话题有点离题。
假设您有一个表示时间段的类,例如这里看到的简单 Era 类:
如果您希望允许用户编辑某个时间段的开始日期和持续时间,您可以使用两个文本框控件,然后将它们的 Text 属性绑定到 Era 实例的属性上。由于用户可在文本框内输入任何他想输入的文本,您无法确保输入的文本可转换为 DateTime 或 TimeSpan 的实例。在这种情况下,您可以使用 ExceptionValidationRule 报告数据转换错误,然后在用户界面上显示这些转换错误。图 11 中所列的 XAML 展示了完成此任务的方式。
Figure 11 一个表示时间段的简单类
这两个文本框展示了在 XAML 中将 ExceptionValidationRule 添加到绑定的 ValidationRules 中的两种方法。“开始日期”文本框使用详细的属性元素语法显式地添加规则。“持续时间”文本框使用简写语法,将绑定的 ValidatesOnExceptions 属性设置为 true。这两类绑定都将其 UpdateSourceTrigger 属性设置为 PropertyChanged,这样每次为文本框的 Text 属性赋新值时都会进行验证,而不是空等控件。该程序的屏幕快照如图 12 所示。
图 12 ExceptionValidationRule 显示验证错误 显示验证错误
如图 13 所示,“Duration”(持续时间)文本框中包含一个无效值。其包含的字符串无法转换为 TimeSpan 实例。该文本框的工具提示显示了一则错误消息,控件的右侧出现一个小的红色错误图标。这种行为并不会自动发生,但很容易实现和自定义。
图 13 向用户呈现输入验证错误
静态 Validation 类通过使用某些附加的属性和静态方法在控件与其包含的任何验证错误之间形成关系。您可以在 XAML 中引用这些附加属性,就用户界面向用户提供输入验证错误的方法创建说明,将其做为标记。图 13 中的 XAML 负责解释如何呈现上一个示例中两个文本框控件的输入错误消息。
图 13 中 Style 的目标是 UI 中文本框的所有实例。它对文本框应用了三种设置。第一个 Setter 影响文本框的 Margin 属性。Margin 属性可设置为适当的值,以提供足够的空间在右侧显示错误图标。
Style 中的下一个 Setter 分配 ControlTemplate,在包含无效数据时它负责呈现文本框。它将附加的 Validation.ErrorTemplate 属性设置为在 Style 之上声明的 ControlTemplate。当 Validation 类报告文本框有一处或多处验证错误时,该文本框将随该模板一同呈现。这里就是红色错误图标的来源,如图 12 所示。
Style 还包含一个用于监控文本框上附加的 Validation.HasError 属性的 Trigger。当 Validation 类为文本框将附加的 HasError 属性设置为 true 时,Style 的 Trigger 激活并向文本框分配一条工具提示。工具提示的内容绑定为异常的错误消息,该异常在尝试将文本框的文本解析为源属性的数据类型实例时抛出。 通过 IDataErrorInfo 验证输入
在引入 Microsoft .NET Framework 3.5 后,WPF 对输入验证的支持得到了显著改进。ValidationRule 方法对于简单的应用程序很有用,但现实中的应用程序需要处理复杂的真实数据和业务规则。将业务规则编码到 ValidationRule 对象中不仅仅是将代码捆绑到 WPF 平台上,还不允许业务逻辑在其所属位置:业务对象中存在!
很多应用程序都有一个业务层,该层的一组业务对象中包含复杂的业务处理规则。针对 Microsoft .NET Framework 3.5 编译时,您可以充分利用 IDataErrorInfo 接口使 WPF 询问业务对象它们是否处于有效状态。这就不必在与业务层分离的对象中放置业务逻辑,并允许您创建独立于 UI 平台的业务对象。由于 IDataErrorInfo 接口已延续多年,这也使得您可以更容易地重新使用旧版 Windows 窗体或 ASP.NET 应用程序中的业务对象。
假设您需要为范围以外的某个时间段提供验证,以确保用户的文本输入可转换为源属性的数据类型。时间段的起始日期不能为将来,因为我们无法了解尚不存在的时间段。要求某个时间段至少持续一毫秒也是有意义的。
这些类型的规则与业务逻辑的一般观点相同,因为它们都是域规则的示例。最好在存储其状态的域对象中实现这些域规则。图 14 中所列的代码显示了 SmartEra 类,该类通过 IDataErrorInfo 接口公开验证错误消息。
图 14 IDataErrorInfo 公开验证错误消息
从 WPF 用户界面使用 SmartEra 类的验证支持非常简单。您唯一要做的就是告知绑定它们应使用其绑定对象上的 IDataErrorInfo 接口。您可以通过两种方法执行此操作,如图 15 所示。
图 15 使用验证逻辑
与显式或隐式地向绑定的 ValidationRules 集合中添加 ExceptionValidationRule 相似,您可以将 DataErrorValidationRule 直接添加到绑定的 ValidationRules 中,也可以将 ValidatesOnDataErrors 属性设置为 true。两种方法所产生的实际效果相同;绑定系统会查询数据源的 IDataErrorInfo 接口是否有验证错误。 结束语
很多开发人员都喜欢 WPF 对数据绑定的丰富支持,这是有一定原因的。在 WPF 中使用绑定时功能如此强大、普及程度如此之深,很多软件开发人员因此不得不重新审视他们对数据与用户界面关系的看法。在诸多核心功能的协作下,WPF 可支持复杂的数据绑定方案,如模板、样式和附加属性。
只要使用几行 XAML,您就可以表达想如何显示分层数据结构或如何验证用户输入。在高级环境下,您可以通过编程方式访问绑定系统以使用其全部功能。借助这样一款您可控制自如的强大基础结构,对于创建现代化业务应用程序的开发人员而言,创建出色的用户体验和引人注目的数据可视化这一长期目标触手可及 |