(八)深入浅出WPF——深入浅出话事件

198 阅读13分钟

就像 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 所示的模型作为简要说明:

6C029BEA-1DDE-4EEA-81F7-A99594F78FEA_4_5005_c.jpeg

在这种模型里,事件的响应者通过订阅关系直接关联在事件拥有者的事件上,为了与 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 自定义路由事件

自定义路由事件可以让对象之间的通信更加灵活。与传统事件需要逐层传递不同,路由事件能在对象树中自由传播。本节将详细介绍如何定义自己的路由事件。

创建自定义路由事件大体可以分为三个步骤:

  1. 声明并注册路由事件。
  2. 为路由事件添加 CLR 事件包装。
  3. 创建可以激发路由事件的方法。

让我们来创建一个用于报告事件时间的路由事件。首先,我们需要从 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元素树上路由并被侦听者捕获。