目錄
- WPF的樹形結構
- 事件
- 路由事件
- 使用WPF內置路由事件
- 自定義路由事件
- ButtonBase類的Click路由事件
- 創建一個路由事件
- RoutedEventArgs的Source與OriginalSource
- 附加事件
- 不使用CLR屬性作為包裝器
- 使用CLR屬性作為包裝器
就像屬性系統在WPF中得到升級、進化為依賴屬性一樣,事件系統在WPF中也被升級一進化成為路由事件(Routed Event),并在其基礎上衍生出命令傳遞機制,
WPF的樹形結構
WPF中有兩種“樹”:一種叫邏輯樹(Logical Tree);一種叫可視元素樹(Visual Tree),
前面見到的所有樹形結構都是Logical Tree,Logical Tree最顯著的特點就是它完全由布局組件和控制元件構成(包括串列類控制元件中的條目元素),它的每個結點不是布局組件就是控制元件,
每個WPF控制元件本身也是一棵由更細微級別的組件(它們不是控制元件,而是一些可視化組件,派生自Visual類)組成的樹,使用Blend可以解剖并觀察一個控制元件的模板(Template)是怎樣的,可以把Template理解為控制元件的骨架,把Logical Tree延伸至Template組件級別,得到的就是Visual Tree,
注:如果你的程式需要借助Visual Tree來完成一些與業務邏輯(而不是純表現邏輯)相關的功能,多半是由程式設計不良而造成的,請重新考慮邏輯、功能和資料型別方面的設計,
如果想在Logical Tree 上導航或查找元素,可以借助LogicalTreeHelper類的static方法來實作:
- BringIntoView:把選定元素帶進用戶可視區域,經常用于可滾動的視圖,
- FindLogicalNode:按給定名稱(Name屬性值)查找元素,包括子級樹上的元素,
- GetChildren:獲取所有直接子級元素,
- GetParent:獲取直接父級元素,
如果想在Visual Tree 上導航或查找元素,則可借助VisualTreeHelper類的static方法來實作,
事件
事件的前身是訊息(Message),訊息本質就是一條資料,這條資料里記載著訊息的類別,必要的時候還記載一些訊息引數(如WM_LBUTTONDOWN訊息所攜帶的引數——滑鼠單擊處的X、Y坐標),也有些訊息是不用攜帶引數的(如按鈕被單擊的訊息——程式員并不關心滑鼠點在按鈕的哪個位置上了),
隨著微軟面向物件開發平臺日趨成熟,微軟把訊息機制封裝成了更容易讓人理解的事件模型,事件模型隱藏了訊息機制的很多細節,訊息驅動機制在事件模型中被簡化為3個關鍵點:
- 事件的擁有者:即訊息的發送者,事件的宿主可以在某些條件下激發它擁有的事件,即事件被觸發,事件被觸發則訊息被發送,
- 事件的回應者:即訊息的接收者、處理者,事件接收者使用其事件處理器(Event Handler)對事件做出回應,
- 事件的訂閱關系:事件的擁有者可以隨時激發事件,但事件發生后會不會得到回應要看有沒有事件的回應者(事件是否被關注),
事件的回應者通過訂閱關系直接關聯在事件擁有者的事件上,為了與WPF的路由事件模型區分開,把這種事件模型稱為直接事件模型或者CLR事件模型,在CLR直接事件模型中,事件的擁有者就是訊息的發送者(sender),
只要支持事件的委托與影響事件的方法在簽名上保持一致(即引數串列和回傳值一致),則一個事件可以由多個事件處理器來回應(多播事件)、一個事件處理器也可以用來回應多個事件,
直接事件模型并不完美——事件的回應者與事件擁有者之間必須建立事件訂閱這個“專線聯系”,至少有兩個弊端:
- 每對訊息是“發送一回應”關系,必須建立顯式的點對點訂閱關系,
- 事件的宿主必須能夠直接訪問事件的回應者,不然無法建立訂閱關系,
直接事件模型的弱點會在下面兩種情況中顯露出來:
- 程式運行期在容器中動態生成一組相同控制元件,每個控制元件的同一個事件都使用同一個事件處理器來回應,在動態生成控制元件的同時就需要顯式書寫事件訂閱代碼,
- 用戶控制元件的內部事件不能被外界所訂閱,必須為用戶控制元件定義新的事件用以向外界暴露內部事件,如果想讓很外層的容器訂閱深層控制元件的某個事件就需要為每一層組件定義用于暴露內部事件的事件、形成事件鏈,
路由事件
路由(Roule):起點與終點間有若干個中轉站,從起點出發后經過每個中轉站時要做出選擇,最終以正確(比如最短或者最快)的路徑到達終點,
從Windows AP1開發到傳統的.NE開發,訊息的傳遞(或者說事件的激發與回應)都是直接模式的,即訊息直接由發送者交給接收者(或者說事件宿主發生的事件直接由事件回應者的事件處理器來處理),
WPF把這種直接訊息模型升級為可傳遞的訊息模型——WPF的UI是由布局組件和控制元件構最的樹形結構,當這棵樹上的某個結點激發出某個事件時,程式員可以選擇以傳統的直接事件模式讓回應者來回應之,也可以讓這個事件在UI組件樹沿著一定的方向傳遞且路過多個中轉結點,并在這個路由程序中被恰當地處理,
路由事件與直接事件的區別在于:
- 直接事件激發時,發送者直接將訊息通過事件訂閱交送給事件回應者,事件回應者使用其事件處理器方法對事件的發生做出回應、驅動程式邏輯按客戶需求運行;
- 路由事件的事件擁有者和事件回應者之間則沒有直接顯式的訂閱關系,事件的擁有者只負責激發事件,事件將由誰回應它并不知道,事件的回應者則安裝有事件偵聽器,針對某類事件進行偵聽,當有此類事件傳遞至此時事件回應者就使用事件處理器來回應事件并決定事件是否可以繼續傳遞,
盡管WPF推出了路由事件機制,但它仍然支持傳統的直接事件模型,
注:WPF的UI可以表示為Logical Tree和Visual Tree,當一個路由事件被激發后是沿著Visual Tree傳遞的——只有這樣,“藏”在Template里的控制元件才能把訊息送出來,
使用WPF內置路由事件
WPF系統中的大多數事件都是可路由事件,以Button的Click事件來說明路由事件的使用,XAML代碼如下:
<Grid x:Name="gridRoot" Background="Lime">
<Grid x:Name="gridA" Margin="10" Background="Blue">
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Canvas x:Name="canvasLeft" Grid.Column="0" Background="Red" Margin="10">
<Button x:Name="buttonLeft" Content="Left" Width="40" Height="100" Margin="10"/>
</Canvas>
<Canvas x:Name="canvasRight" Grid.Column="1" Background="Yellow" Margin="10">
<Button x:Name="butonRight" Content="Right" Width="40" Height="100" Margin="10"/>
</Canvas>
</Grid>
</Grid>
下面為gridRoot安裝針對Button.Click事件的偵聽器,C#代碼如下:
//AddHandler方法源自UIElement類,所有UI控制元件都具有這個方法
this.gridRoot.AddHandler(Button.ClickEvent, new RoutedEventHandler(this.ButtonClicked));
WPF的事件系統也使用了與屬性系統類似的“靜態欄位一包裝器”的策略,路由事件本身是一個RoutedEvent 型別的靜態成員變數(Button.ClickEvent),Button還有一個與之對應的Click事件(CLR包裝)專門用于對外界暴露這個事件,效仿依賴屬性,把路由事件的CLR包裝稱為“CLR事件”,就像每個依賴屬性擁有自己的CLR屬性包裝一樣,每個路由事件都擁有自己的CLR事件,
在XAML里也可以完成,代碼如下:
<Grid x:Name="gridRoot" Background="Lime" ButtonBase.Click="ButtonClicked">
<!--原有內容-->
</Grid>
建議使用ButtonBase.Click而不是Button.Click,因為ClickEvent這個路由事件是ButonBase類的靜態成員變數(Button類是通過繼承獲得它的),而XAML編輯器只認得包含ClickEvent欄位定義的類,
上面的代碼讓最外層的Grid(gridRoot)能夠捕捉到從內部“飄”出來的按鈕單擊事件,捕捉到后會用this.ButonClicked方法來進行回應處理,ButtonClicked方法代碼如下:
private void ButtonClicked(object sender,RoutedEventArgs e)
{
MessageBox.Show((e.OriginalSource as FrameworkElement).Name);
}
傳入ButtonClicked方法的引數sender實際上是gridRoot而不是被單擊的Button,如果想查看事件的源頭(最初發起者)可使用e.OriginalSource,使用它的時候需要使用as/is運算子或者強制型別轉換把它識別/轉換為正確的型別,
運行程式并單擊右邊的按鈕,效果如下:

自定義路由事件
創建自定義路由事件大體可以分為三個步驟:
- 宣告并注冊路由事件,
- 為路由事件添加CLR事件包裝,
- 創建可以激發路由事件的方法,
ButtonBase類的Click路由事件
下面以從ButtonBase類中抽取出的代碼為例來展示這3個步驟,此處對代碼做了些簡化:
public abstract class ButtonBase : ContentControl,ICommandSource
{
//宣告并注冊路由事件
public static readonly RoutedEvent ClickEvent = EventManager.RegisterRoutedEvent
("Click", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ButtonBase));
//為路由事件添加CLR事件包裝器
public event RoutedEventHandler Click
{
add { this.AddHandler(ClickEvent, value); }
remove { this.RemoveHandler(ClickEvent, value);}
}
//激發路由事件的方法,此方法在用戶單擊滑鼠時會被Windows系統呼叫
protected virtual void OnClick()
{
RoutedEventArgs newEvent = new RoutedEventArgs(ButtonBase.ClickEvent,this);
this.RaiseEvent(newEvent);
//..
}
//..
}
定義路由事件:為類宣告一個由public static readonly修飾的RoutedEvent 型別欄位,然后使用EventManager類的RegisterRoutedEvent方法進行注冊,
為路由事件添加CLR事件包裝:與使用CLR屬性包裝依賴屬性的代碼格式非常相近,只是關鍵字get和set被替換為add和remove:
- 當使用運算子(+=)添加對路由事件的偵聽處理時,add分支的代碼會被呼叫,
- 當使用運算子(-=)移除對此事件的偵聽處理時,remove分支的代碼會被呼叫,
注:CLR事件只是“看上去像”一個直接事件,本質上不過是在當前元素(路由的第一站)上呼叫AddHandler和RemoveHandler而已,XAML編輯器也是靠這個CLR事件包裝器來產生自動提示,
激發路由事件:首先創建需要讓事件攜帶的訊息(RoutedEventArgs類的實體)并把它與路由事件關聯,然后呼叫元素的RaiseEvent方法(繼承自UIElement類)把事件發送出去,
注:傳統直接事件的激發是通過呼叫CLR事件的Invoke方法實作的,而路由事件的激發與作為其包裝器的CLR事件毫不相干,
EventManager.RegisterRoutedEvent方法的四個引數:
- 第一個引數:為string型別,被稱為路由事件的名稱,這個字串應該與RoutedEvent變數的前綴和CLR事件包裝器的名稱一致,字串不能為空(需要使用這個字串去生成用于注冊路由事件的Hash Code),
- 第二個引數:稱為路由事件的策略,是一個RoutingStrategy列舉值,
- 第三個引數:用于指定事件處理器的型別,事件處理器的回傳值型別和引數串列必須與此引數指定的委托保持一致,不然會導致在編譯時拋出例外,
- 第四個引數:用于指明路由事件的宿主(擁有者)是哪個型別,這個型別和第一個引數共同參與一些底層演算法且產生這個路由事件的Hash Code并被注冊到程式的路由事件串列中,
WPF路由事件有3種路由策略,即RoutingStrategy列舉有三個值:
- Bubble(冒泡式):路由事件由事件的激發者出發向它的上級容器一層一層路由,直至最外層容器(Window或者Page),
- Tunnel(隧道式):事件的路由方向正好與Bubble策略相反,是由UI樹的樹根向事件激發控制元件移動,
- Direct(直達式):模仿CLR直接事件,直接將事件訊息送達事件處理器,
創建一個路由事件
下面創建一個路由事件,用途是報告事件發生的時間,創建一個RoutedEventArgs類的派生類,并為其添加ClickTime屬性:
//用于承載時間訊息的事件引數
class ReportTimeEventArgs : RoutedEventArgs
{
public ReportTimeEventArgs(RoutedEvent routedEvent, object source)
: base(routedEvent, source) { }
public DateTime ClickTime { get; set; }
}
再創建一個Button類的派生類并按前述步驟為其添加路由事件:
class TimeButton : Button
{
//宣告和注冊路由事件
public static readonly RoutedEvent ReportTimeEvent = EventManager.RegisterRoutedEvent
("ReportTime",RoutingStrategy.Bubble,typeof(EventHandler<ReportTimeEventArgs>),typeof(TimeButton));
//CLR事件包裝器
public event RoutedEventHandler ReportTime
{
add { this.AddHandler(ReportTimeEvent, value); }
remove { this.RemoveHandler(ReportTimeEvent, value); }
}
//激發路由事件,借用Click事件的激發方法
protected override void OnClick()
{
base.OnClick(); //保證Button原有功能正常使用、Click事件能被激發
ReportTimeEventArgs args = new ReportTimeEventArgs(ReportTimeEvent, this);
args.ClickTime = DateTime.Now;
this.RaiseEvent(args);
}
}
程式的界面XAML代碼如下:
<!--省略Window的部分代碼-->
<Window x:Name="windows_1" local:TimeButton.ReportTime="ReportTimeHandler">
<Grid x:Name="grid_1" local:TimeButton.ReportTime="ReportTimeHandler">
<Grid x:Name="grid_2" local:TimeButton.ReportTime="ReportTimeHandler">
<Grid x:Name="grid_3" local:TimeButton.ReportTime="ReportTimeHandler">
<StackPanel x:Name="stackPanel_1" local:TimeButton.ReportTime="ReportTimeHandler">
<ListBox x:Name="listBox"/>
<local:TimeButton x:Name="timeButton" Width="80" Height="80" Content="報時" local:TimeButton.ReportTime="ReportTimeHandler"/>
</StackPanel>
</Grid>
</Grid>
</Grid>
</Window>
ReportTimeHandler的代碼如下:
//ReportTimeEvent 路由事件處理器
private void ReportTimeHandler(object sender, ReportTimeEventArgs e)
{
FrameworkElement element = sender as FrameworkElement;
string timeStr = e.ClickTime.ToLongTimeString();
string content = string.Format("{0}到達{1}", timeStr, element.Name);
this.listBox.Items.Add(content);
}
效果如下:

為TimeButton注冊ReportTimeEvent時使用的是Bubble策略,所以事件是沿這樣的路徑由內向外傳遞的:TimeButton→StackPanel→Grid→Grid→Grid→Window,
如果把TimeReportEvent的策略改為Tunnel,則正好與Bubble策略相反,Tunnel策略使事件沿著從外向內的路徑傳遞:Window→Grid→Grid→Grid→StackPanel→TimeButton,
路由事件攜帶的事件引數必須是RoutedEventArgs類或其派生類的實體,RoutedEventArgs類具有一個bool型別屬性Handled,一旦這個屬性被設定為true,就表示路由事件“已經被處理”了(Handle有“處理”、“搞定”的意思),那么路由事件也就不必再往下傳遞了,
如果把上面的ReportTimeEvent處理器修改為這樣:
//ReportTimeEvent 路由事件處理器
private void ReportTimeHandler(object sender, ReportTimeEventArgs e)
{
FrameworkElement element = sender as FrameworkElement;
string timeStr = e.ClickTime.ToLongTimeString();
string content = string.Format("{0}到達{1}", timeStr, element.Name);
this.listBox.Items.Add(content);
if (element == this.grid_2)
{
e.Handled = true;
}
}
效果如下:

e.Handled被設定為true,無論是Bubble策略還是Tunnel策略,路由事件在經過grid_2后就被處理了、不再向下傳遞,
路由事件將程式中的組件進一步解耦(比用直接事件傳遞訊息還要松散),需要注意的是:
- 很多類的事件都是路由事件,如TextBox類的TextChanged 事件、Binding類的SourceU/pdated事件等,不要墨守傳統NET編程帶來的習慣,活用路由事件,
- 路由事件雖好,但也不要濫用,如讓表單捕捉并處理所有Button的Click 事件,正確的辦法是,事件該由誰來描捉處理,待到這個地方時就應該處理掉,
RoutedEventArgs的Source與OriginalSource
路由事件是沿著VisualTree傳遞的,VisualTree與LogicalTree的區別就在于:LogicalTree的葉子結點是構成用戶界面的控制元件,而VisualTree要連控制元件中的細微結構也算上,
路由事件的訊息包含在RoutedEventArgs實體中,Source和OriginalSource都表示路由事件傳遞的起點(即事件訊息的源頭),區別在于:
- Source表示的是LogicalTree上的訊息源頭,
- OriginalSource則表示VisualfTree上的源頭,
創建了一個名為MyUserControl的UserControl,XAML代碼如下(沒有C#邏輯代碼):
<!--省略UserControl部分代碼-->
<Grid>
<Border BorderBrush="Orange" BorderThickness="3" CornerRadius="5">
<Button x:Name="innerButon" Width="80" Height="80" Content="OK"/>
</Border>
</Grid>
把這個UserControl添加到主表單中:
<Grid>
<local:MyUserControl x:Name="myUserControl" Margin="10"/>
</Grid>
在后臺代碼中為主表單添加對Button.Click路由事件的偵聽:
public MainWindow()
{
InitializeComponent();
//為主表單添加對Button.Click事件的偵聽
this.AddHandler(Button.ClickEvent,new RoutedEventHandler(this.Button_Click));
}
//路由事件處理器
private void Button_Click(object sender, RoutedEventArgs e)
{
string strOriginalSource = string.Format("VisualTree start point:{0},type is {1}",
(e.OriginalSource as FrameworkElement).Name,e.OriginalSource.GetType().Name);
string strSource = string.Format("LogicalTree start point:{0},type is {1}",
(e.Source as FrameworkElement).Name, e.Source.GetType().Name);
MessageBox.Show(strOriginalSource + "\r\n" + strSource);
}
效果如下:

Button.Click 路由事件是從MyUserControl的innerButton 發出來的,主表單中myUserControl是LogicalTree的末端結點,而表單的VisualTree則包含了myUserControl的內部結構,所以e.Source是myUserControl、e.OriginalSource是innerButton,
附加事件
在WPF事件系統中還有一種事件被稱為附加事件(Attached Event),它就是路由事件,擁有附加事件的類有:
- Binding類:SourceUpdated事件、TargetUpdated事件,
- Mouse類:MouseEnter 事件、MouseLeave 事件、MouseDown事件、MouseUp事件等,
- Keyboard類:KeyDown事件、KeyUp事件等,
對比一下那些擁有路由事件的類,路由事件的宿主都是些擁有可視化物體的界面元素,而附加事件則不具備顯示在用戶界面上的能力,
不使用CLR屬性作為包裝器
設計一個名為Student的類,如果Student實體的Name屬性值發生了變化就激發一個路由事件,使用界面元素來捕捉這個事件,這個類的代碼如下:
public class Student
{
//宣告并定義路由事件
public static readonly RoutedEvent NameChangedEvent = EventManager.RegisterRoutedEvent
("NameChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(Student));
public int Id { get; set; }
public string Name { get; set; }
}
設計一個簡單的界面:
<Grid x:Name="gridMain">
<Button x:Name="button1" Content="OK" Width="80" Height="80" Click="Button_Click"/>
</Grid>
后臺代碼如下:
public MainWindow()
{
InitializeComponent();
// 為外層Grid添加路由事件偵聽器
this.gridMain.AddHandler(Student.NameChangedEvent, new RoutedEventHandler(this.StudentNameChangedHandler));
}
//Click 事件處理器
private void Button_Click(object sender,RoutedEventArgs e)
{
Student stu = new Student(){ Id = 101,Name = "Tim"};
stu.Name = "Tom";
//準備事件訊息并發送路由事件
RoutedEventArgs arg = new RoutedEventArgs(Student.NameChangedEvent, stu);
this.button1.RaiseEvent(arg);
}
//Grid 捕捉到NameChangedEvent后的處理器
private void StudentNameChangedHandler(object sender, RoutedEventArgs e)
{
MessageBox.Show((e.OriginalSource as Student).Id.ToString());
}
注:因為Student不是UIElement的派生類,所以它不具有RaiseEvent這個方法,為了發送路由事件就不得不“借用”一下Button的RaiseEvent方法了,
運行程式并單擊按鈕,效果如下:

Student類并非派生自UIElement,因此亦不具備AddHandler和RemoveHandler這兩個方法,所以不能使用CLR屬性作為包裝器(因為CLR屬性包裝器的add和remove分支分別呼叫當前物件的AddHandler和RemoveHandler),
使用CLR屬性作為包裝器
微軟的官方檔案約定要為附加事件添加一個CLR包裝以便XAML編輯器識別并進行智能提示:
- 為目標UI元素添加附加事件偵聽器的包裝器是一個名為Add*Handler的public static方法,星號代表事件名稱(與注冊事件時的名稱一致),
- 解除UI元素對附加事件偵聽的包裝器是名為RemoveHandler的public static方法,星號亦為事件名稱,引數與AddHandler一致,
- AddHandler與RemoveHandler的引數一致,接收兩個引數:第一個引數是事件的偵聽者(型別為DependencyObject),第二個引數為事件的處理器(RoutedEventHandler委托型別),
按照規范,Student類被升級為這樣:
public class Student
{
//宣告并定義路由事件
public static readonly RoutedEvent NameChangedEvent = EventManager.RegisterRoutedEvent
("NameChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(Student));
//為界面元素添加路由事件偵聽
public static void AddNameChangedHandler(DependencyObject d,RoutedEventHandler h)
{
UIElement e = d as UIElement;
if (e!= null)
{
e.AddHandler(Student.NameChangedEvent, h);
}
}
//移除偵聽
public static void RemoveNameChangedHandler(DependencyObject d, RoutedEventHandler h)
{
UIElement e = d as UIElement;
if (e != null)
{
e.RemoveHandler(Student.NameChangedEvent, h);
}
}
public int Id { get; set; }
public string Name { get; set; }
}
原來的代碼只有添加事件偵聽一處需要改動:
//為外層Grid添加路由事件偵聽器
Student.AddNameChangedHandler(this.gridMain, new RoutedEventHandler(this.StudentNameChangedHandler));
UIElement類是路由事件宿主與附加事件宿主的分水嶺,因為從UIElement類開始才具備了在界面上顯示的能力且RaiseEvent、AddHandler和RemoveHandler這些方法也定義在UIElement類中,
附加事件只能算是路由事件的一種用法而非一個新概念,如果在一個非UIElement派生類中注冊了路由事件,則這個類的實體既不能自己激發(Raise)此路由事件也無法自己偵聽此路由事件,只能把這個事件的激發“附著”在某個具有RaiseEvent方法的物件上,借助這個物件的RaiseEvent方法把事件發送出去;事件的偵聽任務也只能交給別的物件去做,
使用附加事件時需注意:
- 路由事件路由時的第一站就是事件的激發者,附加事件路由的第一站是激發它的元素,
- 實際上很少會把附加事件定義在Student這種與業務邏輯相關的類中,一般都是定義在像Binding、Mouse、Keyboard這種全域的Helper類中,
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/262752.html
標籤:.NET技术
