假設您正在創建 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 實例的所有控件的綁定。另一方面,由於您會在集合發生更改時收到通知,因此現在至少可以實現此操作。
請將您想向 Ken 詢問的問題和提出的意見發送至 [email protected]。
代碼:http://msdn.microsoft.com/zh-cn/magazine/dd264663(en-us).aspx