就像 WPF 中的属性系统升级进化为依赖属性一样,事件系统也升级进化成了路由事件(Routed Event),并在此基础上衍生出命令传递机制。这些机制极大地减少了对程序员的束缚,使程序设计和实现更加灵活,同时降低了模块间的耦合度。本章让我们一起来领略这些新消息机制的风采。
8.1 近观 WPF 属性结构
路由(Route)指的是在起点和终点之间,通过多个中转站选择合适路径到达目标。Windows 是一个消息驱动的操作系统,程序必须与系统的消息系统连接才能运行。从早期 Windows API 发展到 .NET 平台,消息传递方式虽然从直接定义消息进化为事件封装,但核心机制始终未变。在传统开发中,消息直接从发送者传递到接收者。而 WPF 引入了全新的可传递消息模型——在 UI 组件树中,事件不仅可以采用传统的直接响应方式,还能沿着树结构传递给多个节点处理。你可以把 WPF 路由事件想象成一只在组件树上爬行的蚂蚁,它经过每个分叉点都会传递消息。
因为 WPF 事件的路由环境是UI组件树,所以我们有必要仔细研究一下这棵树。
WPF中有两种"树":逻辑树(Logical Tree)和可视元素树(Visual Tree)。逻辑树由布局组件和控件构成,每个节点都是这两种类型之一。而可视元素树则是每个 WPF 控件内部的细节结构,由更小的可视化组件(Visual 类的派生类)组成。就像把树叶放在放大镜下,会发现它有着自己的树状结构一样。
展开 Logical Tree 到 Template 组件级别后就形成了 Visual Tree。在日常开发中,我们主要使用 Logical Tree,只有在特殊场景下才需要使用 Visual Tree。不推荐使用 Visual Tree 来处理业务逻辑,因为这往往表明系统设计需要优化。
路由事件沿着 Visual Tree 传递,这样才能确保 Template 中的控件也能接收到消息。
8.2 事件的来龙去脉
事件源于 Windows 系统的消息(Message)机制。消息本质是一条含有类别和必要参数的数据。当发生用户操作(如鼠标点击)时,系统会生成对应消息(如 WM_LBUTTONDOWN)并将其加入处理队列。Windows 将消息发送到目标窗体后,窗体通过消息处理函数进行响应。该函数使用 switch 结构对消息进行分类并调用相应的处理代码。例如,对于鼠标点击,程序可以获取坐标信息并执行相应操作;而某些消息(如按钮点击)则不需要额外参数。
这个消息触发过程被称为消息驱动,但它对于Windows开发者来说既复杂又难以维护。因此,微软将其封装为更简单的事件模型,隐藏了复杂细节,并将消息驱动机制简化为3个关键点:
- 事件的拥有者:即消息的发送者。
- 事件的响应者:即消息的接收者、处理者。
- 事件的订阅关系:事件只有在被订阅(关注)时才会触发响应。
事件模型可以用如图 8-3 所示的模型作为简要说明:
在这种模型里,事件的响应者通过订阅关系直接关联在事件拥有者的事件上,为了与 WPF 的路由事件模型区分开,我把这种事件模型称为直接事件模型或者 CLR 事件模型。
直接事件模型是.NET开发中对象间通信的主要方式,大大简化了开发过程。但它要求事件响应者和拥有者之间必须建立直接的订阅关系,这存在以下缺点:
- 每对消息是“发送→响应”关系,必须建立显式的点对点订阅关系。
- 事件的宿主必须能够直接访问事件的响应者,不然无法建立订阅关系。
注意
直接事件模型的弱点会在下面两种情况中显露出来:
- 在容器中动态生成多个相同控件时,如果这些控件都需要使用同一个事件处理器,我们就必须在生成每个控件时都添加事件订阅代码。
- 要暴露用户控件内部事件时,需要为每层UI组件定义新事件,形成事件链,这在多层级组件结构中特别繁琐。
8.3 深入浅出路由事件
WPF 推出路由事件机制来降低事件订阅的耦合度和代码量。与直接事件不同,路由事件不需要显式订阅关系。事件拥有者只负责激发事件,而事件响应者通过安装事件侦听器来监听和处理特定类型的事件。当事件传递到装有侦听器的节点时,该节点可以处理事件并决定是否继续传递。
以 Button 控件为例:当按钮被点击时,Button.Click 事件会在 Visual Tree 上传播。未安装侦听器的节点会让事件继续传递,而安装了侦听器的节点则会处理事件。事件处理器不仅能获知事件的来源和传递路径,还能控制事件是否继续传播。这种"口耳相传"的机制确保消息能有效地传递给所有关注它的控件。
8.3.1 使用 WPF 内置路由事件
WPF的大多数事件是路由事件。MSDN文档中提供了"Routed Event Information"说明,帮助开发者了解如何使用。下面以Button的Click事件为例来说明。
<Window x:Class="WpfLearn.Routed"
xmlns="<http://schemas.microsoft.com/winfx/2006/xaml/presentation>"
xmlns:x="<http://schemas.microsoft.com/winfx/2006/xaml>"
xmlns:mc="<http://schemas.openxmlformats.org/markup-compatibility/2006>"
xmlns:d="<http://schemas.microsoft.com/expression/blend/2008>"
xmlns:local="clr-namespace:WpfLearn"
mc:Ignorable="d"
Title="Routed" Height="200" Width="200">
<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="Red" Margin="10">
<Button x:Name="ButtonRight" Content="Right" Width="40" Height="100" Margin="10"/>
</Canvas>
</Grid>
</Grid>
</Window>
当点击按钮时,Button.Click事件会沿UI树向上传递:左按钮的传递路径是buttonLeft → canvasLeft → gridA → gridRoot → Window,右按钮则是buttonRight → canvasRight → gridA → gridRoot → Window。目前因为没有控件侦听此事件,所以不会触发任何响应。下面我们将为gridRoot添加事件侦听器。
方法很简单,就是在窗体的构造器中调用 gridRoot 的 AddHandler 方法把想监听的事件与事件的处理器关联起来:
public Routed()
{
InitializeComponent();
this.GridRoot.AddHandler(Button.ClickEvent, new RoutedEventHandler(this.ButtonClicked));
}
AddHandler 方法源自 UIElement 类,所有 UI 控件都继承了这个方法。使用 AddHandler 时,需要注意第一个参数应该是 Button.ClickEvent,而不是 Button.Click。这是因为 WPF 事件系统使用了"静态字段→包装器"模式:路由事件是 RoutedEvent 类型的静态成员(Button.ClickEvent),而 Button.Click 则是它的 CLR 事件包装器。这种设计思路与依赖属性类似,每个路由事件都配有相应的 CLR 事件包装。
上面的代码让最外层的 Grid(gridRoot)能够捕捉到从内部“飘”出来的按钮单击事件,捕捉到后会用 this.ButtonClicked 方法来进行响应处理。ButtonClicked 方法代码如下:
private void ButtonClicked(object sender, RoutedEventArgs e)
{
MessageBox.Show((e.OriginalSource as FrameworkElement).Name);
}
注意:在路由事件中,因为事件从内层传递到外层的 gridRoot,所以传入 ButtonClicked 方法的 sender 参数是 gridRoot 而非 Button。要获取事件的原始发起者,可使用 e.OriginalSource 并通过 as/is 操作符进行类型转换。
上述为元素添加路由事件处理器的事情在 XAML 里也可以完成,只需要把 XAML 代码改成这样即可:
<Grid x:Name="GridRoot" Background="Lime" Button.Click="ButtonClicked">
<!-- original content -->
</Grid>
在 XAML 编辑器中,输入"Button."后不会立即显示路由事件提示,只有在输入"="后才会显示处理器选项。但当使用 ButtonBase 类时会有自动提示,这是因为 ClickEvent 路由事件是在 ButtonBase 类中定义的(Button 类通过继承获得此事件),而编辑器只能识别直接定义事件的类。
8.3.2 自定义路由事件
自定义路由事件可以让对象之间的通信更加灵活。与传统事件需要逐层传递不同,路由事件能在对象树中自由传播。本节将详细介绍如何定义自己的路由事件。
创建自定义路由事件大体可以分为三个步骤:
- 声明并注册路由事件。
- 为路由事件添加 CLR 事件包装。
- 创建可以激发路由事件的方法。
让我们来创建一个用于报告事件时间的路由事件。首先,我们需要从 RoutedEventArgs 派生一个新类,并添加 ClickTime 属性来携带按钮点击时间:
public class TimeButton: Button
{
// Declare and register a routed event
public static readonly RoutedEvent ReportTimeEvent = EventManager.RegisterRoutedEvent("ReportTime",
RoutingStrategy.Bubble, typeof(EventHandler<ReportTimeEventArgs>), typeof(TimeButton));
// CLR event wrapper
public event RoutedEventHandler ReportTime
{
add { this.AddHandler(ReportTimeEvent, value); }
remove { this.RemoveHandler(ReportTimeEvent, value); }
}
// Activate the routed event by borrowing the invocation method of the Click event.
protected override void OnClick()
{
// Ensure that the original functionality of the Button is maintained and the Click event can still be triggered.
base.OnClick();
ReportTimeEventArgs args = new ReportTimeEventArgs(ReportTimeEvent, this);
args.ClickTime = DateTime.Now;
this.RaiseEvent(args);
}
}
然后,再创建一个 Button 类的派生类并按前述步骤为其添加路由事件:
public class TimeButton: Button
{
// Declare and register a routed event
public static readonly RoutedEvent ReportTimeEvent = EventManager.RegisterRoutedEvent("ReportTime",
RoutingStrategy.Bubble, typeof(EventHandler<ReportTimeEventArgs>), typeof(TimeButton));
// CLR event wrapper
public event RoutedEventHandler ReportTime
{
add { this.AddHandler(ReportTimeEvent, value); }
remove { this.RemoveHandler(ReportTimeEvent, value); }
}
// Activate the routed event by borrowing the invocation method of the Click event.
protected override void OnClick()
{
// Ensure that the original functionality of the Button is maintained and the Click event can still be triggered.
base.OnClick();
ReportTimeEventArgs args = new ReportTimeEventArgs(ReportTimeEvent, this);
args.ClickTime = DateTime.Now;
this.RaiseEvent(args);
}
}
下面是程序的界面 XAML 代码:
<Window x:Class="WpfLearn.RoutedEventWindow"
xmlns="<http://schemas.microsoft.com/winfx/2006/xaml/presentation>"
xmlns:x="<http://schemas.microsoft.com/winfx/2006/xaml>"
xmlns:mc="<http://schemas.openxmlformats.org/markup-compatibility/2006>"
xmlns:d="<http://schemas.microsoft.com/expression/blend/2008>"
xmlns:local="clr-namespace:WpfLearn"
xmlns:customControls="clr-namespace:WpfLearn.CustomControls"
customControls:TimeButton.ReportTime="ReportTimeHandler"
mc:Ignorable="d"
Title="RoutedEventWindow" Height="300" Width="300">
<Grid x:Name="Grid1" customControls:TimeButton.ReportTime="ReportTimeHandler">
<Grid x:Name="Grid2" customControls:TimeButton.ReportTime="ReportTimeHandler">
<Grid x:Name="Grid3" customControls:TimeButton.ReportTime="ReportTimeHandler">
<StackPanel x:Name="StackPanel1"
customControls:TimeButton.ReportTime="ReportTimeHandler">
<ListBox x:Name="ListBox">
<customControls:TimeButton x:Name="TimeButton" Width="80" Height="80"
Content="report time" customControls:TimeButton.ReportTime="ReportTimeHandler"/>
</ListBox>
</StackPanel>
</Grid>
</Grid>
</Grid>
</Window>
在 UI 界面上,以 Window 为根节点,嵌套了三层 Grid 和一层 StackPanel(均设置了 x:Name 属性)。在最内层的 StackPanel 中包含一个 ListBox 和一个 TimeButton(即前面创建的 Button 派生类)。值得注意的是,从最内层的 TimeButton 到最外层的 Window,所有控件都监听着 TimeButton.ReportTimeEvent 路由事件,并使用 ReportTimeHandler 方法进行响应。ReportTimeHandler 的代码如下:
private void ReportTimeHandler(object sender, ReportTimeEventArgs e)
{
FrameworkElement element = sender as FrameworkElement;
string timeStr = e.ClickTime.ToLongTimeString();
string content = string.Format($"{timeStr} arrive at {element.Name}");
this.ListBox.Items.Add(content);
}
要在特定节点停止路由事件的传递,有一个简单的方法:路由事件参数(RoutedEventArgs 或其派生类)包含一个 bool 类型的Handled属性。将此属性设置为 true 后,路由事件就会停止继续传递。让我们修改 ReportTimeEvent 处理器如下:
private void ReportTimeHandler(object sender, ReportTimeEventArgs e)
{
FrameworkElement element = sender as FrameworkElement;
string timeStr = e.ClickTime.ToLongTimeString();
string content = string.Format($"{timeStr} arrive at {element.Name}");
this.ListBox.Items.Add(content);
if (element == this.Grid2)
{
e.Handled = true;
}
}
8.3.3 RoutedEventArgs 的 OriginalSource
前面已经提到,路由事件沿着 Visual Tree 传递。Visual Tree 与 Logical Tree 的主要区别在于:Logical Tree 的叶子节点是构成用户界面的控件,而 Visual Tree 则包含了控件内部的所有细节结构。
路由事件在 VisualTree 上传递时,事件消息通过 RoutedEventArgs 实例携带。RoutedEventArgs 的两个属性 Source 和 OriginalSource 都指示事件起点,其中 Source 指向 LogicalTree 的源头,OriginalSource 指向 VisualTree 的源头。以下是示例:
首先创建了一个UserControl,XAML代码如下(没有C#逻辑代码):
<UserControl x:Class="WpfLearn.UserControls.MyUserControl"
xmlns="<http://schemas.microsoft.com/winfx/2006/xaml/presentation>"
xmlns:x="<http://schemas.microsoft.com/winfx/2006/xaml>"
xmlns:mc="<http://schemas.openxmlformats.org/markup-compatibility/2006>"
xmlns:d="<http://schemas.microsoft.com/expression/blend/2008>"
xmlns:local="clr-namespace:WpfLearn.UserControls"
mc:Ignorable="d"
d:DesignHeight="300" d:DesignWidth="300">
<Border BorderBrush="Orange" BorderThickness="3" CornerRadius="5">
<Button x:Name="InnerButton" Width="80" Height="80" Content="OK"/>
</Border>
</UserControl>
这个 UserControl 的类名为 MyUserControl,其中包含一个名为 innerButton 的 Button。然后把这个 UserControl 添加到主窗体中:
<Window x:Class="WpfLearn.ItemsPancelSample"
xmlns="<http://schemas.microsoft.com/winfx/2006/xaml/presentation>"
xmlns:x="<http://schemas.microsoft.com/winfx/2006/xaml>"
xmlns:mc="<http://schemas.openxmlformats.org/markup-compatibility/2006>"
xmlns:d="<http://schemas.microsoft.com/expression/blend/2008>"
xmlns:local="clr-namespace:WpfLearn"
xmlns:userControls="clr-namespace:WpfLearn.UserControls"
mc:Ignorable="d"
Title="ItemsPancelSample" Height="180" Width="300">
<Grid>
<userControls:MyUserControl x:Name="MyUserControl" Margin="10"/>
</Grid>
</Window>
最后在后台代码中为主窗体添加对 Button.Click 路由事件的侦听:
public partial class ItemsPancelSample : Window
{
public ItemsPancelSample()
{
InitializeComponent();
this.AddHandler(Button.ClickEvent, new RoutedEventHandler(this.Button_Click));
}
private void Button_Click(object sender, RoutedEventArgs e)
{
var originalSourceStr = $"VisualTree start point:{(e.OriginalSource as FrameworkElement).Name}," +
$"type is {e.OriginalSource.GetType().Name}";
var sourceStr = $"LogicalTree start point:{(e.Source as FrameworkElement).Name}," +
$"type is {e.Source.GetType().Name}";
MessageBox.Show($"{originalSourceStr}\r\n{sourceStr}");
}
}
8.3.4 附加事件
在 WPF 事件系统中还有一种事件被称为附加事件(Attached Event),它就是路由事件。“那为什么还要起个新名字呢?”你可能会问。让我们看看都有哪些类拥有附加事件:
- Binding 类:SourceUpdated 事件、TargetUpdated 事件。
- Mouse 类:MouseEnter 事件、MouseLeave 事件、MouseDown 事件、MouseUp 事件等。
- Keyboard 类:KeyDowm 事件、KeyUp 事件等。
对比拥有路由事件的类(如Button、Slider、TextBox等),可以发现路由事件的宿主都是可视化的界面元素。相比之下,附加事件的宿主虽然没有界面显示功能,但仍然可以通过附加事件与其他对象通信。
让我们通过一个示例来实践附加事件:创建一个 Student 类,当其Name属性变化时触发路由事件,并由 UI 元素接收该事件。
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; }
}
设计一个简单的界面:
<Window x:Class="WpfLearn.AttachedEventWindow"
xmlns="<http://schemas.microsoft.com/winfx/2006/xaml/presentation>"
xmlns:x="<http://schemas.microsoft.com/winfx/2006/xaml>"
xmlns:mc="<http://schemas.openxmlformats.org/markup-compatibility/2006>"
xmlns:d="<http://schemas.microsoft.com/expression/blend/2008>"
xmlns:local="clr-namespace:WpfLearn"
mc:Ignorable="d"
Title="AttachedEventWindow" Height="200" Width="200">
<Grid x:Name="GridMain">
<Button x:Name="Button1" Content="OK" Width="80" Height="80" Click="Button1_OnClick"></Button>
</Grid>
</Window>
其后台代码如下:
public partial class AttachedEventWindow : Window
{
public AttachedEventWindow()
{
InitializeComponent();
this.GridMain.AddHandler(Student.NameChangedEvent,
new RoutedEventHandler(this.StudentNameChangedHandler));
}
private void Button1_OnClick(object sender, RoutedEventArgs e)
{
Student stu = new Student() { Id = 101, Name = "Time" };
stu.Name = "Tom";
RoutedEventArgs arg = new RoutedEventArgs(Student.NameChangedEvent, stu);
this.Button1.RaiseEvent(arg);
}
private void StudentNameChangedHandler(object sender, RoutedEventArgs e)
{
MessageBox.Show((e.OriginalSource as Student).Id.ToString());
}
}
点击 Button 后会触发 Button_Click 方法。这里需要注意一个重要细节:由于 Student 类并非 UIElement 的派生类,它没有 RaiseEvent 方法,因此我们需要借用 Button 的 RaiseEvent 方法来发送路由事件。在窗体构造器中,我们为 Grid 元素添加了对 Student.NameChangedEvent 的侦听,这种方式与普通路由事件的侦听方式相同。当 Grid 捕获到这个路由事件时,它会显示相应 Student 实例的 Id。
虽然 Student 类现在已有一个附加事件,但根据微软文档规范,我们还需要添加 CLR 包装来支持 XAML 编辑器的智能提示。不过,由于 Student 类并非 UIElement 的派生类,它没有 AddHandler 和 RemoveHandler 方法,因此无法使用标准的 CLR 属性包装器。按照微软的要求:
- 为目标UI元素添加附加事件侦听器的包装器应定义为一个名为Add*Handler的public static方法(其中星号表示事件名称,需与注册事件时的名称保持一致)。该方法需要两个参数:第一个是事件的侦听者(类型为DependencyObject),第二个是事件的处理器(类型为RoutedEventHandler委托)。
- 解除 UI 元素对附加事件侦听的包装器是名为 RemoveHandler 的 public static 方法,星号亦为事件名称,参数与AddHandler一致。
按照规范,Student 类被升级为这样:
public class Student: DependencyObject
{
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, RoutedEventArgs h)
{
UIElement e = d as UIElement;
if (e != null) {}
e.RemoveHandler(Student.NameChangedEvent, h);
}
public int Id { get; set; }
public int Age { get; set; }
public string Name { get; set; }
}
原来的代码也需要做出相应的改动(只有添加事件侦听一处需要改动):
public partial class AttachedEventWindow : Window
{
public AttachedEventWindow()
{
InitializeComponent();
Student.AddNameChangedHandler(
this.GridMain,
new RoutedEventHandler(this.StudentNameChangedHandler));
}
// original content
}
注意
最后分享些实际工作中的经验:
第一,像 Button.Click 这类路由事件,由于其宿主是界面元素,本身就是UI树上的一个节点,所以路由事件的第一站就是事件的激发者。而附加事件的宿主不是UIElement的派生类,无法出现在UI树上,且其事件激发需要借助UI元素实现,因此附加事件路由的第一站是激发它的元素。
第二,我们很少会在Student这种业务逻辑相关的类中定义附加事件,通常是将其定义在Binding、Mouse、Keyboard等全局Helper类中。那么如何让业务逻辑类发送路由事件呢?答案是使用Binding。在良好的程序架构(数据驱动UI)中,业务逻辑会通过Binding对象与UI元素关联。当业务逻辑对象实现INotifyPropertyChanged接口,并将Binding对象的NotifyOnSourceUpdated属性设为true时,Binding就会触发SourceUpdated附加事件,该事件会在UI元素树上路由并被侦听者捕获。