就像属性系统在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事件包装的代码与使用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类中。