WPF 学习笔记(三):路由事件——事件的三种传播策略

写在前面

版本说明:基于 .NET 8 LTS。

承接上一篇依赖属性。WPF 的另一个地基——路由事件(RoutedEvent)——和 DP 一脉相承。事实上,路由事件注册机制几乎是 DP 的翻版:EventManager.RegisterRoutedEvent 对应 DependencyProperty.Register,全局表 + 元数据的思路完全一致。

但路由事件在"行为"上和普通 .NET 事件差异巨大:它能在视觉树里向上或向下传播。这是 WPF 区别于 WinForms 的关键。

本文要回答:

为什么按钮内 Label 的 Click 事件,能被外层 Window 监听到?事件到底是怎么"路由"的?


一、CLR 事件的局限

1.1 普通 .NET 事件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public class Button
{
    public event EventHandler Click;

    protected virtual void OnClick()
        => Click?.Invoke(this, EventArgs.Empty);
}

// 订阅
button.Click += (s, e) => MessageBox.Show("Clicked");
1
2
3
4
5
6
CLR 事件的本质:
  - 一个委托链(多播委托)
  - 触发时依次调用所有订阅者
  - 严格 1:N 订阅模式

问题:WPF 的 UI 是树形结构,事件需要"沿树传播"

1.2 WPF 的痛点

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
场景:复杂 Button
  Button 内部有 Image + TextBlock(模板展开后)

  <Button Click="Handler">
    <StackPanel>
      <Image Source="ok.png"/>
      <TextBlock Text="OK"/>
    </StackPanel>
  </Button>

用户点击 Image 区域:
  WinForms 模型:只有 Image 收到 Click(要 Image 自己暴露事件)
  WPF 模型:Button 收到 Click(视觉树自动路由)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
CLR 事件做不到:
  1. Image 点击 → Image 内部触发 → 怎么"通知"外层 Button?
     - 在 Image 上挂 MouseLeftButtonDown,转发给 Button?崩溃
     - 在 Button 模板内每个元素挂事件?维护噩梦

  2. 同样的逻辑要在多个外层处理(Button、StackPanel、Window)?
     - 每个 handler 都要重新订阅?

WPF 的答案:路由事件
  事件不在"被点击的元素"上"原地触发"
  而是沿视觉树传播,每个节点都有机会处理

二、三种路由策略

2.1 总览

1
2
3
4
5
策略           方向            命名约定           触发顺序
─────────────────────────────────────────────────────────────
Tunneling      从根到目标      PreviewXxx         先发生
Bubbling       从目标到根      Xxx                后发生
Direct         仅目标          Xxx                不传播

2.2 Bubbling(冒泡)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
事件从被点击的元素开始,沿视觉树向上冒泡到根

  Window
    └─ Grid
        └─ Button         ← 用户点击这里
            └─ Border
                └─ TextBlock ← 实际命中(鼠标精确点在文字上)

冒泡顺序:
  TextBlock → Border → Button → Grid → Window

每层都能:
  - 处理事件(订阅 handler)
  - 检查 e.Source(事件源)
  - 设置 e.Handled = true 终止进一步冒泡
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
适用:
  - 大部分输入事件:Click、MouseDown、KeyDown
  - 大部分业务场景都能用 Bubbling

典型应用:
  外层容器统一处理子元素的点击
  <StackPanel Button.Click="Panel_Click">
    <Button/> <Button/> <Button/>
  </StackPanel>
  三个按钮的点击都汇聚到 StackPanel 的 handler

2.3 Tunneling(隧道)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
事件从根开始,沿视觉树向下"挖"到目标元素

  Window             ← PreviewMouseDown 先触发
    └─ Grid
        └─ Button
            └─ Border
                └─ TextBlock  ← PreviewMouseDown 最后到这

隧道顺序:
  Window → Grid → Button → Border → TextBlock

然后 Bubbling 顺序相反:
  TextBlock → Border → Button → Grid → Window
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
命名约定:
  Preview + Bubbling 名字
  PreviewMouseDown (Tunneling)
  MouseDown (Bubbling)

适用:
  - 拦截/取消事件("我不要让子元素收到 Click")
  - 全局快捷键(窗口级 PreviewKeyDown 拦截所有键盘事件)

典型应用:
  <Window PreviewKeyDown="Window_PreviewKeyDown">
    <TextBox/>   <!-- 想禁用某些键 -->
  </Window>

  在 Window_PreviewKeyDown 里 e.Handled = true
  → TextBox 收不到 KeyDown(因为 Preview 终止了隧道)

2.4 Direct(直接)

1
2
3
4
5
6
7
事件不传播,只在目标元素触发

适用:
  - 不需要传播的事件(如 MouseEnter、MouseLeave)
  - 概念上就是"元素本身的事件"(如 TextChanged)

Direct 事件和 CLR 事件几乎一样,只是用了路由事件的基础设施

2.5 完整对照

策略方向触发顺序终止机制
Tunneling根→目标e.Handled=true
Bubbling目标→根e.Handled=true
Direct不传播仅目标N/A

三、注册路由事件

3.1 注册代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class MyButton : Button
{
    // 1. 注册
    public static readonly RoutedEvent TapEvent = EventManager.RegisterRoutedEvent(
        name: "Tap",                                  // 名称
        routingStrategy: RoutingStrategy.Bubbling,    // 策略
        handlerType: typeof(RoutedEventHandler),      // handler 类型
        ownerType: typeof(MyButton));                 // 所属类型

    // 2. CLR 事件包装器
    public event RoutedEventHandler Tap
    {
        add => AddHandler(TapEvent, value);
        remove => RemoveHandler(TapEvent, value);
    }

    // 3. 触发
    protected virtual void OnTap()
    {
        RoutedEventArgs args = new RoutedEventArgs(TapEvent);
        RaiseEvent(args);   // ← 注意:用 RaiseEvent 不是 Invoke
    }
}

3.2 注册内部

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
EventManager.RegisterRoutedEvent 内部:
  1. 创建 RoutedEvent 实例(不可变)
  2. 注册到全局表 RoutedEventList(key=OwnerType+Name)
  3. 返回 RoutedEvent 实例

RoutedEvent 的"身份":
  - Name: "Tap"
  - RoutingStrategy: Bubbling
  - HandlerType: typeof(RoutedEventHandler)
  - GlobalIndex: int(全局唯一)

  和 DP 一模一样的模式:static field + 全局表 + GlobalIndex

3.3 CLR 包装器的作用

1
2
3
4
5
public event RoutedEventHandler Tap
{
    add => AddHandler(TapEvent, value);
    remove => RemoveHandler(TapEvent, value);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
注意两点:
  1. add/remove 用 AddHandler/RemoveHandler,不是 +=
  2. AddHandler 接受 RoutedEvent,不是普通委托

  CLR 事件只是给 XAML 提供语法糖:
  <local:MyButton Tap="Handler"/>  ← XAML 解析时调 add

  也可以不通过 CLR 包装直接订阅:
  myButton.AddHandler(MyButton.TapEvent, (RoutedEventHandler)Handler);
  → 等价

四、e.Handled 的真相

这是路由事件最容易踩的坑。

4.1 表面行为

1
2
3
4
private void Button_Click(object sender, RoutedEventArgs e)
{
    e.Handled = true;  // ← 设置后,事件不再向上冒泡
}

4.2 真相:Handled 不阻止已挂接的 handler

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
"e.Handled = true" 的实际语义:
  ✅ 终止事件的进一步路由(不再向上/向下传播)
  ❌ 不阻止"已经在当前节点挂接的 handler"

举例:
  Button 上挂了两个 Click handler:
  button.Click += Handler1;
  button.Click += Handler2;

  Handler1 设置 e.Handled = true
  → Handler2 仍然被调用!(同节点所有 handler 都调用)

  但事件不会再向 Button 的父级冒泡

4.3 handledEventsToo:强制监听

1
2
3
4
5
6
// 普通订阅:Handled=true 的不调用
button.Click += Handler;

// 强制订阅:Handled=true 也调用
button.AddHandler(Button.ClickEvent, new RoutedEventHandler(Handler),
                  handledEventsToo: true);
1
2
3
4
5
6
7
应用场景:
  - 日志/审计:想知道所有点击事件(不管有没有被处理)
  - 全局监控:调试事件流
  - 不破坏业务逻辑的"旁观者"

  注意:handledEventsToo=true 的 handler 永远会调用
  → 即使别人 e.Handled=true 也阻止不了你

4.4 XAML 中无法直接 handledEventsToo

1
2
3
4
5
6
7
<!-- ❌ XAML 不支持 handledEventsToo 语法 -->
<Button Click="Handler" HandledEventsToo="true"/>   <!-- 不存在 -->

<!-- ✅ 必须在代码后置中订阅 -->
<Button x:Name="myButton"/>
<!-- 代码 -->
myButton.AddHandler(Button.ClickEvent, handler, handledEventsToo: true);

五、Source 与 OriginalSource

RoutedEventArgs 有两个"源"。

5.1 两个源

1
2
3
4
5
6
7
8
public class RoutedEventArgs : EventArgs
{
    public object Source { get; set; }          // 逻辑树源
    public object OriginalSource { get; }       // 视觉树源(最深的命中)
    public RoutedEvent RoutedEvent { get; }     // 事件标识
    public bool Handled { get; set; }           // 处理状态
    public RoutingStrategy RoutingStrategy { get; }
}

5.2 区别

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
场景:Button 模板里有 Image
  <Button Click="Handler">
    <Image Source="ok.png"/>
  </Button>

用户点击 Image:
  OriginalSource = Image(视觉树最深的命中元素)
  Source = Button(逻辑树源,通常更"业务友好")

WPF 自动维护:
  OriginalSource 永远是视觉树命中元素
  Source 默认 = OriginalSource,但有些事件会"提升"到逻辑父级

5.3 实战

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
private void Clickable_Click(object sender, RoutedEventArgs e)
{
    // sender = 挂接事件的元素(如 Button)
    // e.Source = "逻辑上"被点击的(通常是 Button 或逻辑子项)
    // e.OriginalSource = 视觉树最深命中(可能是个 Border 或 TextBlock)

    Console.WriteLine($"Sender: {sender}");
    Console.WriteLine($"Source: {e.Source}");
    Console.WriteLine($"OriginalSource: {e.OriginalSource}");
}

六、命中测试与视觉树传播

路由事件的传播路径,依赖于"命中测试"——哪个元素被点中了。

6.1 命中测试

1
2
3
4
5
鼠标点击屏幕坐标  WPF 沿视觉树找到最深命中元素

  Point pt = e.GetPosition(null);
  HitTestResult result = VisualTreeHelper.HitTest(rootVisual, pt);
  var hitElement = result.VisualHit;
1
2
3
4
5
6
7
8
WPF 命中测试规则:
  1. 从根视觉元素开始
  2. 检查每个子元素的区域
  3. 找最深命中的元素(叶节点优先)
  4. 命中元素 OriginalSource

  Transparent 元素也能命中(不像 CSS)
  IsHitTestVisible=false 的元素不参与命中

6.2 路由路径

1
2
3
4
5
6
7
8
9
OriginalSource = TextBlock(命中元素)

Tunneling 路径(PreviewMouseDown):
  Window → Grid → Button → Border → ContentPresenter → TextBlock
  (从根到最深命中的视觉父链)

Bubbling 路径(MouseDown):
  TextBlock → ContentPresenter → Border → Button → Grid → Window
  (从最深命中的视觉父链反向到根)

七、附加事件(Attached Event)

类似附加属性,事件也可以"附加"。

7.1 XAML 中的附加事件

1
2
3
4
5
6
7
8
9
<StackPanel Button.Click="StackPanel_Click">
    <Button Content="1"/>
    <Button Content="2"/>
    <Button Content="3"/>
</StackPanel>

<!-- StackPanel 本身没有 Click 事件 -->
<!-- 但 Bubbling 的 Click 会冒泡经过它 -->
<!-- 用 Button.Click="..." 显式订阅 -->

7.2 底层机制

1
2
// XAML 解析时
stackPanel.AddHandler(Button.ClickEvent, new RoutedEventHandler(StackPanel_Click));
1
2
3
4
5
注意:
  - Bubbling 路由事件才有"附加订阅"意义
  - 直接订阅(Button.Click="...")写在 Button 上等价
  - 写在外层 StackPanel 上利用冒泡
  - 一次订阅捕获所有子按钮的点击

7.3 实战:动态子元素的事件处理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<ItemsControl ItemsSource="{Binding Items}">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <StackPanel Button.Click="Item_Click"/>
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>

    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <Button Content="{Binding Name}" Tag="{Binding}"/>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>
1
2
3
4
5
6
7
8
9
private void Item_Click(object sender, RoutedEventArgs e)
{
    if (e.Source is Button btn && btn.Tag is ItemModel item)
    {
        // 业务处理
        SelectItem(item);
        e.Handled = true;  // 避免向上冒泡触发其他 handler
    }
}

八、Class Handlers vs Instance Handlers

路由事件有两类 handler。

8.1 Instance Handler

1
button.Click += MyHandler;  // 实例级订阅
1
2
3
4
特点:
  - 针对单个实例
  - 在路由路径上每个节点订阅一次
  - 默认 Handled=true 时不调用

8.2 Class Handler

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 在静态构造函数中注册
static MyButton()
{
    EventManager.RegisterClassHandler(
        typeof(MyButton),
        Button.ClickEvent,
        new RoutedEventHandler(ClassClickHandler));
}

private static void ClassClickHandler(object sender, RoutedEventArgs e)
{
    // 所有 MyButton 实例的 Click 都会先到这里
}
1
2
3
4
5
6
7
8
9
特点:
  - 类型级(所有 MyButton 实例共享一个 handler)
  - 在 Instance Handler 之前调用
  - 可选 handledEventsToo(即使已被处理也调用)

WPF 内部大量使用 Class Handler:
  - Button 的 Click 由 MouseLeftButtonDown 的 Class Handler 触发
  - TextBox 的 TextChanged 在内部多处用 Class Handler
  - 这是"控件内部行为"的标准实现方式

8.3 调用顺序

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
事件触发时执行顺序:

  1. Tunneling 阶段(Preview)
     - 各层的 Class Handler(Tunneling)
     - 各层的 Instance Handler(Tunneling)

  2. 目标元素
     - Class Handler
     - Instance Handler

  3. Bubbling 阶段
     - 各层的 Class Handler
     - 各层的 Instance Handler

九、内建路由事件速查

9.1 常用 Bubbling 事件

事件触发条件命名空间
Click鼠标点击 Button 类控件ButtonBase
MouseDown鼠标按下UIElement
MouseUp鼠标抬起UIElement
MouseMove鼠标移动UIElement
MouseWheel滚轮UIElement
KeyDown键盘按下UIElement
KeyUp键盘抬起UIElement
TextInput文本输入UIElement
SizeChanged尺寸改变FrameworkElement
Loaded加载完成FrameworkElement

9.2 对应的 Tunneling(Preview)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
PreviewMouseDown  → MouseDown
PreviewMouseUp    → MouseUp
PreviewMouseMove  → MouseMove
PreviewMouseWheel → MouseWheel
PreviewKeyDown    → KeyDown
PreviewKeyUp      → KeyUp
PreviewTextInput  → TextInput
PreviewDragEnter  → DragEnter
...

每个 Bubbling 输入事件几乎都有 Preview 版本
注意:Click 没有 PreviewClick!
  (Click 是 MouseLeftButtonDown 的"组合事件",由 ButtonBase 内部触发)

9.3 常用 Direct 事件

事件说明
MouseEnter鼠标进入元素
MouseLeave鼠标离开元素
MouseDirect直接设备事件
ToolTipOpening工具提示打开
ContextMenuOpening右键菜单打开

十、实战:全局快捷键 + 鼠标日志

把前面学的全用上——一个调试用的"全局事件监控"窗口。

10.1 需求

  • 按 Ctrl+Shift+D 切换调试面板
  • 监听所有键盘事件,显示按键日志
  • 监听所有鼠标点击,显示命中元素

10.2 XAML

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<Window x:Class="EventMonitor.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Event Monitor" Width="800" Height="600"
        PreviewKeyDown="Window_PreviewKeyDown"
        PreviewMouseDown="Window_PreviewMouseDown">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>

        <ScrollViewer Grid.Row="0" x:Name="logScroll">
            <StackPanel x:Name="logPanel"/>
        </ScrollViewer>

        <StatusBar Grid.Row="1">
            <TextBlock x:Name="statusText" Text="监控中"/>
        </StatusBar>
    </Grid>
</Window>

10.3 Code-behind

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public partial class MainWindow : Window
{
    private bool _monitoring = true;

    public MainWindow()
    {
        InitializeComponent();
    }

    // 全局快捷键:隧道事件先于子元素
    private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
    {
        // Ctrl+Shift+D 切换监控
        if (e.Key == Key.D &&
            (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control &&
            (Keyboard.Modifiers & ModifierKeys.Shift) == ModifierKeys.Shift)
        {
            _monitoring = !_monitoring;
            statusText.Text = _monitoring ? "监控中" : "已暂停";
            e.Handled = true;   // 阻止子元素收到
            return;
        }

        if (_monitoring)
        {
            LogEvent($"KEY: {e.Key} (Modifiers: {Keyboard.Modifiers})");
        }
    }

    private void Window_PreviewMouseDown(object sender, MouseButtonEventArgs e)
    {
        if (!_monitoring) return;

        var pos = e.GetPosition(this);
        LogEvent($"MOUSE: {e.ChangedButton} at ({pos.X:F0}, {pos.Y:F0})");

        // 找命中的视觉树路径
        var hit = VisualTreeHelper.HitTest(this, pos);
        if (hit?.VisualHit != null)
        {
            var path = new List<string>();
            var current = hit.VisualHit;
            while (current != null)
            {
                path.Add(current.GetType().Name);
                current = VisualTreeHelper.GetParent(current);
            }
            path.Reverse();
            LogEvent($"PATH: {string.Join("  ", path)}");
        }
    }

    private void LogEvent(string text)
    {
        var entry = new TextBlock
        {
            Text = $"[{DateTime.Now:HH:mm:ss.fff}] {text}",
            FontFamily = new FontFamily("Consolas"),
            Margin = new Thickness(2)
        };
        logPanel.Children.Add(entry);

        // 限制日志数量
        if (logPanel.Children.Count > 200)
            logPanel.Children.RemoveAt(0);

        logScroll.ScrollToEnd();
    }
}

10.4 知识点覆盖

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
✅ Tunneling(PreviewKeyDown/PreviewMouseDown)拦截事件
✅ e.Handled = true 阻止子元素
✅ 视觉树路径遍历(VisualTreeHelper)
✅ 命中测试(HitTest)
✅ Keyboard.Modifiers 判断组合键
✅ 调试用日志面板

观察:
  - Preview 事件在 Window 级订阅,能拦到所有子元素的事件
  - e.Handled=true 阻止后续路由(子元素的 KeyDown 收不到)
  - VisualTreeHelper.HitTest 给出精确命中

十一、与 ASP.NET Core 中间件对比

后端工程师最容易理解的方式。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
ASP.NET Core 中间件:
  pipeline ← 请求
    中间件 1(前)
      中间件 2
        ...
          终端处理器
        ...
      中间件 2
    中间件 1(后)
  pipeline → 响应

WPF 路由事件:
  Tunneling(前)
    外层 Preview
      内层 Preview
        目标元素
      内层
    外层
  Bubbling(后)

共同点:
  - 都是"链式处理"
  - 都有短路机制(context.Handled / e.Handled)
  - 都允许"中间层"介入处理

区别:
  - 中间件是"注册顺序",路由事件是"视觉树层级"
  - 中间件可以"修改请求继续传",路由事件不能改 e.Source
  - 中间件无 Preview/AFTER 区分,路由事件有 Tunneling/Bubbling

十二、常见陷阱

12.1 Preview 里不要做副作用

1
2
3
4
5
6
7
// ❌ 在 PreviewMouseDown 里改 UI
private void Window_PreviewMouseDown(...)
{
    someElement.Visibility = Visibility.Visible;  // 改了布局
    // 此时路由还在进行,布局变化可能引发其他事件
    // → 死循环或异常
}
1
2
3
正确做法:
  - Preview 里只读 e,决定是否 e.Handled=true
  - 改 UI 留给 Bubbling 阶段或 dispatcher 异步

12.2 sender 不是 e.Source

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// StackPanel 上挂 Button.Click
<StackPanel Button.Click="Panel_Click">
    <Button/> <Button/>
</StackPanel>

private void Panel_Click(object sender, RoutedEventArgs e)
{
    // sender = StackPanel(挂事件的元素)
    // e.Source = Button(实际被点击的)
    // 不是同一个!

    var clickedButton = (Button)e.Source;
}

12.3 自定义路由事件别用 Click 命名

1
2
3
4
5
6
// ❌ 容易和基类 Click 混淆
public event RoutedEventHandler Click { ... }

// ✅ 用差异化名字
public event RoutedEventHandler Tap { ... }
public event RoutedEventHandler LongPress { ... }

12.4 路由事件的内存泄漏

1
2
3
4
5
6
// ❌ 短生命周期对象订阅长生命周期事件
window.MainMenu.Button.Click += shortLived.Handler;
// shortLived 死不掉(被 window 持有)

// ✅ 用弱事件或显式取消订阅
window.MainMenu.Button.Click -= shortLived.Handler;

十三、小结

本文深入 WPF 的路由事件系统:

  • CLR 事件为什么不够用(不能沿树传播)
  • 三种路由策略(Tunneling / Bubbling / Direct)
  • RegisterRoutedEvent 与全局表
  • e.Handled 的真相(终止路由 ≠ 阻止已挂接的 handler)
  • Source vs OriginalSource 的语义差别
  • 命中测试决定事件源
  • 附加事件的语法本质
  • Class Handler 与 Instance Handler 的执行顺序
  • 实战:全局事件监控窗口
1
2
3
4
记住三句话:
  1. 路由事件是"沿视觉树传播的事件",不是普通委托
  2. e.Handled=true 只终止进一步路由,不阻止同节点其他 handler
  3. Preview 是隧道,能拦截子元素的事件——全局快捷键的标准武器

下一篇将进入 WPF 的布局系统——两阶段布局(Measure/Arrange)和内置 Panel 全家桶。

Licensed under CC BY-NC-SA 4.0