高级基础知识 ObservableCollection 类
ObservableCollection 类
Ken Getz
假设您正在创建 Windows 窗体应用程序,并且已将 DataGridView 控件绑定到标准 List(Of Customer) 数据结构。您希望能够使网格中的项目与基础数据源中的值保持同步。也就是说,如果其他代码或其他窗体更改了 List 中用户的数据,您希望网格随之更新并显示修改的数据。
通常情况下,使用 Windows 窗体可以实现此目的。您可以进行更新,但这种方法很受限制。例如,在正常情况下,您可以立即在网格中看到更新,但是如果有人向数据源中添加新行,则要向网格中添加新行可就没那么容易了。Windows Presentation Foundation (WPF) 在 Microsoft .NET Framework 中添加了一些功能,所以您实际上可以可靠地使绑定控件与其数据源保持一致。我将在本文中演示如何使用 WPF 提供的 ObservableCollection 类。
利用 ObservableCollection 类,WPF 应用程序可以使绑定控件与基础数据源保持同步,但它还提供了更有用的信息,尤其是 ObservableCollection 类还可以在您添加、删除、移动、刷新或替换集合中的项目时引发 CollectionChanged 事件。此功能还可以在您的窗口以外的代码修改基础数据时做出反应。在本月的示例应用程序中,您将了解到如何使用此信息,这正是接下来我要介绍的内容。
ObservableCollection 类简介
System.Collections.ObjectModel.ObservableCollection(Of T) 类从 Collection(Of T)(泛型集合的基类)继承而来,可实现 INotifyCollectionChanged 和 INotifyPropertyChanged 两种接口。INotifyCollectionChanged 接口增加了集合的趣味性,同时也是允许绑定对象(和代码)确定集合是否已发生更改的接口。
值得注意的是,虽然 ObservableCollection 类会广播有关对其元素所做的更改的信息,但它并不了解也不关心对其元素的属性所做的更改。也就是说,它并不关注有关其集合中项目的属性更改通知。
如果您需要了解是否有人更改了集合中某个项目的属性,则您将需要确保集合中的项目可以实现 INotifyPropertyChanged 接口,并需要手动附加这些对象的属性更改事件处理程序。无论您如何更改此集合中的对象属性,都不会触发该集合的 PropertyChanged 事件。事实上,ObservableCollection 的 PropertyChanged 事件处理程序已受到保护 — 除非您从此类中继承并亲自将其公开,否则您甚至无法对其做出反应。在示例应用程序中,我采用的方法比较简单,让客户端应用程序处理单个项目的更改事件,当然,您也可以在继承的集合中处理该集合内每个项目的 PropertyChanged 事件。
如果您忽略了继承的受保护成员(假设您已经熟悉从其中派生 ObservableCollection 类的所有成员的 Collection 基类),则剩下的有趣成员仅有 Move 方法(允许您将某个成员移动到集合中的新位置)和 CollectionChanged 事件(广播有关对集合内容所做的更改的信息)。继续阅读之前,您可能需要下载并安装演示这些功能的示例 WPF 应用程序。
查看示例
示例解决方案 ObservableCollectionTest 包含从 ObservableCollection 继承而来的 CustomerList 类(请参见图 1)。如您所料,CustomerList 类会公开包含 Customer 对象的 ObservableCollection 实例。但是,如果您检查一下代码,便会发现该类仅公开一个列表,所以该类的多个使用者分别检索对同一集合的引用。(这是此次特定演示的关键,但对其他应用程序来说不是必要的。)此类提供了一个私有构造函数,因此,检索此类实例的唯一方法是调用共享的 GetList 方法,该方法用于分发现有集合实例:
Private Shared list As New CustomerList Public Shared Function GetList() As CustomerList Return list End Function
图 1 CustomerList
System.Collections.ObjectModel Imports System.ComponentModel Public Class CustomerList Inherits ObservableCollection(Of Customer) Private Shared list As New CustomerList Public Shared Function GetList() As CustomerList Return list End Function Private Sub New() ' Make the constructor private, enforcing the "factory" concept ' the only way to create an instance of this class is by calling ' the GetList method. AddItems() End Sub Public Shared Sub Reset() list.ClearItems() list.AddItems() End Sub Private Sub AddItems() Add(New Customer("Maria Anders")) Add(New Customer("Ana Trujillo")) Add(New Customer("Antonio Moreno")) End Sub End Class
私有构造函数调用 AddItems 方法;公开共享的 Reset 方法清除列表,然后调用 AddItems 方法。无论采用哪种方法,结果都是显示集合中的三个使用方:
Private Sub AddItems() Add(New Customer("Maria Anders")) Add(New Customer("Ana Trujillo")) Add(New Customer("Antonio Moreno")) End Sub
在本示例中,Customer 类特别简单(简化到仅够演示必要的功能)。图 2 中显示的类仅包含 Name 属性,要不是该类可以实现 INotifyPropertyChanged 接口,以便属性值发生更改时会通知该类实例(包括数据绑定控件)的使用者,它根本不值得一提。
图 2 具有 PropertyChanged 事件的 Customer 类
Imports System.ComponentModel Public Class Customer Implements INotifyPropertyChanged Public Event PropertyChanged( _ ByVal sender As Object, _ ByVal e As PropertyChangedEventArgs) _ Implements INotifyPropertyChanged.PropertyChanged Protected Overridable Sub OnPropertyChanged( _ ByVal PropertyName As String) ' Raise the event, and make this procedure ' overridable, should someone want to inherit from ' this class and override this behavior: RaiseEvent PropertyChanged( _ Me, New PropertyChangedEventArgs(PropertyName)) End Sub Public Sub New(ByVal Name As String) ' Set the backing field so that you don't raise the ' PropertyChanged event when you first create the Customer. _name = Name End Sub Private _name As String Public Property Name() As String Get Return _name End Get Set(ByVal value As String) If _name <> value Then _name = value OnPropertyChanged("Name") End If End Set End Property End Class
某个类实现 INotifyPropertyChanged 接口时,它必须提供 PropertyChanged 事件:
Public Event PropertyChanged( _ ByVal sender As Object, _ ByVal e As PropertyChangedEventArgs) _ Implements INotifyPropertyChanged.PropertyChanged
为了引发使用标准 .NET 设计模式的事件,Customer 类包含受保护且可覆盖的 OnPropertyChanged 过程,该过程引发以下事件:
Protected Overridable Sub OnPropertyChanged( _ ByVal PropertyName As String) ' Raise the event, and make this procedure ' overridable, should someone want to inherit from ' this class and override this behavior: RaiseEvent PropertyChanged( _ Me, New PropertyChangedEventArgs(PropertyName)) End Sub
然后,在 Name 属性的定义范围内,属性 setter 会在新值与属性的当前值不同时调用 OnPropertyChanged 方法:
Private _name As String Public Property Name() As String Get Return _name End Get Set(ByVal value As String) If _name <> value Then _name = value OnPropertyChanged("Name") End If End Set End Property
如果该类引发了 PropertyChanged 事件,则使用该类或该类的实例集合的代码会对 PropertyChanged 事件做出反应,然后基于属性更改采取相应的措施。(请注意,PropertyChangedEventArgs 类仅将 PropertyName 属性添加到标准事件参数,并不提供有关该属性的旧值或新值的任何信息。稍后您会看到,示例应用程序突破了这一限制,至少可以确定已更改属性的新值。)
此示例还包含一个名为 MainWindow 的 WPF 窗口,如图 3 所示。此窗口标记中唯一重要的细节在于 ListBox 控件的定义,其中包括该控件的 ItemsSource 属性的声明式数据绑定。绑定指示该控件应该从 MainWindow 类的 Data 属性中获取它的数据,并且应该显示 Data 属性中每个项目的 Name 属性:
<ListBox DisplayMemberPath="Name" ItemsSource= "{Binding ElementName=MainWindow, Path=Data}" Grid.Column="3" Grid.RowSpan="3" Name="ItemListBox" Margin="5" />
图 3 WPF 窗口示例(单击图像可查看大图)
MainWindow 的代码隐藏类包括以下声明:
Public WithEvents Data As CustomerList = CustomerList.GetList()
此代码在窗口中公开 CustomerList 实例的内容,如图 3 所示。
查看 codebehind 类中代码的其余部分之前,您应该在此处先停下来,体验一下应用程序。因为窗口中的 ListBox 已绑定到从 ObservableCollection 继承的类,所以您希望列表框始终显示最新的集合内容,而演示窗口证实了这一点。
此外,本示例显示两个单独的主窗口实例,因为两个窗口上的 ListBox 控件都已绑定到同一 ObservableCollection 实例,所以在其中一个窗口中所做的更改会同时显示在两个窗口中。为了打开窗口中的两个实例,Application.xaml 文件包含了以下标记,指出应用程序应该首先运行 Application_Startup 过程中的代码:
<Application x:Class="Application" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Startup="Application_Startup"> <Application.Resources> </Application.Resources> </Application>
Application.xaml.vb 代码隐藏文件包含以下启动代码,用于创建两个 MainWindow.xaml 实例,每个实例都有自己的标题:
Private Sub Application_Startup( _ ByVal sender As System.Object, _ ByVal e As System.Windows.StartupEventArgs) Dim window As New MainWindow window.Title = "Observable Collection 1" window.Show() window = New MainWindow window.Title = "Observable Collection 2" window.Show() End Sub
按照下列步骤测验示例应用程序。
- 在 Visual Studio 2008 中,加载并运行示例应用程序。您会在同一窗口中看到两个实例。
- 单击以打开窗口左侧的组合框。请注意,控件包含 0、1 和 2 这三个数字,分别对应当前的三个用户。选择 1,会选中 Ana Trujillo 并将她的名字复制到文本框。
- 在一个窗口中,单击“删除”。Ana Trujillo 会从两个窗口中消失,因为两个 ListBox 控件都已绑定到同一 ObservableCollection 实例,而绑定使得更新会立即显示出来。再次打开组合框,请注意,现在仅会显示两个用户。在每个窗口中都尝试一下此操作,验证两个实例是否都是最新的。
- 再两次单击“删除”,删除所有用户。单击“重置数据”重新填充两个窗口中的列表。
- 在“添加新项”按钮旁边的文本框中,输入您自己的名字,然后单击“添加新项”。新名字即会显示在两个 ListBox 控件中。单击以打开组合框,并验证组合框现在是否包含 0 到 3 四个数字(每个数字分别对应一个用户)。验证两个窗口中的组合框都已更改,很明显,两个窗口的类都收到了指示集合已更改的事件。
- 在一个窗口的 ListBox 中,选择一个名字。在左侧较低的文本框中,修改名字并单击“更改”。首先,会出现一则警报,指示您已更改了属性,将其关闭后,您会立即看到两个窗口中的名字都已更改(请参见图 4)。
图 4 捕获集合中的数据更改事件(单击图像可查看大图)
除了在您更改用户名称和 ComboBox 控件(其各个项目都根据集合中的用户数量进行相应的更改)时出现的警报之外,示例窗口中的所有代码都与此窗口的用户界面有关。换言之,为保持 ListBox 控件与 ObservableCollection 实例同步而执行的所有操作都可“自主”进行,并且由 WPF 管理。
当您添加新项目时,ListBox 将自动显示完整列表。当您更改项目时,ListBox 将自动显示修改后的列表。当您删除项目时,ListBox 将与基础集合保持完全一致。换言之,对于将 ObservableCollection 类与 WPF 中的控件绑定这一任务来说,您可以很轻松地说“一切运行正常”。
实际上,这种情况背后确实有一些“魔法”。将 ListBox 与 ObservableCollection 绑定后,实际上,WPF 实际上会创建一个 CollectionView 实例,以显示处理分组、排序和筛选等操作的数据的视图。您可以在 ListBox 控件中看到集合的默认视图。您可以根据同一集合创建多个 CollectionView 实例,在其中一个 ListBox 控件中以不同的方式(如排序)显示数据。虽然这个问题超出了我们的讨论范围,但是,如果您需要以多种视图显示同一集合,研究一下 CollectionView 类还是有必要的。有关详细信息,请参阅 MSDN 上的CollectionView 类信息。
查看代码
由于 MainWindow 类定义 CustomerList 实例时使用的是 WithEvents 关键字,因此代码可以处理 ObservableCollection 列表的事件,而无需用于手动添加处理程序的代码:
Public WithEvents Data As CustomerList = CustomerList.GetList()
在代码的“更改事件处理程序”区域中,您将看到 CollectionChanged 事件处理程序,它可以验证您在集合中是添加了还是删除了项目。如果确实执行了这些操作,代码会设置组合框的数据源,并在窗口中启用相应的按钮,如图 5 所示。
图 5 检查更改的集合
Private Sub Data_CollectionChanged( _ ByVal sender As Object, _ ByVal e As NotifyCollectionChangedEventArgs) _ Handles Data.CollectionChanged ' Because the collection raises this event, you can modify your user ' interface on any window that displays controls bound to the data. On ' both windows, if you add or remove an item, all the controls update ' to indicate the new collection! ' Did you add or remove an item in the collection? If e.Action = NotifyCollectionChangedAction.Add Or _ e.Action = NotifyCollectionChangedAction.Remove Then ' Set the list of integers in the combo box: SetComboDataSource() ' Enable buttons as necessary: EnableButtons() End If End Sub
这段简单代码的重点在于 NotifyCollectionChangedEventArgs 参数。此参数提供集合中发生更改的内容的相关信息。此外,还提供了五个值得注意的属性,如图 6 所示。
图 6 NotifyCollectionChangedEventArgs 参数
参数 | 说明 |
---|---|
Action | 检索引发事件的操作的相关信息。此属性包含 NotifyCollectionChangedAction 值,该值可以是 Add、Remove、Replace、Move 或 Reset。 |
NewItems | 检索更改集合时引入的新项目的列表。 |
NewStartingIndex | 检索发生更改的集合的索引。 |
OldItems | 检索受“替换”、“删除”或“移动”操作影响的旧项目列表。 |
OldStartingIndex | 检索执行了“替换”、“删除”或“移动”操作的集合的索引。 |
获得所有这些信息之后,您的事件处理程序便可以准确确定集合中执行的哪些操作触发了该事件。如果集合的大小发生更改,示例代码将仅使用 Action 属性更新 ComboBox 控件中的整数列表:
If e.Action = NotifyCollectionChangedAction.Add Or _ e.Action = NotifyCollectionChangedAction.Remove Then
虽然这与 ObservableCollection 类的讨论无关,但是,了解代码如何填充 ComboBox 控件的索引列表也很有趣:
Private Sub SetComboDataSource() ' Set the list of integers shown in the ' combo box: ItemComboBox.ItemsSource = _ Enumerable.Range(0, Data.Count) End Sub
此代码不是通过执行某种循环来生成包含 0 至集合中编号最高的索引之间的整数的列表,而是直接调用 Enumerable.Range 方法来检索从 0 开始且包含 Data.Count 值的整数集合。只要代码将 ComboBox 控件的 ItemsSource 属性设置为返回的集合即可 — 就是这么简单!(如果想了解有关 Enumerable 类的详细信息,请阅读我编写的前两期“高级基础知识”专栏:LINQ Enumerable 类,第 1 部分和 LINQ Enumerable 类,第 2 部分。)
为了在您更改集合中某个项目的属性后通知示例应用程序,您必须再编写一些代码。每次某些代码更改此类中的每个属性值时,都会引发 PropertyChanged 事件。(当然,这要由具体类的作者来确定更改属性时会引发 PropertyChanged 事件,因为该事件不会自动发生。如您所知,Customer 类的 Name 属性可以实现此目的。)
在 MainWindow 类中,您可以看到 HookupChangeEventHandler 过程,该过程可挂接单独 Customer 对象的 PropertyChanged 事件:
Private Sub HookupChangeEventHandler(ByVal cust As Customer) ' Add a PropertyChanged event handler for ' the specified Customer instance: AddHandler cust.PropertyChanged, _ AddressOf HandlePropertyChanged End Sub
HookupChangeEventHandlers 过程可挂接用户的 ObservableCollection 类中每个 Customer 对象的事件处理程序,如下所示:
Private Sub HookupChangeEventHandlers() For Each cust As Customer In Data HookupChangeEventHandler(cust) Next End Sub
当窗口加载或您单击“重置”按钮时,代码将调用 HookupChangeEventHandlers 过程。如果单击“删除”,会同时从窗口中删除集合中的项目和事件处理程序:
' From DeleteItemButton_Click Dim index As Integer = ItemComboBox.SelectedIndex If index >= 0 Then RemoveHandler Data.Item(index).PropertyChanged, _ AddressOf HandlePropertyChanged Data.RemoveAt(index)
如果单击“添加新项”,代码会创建新的用户,并挂接其 PropertyChanged 事件:
' From NewItemButton_Click cust = New Customer(NewItemTextBox.Text) HookupChangeEventHandler(cust) Data.Add(cust)
当然,由于窗口将 ListBox 控件绑定到 ObservableCollection 实例,因此,所有这些更改会自动显示在窗口的两个实例中,而无需借助任何代码支持。实际上,只有在以编程方式在列表中添加或删除用户,然后挂接并响应单个用户中发生的更改时才需要使用代码支持。
如果您确实更改了 Customer 类中某个属性的值,客户端应用程序会通过 PropertyChanged 事件处理程序接收通知。请注意,HandlePropertyChanged 过程(如图 7 所示)包含应用程序中最复杂的代码。由于更改通知只提供更改属性的名称,因此请务必记住,需要依靠代码来检索此属性的当前值(如果您需要此值)。
图 7 使用 Reflection 的 HandlePropertyChanged
Private Sub HandlePropertyChanged( _ ByVal sender As Object, _ ByVal e As PropertyChangedEventArgs) ' In this particular application, you only want to bother with this ' code for the first window, although both will run the code. In this ' case, if the event was raised by the window whose title is ' "Observable Collection 1" then process the event: If Me.Title.EndsWith("1") Then Dim propName As String = e.PropertyName Dim myCustomer As Customer = CType(sender, Customer) ' Unfortunately, no one hands you the old property value, or the new ' property value. You can use Reflection to retrieve the new property ' value, given the object that raised the event and the name of the ' property: Dim propInfo As System.Reflection.PropertyInfo = _ GetType(Customer).GetProperty(propName) Dim value As Object = _ propInfo.GetValue(myCustomer, Nothing) MessageBox.Show(String.Format( _ "You changed the property '{0}' to '{1}'", _ propName, value)) End If End Sub
此过程首先确保代码只运行一次 — 因为您打开了窗口的两个实例,否则代码会分别针对每个实例运行一次,但没有必要显示两次警报。此代码仅检查标题的最后一个字符(假设您没有更改窗口的 Title 属性),并限制仅在一个窗口中进行操作:
If Me.Title.EndsWith("1") Then 'Code removed here… End If
此代码检索并存储发生更改的属性的名称以及对引发事件的对象(即当前用户)的引用:
Dim propName As String = e.PropertyName Dim myCustomer As Customer = CType(sender, Customer)
然后,获得属性的名称和类型之后,代码将使用 Reflection 检索 System.Reflection.PropertyInfo 实例:
Dim propInfo As System.Reflection.PropertyInfo = _ GetType(Customer).GetProperty(propName)
获得 PropertyInfo 对象和特定的 Customer 实例之后,代码随后就会检索属性的当前值:
Dim value As Object = _ propInfo.GetValue(myCustomer, Nothing)
应用程序中的其余代码维护用户界面,包括启用/禁用按钮以及使组合框和列表框保持同步等等。
虽然此应用程序利用了 ObservableCollection 类提供的绑定支持,并响应 CollectionChanged 事件来更新用户界面,但是您不必按照这种方法使用此类。因为它会在其内容发生更改时通知侦听程序,所以您可以替换与 ObservableCollection 实例一起使用的任何 List 或 Collection 实例(即使您创建的不是 WPF 应用程序),然后挂接事件处理程序以通知客户端,集合的内容已发生更改。
正如示例窗口在集合大小发生更改时更新与集合索引对应的整数列表一样,您可以使用任一必要的方法来响应客户端类的集合中发生的更改。但请记住,集合本身不会告诉您其子元素的属性是否发生了更改。您必须挂接客户端中的事件处理程序,以便客户端在集合中的子元素的属性发生更改时收到通知。
另请记住,您在示例应用程序中看到的丰富数据绑定支持仅适用于 WPF 应用程序。如果您创建的是 Windows 窗体应用程序,那么当集合发生更改时,您仍然需要手动刷新绑定到 ObservableCollection 实例的所有控件的绑定。另一方面,由于您会在集合发生更改时收到通知,因此现在至少可以实现此操作。