一、消息驱动与直接事件模型
事件的前身是消息(Message)。Windows 是消息驱动的系统,运行其上的程序也遵循这个原则。消息的本质就是一条数据,这条消息里面包含着消息的类别,必要的时候还记载着一些消息参数。例如:当你在按下鼠标左键时,一条名为 WM_LBUTTONDOWN 的消息被生成并加入到 Windows 待处理的消息队列中。当 Windows 处理到这条消息时,会把消息发送给单击的窗体,窗体会按照自己的算法来响应这条消息。以上过程,被称为消息驱动。
随着微软面向对象平台开发日趋成熟,微软把消息驱动包装成了更容易让人理解的事件模型。事件模型隐藏了消息的消息驱动的许多细节,让程序开发变得简单,繁琐的消息驱动机制在事件模型中简化为以下 3 个关键点:
-
事件的拥有者:即消息的发送者。事件的宿主可以在某些条件下激发它拥有的事件。
-
事件的响应者:即消息的接收者、处理者。事件接收者通过使用其事件处理器(Evenet Handler)对事件做出响应。
-
事件的订阅关系:事件的拥有者可以随时激发事件,但事件发生后会不会得到响应,需要看这个事件是否被订阅。
在上述事件模型中,事件的响应者通过订阅关系直接关联到事件拥有者的事件上,这种直接事件模型的不完美之处在于事件的拥有者和响应者必须通过事件订阅建立“专线”联系,即必须显式的建立点对点订阅关系,也就意味着事件的宿主必须能够直接访问事件的响应者,不然,无法建立订阅关系。
为了解决直接事件模型的缺点,.NET 推出了路由事件。
二、路由事件(Routed Event)
路由事件中,事件的拥有者和事件的响应者之间没有直接显式的订阅关系,事件的拥有者只负责激发事件,事件将由谁响应它并不知道,事件的响应者则安装有事件侦听器,针对某类事件进行侦听,当有此类事件传递至此时,就使用事件处理器响应并决定是否继续传递。
WPF 中大多数事件都是可路由事件。我们以 Button 的 Click 事件来说明路由事件的用法:
// 声明一个事件处理器
private void ButtonClicked(object sender, RoutedEventArgs e)
{
MessageBox.Show($"由事件的拥有者 {(e.OriginalSource as FrameworkElement).Name} 发起,并由事件的响应者 {(sender as FrameworkElement).Name}响应!");
}
<!--Grid 捕获从内部“飘出”的 Button 单击事件并通过方法 ButtonClicked 来响应-->
<Grid x:Name="GridRoot" Button.Click="ButtonClicked">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"></ColumnDefinition>
<ColumnDefinition Width="*"></ColumnDefinition>
</Grid.ColumnDefinitions>
<Canvas x:Name="CanvasLeft" Grid.Column="0" Margin="20">
<Button x:Name="ButtonLeft" Width="200" Height="100" Content="Left"></Button>
</Canvas>
<Canvas x:Name="CanvasRight" Grid.Column="1" Margin="20">
<Button x:Name="ButtonRight" Width="200" Height="100" Content="Right"></Button>
</Canvas>
</Grid>
当我们点击不同的按钮时,显示不同的内容,具体如下:
在上述的例子中,我们也可以在 Canvas 控件上添加针对按钮的路由事件,此时点击 Button 后,就会依次触发 Canvas 和 Grid 添加的事件。
三、自定义路由事件
创建自定义路由事件分为以下三个步骤:
- 声明并注册路由事件
- 为路由事件添加 CLR 事件包装
- 创建可以激发路由事件的方法
例如:我们可以自定义一个 Button 类型,可以让事件消息携带被单击时的时间。首先,我们自定义一个事件参数:
// 用于承载时间消息的消息参数
public class ReportTimeRoutedEventArgs : RoutedEventArgs
{
public ReportTimeRoutedEventArgs(RoutedEvent routedEvent, object source) : base(routedEvent, source)
{
}
public DateTime ClickTime { get; set; }
}
然后,我们自定义一个 Button 类,并添加自定义路由事件:
public class DyButton : Button
{
// 声明和注册路由事件
public static readonly RoutedEvent ReportTimeEvent = EventManager.RegisterRoutedEvent("ReportTime",
RoutingStrategy.Direct, typeof(EventHandler<ReportTimeRoutedEventArgs>), typeof(DyButton));
// 将路由事件包装成 CLR 事件
public event RoutedEventHandler ReportTime
{
add { this.AddHandler(ReportTimeEvent, value); }
remove { this.RemoveHandler(ReportTimeEvent, value); }
}
// 激发路由事件,借用 Click 事件激发
protected override void OnClick()
{
base.OnClick();
var rtArgs = new ReportTimeRoutedEventArgs(ReportTimeEvent, this) { ClickTime = DateTime.Now };
this.RaiseEvent(rtArgs);
}
}
最后,在 XAML 上绑定此事件:
// 声明一个事件处理器
private void DyButtonClicked(object sender, ReportTimeRoutedEventArgs e)
{
this.lst1.Items.Add(
$"由事件的拥有者 {(e.OriginalSource as FrameworkElement).Name} 在 {e.ClickTime.ToString("HH:mm:ss.fff")} 发起,并由事件的响应者 {(sender as FrameworkElement).Name} 响应!");
}
<Grid x:Name="g1" local:DyButton.ReportTime="DyButtonClicked">
<Grid x:Name="g2" local:DyButton.ReportTime="DyButtonClicked">
<StackPanel x:Name="s1" local:DyButton.ReportTime="DyButtonClicked">
<ListBox x:Name="lst1" Margin="20" Height="200" local:DyButton.ReportTime="DyButtonClicked"></ListBox>
<local:DyButton x:Name="ButtonDwayne" Margin="20" Height="100" Content="OK" ></local:DyButton>
</StackPanel>
</Grid>
</Grid>
当我们点击 “OK” 按钮时,可以看到如下两种效果,同一个路由事件,展示的路由路径不一样,是由注册路由事件的路由策略不同造成的,当我们使用冒泡策略(RoutingStrategy.Bubble)时和隧道策略(RoutingStrategy.Tunnel)时,效果分别如下图所示,可以清晰的看到冒泡策略使事件从内向外,而隧道策略使事件从外向内传递:
注意:我们知道路由事件是沿着 Visual Tree 进行传递的,路由事件的传递参数 RoutedEventArgs 有两个属性:Source 和 OriginalSource,都表示事件消息的源头。两者的区别在于, Source 表示 Local Tree 上的消息源头,OriginalSource 表示 Visual Tree 上的消息源头(Local Tree 和 Visual Tree 可阅读:WPF 中的逻辑树(Logical Tree)与可视化元素树(Visual Tree))。
四、附加事件(Attached Event)
路由事件的宿主全是拥有可视化实体的界面元素,附加事件需借助界面元素去与其他对象进行沟通。例如:设计一个 Student 类,当 Name 属性发生变化时,激发一个路由事件,同时界面捕获到这个事件,具体实现如下:
首先,我们声明一个 Student 类,并注册和声明路由事件:
public class Student
{
public int Id { get; set; }
public string Name { get; set; }
// 声明和注册路由事件(由于 Student 类 UIElment 类的派生类,因此不具备 AddHandler 和 RemoveHandler 方法,需要手动实现)
public static readonly RoutedEvent NameChanedEvent = EventManager.RegisterRoutedEvent("NameChaned",
RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(Student));
// 为界面元素添加路由事件
public static void AddNameChangedHandler(DependencyObject sender, RoutedEventHandler e)
{
UIElement ui= sender as UIElement;
if (ui != null)
{
ui.AddHandler(Student.NameChanedEvent,e);
}
}
// 为界面元素移除路由事件
public static void RemoveNameChangedHandler(DependencyObject sender, RoutedEventHandler e)
{
UIElement ui = sender as UIElement;
if (ui != null)
{
ui.RemoveHandler(Student.NameChanedEvent, e);
}
}
}
其次,我们添加一个事件处理器:
// 声明一个事件处理器
private void NameChanged(object sender, RoutedEventArgs e)
{
var s = (e.OriginalSource as Student);
MessageBox.Show($"{s.Id},{s.Name}");
}
然后,我们在 UI 元素上添加对 NameChanged 事件的侦听与触发:
<Grid local:Student.NameChanged="NameChanged">
<Button x:Name="Button1" Click="ButtonBase_OnClick"></Button>
</Grid>
// 触发 NameChanged 事件
private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
{
Student s = new Student() {Name = "Tim", Id = 0,};
RoutedEventArgs reArgs = new RoutedEventArgs(Student.NameChanedEvent, s);
this.Button1.RaiseEvent(reArgs);
}
最后,当我们点击按钮,触发 NameChanged 事件后,输出结果如下所示:
0,Tim