就像属性系统在WPF中得到了升级、进化为依赖属性一样,事件系统在WPF也得到了升级-----进化成为了路由事件(Routed Event),并在其基础上衍生出命令传递机制。这些机制在很大程度上减少了对程序员的束缚,让程序的设计和实现更加的灵活,模块之间的耦合度也进一步降低,这一章我们一起来领略一下新消息机制的风采。
8.1 近观WPF的树形结构
路由一词的意思大概是这样:起点和终点间有若干个中转站。从起点出发后经过每个中转站时要做出选择,最终以最快的路径形式到达终点。编程的本质是借助编译器来扩展操作系统的功能。所以,程序的基本运行不可能脱离操作系统------Windows本身就是一种消息驱动的操作系统,所以我们的程序注定是消息驱动的。程序运行的时候也要把自己的消息系统和整个系统的消息系统“连通”才能够被执行或者相应。纵观几代WIndows平台程序开发,最早的WIndowAPI开发(C语言)和MFC开发我们可以直接看到各种消息并可以定义自己的消息;到了COM和VB时代,消息被分装为事件(EVENT)并一直沿用至.net开发平台---无论怎么说,程序间模块使用消息互相通讯的本质是没有变的。从Windows API到传统的.NET开发,消息的传递都是直接模式的---即消息直接由发送中交给接收者。WPF把这种直接消息模型升级为可传递的消息模型----前面我们已经知道,WPF的UI是由布局控件和控件构成的树形结构,当这棵树上的某个节点激发出某个事件的时候,程序员可以选择用传统的直接事件模式让响应者来响应,也可以让这个事件在UI组件树上沿着一定的方向传递且通过多个中转节点。并在这个路由过程中被恰当的处理。你可以把WPF的路由事件看作是一只小蚂蚁,它可以从树的根部向顶部(或反向)目标爬行。每路过一个分支的交叉点就会把这个消息带给这个交叉点。
因为WPF的路由环境是是UI组件树,下面我们主要来研究一下这棵组件树:一种叫逻辑元素树,一种叫可视元素树。听起来一头雾水吧,其实很简单,前面我们见的所有树形结构都是LogicTree;LogicTree最显著的特点就是完全有布局控件和控件构成,换句话说,每个节点不是布局控件就是控件。那么什么是VisualTree呢?我们知道,如果把一个树叶放在显微镜下观察,你会发现这片叶子也像一棵树----有自己的基部并向上生长出多级分叉。在WPF的Logic Tree上,扮演叶子的一般都是控件。如果我们把WPF中的控件也放在显微镜下观察,你会发现WPF控件本身也是一颗由更细微级别的组件(他们不是控件,而是一些可视化组件,派生至Visual类)组成的树。用来观测WPF控件的放大镜是我们之前提到过的Blend,使用Blend可以剖析并观察一个控件模板。目前你可以把Template理解为控件的骨架,我们甚至在保证控件功能不丢失的情况下为控件换一副新骨架,让它更漂亮。
上图是一个进度条被拆解后的显示。在日常编程工作中,进度条总是以一个整体的角色控件出现在Logic Tree中发挥它的作用。但有时候我们也需要将它拆解开,重新为它设计内部结构,比如我想把一个进度条改变成一个温度计,就需要在内部添加显示刻度的组件,改变它的填充颜色等。下图是进度条内部结构树形图:
如果把Logic Tree延伸至Template组件级别,我们得到的就是Visual Tree。实际工作中我们大多数情况下都在和Logic Tree打交道,有时候为了实现一些棘手的功能会向Visual Tree求助。依个人意见,如果你的程序要借助Visual Tree来完成一些业务逻辑(而不是纯表现逻辑)相关的功能,多半是由程序设计不良造成的。请重新考虑新逻辑,功能和数据类型方面的设计。
如果想在Logic Tree上导航或者查找元素,可以借助LogicTreeHelper类的static方法来实现:
BringIntoView:把选定元素带进用户可视区域,经常用于可滚动的视图。
FindLogicNode:按给定名称(Name属性)查找元素,包括子集树上的元素。
GetChildren:获取所有直接子集元素。
GetParent:获取直接父级元素。
如果想在Visual Tree上导航或者查找元素,则可以借助VisualTreeHelper类的static方法来实现。请大家查阅MSDN文档,在此不在嶅述。
现在我们已经知道,WPF的UI可以表示为LogicTree和VisualTree,那么当一个路由事件被激发后是沿着LogicTree传递还是沿VisualTree传递呢?答案是VisualTree---只有这样,“藏”在Template里的控件才能把消息送出来。
Logic Tree 和 Visual Tree的区别在后面讲述资源(Resource)的时候还会提到。
1.2 事件的来龙去脉
事件的前身是消息(Message)。Windows是消息驱动的操作系统,运行在其上的程序也遵照这个机制运行。消息的本质就是一条数据,这条数据里面记录了消息的类别,必要的时候还记录一些消息参数。比如,当你的窗体按下鼠标左键的时候,一条名为WM_LBUTTONDOWN消息就被生成并加入Windows的待处理的消息队列中---大部分情况下Windows的消息队列里不会有太多消息在排队、消息会立即被处理,如果你的计算机很慢并出在很忙的状态(如播放电影),那这条消息要等一会才会被处理到,这就是常见的操作系统延迟。当Windows处理到这条消息的时候,会把这条消息发送给你单击的窗体,窗体会用自己的一套算法来响应这个消息。这个算法就是Windows API开发中常说的消息处理函数。消息处理函数中有一个多级嵌套的Switch结构,进入这个Switch结构的消息会被分门类并最终流入某个末端分支,在这个函数里面会有一个程序员编写的函数被调用。例如,WM_LBUTTONDOWN这个消息,程序员可能会编写一个函数来查看它所携带的参数(鼠标单击处的X,Y坐标),然后决定把它们显示出来还是在这个点上绘制图形。也有一些函数是不携带参数的,比如按钮被单击的消息,当它流入某个分支后程序员就已经知道是按钮被单击了,程序员并不关心鼠标点在按钮的哪个位置上。
上面叙述的过程就是消息触发算法逻辑的过程,又称消息驱动。这样一个过程对于一个想入门Windows开发的人来说门槛太高,对于大型的Windows程序来说开发与维护的成本也不低。随着微软面向对象开发平台日趋成熟。微软把消息机制封装成了更容易让人理解的事件模型。
事件模型隐藏了消息机制的很多细节,让程序开发变的简单。繁琐的消息驱动机制在事件模型中被简化为了3个关键点:
事件的拥有者:即消息的发送者。事件的宿主可以在某些条件下激发它拥有的事件,即事件被触发。事件被触发则消息被发送。
事件的响应者:即消息的接收者、处理者。事件接收者使用其事件处理器(EventHandler)对事件做出响应。
事件的订阅关系:事件的拥有者可以随时激发事件,但事件发生后会不会得到响应要看有没有事件响应者,或者说要看这个事件是否被关注。如果对象A关注对象B的某个事件是否发生,则称A订阅了B的某个事件。更进一步讲,事件实际上是一个使用Event关键字修饰的委托类型的成员变量,事件处理器则是一个函数,说A订阅了B的某个事件,本质就是让B.Event和A.EventHandler关联起来。所谓事件激发就是B.Event被调用,这时,与其关联的A.EventHandler就会被调用。事件模型可以用如下图所示的模型做简要说明:
在这种模型里,事件的响应者通过订阅关系直接关联在事件拥有者的事件上,为了与WPF路由事件模型分开,我们把这种事件模型称为直接事件模型或CLR事件模型。因为CLR事件模型的本质是一个Event关键字修饰的委托实例,我们暂且模仿CLR属性的说法,把CLR事件定义为一个委托类型实例的包装器或者说有一个委托类型的实例支持(backing)一个CLR事件。
让我们看一个例子。新建一个Winform应用程序,在窗体里面放入一个按钮并命名为myButton。双击按钮,VS会自动为我们创建myButton的Click处理器(myButton_Click方法)并跳转到其中。这时,一个完整的事件模型就完成了。让我们识别一下事件模型的几个关键部分:
事件的拥有者:myButton。
事件:myButton_Click。
事件响应者:窗体本身。
事件处理器:this.myButton_Click方法。
订阅关系:可以在Form1.Designer.cs文件中找到的一句代码是:
this.myButton.Click+=new System.EventHandle(this.myButton_Click);
此句即为确定订阅关系的代码。
这说明在CLR直接事件模型中,事件的拥有者就是事件的发送者(sender)。
前面这个例子只是直接事件模型最简单的应用,实际上,只要支持事件的委托与影响事件的方法在签名上保持一致(即参数列表和返回值一致),则一个事件可以由多个事件处理器来响应(多播事件),一个事件处理器也可以同时响应多个事件,再这就不一一举例了。
直接事件模型是传统.net开发中对象间相互协同,沟通信息的主要手段,他在很大程度上简化了程序的开发。然而直接事件并不完美,它的不完美就是事件的拥有者和事件的响应者之间必须建立“事件订阅”关系,这样至少有两个弊端。
- 每条消息是“发送---响应”关系,必须显示的建立点对点订阅关系。
- 事件的宿主必须能够直接访问事件的响应者,不然无法建立订阅关系。
注意:
直接事件模型的弱点在以下两种情况下会暴露出来:
(1)程序运行期在容器中动态生成一组相同的控件,每个控件的同一个事件都使用同一个事件处理器来响应。面对这种情况,我们在动态生成代码的同时就需要显示书写事件订阅代码。
(2)用户控件内部事件不能被外接所订阅,必须对用户控件定义新的事件向外界暴露内部事件。当模块划分很细的时候,UI组件的层级会很多,如果想让最外层的容器订阅深层控件的某个事件就需要为每一层组件定义用于暴露内部事件的事件,形成事件链。
路由事件的出现,很好的解决了上面提到的两个问题。
1.3 深入浅出路由事件
为了降低由事件订阅带来的耦合度和代码量,WPF推出了路由事件机制。路由事件和直接事件的区别在于,直接事件激发时,发送者直接将消息通过事件订阅交给事件的响应者,事件响应者使用其事件处理器方法对事件的发生做出响应驱动逻辑程序按客户需求运行。路由事件的拥有者和事件响应者之间则没有直接显示的订阅关系,事件的拥有者只负责激发事件,事件将由谁响应它并不知道,事件的响应者则安装有事件侦听器,针对某类事件进行侦听。当有某类事件传递至此时事件响应者就使用事件处理器来响应事件并决定事件是否可以继续传递。举个例子,在Visual Tree上有一个button控件,当它被单击的时候就相当于自己喊了一声“我被单击了”,这样一个button.Click开始在Visual Tree上开始传播。当事件经过某个节点的时候如果这个节点没有安装用于侦听button.Click事件的“耳朵”,那么它会无视这个事件,让它继续畅通无阻的继续传播,如果某个节点安装了针对button.Click的侦听器,它的事件处理器就会被调用,在事件处理器内部,程序员可以查看路由事件原始的出发点是哪个控件,上一站是哪里,还可以决定事件传递到此为止还是可以继续往下传递----路由事件就是这样依靠“口耳相传”的办法将消息传递给“关心”它的控件的。
顺便说一句,虽然WPF推出了路由事件机制,但它仍然支持传统的直接事件模型。本节我们就聊聊路由事件的使用,再谈如何申明和使用路由事件。
1.3.1 使用WPF内置路由事件
WPF中的大部分事件都是可路由事件,可路由事件在MSDN文档里会具有Routed Event Infomation一栏,使用者可以通过这一栏信息了解如何响应这一路由事件。我们以Button的Click来说明事件的使用,请看下面的例子:
XAML代码如下:
<Window x:Class="WpfApplication1.Window24"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Window24" Height="328" Width="426">
<Grid x:Name="gridRoot" Background="Lime">
<Grid x:Name="gridA">
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Canvas x:Name="canvasLeft" Grid.Column="0" Background="Red" Margin="10">
<Button Content="Left" Height="100" Name="buttonLeft" Width="40" Margin="10"/>
</Canvas>
<Canvas Grid.Column="1" x:Name="canvasRight" Margin="10" Background="Yellow">
<Button Content="Right" Height="100" Name="buttonRight" Width="40" Margin="10"/>
</Canvas>
</Grid>
</Grid>
</Window>
运行效果和Logic Tree结构如下:
当单击buttonLeft时,Button.Click事件就会沿着buttonLeft---CanvasLeft-----gridA-------gridRoot-----Window线路传递。因为目前还没有哪个节点侦听Click事件,所以单击按钮之后尽管事件向上传递却并没有接到响应。下面,我们让gridRoot安装针对Button.Click的事件侦听器。
方法很简单,就是在窗体的构造器中调用gridRoot的AddHandler方法把想侦听的事件和事件处理器关联起来:
public Window24()
{
InitializeComponent();
this.gridRoot.AddHandler(Button.ClickEvent, new RoutedEventHandler(Button_Click));
}
Add方法源至于UIElement类,也就是说,所有的UI控件都具有这个方法。书写AddHandle方法时你会发现它的第一参数是Button.ClickEvent而不是Button.Click。原来WPF事件系统也使用了与属性系统类似的“静态字段---包装器”的策略。也就是说路由事件本身是一个RoutedEvent类型的静态成员变量(Button.ClickEvent)。Button还有一个与之对应的Click事件(CLR包装)专门用于向外界暴露这个事件。“名字叫路由事件,可我得选择一个静态字段”,这是很多初学者迷惑的地方。所以我们不妨效仿依赖属性,把路由事件的CLR包装称为CLR事件,如此,就像每个依赖属性拥有自己的CLR属性包装一样,每个路由事件都有自己的CLR事件。
上面的代码让最外层的Grid(gridRoot)能够捕捉到从“内部”飘出来的按钮单击事件,捕捉到会用this.ButtonClicked方法来进行响应处理。ButtonClicked代码如下:
private void Button_Click(object obj, RoutedEventArgs e)
{
MessageBox.Show((e.OriginalSource as FrameworkElement).Name);
}
这里有一点非常重要:因为路由事件(的消息)是从内部一层层传递出来最后到达最外层的gridRoot,并且由gridRoot元素把消息事件交给Button_Click方法来处理,所以传入Button_Click方法的参数obj实际上是gridRoot而不是被单击的Button,这与直接的传统事件有些不一样。如果想查看事件的源头(最初发起者)怎么办呢?答案是使用e.OriginalSource,使用它的时候需要是用as/is操作符或着强制类型把它识别/转换为正确的类型。
运行程序单击右边的按钮,效果如下:
上述为元素添加路由时间在XAML代码里面也可以完成,只需要把XAML代码改成这样即可:
<Grid x:Name="gridRoot" Background="Lime" Button.Click="Button_Click">
遗憾的是,XAML编译器的自动提示功能对于给元素添加路由事件处理器支持的并不是很完美,所以当你写出Button后编译器并不会给出什么提示,这时候你必须“勇敢的写下去”,直到你敲出“=”时它才会给出提示----问你使用新事件还是使用一个现在已有的能与此事件匹配的事件处理器。
不过,如果你使用ButtonBase而不是Button,就能获得XAML代码的自动提示功能。道理很简单,因为ClickEvent这个路由时间是ButtonBase的静态成员变量(Button类是通过继承它获得的),而XAML编辑器只认得包含ClickEvent字段定义的类。
1.3.2 自定义路由事件
为了方便程序间对象之间的通讯常需要我们自己定义一些路由事件,说实话,在程序中定义这种能够在对象间“飞来飞去”的事件、不再接受直接事件的束缚感觉真的很棒!那么我们怎么才能够定义自己的路由事件呢?
创建自定义路由事件大体分为3个步骤:
- 声明并注册路由事件。
- 为路由事件添加CLR事件包装。
- 创建可以激发路由事件的方法。
下面以从ButtonBase类中抽取出的代码为例来展示这3个步骤。为了避免生疏代码对学习的干扰,此代码进行了简化:
定义路由事件和定义依赖属性的手法极为相似---为你的类声明一个public static readonly修饰的RoutEvent类型字段,然后用EventManager类的RegisterRoutedEvent方法进行注册。可行VS2008并未声明注册路由事件的代码片段(snippet),所以这一过程需要手写代码。互联网上有一些用于注册路由事件的代码片段,大家可以自己下载并添加。
为事件添加CLR事件包装是为了让路由事件暴露的像一个传统的直接事件,如果不关注底层实现,程序员不会感觉到它与传统直接事件的区别,仍然可以使用操作符(+=)为事件添加处理器和使用操作符(-=)移除不在使用的事件处理器。为路由添加CLR事件包装的代码与使用CLR属性包装依赖属性的代码格式非常接近,只是关键字get和set被替换为add和remove。当使用操作符+=添加对路由事件的侦听处理时,add分支的代码会被调用;当使用-=操作符移除对事件的侦听处理时,remove分支的代码会被调用-----CLR事件只是看上去像一个直接事件,本质上不过是在当前元素上嗲用AddHandle和RemoveHandle而已。另外,XAML编译器也是靠这个CLR事件包装器来实现自动提示的。
激发路由事件很简单,首先创建需要让事件携带的消息(RoutedEventArgs类的实例)并把它与路由事件关联,然后调用元素的RaiseEvent方法(继承至UIElement类)把事件发送出去。注意,这与激发传统的直接事件方法不同,传统直接事件的激发是通过调用CLR事件的Invoke方法实现的,而路由事件的激发和作为其包装器的CLR事件毫不相关。
了解了创建自定义路由事件的步骤之后,让我们关注用于路由事件的代码。完整的注册代码如下:
public static readonly RoutedEvent ClickEvent = EventManager.RegisterRoutedEvent("Click", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(Button));
最重要的是了解EventManager的RegisterRoutedEvent的4个参数。
第一个参数是一个String类型,被称为路由事件的名称,按微软的建议,这个字符串应该于RountEvent变量的前缀和CLR事件包装器名称一致。本例中,路由事件的名称是ClickEvent,则此字符串是Click,CLR事件名亦为Click。因为底层算法与依赖属性类似,需要使用这个字符串去生成用于注册路由事件的Hash Code,所以这个字符串不能为空。
第二个参数为路由事件的策略。WPF路由事件有三种路由策略:
Bubble,冒泡式:路由事件由事件激发者出发向它的上一层容器一层一层路由,直至最外层的容器(Windows或Page)。因为是由树的底部想树的顶部移动,而且从事件激发元素到UI树的树根只有确定的一条路径,所以这种策略被形象的命名为“冒泡式”。
Tunnel,隧道式:事件的路由刚好和冒泡式相反,是由树的树根向事件激发者移动,这就想当于在树根和目标控件之间挖了一条隧道,事件只能沿着隧道移动,所以称为“隧道式”。
Direct,直达式:模仿CLR直接事件,直接将事件消息送达事件处理器。
第三个参数用于指定事件处理器的类型。事件处理器的返回值类型和参数列表必须与此参数指定的委托保持一致,不然会导致在编译的时候报异常。
第四个参数用于指定路由事件的宿主(拥有者)是哪个类型。与依赖属性类似,这个类型和第一个参数共同参与一些底层算法且产生这个路由事件的Hash Code并被注册到程序的路由事件列表中。
下面我们自己手动创建一个路由事件,这个事件的用途时报告事件的发送时间。
所谓“兵马未动,粮草先行”-----为了让事件消息能携带按钮被单击的事件,我们创建一个EventArgs的派生类,并为其添加ClickTime属性:
public class ReportTimeEventArgs:RoutedEventArgs
{
public ReportTimeEventArgs(RoutedEvent routedEvent, object source) : base(routedEvent, source) { }
public DateTime ClickTime { get; set; }
}
然后在创建一个Button了的派生类并按之前的步骤为其添加路由事件:
public class TimeButton : Button
{
//声明和注册路由事件
public static readonly RoutedEvent reprotTimeEvent = EventManager.RegisterRoutedEvent("ReportTime", RoutingStrategy.Bubble,typeof(EventHandler<ReportTimeEventArgs>),typeof(TimeButton));
//CLR事件包装器
public event RoutedEventHandler ReprortTime
{
add{this.AddHandler(reprotTimeEvent,value);}
remove { this.RemoveHandler(reprotTimeEvent, value); }
}
//激发路由事件,借用Click事件的激活方法
protected override void OnClick()
{
base.OnClick();//保证Button的原有功可以正常使用、Click事件能被激发。
ReportTimeEventArgs args = new ReportTimeEventArgs(reprotTimeEvent, this);
args.ClickTime = DateTime.Now;
this.RaiseEvent(args);
}
}
下面是程序的界面XAML代码:
<Window x:Class="WpfApplication1.Window25"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local ="clr-namespace:WpfApplication1.Model"
Title="Window25" Height="300" Width="300" x:Name="Window24" local:TimeButton.ReportTime="ReportTimeHandle">
<Grid x:Name="gd_1" Margin="10" Background="AliceBlue" local:TimeButton.ReportTime="ReportTimeHandle">
<Grid x:Name="gd_2" Margin="10" Background="AntiqueWhite" local:TimeButton.ReportTime="ReportTimeHandle">
<Grid x:Name="gd_3" Margin="10" Background="Aqua" local:TimeButton.ReportTime="ReportTimeHandle">
<StackPanel Margin="10" Background="Aquamarine" x:Name="sp_1" local:TimeButton.ReportTime="ReportTimeHandle">
<ListBox x:Name="lb_view" MinHeight="30" MaxHeight="150"></ListBox>
<local:TimeButton x:Name="tb_main" local:TimeButton.ReportTime="ReportTimeHandle" Content="Test" Width="50"></local:TimeButton>
</StackPanel>
</Grid>
</Grid>
</Grid>
</Window>
在UI界面上,以Windows为根,套上3层Grid和一层StackPanel(它们都设定了X:Name),最里面的StackPanel里面放了一个ListBox和一个TimeButton。注意,从最内层的TimeButton到最外层的Window都侦听着TimeButton的ReportTimeEvent这个路由事件,并用ReportTimeHandle方法来响应这个事件。ReportTimeHandle的代码如下:
public void ReportTimeHandle(object sender,ReportTimeEventArgs e)
{
FrameworkElement element = sender as FrameworkElement;
string timeStr = e.ClickTime.ToLongTimeString();
string content = string.Format("{0}到达{1}",timeStr,element.Name);
this.lb_view.Items.Add(content);
}
运行程序,单击按钮,效果如图:
注意:
因为TimeButton注册ReportEvent是使用的是Bubble策略,所以事件是按这样的路径由外向内传递的:
TimeButton----StatckPanel----grid3-----grid2-----grid1-----Window。
如果我们把TimeReportEvent的策略改为Tunnel:
//声明和注册路由事件
public static readonly RoutedEvent ReportTimeEvent = EventManager.RegisterRoutedEvent("ReportTime", RoutingStrategy.Tunnel,typeof(EventHandler<ReportTimeEventArgs>),typeof(TimeButton));
单击按钮以后,效果如下图:
正好与Bubble相反,tunnel策略使事件沿着从内到外的路径传递。
说到这里,不禁想起了一句名言:Bucket stop here,意思是麻烦事传递到自己这里就不要传递了。那么,如果让一个路由时间Bucket stop here呢?换句话说,如何让一个路由事件在某个节点出不再继续传递了呢?办法非常简单:路由事件携带的事件参数必须是RoutedEventArgs类或其派生类的实例。RoutedEventArgs有一个bool类型的属性Handled,一旦这个属性被赋值为true,就表示“路由事件”已经处理了(Hadle有“处理”、“搞定”的意思)。那么路由事件也就不必在往下传递了。如果把上面ReportTimeEvent处理器修改成这样:
FrameworkElement element = sender as FrameworkElement;
string timeStr = e.ClickTime.ToLongTimeString();
string content = string.Format("{0}到达{1}",timeStr,element.Name);
this.lb_view.Items.Add(content);
if(element==gd_2)
{
e.Handled = true;
}
运行程序,单击按钮,效果如下图:(分别为Tunnel策略和Bubble策略)
显然,因为e.Handled被设置为了True;无论是Bubble或者Tunnel策略,路由事件在经过gd_2之后就不在处理了、不在向下传递。
注意:
路由事件将程序中的组件进一步解耦(比用直接事件传递还要松散),使程序员可以更自由的编写代码、实现设计。这里有两点经验与大家分享:
- 很多类的事件都是路由事件,如TextBox的TextChanged事件、Binding的SourceUpdated事件等。所以在用这些类型的时候不要墨守传统的.net编程带来的习惯,要发挥自己的想象力,让程序结构更加合理、代码更加简洁。
- 路由事件虽好,但也不能滥用。举个例子,如果让所有的Button(包括组件里的Button)的Click事件都传递到最外层窗体,让窗体捕捉并处理它,那么程序的架构就变的毫无意义。正确的办法是,事件该由谁来捕捉处理,传到这个地方时就应该处理掉。
1.3.3 RoutedEventArgs的Source和OriginSource
前面已经提过,路由事件是经过VisualTree向上传递的。VisualTree和LogicTree的本质区别就是:LogicTree的叶子节点是构成用户界面的控件,而VisualTree连控件中的细微结构也要算上。
我们说路由事件在VisualTree上传递,本意是:路由事件的消息在VisualTree上传递。而路由事件消息则包含在RoutedEventArgs实例中。RoutedEventArgs有两个属性Source和OriginSource。这两个属性都表示路由事件传递的起点(即事件消息的源头),只不过Source表示的是LogincTree上的消息源头。而OrignSource则表示的是VisualTree上的源头。请看下面的这个例子:
首先创建一个UserControl,XAML代码如下:
<UserControl x:Class="WpfApplication1.MyUserControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Border BorderBrush="Orange" BorderThickness="3" CornerRadius="5">
<Button Width="80" Height="80" Content="OK"></Button>
</Border>
</UserControl>
这个UserControl的名字是MyUserControl,其中包含一个innerButton的Button。然后把这个UserControl添加到主窗体上:
<Window x:Class="WpfApplication1.Window26"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Window26" Height="300" Width="300" xmlns:my="clr-namespace:WpfApplication1" WindowStyle="ToolWindow">
<Grid>
<my:MyUserControl Margin="10" x:Name="myUserControl1" />
</Grid>
</Window>
最后在主窗体中添加对Button.Click的侦听:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
namespace WpfApplication1
{
/// <summary>
/// Window26.xaml 的交互逻辑
/// </summary>
public partial class Window26 : Window
{
public Window26()
{
InitializeComponent();
this.AddHandler(Button.ClickEvent, new RoutedEventHandler(ButtonClick));
}
private void ButtonClick(object sender,RoutedEventArgs e)
{
string originSource = string.Format("VisualTree StartPoint:{0},type is {1}",(e.OriginalSource as FrameworkElement).Name,e.OriginalSource.GetType().Name);
string stringSource = string.Format("LogicTree startPoint:{0},Type is {1}",(e.Source as FrameworkElement).Name,e.Source.GetType().Name);
MessageBox.Show(originSource+"\r\n"+stringSource);
}
}
}
运行程序,单击按钮,效果如图:
Button的Click事件是由MyUserControl的innerButton里面发出来的,在主窗体中,myUserControl是LogicTree的末节点,所以e.Source就是myUserControl;而窗体的VisualTree则包含了MyUserControl的内部结构,所以“看见”路由事件究竟是丛哪个控件发出来的,所以使用e.OriginalSource可以获得innerButton。
1.3.4 事件也附加--深入浅出附加事件
在WPF事件系统中还有一种被称为附加事件(Attached Event),它就是路由事件。为什么会给他起个新名字呢?
“身无彩凤双飞翼,心有灵犀一点通”,这就是附加事件的真实写照。怎么解释呢?让我们先来看看什么类都有附加事件:
- Binding类:SourceUpdate事件、TargetUpdate事件。
- Mouse类:MouseEnter事件、MouseLeave事件、MouseDown事件、MouseUp事件。
- KeyBoard类:KeyDown事件、KeyUp事件等。
再对比一下哪些拥有路由事件的类,如Button、Slider、TextBox....发现什么问题了吗?原来,路由事件的宿主都是拥有可视化实体的界面元素,而附加事件则不具备显示在用户界面上的能力。也就是说,附加事件的宿主没有界面渲染这双“飞翼”,但一样可以使用附加事件这个“灵犀”与其它对象进行沟通。
理解了附加事件的原理,让我们动手写一个例子。我想实现的逻辑是这样的:设计一个名为Student的类,如果Studen的Name属性发生了变化就激发一个路由事件,我会用界面元素来捕捉这个事件:
这个类的代码如下:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows;
namespace WpfApplication1
{
public class Person
{
public static readonly RoutedEvent NameChangedEvent = EventManager.RegisterRoutedEvent("NameChanged", RoutingStrategy.Bubble,typeof(RoutedEventHandler),typeof(Person));
public int Id { get; set; }
public string Name { get; set; }
}
}
设计一个简单的界面:
<Window x:Class="WpfApplication1.Window27"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Window27" Height="272" Width="349">
<Grid x:Name="gd_main">
<Button Content="Button" x:Name="button1" Width="75" Height="75" Margin="10" Click="button1_Click" />
</Grid>
</Window>
其后台代码如下:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
namespace WpfApplication1
{
/// <summary>
/// Window27.xaml 的交互逻辑
/// </summary>
public partial class Window27 : Window
{
public Window27()
{
InitializeComponent();
//为外层Grid添加路由事件
this.gd_main.AddHandler(Person.NameChangedEvent, new RoutedEventHandler(PersonNameChanged));
}
private void PersonNameChanged(object obj, RoutedEventArgs e)
{
MessageBox.Show((e.OriginalSource as Person).Name);
}
private void button1_Click(object sender, RoutedEventArgs e)
{
Person persion = new Person();
persion.Id = 0;
persion.Name = "Darren";
//准备事件消息并发送路由事件
RoutedEventArgs arg = new RoutedEventArgs(Person.NameChangedEvent, persion);
this.button1.RaiseEvent(arg);
}
}
}
后台代码中,当界面上唯一的button被单击会触发buttonClick方法。有一点必须注意的是:因为Student不是UIElement的派生类,所以它不具备RaiseEvent这个方法,为了发送路由事件就不得不借用一下Button的RaiseEvent方法。在窗体的构造器中为Grid元素添加了对Student.NameChangedEvent的侦听,这与添加对路由事件的侦听没有任何区别。Grid在捕获到路由事件会显示事件的消息源(一个Person实例)的Name。
运行程序,单击按钮,效果如下图:
理论上现在的Student类已经具有一个附加事件了,但微软的官方文档约定要为这个附加事件添加一个CLR属性包装以便XAML编辑器识别并进行智能提示。可惜的是,Student类并非派生自UIElement,因此亦不具备AddHandle和RemoveHandle这两个方法,因此不能使用CLR属性作为包装器(因为CLR属性包装器的Add和Remove分支分别调用当前对象的AddHandle和RemoveHandle)。微软规定:
为目标UI添加附加事件侦听器的包装器是一个名为Add*Handle的public static方法,*号代表事件名称(与注册的事件名称一致)。此方法接收两个参数,第一个是事件侦听者(类型是DependencyObject)第二个参数为事件的处理器(RoutedEventHandle委托类型);
解除UI对附加事件侦听的包装器是名为Remove*Handle的public static方法,星号亦是事件名称,参数与Add*Handle一致。
按照规范,Student类被升级为:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows;
namespace WpfApplication1
{
public class Person
{
public static readonly RoutedEvent NameChangedEvent = EventManager.RegisterRoutedEvent("NameChanged", RoutingStrategy.Bubble,typeof(RoutedEventHandler),typeof(Person));
//为界面添加路由侦听
public static void AddNameChangedHandle(DependencyObject d,RoutedEventHandler h)
{
UIElement e = d as UIElement;
if(null!=e)
{
e.AddHandler(NameChangedEvent, h);
}
}
//移除侦听
public static void RemoveNameChangedHandle(DependencyObject d,RoutedEventHandler h)
{
UIElement e = d as UIElement;
if(null!=e)
{
e.RemoveHandler(NameChangedEvent,h);
}
}
public int Id { get; set; }
public string Name { get; set; }
}
}
原来的代码也需要做出相应的改动(只有添加事件侦听一处需要改动)
public Window27()
{
InitializeComponent();
//为外层Grid添加路由事件
Person.AddNameChangedHandle(this.gd_main, new RoutedEventHandler(PersonNameChanged));
}
现在让我们仔细理解一下附加事件的附加。确切的说,UIElement类是路由事件宿主和附加事件宿主的分水岭,不单是从UIElement类开始才具备了在界面上显示的能力,还因为RaiseEvent、AddHandle和RemoveHandle这些方法也定义在了UIElement类中。因此,如果在一个非UIElement派生类中注册了路由事件,只能把这个事件的激发附在某个具有RaiseEvent的方法的对象上。借助这个对象的RasseEvent将事件发送出去;事件的侦听任务也只能交给别的对象去做。总之,附加事件只能算是路由事件的一种方法而非一个新概念,说不定微软哪天就把附加事件这个概念撤销了。
注意:
最后分享一下在实际工作中的一些经验:
- 第一、像Button.Click这些路由事件,因为事件的宿主是界面元素,本身就是UI树上的一个节点,所以路由事件路由时的第一站就是事件的激发者。附加事件的宿主不是UIElement的派生类,所以不能出现在UI树上的节点,而且附加事件的激发是借助UI元素实现的,因此,附加事件路由的第一站是激发它的元素。
- 第二、实际上很少将附加事件定义在Person这种与业务逻辑相关的类中,一般都是定义在像Binding,Mouse,KeyBoard这种全局的Helper类中。如果需要业务逻辑类的对象能发送路由事件怎么办?我们不是有Binding吗!如果程序架构设计的好(使用数据驱动UI),那么业务逻辑一定会使用Binding对象与UI元素关联。一旦与业务逻辑相关的对象实现了INotyfyPropertyChanged接口并且Binding对象的NotifyOnSourceUpdated属性设置为了true,则Binding就会激发其SourceUpdated事件,此事件会在UI元素树上路由并被侦听者捕获。