WPF路由事件
简介
这是一篇记录笔者阅读学习刘铁猛老师的《深入浅出WPF》的读书笔记,如果文中内容阅读不畅,推荐购买正版书籍详细阅读。
什么是事件
事件:闹钟响了我起床
- 事件的拥有者,闹钟
- 事件成员,响了
- 事件的响应者,我
- 事件处理器,起床
- 事件订阅,我订阅闹钟
什么是路由事件
路由:起点与终点有若干各中转站,从起点出发后经过每个中转站时要做出选择,最终以正确(比如最短或者最快)的路径到达终点。
事件:闹钟响了你起床
WPF事件的路由环境:UI组件树,逻辑树(Logical Tree)、可视元素树(Visual Tree).
路由事件:你可以把WPF的路由事件看成是小蚂蚁,它可以从树的基部向顶部(或反向)目标行进,每路过一个树枝的分叉点就会把消息带给这个分叉点。
为什么需要路由事件
为了降低CLR事件订阅带来的耦合度和代码量,所以推出了路由事件机制。CLR事件与路由事件的最大区别在于订阅关系,CLR是点对点订阅,路由事件是没有订阅关系,自带了事件监听器对事件进行侦听,具体区别如下:
CLR直接事件模型的弊端缺陷在于事件的响应者与事件拥有者必须鉴定事件订阅“这个专线联系”,耦合度和代码量较高,这样至少有两个弊端:
- 每对消息时”发送=>响应“关系,必须建立显示的点对点订阅关系。
- 事件的宿主必须能够直接访问事件的响应者,不然无法建立订阅关系。
路由事件激活后是沿着VisualTree传递的。路由事件与直接事件的区别在于:
- 直接事件激发是,发送者直接将消息通过事件订阅交给事件响应者,事件响应者使用其事件处理方法对事件的发生做出响应、驱动程序逻辑按客户需求运行;
- 路由事件的事件拥有者和事件响应者之间则没有直接显示的订阅关系,事件的拥有者只负责激发事件,事件将由谁响应它并不知道,事件的响应者则安装有事件监听器,针对某类事件进行侦听,当有此类事件传递至此时事件响应者就是用事件处理器来响应事件并决定事件是否可以继续传递。
路由事件的使用
WPF内置路由事件
WPF系统中的大多数事件都是可路由事件,可路由事件在MSDN文档里会具有Routed Event Inforation 一栏,可自行查阅。
示例:Button.Click,设计一个具有左、右两个按钮的示例。
传统事件会直接将事件和事件处理器直接绑定到一起,而且需要分别为左右两个按钮绑定对应的事件处理程序,路由事件则不需要分别为左右两个按钮订阅,只需要给它的父级订阅路由事件即可,下列代码中GirdRoot这个父级元素侦听了按钮的单击事件,则内部则Button子元素一旦触发了单击事件,就会被该Gird捕获并运行,另外还通过RoutedEbentArgs参数获取到了触发该事件的控件。
关键点:因为路由事件(的消息)是从内部一层一层传递出来最后到达最外层的gridRoot,并且由gridRoot元素将事件消息交给ButtonClicked方法来处理,所以传入ButtonClicked方法的参数sender实际上是gridRoot而不是被单击的Button。
UI:
<Grid x:Name="gridRot" Background="Blue">
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Canvas x:Name="canvasLeft" Grid.Column="0" Background="Red" Margin="10">
<Button x:Name="buttonLeft" Content="左" Width="40" Height="100" Margin="10" />
</Canvas>
<Canvas x:Name="canvasRight" Grid.Column="1" Background="Yellow" Margin="10">
<Button x:Name="buttonRight" Content="右" Width="40" Height="100" Margin="10" />
</Canvas>
</Grid>
c#代码添加事件处理器:
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
this.gridRot.AddHandler(Button.ClickEvent, new RoutedEventHandler(this.ButtonClicked));
}
private void ButtonClicked(object sender, RoutedEventArgs e)
{
MessageBox.Show((e.OriginalSource as FrameworkElement).Name);
}
}
XAML代码添加事件处理器:
<Grid x:Name="gridRot" Background="Blue" ButtonBase.Click="ButtonClicked">
解析:
//
// 摘要:
// 为指定的路由事件添加路由事件处理程序,并将该处理程序添加到当前元素的处理程序集合中。
//
// 参数:
// routedEvent:
// 要处理的路由事件的标识符。
//
// handler:
// 对处理程序实现的引用。
public void AddHandler(RoutedEvent routedEvent, Delegate handler);
//
// 摘要:
// 表示将处理各种路由事件的方法,这些路由事件不包含除所有路由事件共有数据之外的其他特定事件数据
//
// 参数:
// sender:
// 事件处理程序所附加到的对象。
//
// e:
// 事件数据。
public delegate void RoutedEventHandler(object sender, RoutedEventArgs e);
RoutedEventArgs类:
public class RoutedEventArgs : EventArgs
{
//
// 摘要:
// 初始化 System.Windows.RoutedEventArgs 类的新实例。
public RoutedEventArgs();
//
// 摘要:
// 使用提供的路由事件标识符初始化 System.Windows.RoutedEventArgs 类的一个新实例。
//
// 参数:
// routedEvent:
// System.Windows.RoutedEventArgs 类的此实例的路由事件标识符。
public RoutedEventArgs(RoutedEvent routedEvent);
//
// 摘要:
// 使用提供的路由事件标识符初始化 System.Windows.RoutedEventArgs 类的一个新实例,同时提供为事件另外声明一个源的机会。
//
// 参数:
// routedEvent:
// System.Windows.RoutedEventArgs 类的此实例的路由事件标识符。
//
// source:
// 将在处理事件时报告的备用源。 这将预先填充 System.Windows.RoutedEventArgs.Source 属性。
public RoutedEventArgs(RoutedEvent routedEvent, object source);
//
// 摘要:
// 获取或设置一个值,该值指示针对路由事件(在其经过路由时)的事件处理的当前状态。
//
// 返回结果:
// 设置的时候,如果事件将标记为已处理,则设置为 true ;否则为 false。 如果读取此值, true 指示沿路由的一个类处理程序或某个实例处理程序已将此事件标记为已处理。
// false.指示没有这类处理程序将该事件标记为已处理。 默认值为 false。
public bool Handled { get; set; }
//
// 摘要:
// 在父类进行任何可能的 System.Windows.RoutedEventArgs.Source 调整之前,获取由纯命中测试确定的原始报告源。
//
// 返回结果:
// 在类处理可能对展平复合元素树进行任何 System.Windows.RoutedEventArgs.Source 调整之前的原始报告源。
public object OriginalSource { get; }
//
// 摘要:
// 获取或设置与此 System.Windows.RoutedEventArgs 实例关联的 System.Windows.RoutedEventArgs.RoutedEvent。
//
// 返回结果:
// 已调用的事件的标识符。
//
// 异常:
// T:System.InvalidOperationException:
// 在路由事件时试图更改 System.Windows.RoutedEventArgs.RoutedEvent 值。
public RoutedEvent RoutedEvent { get; set; }
//
// 摘要:
// 获取或设置对引发事件的对象的引用。
//
// 返回结果:
// 引发事件的对象。
public object Source { get; set; }
//
// 摘要:
// 当在派生类中重写时,提供特定于类型的调用事件处理程序的方式,该方式与基实现相比可提高效率。
//
// 参数:
// genericHandler:
// 要调用的泛型处理程序/委托实现。
//
// genericTarget:
// 应在其上调用所提供的处理程序的目标。
protected virtual void InvokeEventHandler(Delegate genericHandler, object genericTarget);
//
// 摘要:
// 在派生类中重写时,每当实例的 System.Windows.RoutedEventArgs.Source 属性的值发生更改,则提供一个通知回调入口点。
//
// 参数:
// source:
// System.Windows.RoutedEventArgs.Source 所设置为的新值。
protected virtual void OnSetSource(object source);
}
自定义路由事件
创建自定义路由事件大体分为三个步骤:
- 声明并注册路由事件。
- 为路由事件添加CLR事件包装。
- 创建可以激发路由事件的方法。
public abstract class ButtonBase : ContentControl, ICommandSource
{
//声明并注册路由事件
public static readonly RoutedEvent ClickEvent = /*注册路由事件*/;
//为路由事件添加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 ClickEvent1 = EventManager.RegisterRoutedEvent("Click",RoutingStrategy.Bubble,typeof(RoutedEventHandler),typeof(ButtonBase));
//
// 摘要:
// 向 Windows Presentation Foundation (WPF) 事件系统注册新的路由事件。
//
// 参数:
// name:
// 路由事件的名称。 该名称在所有者类型中必须是唯一的,并且不能为 null 或空字符串。
//
// routingStrategy:
// 作为枚举值的事件的路由策略,Bubble冒泡,Tunnel隧道,Direct直达
//
// handlerType:
// 事件处理程序的类型。 该类型必须为委托类型,并且不能为 null。
//
// ownerType:
// 路由事件的所有者类类型。 该类型不能为 null。
//
// 返回结果:
// 新注册的路由事件的标识符。 现在可将该标识符对象存储为类中的静态字段,然后将其用作将处理程序附加到事件的方法的参数。 路由事件标识符也用于其他事件系统 API。
public static RoutedEvent RegisterRoutedEvent(string name, RoutingStrategy routingStrategy, Type handlerType, Type ownerType);
自定义路由事件示例
Bubble策略XAML代码:从内向外,子到父
<Window x:Class="WpfApplicationTimeButton.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WpfApplicationTimeButton"
local:TimeButton.ReportTime="ReportTimeHandler"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
<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>
Bubble策略c#代码:从内向外,子到父
namespace WpfApplicationTimeButton
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
//ReportTimeEvent 路由事件处理器
private void ReportTimeHandler(object sender, ReportTimeEventArgs e)
{
FrameworkElement element = sender as FrameworkElement;
string timeStr = e.ClickTime.ToLongTimeString();
string content = string.Format($"{timeStr}到达{element.Name}");
this.listBox.Items.Add(content);
}
}
//01 创建用于承载时间消息的事件参数
class ReportTimeEventArgs:RoutedEventArgs
{
public ReportTimeEventArgs(RoutedEvent routedEvent,object source) : base(routedEvent, source) { }
public DateTime ClickTime { get; set; }
}
//02 创建一个Button类的派生类为其添加路由事件
class TimeButton:Button
{
//2.1 声明和注册路由事件
public static readonly RoutedEvent ReportTimeEvent = EventManager.RegisterRoutedEvent("ReportTime",RoutingStrategy.Bubble,typeof(EventHandler<ReportTimeEventArgs>),typeof(TimeButton));
// 2.2 CLR 事件包装器
public event RoutedEventHandler ReportTime
{
add { this.AddHandler(ReportTimeEvent,value); }
remove { this.RemoveHandler(ReportTimeEvent, value); }
}
//2.3 激发路由事件,借用Click事件的激发方法
protected override void OnClick()
{
base.OnClick(); //保证Button原有功能正常使用,Click事件能被激发
ReportTimeEventArgs args = new ReportTimeEventArgs(ReportTimeEvent,this);
args.ClickTime = DateTime.Now;
this.RaiseEvent(args);
}
}
}
Tunnel策略c#代码:从外向内,父到子
//声明和注册路由事件
public static readonly RoutedEvent ReportTimeEvent = EventManager.RegisterRoutedEvent("ReportTime",RoutingStrategy.Tunnel,typeof(EventHandler<ReportTimeEventArgs>),typeof(TimeButton));
停止路由事件传递
让路由事件在某个节点处不再继续传递,路由事件写待的事件参数必须是RoutedEventArgs类或派生类的示例,RoutedEventArgs类具有一个bool类型的属性Handled,一旦这个属性被设置未true,就表示路由事件“已经被处理”了,那么路由事件也就不再往下传递了。
示例:将上面的案例中的ReportTimeEvent处理器修改为如下,则无论是Bubble还是Tunnel策略,路由事件在经过grid_2后就被处理了,不再向下传递。
//ReportTimeEvent 路由事件处理器
private void ReportTimeHandler(object sender, ReportTimeEventArgs e)
{
FrameworkElement element = sender as FrameworkElement;
string timeStr = e.ClickTime.ToLongTimeString();
string content = string.Format($"{timeStr}到达{element.Name}");
this.listBox.Items.Add(content);
if (element == this.grid_2)
{
e.Handled = true;
}
}
RoutedEventArgs的Source与OriginalSource
我们说“路由事件在ViseualTree上传递”,本意上是说“路由事件的消息在ViseualTree上传递”,而路由事件的消息则包含在RoutedEventArgs实例中。
RoutedEventArgs有两个属性Source和OriginalSource,这两个属性都表示路由事件传递的起点(即事件消息的源头),只不过Source表示的是LogicalTree上的消息源头,而OriginalSource则表示VisualTree上的源头。
附加事件
附加事件的本质也是路由事件,路由事件的宿主是Button、Grid等这些我们可以在界面上看得见的控件对象,而附加事件的宿主是Binding类、Mouse类、KeyBoard类这种无法在界面显示的类对象。附加事件的提出就是为了让这种我们无法看见的类也可以通过路由事件同其他类对象进行交流。
示例:
XAML代码:
<Grid x:Name="gridMain">
<Button x:Name="button1" Content="OK" Width="80" Height="80" Click="Button_Click"/>
</Grid>
C#代码:
namespace 附加事件
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
//04 为外层Grid添加路由事件侦听器
this.gridMain.AddHandler(Student.NameChangedEvent,new RoutedEventHandler(this.StudentNameChangedHandler));
}
//02 Click事件处理器
private void Button_Click(object sender, RoutedEventArgs e)
{
Student stu = new Student() { Id = 101, Name = "Tim" };
stu.Name = "Tom";
//03 准备事件消息并发送路由事件
RoutedEventArgs arg = new RoutedEventArgs(Student.NameChangedEvent,stu);
this.button1.RaiseEvent(arg);
}
//05 Grid捕捉到NameChangedEvent后的处理器
private void StudentNameChangedHandler(object sender,RoutedEventArgs e)
{
MessageBox.Show((e.OriginalSource as Student).Id.ToString());
}
}
public class Student
{
//01 声明并定义路由事件
public static readonly RoutedEvent NameChangedEvent = EventManager.RegisterRoutedEvent
("NameChanged",RoutingStrategy.Bubble,typeof(RoutedEventHandler),typeof(Student));
public int Id { get; set; }
public string Name { get; set; }
}
}
理论上现在的Student类已经算是具有一个附加事件了,但微软的官方文档约定要为这个附加事件添加一个CLR包装以便XAML编辑器识别并进行智能提示。可惜的是,Student类并非派生自UIElement,因此亦不具备AddHandler和RemoveHandler两个方法,所以不能使用CLR属性作为包装器(因为CLR属性包装器的add和remove分支分别调用当前对象的AddHandler和RemoveHandler)。微软规定:
- 为目标UI元素添加附加事件侦听器的包装器是一个名为Add*Handler的public static方法,星号代表事件名称(与注册事件时的名称一致)。此方法接收两个参数,第一个参数是事件的侦听者(类型为DependencyObject),第二个参数为事件的处理器(RoutedEventHandler委托类型)。
- 解除UI元素对附加事件侦听的包装器是名为RemoveHandler的public static方法,星号亦为事件名称,参数与AddHandler一致。
按照规范,Student类被升级为这样:
namespace 附加事件
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
//为外层Grid添加路由事件侦听器
//this.gridMain.AddHandler(Student.NameChangedEvent, new RoutedEventHandler(this.StudentNameChangedHandler));
// 为外层Grid添加路由事件侦听器
Student.AddNameChangedHandler(this.gridMain,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());
}
}
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; }
}
}
现在让我们仔细理解一下附加事件的“附加”。确切地说,UIElement类是路由事件宿主与附加事件宿主的分水岭,不单是因为从UIElement类开始才具备了在界面上显示的能力,还因为RaiseEvent、AddHander和RemoveHandler这些方法也定义在UIElement类中。因此,如果在一个非UIElement派生类中注册了路由事件,则这个类的实例即不能自己激发(Raise)此路由事件也无法自己侦听此路由事件,只能把这个事件的激发“附着”在某个具有RaiseEvent方法的对象上、借助这个对象的RaiseEvent方法把事件发送出去;事件的侦听任务也只能交给别的对象去做。总之,附加事件只能算是路由事件的一种用法而非一个新概念,说不定哪天微软就把附加事件这个概念撤消了。
注意:
第一,像Button.Click这些路由事件,因为事件的宿主是界面元素、本身就是UI树上是一个结点,所以路由事件路由时的第一站就是事件的激发者。附加事件宿主不是UIElement的派生类,所以不能出现在UI树上的结点,而且附加事件的激发是借助UI元素实现的,因此,附加事件路由的第一站是激发它的元素。
第二,实际上很少会把附加事件定义在Student这种与业务逻辑相关的类中,一般都是定义在像Binding、Mouse、Keyboard这种全局的Helper类中。如果需要业务逻辑类的对象能发送出路由事件来怎么办?我们不是有Binding吗!如果程序架构设计的好(使用数据驱动UI),那么业务逻辑一定会使用Binding对象与UI元素关联,一旦与业务逻辑相关的对象实现了INotifyPropertyChanged接口并且Binding对象的NotifyOnSourceUpdated属性设为true,则Binding就会激发其SourceUpdated附加事件,此事件会在UI元素树上路由并被侦听者捕获。