面向刚学完数据绑定,准备理解「程序怎样启动和退出」「事件怎样传递」「拖动和框选怎样实现」的读者。
推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,当前电脑可直接编译;这里用到的 API 在 .NET 9 与 .NET 10 中没有变化。
最后更新:2026-10-21

目录

  1. App生命周期与全局异常捕获
  2. 对象事件总结说明
  3. 鼠标输入事件
  4. 对象拖动案例
  5. 框选基本逻辑
  6. 路由事件的处理过程

写在前面

这份笔记解决什么问题

前面的笔记主要在处理「数据怎样显示到界面」。这一篇换一个方向,观察「程序怎样运行」以及「用户操作怎样进入程序」:

1
2
3
4
5
6
7
8
WPF 程序启动时,Application 和 Window 谁先谁后?
窗口关闭后,程序一定会立刻退出吗?
UI 线程、后台线程和 Task 中的异常,能不能只写一个处理器?
普通事件、路由事件、生命周期事件有什么区别?
鼠标按下时,sender 和 OriginalSource 为什么不是同一个对象?
拖动卡片时,为什么卡片会突然跳到鼠标下面?
从右下向左上框选时,为什么矩形宽度会变成负数?
PreviewMouseDown 和 MouseDown 的触发顺序为什么相反?

这篇笔记不要求你一次记住所有 API。更重要的是先建立三个观察角度:

  1. 时间顺序:先发生什么,后发生什么。
  2. 对象层级:事件从谁开始,经过谁,最后到哪里。
  3. 状态变化:按下、移动、抬起之间要保存哪些数据。

本篇六个知识点总览

图 1:先理解程序生命周期,再逐步进入对象事件、鼠标输入、拖动、框选和路由事件。

建议的学习顺序

  1. 先运行随附示例,依次点开六个页签,不要急着改代码。
  2. 第 1 章用日志观察启动和退出,再分别触发一次 UI 异常。
  3. 第 2、3 章只观察,不写复杂逻辑,先分清事件名称和触发时机。
  4. 第 4、5 章亲手拖动卡片、从四个方向框选,重点观察坐标。
  5. 第 6 章最后学,因为路由事件会把前面的事件知识串起来。
  6. 每完成一章,都在自己的练习项目里复现对应页签,再完成章末小练习。

示例项目位置

1
C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfLifecycleEventsDemo

在 PowerShell 中运行:

1
2
3
cd "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfLifecycleEventsDemo"
dotnet build
dotnet run

如果使用 Visual Studio:

  1. 打开 WpfLifecycleEventsDemo.csproj。
  2. 确认启动项目是 WpfLifecycleEventsDemo。
  3. 按 F5 启动。
  4. 打开“输出”窗口,将“显示输出来源”切到“调试”。
  5. 出现异常时,同时观察界面日志和“输出”窗口。

2026 年的目标框架

如果电脑安装了 .NET 10 SDK,新练习项目推荐:

1
<TargetFramework>net10.0-windows</TargetFramework>

如果只安装了 .NET 9 SDK,就使用:

1
<TargetFramework>net9.0-windows</TargetFramework>

本篇示例已经在 .NET 9 上完成编译、交互和日志验证。把目标框架改为 .NET 10 后,示例代码不需要修改。

先建立自己的跟做项目

打开 PowerShell:

1
2
3
4
cd "C:\Users\North\Desktop\WPF学习笔记\示例代码"
dotnet new wpf -n WpfLifecycleEventsPractice -o .\WpfLifecycleEventsPractice
cd .\WpfLifecycleEventsPractice
dotnet run

看到默认窗口后,先关闭程序。接下来把 MainWindow.xaml 改成六个页签的骨架:

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
<Window x:Class="WpfLifecycleEventsPractice.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="WPF 生命周期与输入练习"
Width="1280"
Height="900"
WindowStartupLocation="CenterScreen"
Background="#F3F6FB"
FontFamily="Microsoft YaHei UI">
<TabControl Margin="20">
<TabItem Header="1 生命周期与异常">
<!-- 第 1 章 -->
</TabItem>
<TabItem Header="2 对象事件">
<!-- 第 2 章 -->
</TabItem>
<TabItem Header="3 鼠标输入">
<!-- 第 3 章 -->
</TabItem>
<TabItem Header="4 对象拖动">
<!-- 第 4 章 -->
</TabItem>
<TabItem Header="5 框选">
<!-- 第 5 章 -->
</TabItem>
<TabItem Header="6 路由事件">
<!-- 第 6 章 -->
</TabItem>
</TabControl>
</Window>

再按一次 F5,看到六个空页签,说明跟做环境正常。

先准备一个记录事件的小工具

生命周期和路由事件都发生在很短的时间里。只靠 MessageBox 会挡住程序,看不清顺序,所以先准备一个简单的日志类。在项目中新建 EventLogStore.cs:

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
using System.IO;
using System.Text;

namespace WpfLifecycleEventsPractice;

public static class EventLogStore
{
private static readonly object SyncRoot = new();

public static string FilePath { get; } =
Path.Combine(AppContext.BaseDirectory, "events.log");

public static void Reset()
{
File.WriteAllText(
FilePath,
"WPF 生命周期与输入实验日志" + Environment.NewLine,
new UTF8Encoding(false));
}

public static void Write(string stage, string detail)
{
var line = $"{DateTime.Now:HH:mm:ss.fff} | {stage} | {detail}";

lock (SyncRoot)
{
File.AppendAllText(
FilePath,
line + Environment.NewLine,
new UTF8Encoding(false));
}
}

public static string[] ReadAllLines()
{
return File.Exists(FilePath)
? File.ReadAllLines(FilePath, Encoding.UTF8)
: Array.Empty<string>();
}
}

这里把日志写到 bin\Debug\net9.0-windows\events.log。AppContext.BaseDirectory 是程序运行目录,不要把它误认为项目根目录。

示例文件分工

1
2
3
4
5
6
7
8
9
10
11
WpfLifecycleEventsDemo
├─ App.xaml 应用启动配置
├─ App.xaml.cs 生命周期与全局异常入口
├─ MainWindow.xaml 六个实验页签的界面
├─ MainWindow.xaml.cs 输入、拖动、框选和路由事件处理
├─ InteractionMath.cs 可单独测试的几何计算
├─ Models
│ ├─ AppOptions.cs 启动参数解析
│ └─ EventRecord.cs 日志记录模型
└─ ViewModels
└─ MainViewModel.cs 界面状态与日志集合

跟做时不必一次创建所有文件。先让每一章能单独运行,再把代码合并到同一个项目里。


1. App生命周期与全局异常捕获

本节目标

完成本章后,你应当能够:

  • 说清 Application.Startup、Window.Loaded、Window.ContentRendered、Window.Closing、Window.Closed、Application.Exit 的先后关系。
  • 区分「窗口关闭」和「应用退出」。
  • 知道 UI 线程异常、后台线程异常、未观察任务异常分别从哪里进入。
  • 写出一个不会让异常悄无声息消失的全局异常记录器。

1.1 先看完整时间线

窗口从创建到退出的事件顺序

图 2:本篇示例实际记录到的顺序。​​Loaded​​ 和 ​​ContentRendered​​ 都属于窗口显示阶段,但含义不同。

本篇示例使用:

1
2
3
4
5
<Application x:Class="WpfLifecycleEventsDemo.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
ShutdownMode="OnMainWindowClose">
</Application>

ShutdownMode="OnMainWindowClose" 的意思是:主窗口关闭后,应用进入退出流程。它不是「窗口一关就立即消失」,中间还会经过 Closing、Closed 和 Exit。

1.2 Startup:应用已经开始,窗口还没有显示

重写 App.OnStartup:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using System.Windows;

namespace WpfLifecycleEventsPractice;

public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);

EventLogStore.Reset();
EventLogStore.Write(
"Application.Startup",
$"参数数量={e.Args.Length}");

var window = new MainWindow();
MainWindow = window;
window.Show();
}
}

此时可以读取命令行参数,也可以决定第一个窗口是谁。不要在这里假设所有控件都已经完成布局,因为窗口才刚刚创建。

1.3 Loaded:元素树已经可用

在窗口构造函数中订阅:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public MainWindow()
{
InitializeComponent();

Loaded += OnWindowLoaded;
ContentRendered += OnWindowContentRendered;
Closing += OnWindowClosing;
Closed += OnWindowClosed;
}

private void OnWindowLoaded(object sender, RoutedEventArgs e)
{
EventLogStore.Write("Window.Loaded", "窗口元素已经加载");
}

Loaded 适合做:

  • 读取控件当前的 ActualWidth、ActualHeight。
  • 第一次加载数据。
  • 根据界面尺寸调整内部布局。

但 Loaded 不保证窗口已经完成第一次绘制。此时界面可能已经参与布局,却还没有真正显示到屏幕上。

1.4 ContentRendered:第一次渲染完成

1
2
3
4
5
6
private void OnWindowContentRendered(object? sender, EventArgs e)
{
EventLogStore.Write(
"Window.ContentRendered",
"窗口第一次完成渲染");
}

ContentRendered 比 Loaded 更晚,通常适合:

  • 启动首次进入动画。
  • 在界面已经可见后显示引导层。
  • 需要等待第一次画面呈现再执行的操作。

不要把两者简单记成「Loaded 是加载,ContentRendered 是渲染」就结束。真正的判断方法是:你需要元素树可用,还是需要第一帧已经画出来?

1.5 Closing 与 Closed:一个能取消,一个不能取消

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using System.ComponentModel;

private void OnWindowClosing(object? sender, CancelEventArgs e)
{
EventLogStore.Write("Window.Closing", "准备关闭");

// 例如有未保存内容时:
// e.Cancel = true;
}

private void OnWindowClosed(object? sender, EventArgs e)
{
EventLogStore.Write("Window.Closed", "窗口已经关闭");
}

两者最重要的区别:

事件 时机 能否取消 常见用途
Closing 关闭动作正在进行 可以,设置 e.Cancel = true 询问是否保存、阻止误关闭
Closed 窗口已经关闭 不可以 释放资源、记录最终状态

如果用户在 Closing 中取消关闭,后续的 Closed 和 Application.Exit 都不应继续执行。

1.6 Exit:应用退出时收尾

1
2
3
4
5
6
7
8
protected override void OnExit(ExitEventArgs e)
{
EventLogStore.Write(
"Application.Exit",
$"退出码={e.ApplicationExitCode}");

base.OnExit(e);
}

Exit 是应用级事件,不等于某一个窗口的 Closed。如果应用有多个窗口,或者 ShutdownMode 不是 OnMainWindowClose,窗口关闭和应用退出之间可能没有你想象的同步关系。

1.7 三种全局异常入口

全局异常捕获的三个入口

图 3:三个入口覆盖不同来源,不能只写一个就以为捕获了全部异常。

在 App 构造函数中注册三个入口:

1
2
3
4
5
6
public App()
{
DispatcherUnhandledException += OnDispatcherUnhandledException;
AppDomain.CurrentDomain.UnhandledException += OnDomainUnhandledException;
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
}

入口一:DispatcherUnhandledException

1
2
3
4
5
6
7
8
9
10
11
12
using System.Windows.Threading;

private static void OnDispatcherUnhandledException(
object sender,
DispatcherUnhandledExceptionEventArgs e)
{
EventLogStore.Write(
"DispatcherUnhandledException",
$"{e.Exception.GetType().Name}: {e.Exception.Message}");

e.Handled = true;
}

它负责 UI 线程上的未处理异常,例如按钮点击、布局、渲染和 Dispatcher.BeginInvoke 回调中的异常。设置 e.Handled = true 可以让程序继续运行,但这不代表异常已经修复。

生产程序中至少应记录完整堆栈,并提示用户保存工作。对于无法安全恢复的异常,继续运行反而更危险。

入口二:AppDomain.UnhandledException

1
2
3
4
5
6
7
8
9
10
private static void OnDomainUnhandledException(
object? sender,
UnhandledExceptionEventArgs e)
{
var exception = e.ExceptionObject as Exception;

EventLogStore.Write(
"AppDomain.UnhandledException",
$"{exception?.GetType().Name ?? "未知异常"}: {exception?.Message}");
}

它能看到非 UI 线程中最终没有被处理的异常。这个入口主要用于记录和诊断,通常不能像 DispatcherUnhandledException 那样简单地让进程继续运行。

入口三:TaskScheduler.UnobservedTaskException

1
2
3
4
5
6
7
8
9
10
private static void OnUnobservedTaskException(
object? sender,
UnobservedTaskExceptionEventArgs e)
{
EventLogStore.Write(
"TaskScheduler.UnobservedTaskException",
$"{e.Exception.GetType().Name}: {e.Exception.Message}");

e.SetObserved();
}

它处理「任务已经失败,但异常一直没有被 await、Wait() 或其他方式观察」的情况。触发时间可能比任务真正失败晚,不能把它当成实时错误通知。

1.8 跟做:做一个异常实验按钮

在 MainWindow.xaml 第 1 个页签中放一个按钮和日志列表:

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
<Grid Margin="24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>

<StackPanel>
<TextBlock Text="生命周期与异常日志"
FontSize="22"
FontWeight="Bold" />
<WrapPanel Margin="0,14,0,0">
<Button Content="刷新日志"
Padding="14,8"
Click="OnRefreshLogClick" />
<Button Content="触发 UI 异常"
Margin="10,0,0,0"
Padding="14,8"
Click="OnThrowUiExceptionClick" />
<Button Content="关闭窗口"
Margin="10,0,0,0"
Padding="14,8"
Click="OnCloseWindowClick" />
</WrapPanel>
</StackPanel>

<ListBox x:Name="LifecycleLogList"
Grid.Row="1"
Margin="0,14,0,0"
FontFamily="Consolas"
FontSize="13"
Background="#0F172A"
Foreground="#DCE7F8" />
</Grid>

后台代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void OnRefreshLogClick(object sender, RoutedEventArgs e)
{
LifecycleLogList.ItemsSource =
EventLogStore.ReadAllLines().Reverse().ToArray();
}

private void OnThrowUiExceptionClick(object sender, RoutedEventArgs e)
{
Dispatcher.BeginInvoke(new Action(() =>
{
throw new InvalidOperationException("这是 UI 线程演示异常。");
}));
}

private void OnCloseWindowClick(object sender, RoutedEventArgs e)
{
Close();
}

注意按钮点击处理器本身不要直接写 throw 后期待界面继续更新。使用 Dispatcher.BeginInvoke 可以更清楚地模拟「UI 消息循环中的一次未处理异常」。

1.9 跟做检查点

按顺序执行下面四步:

  1. 启动程序,点击“刷新日志”,应看到 Application.Startup、Window.Loaded、Window.ContentRendered。
  2. 点击“触发 UI 异常”,再刷新日志,应出现 DispatcherUnhandledException。
  3. 点击“关闭窗口”,日志应依次出现 Window.Closing、Window.Closed。
  4. 重新打开日志文件,最后应有 Application.Exit。

示例项目实际验证得到的顺序:

1
2
3
4
5
6
7
Application.Startup
Window.Loaded
Window.ContentRendered
DispatcherUnhandledException
Window.Closing
Window.Closed
Application.Exit

ContentRendered 与 DispatcherUnhandledException 的相对位置会受异常触发时机影响,不要把这两行当成永远固定的相邻关系。真正稳定的是:Startup 在窗口显示前,Loaded 早于 ContentRendered,Closing 早于 Closed,Exit 在应用退出阶段。

1.10 常见错误

现象 常见原因 修正方法
只看到 Loaded,看不到 ContentRendered 在 Loaded 中阻塞 UI 线程 不在 UI 线程中长时间等待
在 Closed 中设置 e.Cancel 编译失败 Closed 的事件参数不是 CancelEventArgs 在 Closing 中取消
后台异常没有被记录 只注册了 DispatcherUnhandledException 同时注册后台线程和任务入口
任务异常日志很晚才出现 异常没有被观察 主动 await 任务并记录
e.Handled = true 后界面状态错乱 把异常吞掉了,却没有恢复状态 记录、提示、停止危险操作

1.11 小练习

  1. 在 Closing 中弹出一个确认框,用户选择“否”时设置 e.Cancel = true,观察 Closed 是否还会出现。
  2. 在 OnStartup 中输出 e.Args,使用 dotnet run -- hello WPF 启动,确认参数能读到。
  3. 把 ShutdownMode 改成 OnExplicitShutdown,关闭主窗口,观察程序是否立即退出。测试结束后记得改回 OnMainWindowClose。

2. 对象事件总结说明

本节目标

  • 把 WPF 中常见事件分成应用生命周期事件、普通对象事件和路由事件三层。
  • 理解事件发布者和订阅者的关系。
  • 分清 Click、PreviewMouseDown、MouseDown 各自处于什么阶段。
  • 知道什么时候该用 sender,什么时候该看 e.OriginalSource。

2.1 三层事件模型

应用事件、普通事件和路由事件

图 4:同一段交互里,三层事件可能同时出现。先判断它属于哪一层,再判断传播方式。

第一层:应用生命周期事件

例如 Application.Startup、Application.Exit、Window.Loaded、Window.Closing。它们描述的是程序或窗口的生命周期,通常只发生一次,或者发生次数很少。

第二层:对象普通事件

例如属性变化通知 PropertyChanged、按钮的 Click、定时器的 Tick。普通事件由对象自己触发,通常没有沿元素树自动传播的过程。

第三层:路由事件

例如 PreviewMouseDown、MouseDown、KeyDown。路由事件会沿元素树按特定策略传播,可能先向下,再向上。

判断一个事件时先问三个问题:

1
2
3
谁触发?
谁订阅?
设置 Handled 后,还要不要继续传递?

2.2 事件处理器里的两个 sender

在 WPF 中经常会看到:

1
2
3
4
5
private void OnSomeEvent(object sender, RoutedEventArgs e)
{
var handlerOwner = sender;
var original = e.OriginalSource;
}

它们不是一回事:

名称 含义 典型用途
sender 当前挂载这个事件处理器的对象 知道是哪个控件注册了处理器
e.Source 路由事件开始时设置的逻辑来源 逻辑树上的来源
e.OriginalSource 最初命中的可视元素 判断鼠标真正点到了什么

例如窗口订阅了 MouseDown,用户点在窗口里的一个 TextBlock 上:

  • sender 可能是窗口。
  • e.OriginalSource 可能是那个 TextBlock。
  • e.Source 可能经过逻辑树调整,和 OriginalSource 不一定相同。

不要用 sender 去判断「用户点了哪个子元素」。这类判断通常应该看 e.OriginalSource。

2.3 Click 不是 MouseDown 的别名

按钮的 Click 表示一次完整的点击语义:鼠标在按钮上按下,又在同一个按钮上抬起,并且没有被取消。MouseDown 只表示按下的瞬间。

所以不能这样理解:

1
MouseDown 发生 → 一定会有 Click

如果按下后把鼠标拖出按钮再抬起,MouseDown 已经发生,但 Click 可能不会发生。

2.4 跟做:一个按钮观察三种事件

在第 2 个页签加入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<StackPanel Margin="24">
<TextBlock Text="对象事件观察"
FontSize="22"
FontWeight="Bold" />

<Button x:Name="EventProbeButton"
Margin="0,16,0,0"
Padding="18,10"
HorizontalAlignment="Left"
Content="点击我"
PreviewMouseDown="OnProbePreviewMouseDown"
Click="OnProbeClick" />

<TextBlock x:Name="EventResultText"
Margin="0,18,0,0"
FontSize="18"
Text="还没有操作" />
</StackPanel>

后台代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
private void OnProbePreviewMouseDown(
object sender,
MouseButtonEventArgs e)
{
EventResultText.Text =
$"PreviewMouseDown:sender={((FrameworkElement)sender).Name}";
}

private void OnProbeClick(object sender, RoutedEventArgs e)
{
EventResultText.Text =
"Click:按下和抬起都完成了";
}

2.5 跟做检查点

  1. 单击按钮,先看到 PreviewMouseDown,抬起后看到 Click。
  2. 在按钮上按下,拖到按钮外面再抬起,观察 Click 是否发生。
  3. 给按钮加一个 MouseDown="OnProbeMouseDown",再观察它和 PreviewMouseDown 的先后。

如果事件处理器没有触发,先检查 XAML 中的事件名是否和后台方法签名完全一致。WPF 的事件处理器通常需要 object sender 和对应的事件参数类型。

2.6 常见错误

  • 把 Click 当成鼠标按下事件,导致拖动场景中逻辑过早执行。
  • 用 sender 判断子元素,结果总是得到订阅事件的外层容器。
  • 事件处理器签名写成 EventArgs,但实际事件要求 MouseButtonEventArgs,导致编译失败。
  • 在 Loaded 里订阅同一个事件多次,刷新页面后处理器执行多次。

2.7 小练习

  1. 给按钮增加 MouseEnter 和 MouseLeave,把结果写入一个列表。
  2. 用 e.OriginalSource 判断点击是否来自按钮内部的 TextBlock。
  3. 把 PreviewMouseDown 改成 PreviewMouseLeftButtonDown,比较两者在右键点击时的差异。

3. 鼠标输入事件

本节目标

  • 理解鼠标事件从进入到离开的常见顺序。
  • 会用 e.GetPosition(元素) 取得相对于指定元素的坐标。
  • 能读取按键、滚轮增量和鼠标离开状态。
  • 知道 Preview 事件和普通鼠标事件的区别。

3.1 一次鼠标交互的常见顺序

鼠标输入事件链

图 5:一次左键点击通常经过进入、移动、预览按下、按下、预览抬起、抬起和 Click。具体事件会受控件和操作方式影响。

常见顺序可以这样记:

1
2
3
4
5
6
7
MouseEnter
→ PreviewMouseMove
→ PreviewMouseDown
→ MouseDown
→ PreviewMouseUp
→ MouseUp
→ Click(仅特定控件在完整点击后产生)

Preview 是隧道阶段,通常从外层向目标向下传递;不带 Preview 的鼠标事件是冒泡阶段,通常从目标向外层向上传递。第 6 章会把这条路径完整跑一遍。

3.2 e.GetPosition 的参照物

1
var point = e.GetPosition(MouseProbeArea);

这行代码得到的是相对于 ​​MouseProbeArea​​ 左上角的坐标,不是相对于整个窗口,也不是相对于屏幕。

同一个鼠标位置,调用不同的参照元素会得到不同结果:

1
2
var relativeToArea = e.GetPosition(MouseProbeArea);
var relativeToWindow = e.GetPosition(this);

拖动和框选时,所有参与计算的坐标必须使用同一个参照物。混用坐标是「对象跳动」「选框偏移」的常见原因。

3.3 跟做:鼠标观察区

在第 3 个页签加入一个带边框的区域:

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
<Grid Margin="24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="430" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>

<Border x:Name="MouseProbeArea"
Height="230"
Background="#EEF4FF"
BorderBrush="#B9CDEE"
BorderThickness="1"
CornerRadius="10"
PreviewMouseMove="OnMouseProbeMove"
PreviewMouseDown="OnMouseProbeDown"
PreviewMouseUp="OnMouseProbeUp"
PreviewMouseWheel="OnMouseProbeWheel"
MouseLeave="OnMouseProbeLeave">
<TextBlock Text="在这里移动、点击和滚动"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="#2456A6" />
</Border>

<StackPanel Grid.Column="1" Margin="24,0,0,0">
<TextBlock Text="当前坐标"
Foreground="#617089" />
<TextBlock x:Name="MousePositionText"
Margin="0,6,0,0"
FontSize="22"
FontWeight="Bold"
Text="尚未进入观察区" />

<TextBlock Margin="0,18,0,0"
Text="最近事件"
Foreground="#617089" />
<TextBlock x:Name="MouseEventText"
Margin="0,6,0,0"
FontSize="17"
Text="等待鼠标输入" />

<TextBlock Margin="0,18,0,0"
Text="滚轮增量"
Foreground="#617089" />
<TextBlock x:Name="MouseWheelText"
Margin="0,6,0,0"
FontSize="17"
Text="0" />
</StackPanel>
</Grid>

后台代码:

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
private void OnMouseProbeMove(object sender, MouseEventArgs e)
{
var point = e.GetPosition(MouseProbeArea);

MousePositionText.Text = $"X={point.X:N1} Y={point.Y:N1}";
MouseEventText.Text = "PreviewMouseMove";
}

private void OnMouseProbeDown(object sender, MouseButtonEventArgs e)
{
var point = e.GetPosition(MouseProbeArea);

MousePositionText.Text = $"X={point.X:N1} Y={point.Y:N1}";
MouseEventText.Text = $"PreviewMouseDown:{e.ChangedButton}";
}

private void OnMouseProbeUp(object sender, MouseButtonEventArgs e)
{
var point = e.GetPosition(MouseProbeArea);

MousePositionText.Text = $"X={point.X:N1} Y={point.Y:N1}";
MouseEventText.Text = $"PreviewMouseUp:{e.ChangedButton}";
}

private void OnMouseProbeWheel(object sender, MouseWheelEventArgs e)
{
MouseWheelText.Text = e.Delta.ToString();
MouseEventText.Text = "PreviewMouseWheel";
}

private void OnMouseProbeLeave(object sender, MouseEventArgs e)
{
MouseEventText.Text = "MouseLeave:指针已经离开观察区";
}

3.4 跟做检查点

  1. 鼠标移动到区域中,坐标应随位置变化,X 和 Y 不应出现负数(在区域内部时)。
  2. 在区域左上角附近移动,坐标应接近 0, 0。
  3. 滚轮向前和向后滚动,Delta 应分别出现正值和负值。
  4. 鼠标移出区域,最近事件应变为 MouseLeave。
  5. 分别按左键、右键、中键,观察 ChangedButton。

3.5 常见错误

  • 使用 e.GetPosition(this) 取得坐标,却拿它和画布中的对象坐标比较。
  • 只监听 MouseMove,没有处理 MouseLeave,状态一直停在最后位置。
  • 把滚轮的 Delta 当成固定 1 或 -1;实际它通常以 120 为一步。
  • 在 PreviewMouseDown 中设置 e.Handled = true,导致普通 MouseDown 处理器不再收到事件。

3.6 小练习

  1. 增加一个 MouseEnter 事件,把最近事件显示为“进入观察区”。
  2. 在坐标旁边显示当前左键状态:e.LeftButton。
  3. 把观察区放进另一个 Border,在两个元素上分别显示相对坐标,理解参照物。

4. 对象拖动案例

本节目标

  • 理解拖动时为什么不能直接把鼠标坐标赋给对象左上角。
  • 会记录按下点相对于对象的偏移量。
  • 会使用鼠标捕获,让鼠标移出对象后仍能继续拖动。
  • 会把对象限制在容器范围内。

4.1 直接赋坐标会发生什么

拖动偏移公式

图 6:鼠标点不一定在对象左上角。直接使用鼠标坐标,对象会突然跳到指针下面。

假设对象左上角是 (90, 80),你在对象内部 (30, 20) 的位置按下。此时:

1
2
offsetX = 30
offsetY = 20

鼠标移动到画布坐标 (200, 150) 时,对象的左上角应该是:

1
2
left = 200 - 30 = 170
top = 150 - 20 = 130

如果直接把 left = 200、top = 150 赋给对象,卡片左上角会跳到鼠标位置,视觉上就是卡片突然「跳一下」。

4.2 鼠标捕获为什么必要

拖动速度很快时,鼠标可能瞬间移出卡片。如果没有捕获,卡片收不到后续的 MouseMove 和 MouseUp,拖动就会卡住。

1
2
3
DragCard.CaptureMouse();
// 拖动结束后
DragCard.ReleaseMouseCapture();

捕获的意思是:在这段时间内,鼠标输入优先送到捕获它的元素。即使指针已经离开卡片边界,卡片仍然能收到移动和抬起。

4.3 位置限制

如果容器宽 400、对象宽 80,对象左上角的 X 最大值是:

1
400 - 80 = 320

所以 X 要限制在 0 到 320 之间。对象比容器还大时,最大值为负数,此时应该把上限归零,避免出现反向区间。

在项目中新建 InteractionMath.cs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using System.Windows;

namespace WpfLifecycleEventsPractice;

public static class InteractionMath
{
public static Point ClampTopLeft(
Point desired,
Size item,
Size container)
{
var maxX = Math.Max(0, container.Width - item.Width);
var maxY = Math.Max(0, container.Height - item.Height);

return new Point(
Math.Clamp(desired.X, 0, maxX),
Math.Clamp(desired.Y, 0, maxY));
}
}

4.4 跟做:可拖动卡片

第 4 个页签的 XAML:

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
<Grid Margin="24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="360" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>

<StackPanel>
<TextBlock Text="按住卡片并移动"
FontSize="22"
FontWeight="Bold" />
<TextBlock Margin="0,10,0,0"
Text="拖动结束后,卡片不会越过画布边界。"
TextWrapping="Wrap"
Foreground="#617089" />

<TextBlock Margin="0,22,0,0"
Text="拖动状态"
Foreground="#617089" />
<TextBlock x:Name="DragStatusText"
Margin="0,6,0,0"
Text="尚未开始" />

<TextBlock Margin="0,18,0,0"
Text="对象位置"
Foreground="#617089" />
<TextBlock x:Name="DragPositionText"
Margin="0,6,0,0"
FontFamily="Consolas"
Text="(90, 80)" />
</StackPanel>

<Border Grid.Column="1"
Width="820"
Height="590"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Background="#FBFCFE"
BorderBrush="#DCE5F2"
BorderThickness="1"
CornerRadius="10"
ClipToBounds="True">
<Canvas x:Name="DragCanvas"
Background="#FBFCFE">
<Border x:Name="DragCard"
Width="170"
Height="104"
Canvas.Left="90"
Canvas.Top="80"
Background="#2456A6"
CornerRadius="10"
Cursor="SizeAll"
PreviewMouseLeftButtonDown="OnDragCardDown"
PreviewMouseMove="OnDragCardMove"
PreviewMouseLeftButtonUp="OnDragCardUp">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center">
<TextBlock Text="拖动卡片"
FontSize="18"
FontWeight="Bold"
Foreground="White" />
<TextBlock Margin="0,5,0,0"
Text="按住并移动"
HorizontalAlignment="Center"
Foreground="#D9E7FF" />
</StackPanel>
</Border>
</Canvas>
</Border>
</Grid>

后台代码:

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
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

private Point _dragOffset;
private bool _isDragging;

private void OnDragCardDown(
object sender,
MouseButtonEventArgs e)
{
_dragOffset = e.GetPosition(DragCard);
_isDragging = true;
DragCard.CaptureMouse();
e.Handled = true;

DragStatusText.Text = "正在拖动";
}

private void OnDragCardMove(object sender, MouseEventArgs e)
{
if (!_isDragging || e.LeftButton != MouseButtonState.Pressed)
{
return;
}

var point = e.GetPosition(DragCanvas);
var desired = new Point(
point.X - _dragOffset.X,
point.Y - _dragOffset.Y);

var clamped = InteractionMath.ClampTopLeft(
desired,
new Size(DragCard.ActualWidth, DragCard.ActualHeight),
new Size(DragCanvas.ActualWidth, DragCanvas.ActualHeight));

Canvas.SetLeft(DragCard, clamped.X);
Canvas.SetTop(DragCard, clamped.Y);

DragPositionText.Text = $"({clamped.X:N0}, {clamped.Y:N0})";
}

private void OnDragCardUp(
object sender,
MouseButtonEventArgs e)
{
if (!_isDragging)
{
return;
}

_isDragging = false;
DragCard.ReleaseMouseCapture();
DragStatusText.Text = "拖动结束";
}

4.5 为什么按下和移动使用不同参照物

这里故意使用了两个参照物:

1
2
_dragOffset = e.GetPosition(DragCard);    // 相对卡片
var point = e.GetPosition(DragCanvas); // 相对画布

偏移量是「按下点在卡片内部的位置」,必须相对卡片计算。移动后的指针位置要用来设置卡片在画布中的左上角,所以必须相对画布计算。两者分工不同,不是混用。

4.6 跟做检查点

  1. 在卡片中央按下并移动,卡片应跟随鼠标,不应跳到鼠标左上角。
  2. 快速拖到卡片外面再移动,卡片仍应继续跟随。
  3. 拖到画布左边界和上边界,位置应停在 0 附近,不能出现负坐标。
  4. 拖到右下边界,卡片应完整留在画布中。
  5. 在拖动过程中按 Esc 不会自动取消,因为本例没有实现取消逻辑,这是后续可扩展点。

4.7 常见错误

现象 原因 修正
卡片按下后跳到鼠标处 没有减偏移量 使用 point - offset
快速移动后拖动中断 没有鼠标捕获 按下时 CaptureMouse()
卡片能拖出边界 没有限制坐标 使用 Math.Clamp
卡片大小计算为 0 在布局完成前读取 ActualWidth 在 Loaded 之后读取
鼠标抬起后仍然跟随 没有重置 _isDragging 抬起时清理状态并释放捕获

4.8 小练习

  1. 增加键盘微调:选中卡片后,方向键每次移动 5 像素。
  2. 增加双击复位:双击卡片回到 (90, 80)。
  3. 在拖动开始时记录原位置,按 Esc 取消拖动并恢复原位。

5. 框选基本逻辑

本节目标

  • 会记录框选起点和终点。
  • 会用 Math.Min、Math.Abs 标准化矩形。
  • 会用 Rect.IntersectsWith 判断项目是否与选框相交。
  • 能处理从四个方向拖出的选框。

5.1 为什么矩形要标准化

框选矩形标准化

图 7:从右下向左上拖动时,终点坐标可能小于起点。直接使用差值会产生负宽度。

Rect 的 Width 和 Height 应是非负数。标准化公式:

1
2
3
4
left   = Min(start.X, end.X)
top = Min(start.Y, end.Y)
width = Abs(end.X - start.X)
height = Abs(end.Y - start.Y)

5.2 相交和包含不是一回事

选框与项目相交判断

图 8:只要选框和项目有重叠就算相交;要求项目完全在选框内,是另一种产品规则。

本篇使用:

1
selection.IntersectsWith(item)

如果产品要求「完全包含才选中」,应改成类似:

1
selection.Contains(item)

先明确产品规则,再选 API。不要因为示例用了相交,就认为所有框选都必须这样。

5.3 跟做:补全几何工具

在 InteractionMath.cs 中继续加入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static Rect NormalizeRect(Point start, Point end)
{
var left = Math.Min(start.X, end.X);
var top = Math.Min(start.Y, end.Y);
var width = Math.Abs(end.X - start.X);
var height = Math.Abs(end.Y - start.Y);

return new Rect(left, top, width, height);
}

public static bool ShouldSelect(Rect selection, Rect item)
{
return selection.Width >= 2
&& selection.Height >= 2
&& selection.IntersectsWith(item);
}

Width >= 2 和 Height >= 2 是为了避免一次普通点击产生一个几乎看不见的选框,从而误选项目。这个阈值是业务规则,不是 WPF 强制要求。

5.4 跟做:框选画布

第 5 个页签的关键 XAML:

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
<Border Width="820"
Height="590"
Background="#FBFCFE"
BorderBrush="#DCE5F2"
BorderThickness="1"
ClipToBounds="True">
<Canvas x:Name="SelectionCanvas"
Background="Transparent"
PreviewMouseLeftButtonDown="OnSelectionCanvasDown"
PreviewMouseMove="OnSelectionCanvasMove"
PreviewMouseLeftButtonUp="OnSelectionCanvasUp">
<Border x:Name="SelectionBox"
Background="#332456A6"
BorderBrush="#2456A6"
BorderThickness="1.5"
Visibility="Collapsed"
IsHitTestVisible="False" />

<Border x:Name="SelectItemA"
Width="110"
Height="76"
Canvas.Left="60"
Canvas.Top="55"
Tag="项目 A"
Background="#DDEBFF"
BorderBrush="#8FB4E8"
BorderThickness="1">
<TextBlock Text="项目 A"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>

<Border x:Name="SelectItemB"
Width="130"
Height="88"
Canvas.Left="250"
Canvas.Top="90"
Tag="项目 B"
Background="#E8F6EF"
BorderBrush="#9ED2B8"
BorderThickness="1">
<TextBlock Text="项目 B"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
</Canvas>
</Border>

SelectionCanvas 必须设置 Background="Transparent"。如果背景是 null,空白区域可能不参与命中测试,鼠标按在空白处不会触发画布事件。

后台代码:

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;

private Point? _selectionStart;
private bool _isSelecting;
private readonly List<Border> _selectableItems = new();

private void InitializeSelectionItems()
{
_selectableItems.Add(SelectItemA);
_selectableItems.Add(SelectItemB);
}

private void OnSelectionCanvasDown(
object sender,
MouseButtonEventArgs e)
{
if (e.OriginalSource is not Canvas)
{
return;
}

_selectionStart = e.GetPosition(SelectionCanvas);
_isSelecting = true;
SelectionBox.Visibility = Visibility.Visible;
SelectionCanvas.CaptureMouse();

UpdateSelectionBox(_selectionStart.Value, _selectionStart.Value);
e.Handled = true;
}

private void OnSelectionCanvasMove(
object sender,
MouseEventArgs e)
{
if (!_isSelecting || _selectionStart is null)
{
return;
}

UpdateSelectionBox(
_selectionStart.Value,
e.GetPosition(SelectionCanvas));
}

private void OnSelectionCanvasUp(
object sender,
MouseButtonEventArgs e)
{
if (!_isSelecting || _selectionStart is null)
{
return;
}

var selection = InteractionMath.NormalizeRect(
_selectionStart.Value,
e.GetPosition(SelectionCanvas));

_isSelecting = false;
_selectionStart = null;
SelectionCanvas.ReleaseMouseCapture();
SelectionBox.Visibility = Visibility.Collapsed;

foreach (var item in _selectableItems)
{
var itemBounds = GetCanvasBounds(item);
var selected = InteractionMath.ShouldSelect(
selection,
itemBounds);

item.BorderThickness = selected
? new Thickness(3)
: new Thickness(1);
item.BorderBrush = selected
? new SolidColorBrush(Color.FromRgb(36, 86, 166))
: new SolidColorBrush(Color.FromRgb(185, 205, 238));
}

e.Handled = true;
}

private void UpdateSelectionBox(Point start, Point end)
{
var rect = InteractionMath.NormalizeRect(start, end);

Canvas.SetLeft(SelectionBox, rect.Left);
Canvas.SetTop(SelectionBox, rect.Top);
SelectionBox.Width = rect.Width;
SelectionBox.Height = rect.Height;
}

private static Rect GetCanvasBounds(FrameworkElement item)
{
var left = Canvas.GetLeft(item);
var top = Canvas.GetTop(item);

return new Rect(
double.IsNaN(left) ? 0 : left,
double.IsNaN(top) ? 0 : top,
item.ActualWidth,
item.ActualHeight);
}

在窗口构造函数中调用一次:

1
InitializeSelectionItems();

5.5 运行截图

框选项目 A 和 B 后的界面

图 9:示例中项目 A、B 与选框相交后被选中。选框在鼠标抬起后隐藏,选中结果保留。

5.6 跟做检查点

  1. 从空白处向右下拖,选框应随鼠标扩大。
  2. 从右下向左上拖,选框仍应是正常矩形,宽度和高度不能变成负数。
  3. 选框只碰到项目边缘时,应算相交;完全分离时,不应选中。
  4. 拖到画布外再抬起,捕获应让抬起事件仍然到达画布。
  5. 只点击空白处,不应误选项目。

5.7 常见错误

现象 原因 修正
从空白处按不下 画布背景为 null 设置 Background="Transparent"
反向拖动时选框消失 使用负数宽高 先标准化矩形
点一下项目就开始框选 没有判断 e.OriginalSource 只在画布空白处开始
快速拖出画布后选框卡住 没有捕获鼠标 按下时 CaptureMouse()
选中判断总是失败 使用窗口坐标和画布坐标混算 全部换算到画布坐标

5.8 小练习

  1. 增加项目 C 和 D,观察多项目框选。
  2. 把相交规则改成完全包含,并比较两种体验。
  3. 在结果区域显示被选中的项目名称,而不是只改变边框。
  4. 增加按住 Ctrl 追加选择的逻辑,先设计状态再写代码。

6. 路由事件的处理过程

本节目标

  • 理解路由事件为什么需要「路由」。
  • 能画出 PreviewMouseDown 的隧道路径和 MouseDown 的冒泡路径。
  • 理解 sender、e.Source、e.OriginalSource 在路由中的不同角色。
  • 知道 e.Handled 对后续处理器的影响。
  • 会使用 AddHandler(..., handledEventsToo: true) 观察已被处理的事件。

6.1 三种路由策略

WPF 路由事件常见三种策略:

策略 传播方向 典型事件
隧道 从根节点向目标向下 PreviewMouseDown
冒泡 从目标向祖先向上 MouseDown
直接 只作用于来源本身 MouseEnter、部分控件自定义事件

鼠标按下通常先走隧道,再走冒泡:

1
2
3
4
5
6
7
8
Window.PreviewMouseDown
→ Outer.PreviewMouseDown
→ Middle.PreviewMouseDown
→ Target.PreviewMouseDown
→ Target.MouseDown
→ Middle.MouseDown
→ Outer.MouseDown
→ Window.MouseDown

Preview 隧道与 MouseDown 冒泡

图 10:同一棵元素树,Preview 阶段向下,普通 MouseDown 阶段向上。

6.2 跟做:四层嵌套元素

第 6 个页签放入四层 Border:

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
<Grid Margin="24">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="220" />
</Grid.RowDefinitions>

<Border x:Name="RouteWindow"
Background="#F7F9FD"
BorderBrush="#B9CDEE"
BorderThickness="1"
Padding="28"
PreviewMouseDown="OnRouteWindowPreviewMouseDown"
MouseDown="OnRouteWindowMouseDown">
<Border x:Name="RouteOuter"
Background="#EEF4FF"
BorderBrush="#B9CDEE"
BorderThickness="1"
Padding="28"
PreviewMouseDown="OnRouteOuterPreviewMouseDown"
MouseDown="OnRouteOuterMouseDown">
<Border x:Name="RouteMiddle"
Background="#DDEBFF"
BorderBrush="#8FB4E8"
BorderThickness="1"
Padding="28"
PreviewMouseDown="OnRouteMiddlePreviewMouseDown"
MouseDown="OnRouteMiddleMouseDown">
<Border x:Name="RouteTarget"
Background="#2456A6"
Padding="18,12"
HorizontalAlignment="Center"
VerticalAlignment="Center"
PreviewMouseDown="OnRouteTargetPreviewMouseDown"
MouseDown="OnRouteTargetMouseDown">
<TextBlock Text="点击目标"
Foreground="White" />
</Border>
</Border>
</Border>
</Border>

<ListBox x:Name="RouteLogList"
Grid.Row="1"
Margin="0,12,0,0"
FontFamily="Consolas"
Background="#0F172A"
Foreground="#DCE7F8" />
</Grid>

后台记录处理器:

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
private readonly List<string> _routeSequence = new();

private void RecordRoute(string stage, RoutedEventArgs e)
{
_routeSequence.Add(stage);
RouteLogList.Items.Add(stage);

var sourceName = e.OriginalSource is FrameworkElement element
? element.Name
: e.OriginalSource?.GetType().Name ?? "未知";

EventLogStore.Write(
"RoutedEvent",
$"{stage};OriginalSource={sourceName}");
}

private void OnRouteWindowPreviewMouseDown(
object sender,
MouseButtonEventArgs e)
=> RecordRoute("RouteWindow.PreviewMouseDown", e);

private void OnRouteWindowMouseDown(
object sender,
MouseButtonEventArgs e)
=> RecordRoute("RouteWindow.MouseDown", e);

其余三个元素按同样方式挂载各自的 Preview 和普通 MouseDown 处理器。点击目标后,列表应按图 10 的顺序增长。

6.3 自动验证事件顺序

为了避免每次手动点击,可以创建一个鼠标事件参数并主动触发:

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
private void RunRouteTest()
{
_routeSequence.Clear();
RouteLogList.Items.Clear();

var previewArgs = new MouseButtonEventArgs(
Mouse.PrimaryDevice,
Environment.TickCount,
MouseButton.Left)
{
RoutedEvent = UIElement.PreviewMouseDownEvent,
Source = RouteTarget
};
RouteTarget.RaiseEvent(previewArgs);

var bubbleArgs = new MouseButtonEventArgs(
Mouse.PrimaryDevice,
Environment.TickCount,
MouseButton.Left)
{
RoutedEvent = UIElement.MouseDownEvent,
Source = RouteTarget
};
RouteTarget.RaiseEvent(bubbleArgs);
}

预期顺序:

1
2
3
4
5
6
7
8
RouteWindow.PreviewMouseDown
RouteOuter.PreviewMouseDown
RouteMiddle.PreviewMouseDown
RouteTarget.PreviewMouseDown
RouteTarget.MouseDown
RouteMiddle.MouseDown
RouteOuter.MouseDown
RouteWindow.MouseDown

6.4 Handled 怎样影响传播

Handled 对路由事件传播的影响

图 11:普通处理器默认跳过已标记 ​​Handled​​ 的事件。

如果在 RouteMiddle.PreviewMouseDown 中设置:

1
e.Handled = true;

后续普通 PreviewMouseDown 处理器可能不再被调用。注意:Handled 不是「事件停止对象级传播」那么简单,它影响的是后续处理器是否接收该事件。

如果你确实需要观察已处理事件,可以使用:

1
2
3
4
5
6
7
8
9
RouteWindow.AddHandler(
UIElement.PreviewMouseDownEvent,
new MouseButtonEventHandler((sender, e) =>
{
EventLogStore.Write(
"ObserveHandled",
$"Handled={e.Handled}");
}),
handledEventsToo: true);

只在诊断、日志或特殊交互中使用 handledEventsToo: true。普通业务逻辑随意绕过 Handled,会让事件流难以推理。

6.5 sender、Source、OriginalSource 的现场对照

假设用户点击了 RouteTarget 内部的 TextBlock:

1
2
3
sender           = 当前挂载处理器的 Border
e.Source = 路由事件设置的逻辑来源
e.OriginalSource = 最初命中的 TextBlock

因此日志中出现:

1
2
RouteWindow.PreviewMouseDown;OriginalSource=TextBlock
RouteTarget.PreviewMouseDown;OriginalSource=TextBlock

这并不矛盾。处理器挂在不同层,sender 会变化;OriginalSource 仍然是同一个最初命中的元素。

6.6 跟做检查点

  1. 点击最内层“点击目标”,日志应从外层 Preview 开始,到内层 Preview,再向外冒泡。
  2. 点击外层空白区域,OriginalSource 不应仍然是最内层目标。
  3. 在中间层设置 e.Handled = true,观察后续处理器是否减少。
  4. 使用 handledEventsToo: true 注册观察器,确认它仍能看到已处理事件。
  5. 把 PreviewMouseDown 换成 PreviewMouseLeftButtonDown,比较右键点击时的差异。

6.7 常见错误

现象 原因 修正
只看到 Preview,没有普通 MouseDown Preview 中设置了 Handled 检查 e.Handled
所有处理器都收到事件 没有设置 Handled 这是正常冒泡行为
以为 sender 是点击目标 处理器挂在外层 看 e.OriginalSource
路由测试顺序不稳定 混用了手动点击和自动触发 每次测试前清空日志
在错误元素上注册处理器 元素树层级不清楚 给每层命名并画路径

6.8 小练习

  1. 在每层显示 sender 的类型和名称。
  2. 只处理左键,右键点击时直接返回。
  3. 设计一个规则:最内层已处理事件,但最外层仍记录日志,使用 handledEventsToo 实现。
  4. 把 MouseDown 改成 PreviewMouseDown 再观察一次,确认自己不是靠事件名猜顺序。

完整项目结构

跟做完成后,项目可以整理成下面的结构:

1
2
3
4
5
6
7
8
9
WpfLifecycleEventsPractice
├─ App.xaml
├─ App.xaml.cs
├─ MainWindow.xaml
├─ MainWindow.xaml.cs
├─ EventLogStore.cs
├─ InteractionMath.cs
└─ Models
└─ EventRecord.cs

如果使用随附完整示例,还会看到:

1
2
3
4
5
6
7
8
9
WpfLifecycleEventsDemo
├─ Models
│ ├─ AppOptions.cs
│ └─ EventRecord.cs
├─ ViewModels
│ └─ MainViewModel.cs
├─ SelfTestRunner.cs
├─ ScreenshotMath.cs
└─ UiProbe.cs

AppOptions 负责命令行参数,SelfTestRunner 负责自动检查,UiProbe 负责截图。它们不是学习前五章的前置条件,可以最后再读。

完整示例截图

生命周期与全局异常

生命周期与异常页签

图 12:日志列表倒序显示,最新的 ​​Application.Exit​​ 会出现在最上面。

对象事件总结

对象事件页签

图 13:普通事件观察页签。

鼠标输入事件

鼠标输入页签

图 14:鼠标坐标、最近事件和滚轮增量。

对象拖动

对象拖动页签

图 15:卡片在画布内被拖动,并显示当前位置。

路由事件

路由事件页签

图 16:点击目标后,路由日志按 Preview 和冒泡顺序显示。

常见错误速查

生命周期相关

问题 检查点
程序关闭后没有日志 日志目录是否有写权限,OnExit 是否被调用
Loaded 中读不到尺寸 控件是否已经完成布局,是否读取了 ActualWidth
Closing 取消后仍执行清理 清理逻辑是否错误地放在 Closed 之外
多个窗口关闭顺序混乱 检查 ShutdownMode 和 MainWindow

输入与拖动相关

问题 检查点
坐标偏移 两次 GetPosition 是否使用同一个参照物
拖动中断 是否在按下时捕获鼠标
卡片越界 是否计算 容器尺寸 - 对象尺寸
拖动结束后仍跟随 是否重置 _isDragging

框选相关

问题 检查点
反向框选失败 是否标准化矩形
空白区域不能开始 画布是否设置透明背景
点一下也选中 是否设置最小宽高阈值
选中范围偏移 项目坐标是否换算到画布坐标系

路由事件相关

问题 检查点
顺序与预期相反 Preview 是隧道,普通事件是冒泡
后续处理器不执行 是否有人设置了 e.Handled = true
分不清事件来源 同时打印 sender 和 e.OriginalSource
处理器重复执行 是否重复订阅了同一个事件

综合练习

练习一:做一个可取消的关闭流程

要求:

  1. 在窗口中放一个 TextBox。
  2. 文本框有内容且未保存时,关闭窗口弹出确认。
  3. 用户选择取消时,窗口保持打开。
  4. 用户选择不保存时,记录 Window.Closing 和 Window.Closed。

提示:取消关闭只能在 Closing 中做,Closed 已经来不及。

练习二:做一个受限制的拖动卡片

要求:

  1. 卡片只能在画布内移动。
  2. 拖动时显示实时坐标。
  3. 按 Esc 取消本次拖动并回到原位。
  4. 双击卡片复位。

提示:按下时同时记录 _dragStart 和 _cardStart,取消时恢复 _cardStart。

练习三:做一个框选统计器

要求:

  1. 画布中有至少四个项目。
  2. 支持从四个方向框选。
  3. 显示选中数量和名称。
  4. 按住 Ctrl 时追加选择,不按 Ctrl 时重新选择。

提示:先把「本次框选结果」和「累计选择集合」分开,再处理 Ctrl 状态。

练习四:观察路由事件

要求:

  1. 做四层嵌套容器。
  2. 记录 Preview 和普通 MouseDown。
  3. 在中间层设置 Handled。
  4. 使用 handledEventsToo 增加一个旁路观察器。
  5. 比较两次日志的差异。

提示:不要只看事件名,记录 sender.Name 和 OriginalSource。

结语

这一篇的六个主题看起来分散,其实都在回答同一个问题:一个界面操作是怎样在正确的时间、沿着正确的对象关系,改变正确的状态。

生命周期告诉你什么时候可以开始和收尾;对象事件告诉你谁在通知谁;鼠标事件告诉你输入从哪里来;拖动和框选把输入变成几何状态;路由事件则解释这些通知为什么能穿过一层层容器。

学习时不要只复制最终代码。每做完一章,都回到运行结果问自己三个问题:

1
2
3
这件事发生在什么时刻?
参与的对象分别是谁?
我保存的状态在按下、移动、抬起之间有没有被正确更新?

能回答这三个问题,再遇到新的 WPF 控件或交互需求时,就不会只靠背事件名了。