路由事件概述 (WPF .NET)

Windows Presentation Foundation (WPF) 应用程序开发人员和组件创建者可以使用路由事件,通过元素树来传播事件,并在树中的多个侦听器上调用事件处理程序。 公共语言运行时 (CLR) 事件中没有这些功能。 有一些 WPF 事件是路由事件,例如 ButtonBase.Click。 本文介绍路由事件的基本概念,并对何时以及如何响应路由事件提供了指导。

重要

面向 .NET 7 和 .NET 6 的桌面指南文档正在撰写中。

先决条件

本文假定读者已基本了解公共语言运行时 (CLR)、面向对象的编程以及如何将 WPF 元素布局概念化为树。 若要理解本文中的示例,还应当熟悉 Extensible Application Markup Language (XAML) 并知道如何编写 WPF 应用程序

什么是路由事件?

可以从功能或实现的角度来理解路由事件:

  • 从功能角度来看,路由事件是一种可以针对元素树中的多个侦听器(而不是仅针对事件源)调用处理程序的事件。 事件侦听器是附加和调用了事件处理程序的元素。 事件源是最初引发事件的元素或对象。

  • 从实现的角度来看,路由事件是向 WPF 事件系统注册的事件,由 RoutedEvent 类的实例提供支持,并由 WPF 事件系统处理。 通常,路由事件是使用 CLR 事件“包装器”实现的,可以在 XAML 和代码隐藏中启用附加处理程序,就像你使用 CLR 事件一样。

WPF 应用程序通常包含许多元素,它们要么在 XAML 中声明,要么在代码中实例化。 应用程序的元素存在于其元素树中。 根据路由事件的定义方式,当事件在源元素上被引发时,它会:

  • 通过元素树从源元素浮升到根元素(通常是页面或窗口)。
  • 通过元素树从根元素到源元素向下进行隧道操作。
  • 不会遍历元素树,只发生在源元素上。

来看看下面的一部分元素树:

<Border Height="30" Width="200" BorderBrush="Gray" BorderThickness="1">
    <StackPanel Background="LightBlue" Orientation="Horizontal" Button.Click="YesNoCancelButton_Click">
        <Button Name="YesButton">Yes</Button>
        <Button Name="NoButton">No</Button>
        <Button Name="CancelButton">Cancel</Button>
    </StackPanel>
</Border>

元素树的呈现的效果如下所示:

Yes, No, and Cancel buttons.

这三个按钮中的每一个都是潜在的 Click 事件源。 单击其中一个按钮时,它会引发 Click 事件,从按钮浮升到根元素。 ButtonBorder 元素没有附加事件处理程序,但 StackPanel 有。 树中较高、未显示的其他元素可能也附加了 Click 事件处理程序。 当 Click 事件到达 StackPanel 元素时,WPF 事件系统将调用附加到它的 YesNoCancelButton_Click 处理程序。 示例中 Click 事件的事件路由为:Button>StackPanel>Border> 连续的父元素。

注意

最初引发路由事件的元素在事件处理程序参数中被标识为 RoutedEventArgs.Source。 事件侦听器是附加和调用了事件处理程序的元素,它在事件处理程序参数中标识为 sender

路由事件的顶层方案

下面介绍一些需运用路由事件概念的方案,并将其与典型的 CLR 事件进行区分:

  • 控件的撰写和封装:WPF 中的各个控件都有一个丰富的内容模型。 例如,可以将图像放在 Button 的内部,这会有效地扩展按钮的可视化树。 但是,添加的图像不能破坏按钮的命中测试行为,它需要在用户单击图像像素时做出响应。

  • 单一处理程序附加点:可以为每个按钮的 Click 事件注册一个处理程序,但通过路由事件,可以附加单个处理程序,如前面的 XAML 示例所示。 这样,你就可以更改单一处理程序下的元素树,例如添加或删除更多按钮,而无需注册每个按钮的 Click 事件。 引发 Click 事件时,处理程序逻辑可以确定事件来自何处。 在前面演示的 XAML 元素树中指定的以下处理程序包含该逻辑:

    private void YesNoCancelButton_Click(object sender, RoutedEventArgs e)
    {
        FrameworkElement sourceFrameworkElement = e.Source as FrameworkElement;
        switch (sourceFrameworkElement.Name)
        {
            case "YesButton":
                // YesButton logic.
                break;
            case "NoButton":
                // NoButton logic.
                break;
            case "CancelButton":
                // CancelButton logic.
                break;
        }
        e.Handled = true;
    }
    
    Private Sub YesNoCancelButton_Click(sender As Object, e As RoutedEventArgs)
        Dim frameworkElementSource As FrameworkElement = TryCast(e.Source, FrameworkElement)
    
        Select Case frameworkElementSource.Name
            Case "YesButton"
                ' YesButton logic.
            Case "NoButton"
                ' NoButton logic.
            Case "CancelButton"
                ' CancelButton logic.
        End Select
    
        e.Handled = True
    End Sub
    
  • 类处理:路由事件支持在类中定义的类事件处理程序 。 类处理程序会在该类的任何实例上处理同一事件的任何实例处理程序之前处理事件。

  • 在没有反射的情况下引用事件:每个路由事件都会创建一个 RoutedEvent 字段标识符,从而提供一种不需要静态反射或运行时反射的可靠的事件标识技术来标识事件。

路由事件的实现方式

路由事件是向 WPF 事件系统注册的事件,由 RoutedEvent 类的实例提供支持,并由 WPF 事件系统处理。 从注册获取的 RoutedEvent 实例通常存储为已注册的类的 public static readonly 成员。 该类称为事件“拥有者”类。 通常,路由事件会实现一个名称相同的 CLR 事件“包装器”。 CLR 事件包装器包含 addremove 访问器,可以通过特定于语言的事件语法在 XAML 和代码隐藏中附加处理程序。 addremove 访问器重写其 CLR 实现,并调用路由事件 AddHandlerRemoveHandler 方法。 路由事件的支持和连接机制在概念上与以下机制相似:依赖属性是一个 CLR 属性,该属性由 DependencyProperty 类提供支持并向 WPF 属性系统注册。

以下示例注册 Tap 路由事件,存储返回的 RoutedEvent 实例,并实现 CLR 事件包装器。

// Register a custom routed event using the Bubble routing strategy.
public static readonly RoutedEvent TapEvent = EventManager.RegisterRoutedEvent(
    name: "Tap",
    routingStrategy: RoutingStrategy.Bubble,
    handlerType: typeof(RoutedEventHandler),
    ownerType: typeof(CustomButton));

// Provide CLR accessors for adding and removing an event handler.
public event RoutedEventHandler Tap
{
    add { AddHandler(TapEvent, value); }
    remove { RemoveHandler(TapEvent, value); }
}
' Register a custom routed event using the Bubble routing strategy.
Public Shared ReadOnly TapEvent As RoutedEvent = EventManager.RegisterRoutedEvent(
    name:="Tap",
    routingStrategy:=RoutingStrategy.Bubble,
    handlerType:=GetType(RoutedEventHandler),
    ownerType:=GetType(CustomButton))

' Provide CLR accessors for adding and removing an event handler.
Public Custom Event Tap As RoutedEventHandler
    AddHandler(value As RoutedEventHandler)
        [AddHandler](TapEvent, value)
    End AddHandler

    RemoveHandler(value As RoutedEventHandler)
        [RemoveHandler](TapEvent, value)
    End RemoveHandler

    RaiseEvent(sender As Object, e As RoutedEventArgs)
        [RaiseEvent](e)
    End RaiseEvent
End Event

路由策略

路由事件使用以下三种路由策略之一:

  • 浮升:最初,调用事件源上的事件处理程序。 路由事件随后会路由到后续的父级元素,依次调用其事件处理程序,直到到达元素树的根。 大多数路由事件都使用浮升路由策略。 浮升路由事件通常用于报告来自复合控件或其他 UI 元素的输入或状态变化。

  • 隧道:最初将调用元素树的根处的事件处理程序。 路由事件随后会路由到后续的子级元素,依次调用其事件处理程序,直到到达事件源。 遵循隧道路由的事件也称为预览事件。 WPF 输入事件通常是作为预览和浮升对实现的。

  • 直接:仅调用事件源上的事件处理程序。 这种非路由策略类似于 Windows 窗体 UI 框架事件,它是标准 CLR 事件。 与 CLR 事件不同,直接路由事件支持类处理,可由 EventSettersEventTriggers 使用。

为什么使用路由事件?

作为应用程序开发人员,你不需要始终了解或关注要处理的事件是否作为路由事件实现。 路由事件具有特殊的行为,但是,如果在引发该行为的元素上处理事件,则该行为通常会不可见。 但是,如果要将事件处理程序附加到父元素以处理子元素引发的事件(例如在复合控件中),路由事件就是有意义的。

路由事件侦听器不需要让它们处理的路由事件成为其类的成员。 任何 UIElementContentElement 可以是任一路由事件的事件侦听器。 由于可视元素派生自 UIElementContentElement,你可以将路由事件作为一个概念性的“接口”,支持在应用程序中的不同元素之间交换事件信息。 路由事件的这个“接口”概念特别适用于输入事件

路由事件支持在事件路由路线上的元素之间交换事件信息,因为每个侦听器都可以访问事件数据的同一实例。 如果事件数据中的某个元素更改了某些内容,则该更改对事件路由中的后续元素可见。

除了路由方面的原因,还有以下两个原因让你可能会选择实现路由事件而不是标准 CLR 事件:

  • 一些 WPF 样式和模板功能(如 EventSettersEventTriggers)要求被引用的事件是路由事件。

  • 路由事件支持类事件处理程序,在侦听器类的任何实例上处理同一事件的任何实例处理程序之前处理事件。 此功能在控件设计中非常有用,因为类处理程序可以强制执行事件驱动的类行为,这些行为不能被实例处理程序意外地禁止。

附加并实现路由事件处理程序

在 XAML 中,通过将事件名称声明为事件侦听器元素上的属性,将事件处理程序附加到元素。 属性值是处理程序方法名称。 处理程序方法必须在 XAML 页的代码隐藏分部类中实现。 事件侦听器是附加和调用了事件处理程序的元素。

对于(通过继承或其他方式)成为侦听器类的成员的事件,可以按如下所示附加处理程序:

<Button Name="Button1" Click="Button_Click">Click me</Button>

如果事件不是侦听器类的成员,则必须以 <owner type>.<event name> 的形式使用限定的事件名称。 例如,由于 StackPanel 类不实现 Click 事件,若要将处理程序附加到浮升到该元素的 Click 事件的 StackPanel,需要使用限定的事件名称语法:

<StackPanel Name="StackPanel1" Button.Click="Button_Click">
    <Button>Click me</Button>
</StackPanel>

代码隐藏中事件处理程序方法的签名必须与路由事件的委托类型匹配。 Click 事件的 RoutedEventHandler 委托的 sender 参数指定事件处理程序附加到的元素。 RoutedEventHandler 委托的 args 参数包含事件数据。 Button_Click 事件处理程序的兼容代码隐藏实现可能是:

private void Button_Click(object sender, RoutedEventArgs e)
{
    // Click event logic.
}
Private Sub Button_Click(sender As Object, e As RoutedEventArgs)
    ' Click event logic.
End Sub

尽管 RoutedEventHandler 是基本的路由事件处理程序委托,但某些控件或实现方案需要不同的委托来支持更专用的事件数据。 例如,对于 DragEnter 路由事件,处理程序应实现 DragEventHandler 委托。 通过执行此操作,处理程序代码可以访问事件数据中的 DragEventArgs.Data 属性,其中包含来自拖动操作的剪贴板有效负载。

用于添加路由事件处理程序的 XAML 语法与标准 CLR 事件处理程序的语法相同。 有关在 XAML 中添加事件处理程序的详细信息,请参阅 WPF 中的 XAML。 有关如何使用 XAML 向元素中添加事件处理程序的完整示例,请参阅如何处理路由事件

若要使用代码将路由事件的事件处理程序附加到元素,通常有两个选择:

  • 直接调用 AddHandler 方法。 始终可以通过这种方式附加路由事件处理程序。 下面的示例使用 AddHandler 方法将 Click 事件处理程序附加到按钮:

    Button1.AddHandler(ButtonBase.ClickEvent, new RoutedEventHandler(Button_Click));
    
    Button1.[AddHandler](ButtonBase.ClickEvent, New RoutedEventHandler(AddressOf Button_Click))
    

    将按钮的 Click 事件的处理程序附加到事件路由中的不同元素,例如名为 StackPanel1StackPanel

    StackPanel1.AddHandler(ButtonBase.ClickEvent, new RoutedEventHandler(Button_Click));
    
    StackPanel1.[AddHandler](ButtonBase.ClickEvent, New RoutedEventHandler(AddressOf Button_Click))
    
  • 如果路由事件实现 CLR 事件包装器,请使用特定于语言的事件语法添加事件处理程序,就像对标准 CLR 事件的操作一样。 大多数现有的 WPF 路由事件实现 CLR 包装器,从而实现了特定于语言的事件语法。 此示例使用特定于语言的语法将 Click 事件处理程序附加到按钮:

    Button1.Click += Button_Click;
    
    AddHandler Button1.Click, AddressOf Button_Click
    

有关如何在代码中附加事件处理程序的示例,请参阅如何使用代码添加事件处理程序。 如果是使用 Visual Basic 编码,则还可以使用 Handles 关键字,将处理程序添加到处理程序声明中。 有关详细信息,请参阅 Visual Basic 和 WPF 事件处理

“已处理”概念

所有路由事件都有一个共同的事件数据基类,即 RoutedEventArgs 类。 RoutedEventArgs 类定义布尔 Handled 属性。 Handled 属性的目的在于,允许事件路由线路中的任何事件处理程序将路由事件标记为“已处理”。 若要将事件标记为已处理,请在事件处理程序代码中将 Handled 的值设置为 true

Handled 的值会影响路由事件在沿事件路由传播过程中的处理方式。 如果在路由事件的共享事件数据中 Handledtrue,则通常不会为该特定事件实例调用附加到事件路由中其他元素的处理程序。 在最常见的处理程序方案中,将事件标记为已处理,会有效地阻止事件路由中的后续处理程序(无论是实例处理程序还是类处理程序)响应该特定事件实例。 但是,在极少数情况下,需要事件处理程序来响应已标记为已处理的路由事件,可以执行以下操作:

Handled 的概念可能会影响你对应用程序和的设计和对事件处理程序的编码。 可以将 Handled 概念化为用于处理路由事件的一个简单协议。 你如何使用此协议由你决定,但 Handled 参数的预期用法是:

  • 如果路由事件标记为“已处理”,则它不必由该路由中的其他元素再次处理。

  • 如果路由事件未标记为已处理,事件路由中较早的侦听器就没有该事件的处理程序,或者没有已注册的处理程序对该事件的响应可以证明将该事件标记为已处理。 当前侦听器上的处理程序有三个可能的操作过程:

    • 根本不执行任何操作。 该事件保持未处理状态,并路由到树中的下一个侦听器。

    • 运行代码以响应事件,但不能达到可证明将事件标记为已处理的程度。 该事件保持未处理状态,并路由到树中的下一个侦听器。

    • 运行代码以响应事件,并且达到可证明将事件标记为已处理的程度。 在事件数据中将事件标记为已处理。 事件仍路由到树中的下一个侦听器,但大多数侦听器将不会调用更多处理程序。 例外情况是具有专门注册的处理程序的侦听器,它们的 handledEventsToo 设置为 true

若要详细了解如何处理路由事件,请参阅将路由事件标记为“已处理”和“类处理”

尽管只针对引发浮升路由事件的对象来处理该事件的开发人员可能不关心其他侦听器,但最好还是将事件标记为已处理。 这样做是为了防止事件路由路线上更远的元素在具有相同路由事件的处理程序时产生意想不到的副作用。

类处理程序

路由事件处理程序可以是实例处理程序或类处理程序。 给定类的类处理程序会在任何实例处理程序对该类的任何实例响应相同事件之前进行调用。 由于此行为,当路由事件标记为已处理时,它们通常会在类处理程序中标记为这样。 有两种类型的类处理程序:

有些 WPF 控件对某些路由事件具有固有的类处理。 类处理可能看起来从未引发过路由事件,但实际上它被标记为由类处理程序处理。 如果需要事件处理程序来响应已处理的事件,可以注册处理程序,并将 handledEventsToo 设置为 true。 若要详细了解如何实现自己的类处理程序或解决不需要的类处理,请参阅将路由事件标记为“已处理”和“类处理”

WPF 中的附加事件

XAML 语言还定义了一个名为附加事件的特殊类型的事件。 附加事件可用于在非元素类中定义新的路由事件,并在树中的任何元素上引发该事件。 为此,必须将附加事件注册为路由事件,并提供支持附加事件功能的特定支持代码。 由于附加事件注册为路由事件,因此在元素上引发时,它们会通过元素树传播。

在 XAML 语法中,附加事件由其事件名称及其所有者类型指定,格式为 <owner type>.<event name>。 因为事件名称是使用具有其所有者类型的名称限定的,所以语法允许将该事件附加到可以实例化的任何元素。 此语法也适用于附加到沿事件路由的任意元素的常规路由事件的处理程序。 还可以通过在处理程序应附加到的对象上调用 AddHandler 方法,在代码隐藏中为附加事件附加处理程序。

WPF 输入系统广泛使用附加事件。 但是,几乎所有附加事件都通过基本元素显示为等效的非附加路由事件。 你很少会直接使用或处理附加事件。 例如,与在 XAML 或代码隐藏中使用附加事件语法相比,通过等效 UIElement.MouseDown 路由事件处理 UIElement 上的基础附加 Mouse.MouseDown 事件更为容易。

有关 WPF 中附加事件的详细信息,请参阅附加事件概述

XAML 中的限定事件名称

<owner type>.<event name> 语法使用其所有者类型的名称限定事件名称。 此语法允许将事件附加到任何元素,而不仅仅是将事件作为其类的成员实现的元素。 在 XAML 中为附加事件或沿事件路由的任意元素上的路由事件附加处理程序时,该语法适用。 考虑一下这样的场景:你想要将处理程序附加到父元素,以便处理子元素上引发的路由事件。 如果父元素没有作为成员的路由事件,你将需要使用限定的事件名称语法。 例如:

<StackPanel Name="StackPanel1" Button.Click="Button_Click">
    <Button>Click me</Button>
</StackPanel>

在此示例中,向其添加事件处理程序的父元素侦听器是 StackPanel。 但是,Click 路由事件是在 ButtonBase 类上实现和引发的,并通过继承提供给 Button 类。 尽管 Button 类“拥有”Click 事件,但是路由事件系统允许将任何路由事件的处理程序附加到任何 UIElementContentElement 实例侦听器,否则就会有 CLR 事件的处理程序。 对于这些限定的事件属性名称来说,默认的 xmlns 命名空间通常是默认的 WPF xmlns 命名空间,但是还可以为自定义路由事件指定带有前缀的命名空间。 有关 xmlns 的详细信息,请参阅 WPF XAML 的 XAML 命名空间和命名空间映射

WPF 输入事件

路由事件在 WPF 平台中的常见应用之一是用于输入事件。 按照约定,遵循隧道路由的 WPF 路由事件的名称以“Preview”为前缀。 “Preview”前缀表示预览事件在配对浮升事件开始之前完成。 输入事件通常成对出现,一个是预览事件,另一个是浮升路由事件。 例如,PreviewKeyDownKeyDown。 事件对共享相同的事件数据实例,对于 PreviewKeyDownKeyDown,类型为 KeyEventArgs。 有时,输入事件只有浮升版本,或者只有直接路由版本。 在 API 文档中,路由事件主题交叉引用路由事件对,并阐明每个路由事件的路由策略。

实现成对出现的 WPF 输入事件,使来自输入设备的单个用户操作(如按鼠标按钮)按顺序引发预览和浮升路由事件。 首先引发预览事件并完成其路由。 预览事件完成后,将引发浮升事件并完成其路由。 引发浮升事件的实现类中的 RaiseEvent 方法调用将重复使用来自浮升事件的预览事件中的事件数据。

标记为已处理的预览输入事件不会为预览路由的其余部分调用任何正常注册的事件处理程序,并且不会引发配对的浮升事件。 对于希望在其控件的顶层报告基于命中测试的输入事件或基于焦点的输入事件的复合控件设计人员而言,此处理行为非常有用。 控件的顶级元素有机会对控件子组件中的预览事件进行类处理,以便用特定于控件的顶层事件“替换”它们。

为了说明输入事件处理的工作方式,请思考下面的输入事件示例。 在下面的树插图中,leaf element #2PreviewMouseDownMouseDown 配对事件的源:

Event routing diagram.

对叶元素 #2 执行鼠标按下操作后的事件处理顺序如下:

  1. 根元素上的 PreviewMouseDown 隧道事件。
  2. 中间元素 #1 上的 PreviewMouseDown 隧道事件。
  3. 叶元素 #2(源元素)上的 PreviewMouseDown 隧道事件。
  4. 叶元素 #2(源元素)上的 MouseDown 浮升事件。
  5. 中间元素 #1 上的 MouseDown 浮升事件。
  6. 根元素上的 MouseDown 浮升事件。

路由事件处理程序委托提供对以下两个对象的引用:引发该事件的对象以及在其中调用处理程序的对象。 最初引发了事件的对象由事件数据中的 Source 属性报告。 在其中调用处理程序的对象是由 sender 参数报告的对象。 对于任何给定的路由事件实例,引发事件的对象在事件通过元素树时不会更改,但 sender 会更改。 在上图的步骤 3 和 4 中,Sourcesender 是同一对象。

如果输入事件处理程序完成了处理事件所需的特定于应用程序的逻辑,应将输入事件标记为已处理。 通常,一旦输入事件被标记为 Handled,就不会调用事件路由路线上的后续处理程序。 但是,即使事件标记为已处理,也会调用已注册且 handledEventsToo 参数设置为 true 的输入事件处理程序。 有关详细信息,请参阅预览事件将路由事件标记为“已处理”和“类处理”

预览和浮升事件对的概念(共享事件数据以及依次引发预览事件和浮升事件)仅适用于某些 WPF 输入事件,并不能适用于所有路由事件。 如果你要实现自己的输入事件来解决高级方案,请考虑遵循 WPF 输入事件对方法。

如果你要实现自己的可以响应输入事件的复合控件,请考虑使用预览事件来抑制子组件上引发的输入事件,并将其替换为表示完整控件的顶层事件。 有关详细信息,请参阅将路由事件标记为“已处理”和类处理

有关 WPF 输入系统以及在典型的应用程序方案中输入和事件如何交互的详细信息,请参阅输入概述

EventSetter 和 EventTrigger

在标记样式中,可以使用 EventSetter 添加预声明的 XAML 事件处理语法。 在处理 XAML 时,所引用的处理程序会添加到带样式的实例中。 只能针对路由事件声明 EventSetter。 在下面的示例中,引用的 ApplyButtonStyle 事件处理程序方法在代码隐藏中实现。

<StackPanel>
    <StackPanel.Resources>
        <Style TargetType="{x:Type Button}">
            <EventSetter Event="Click" Handler="ApplyButtonStyle"/>
        </Style>
    </StackPanel.Resources>
    <Button>Click me</Button>
    <Button Click="Button_Click">Click me</Button>
</StackPanel>

Style 节点可能已包含与指定类型的控件相关的其他样式信息,并且使 EventSetter 成为这些样式的一部分可以提高代码的重用率,即使在标记级别也是如此。 此外,与常用的应用程序和页面标记相比,EventSetter 还提取处理程序的方法名称。

另一个将 WPF 的路由事件和动画功能结合在一起的专用语法是 EventTrigger。 与 EventSetter 一样,只能针对路由事件声明 EventTrigger。 通常将 EventTrigger 声明为样式的一部分,但是可以在页面级元素上将 EventTrigger 声明为 Triggers 集合的一部分,或者在 ControlTemplate 中对其进行声明。 使用 EventTrigger,可以指定当路由事件到达其路由中的某个元素(这个元素针对该事件声明了 EventTrigger)时将运行的 Storyboard。 与只是处理事件并且使其启动现有情节提要相比,EventTrigger 的优势在于,EventTrigger 可对情节提要及其运行时行为提供更好的控制。 有关详细信息,请参阅在情节提要启动之后使用事件触发器来控制情节提要

有关路由事件的更多信息

在你自己的类中创建自定义路由事件时,可以从本文中的概念和指南开始入门。 还可以使用专用的事件数据类和委托来支持自定义事件。 路由事件的所有者可以是任何类,但是路由事件只有由 UIElementContentElement 派生类引发和处理才有用。 有关自定义事件的详细信息,请参阅创建自定义路由事件

另请参阅