面向刚学完普通数据绑定,准备理解「多个值怎样合成一个结果」以及「WPF 程序怎样启动和退出」的读者。
推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,当前电脑可直接编译;这里用到的 API 在 .NET 9 与 .NET 10 中没有变化。
最后更新:2026-10-20

目录

  1. 多重绑定基本使用
  2. 多重值转换器
  3. 优先级绑定
  4. 绑定关系清除
  5. 应用启动事件与启动参数
  6. 应用退出事件

写在前面

这份笔记解决什么问题

前面的笔记已经讲过一条绑定怎样把一个来源值送到一个目标属性。真正写界面时,经常还会遇到下面这些情况:

1
2
3
4
5
6
姓、名、职位分别存在三个属性里,界面要合成一句话。
单价、数量、折扣共同决定应付金额,不能只绑定其中一个。
主价格没有时用缓存价,缓存价没有时用默认价,再没有就显示“暂无报价”。
某条绑定暂时不想用了,怎样停掉?以后还能恢复吗?
程序启动时怎样接收命令行参数?
窗口关闭和应用退出有什么区别?退出码在哪里读?

本篇分成两个部分。第 1 到第 3 章处理多个值怎样合成一个结果;第 4 章讲绑定关系的清除与恢复;第 5、6 章把绑定放进整个 WPF 程序的启动和退出流程里观察。

本篇六个知识点总览

图 1:本篇六章的知识地图。先学多值合成,再学绑定控制,最后观察应用程序生命周期。

建议的学习顺序

  1. 先运行随附示例,依次点开六个页签,观察输入变化后输出怎样变化。
  2. 第 1、2 章一起练:先会拼文字,再会写 IMultiValueConverter。
  3. 第 3 章单独练:重点不是“值相加”,而是“从高优先级往下挑第一个可用结果”。
  4. 第 4 章亲手点几次“清除”和“恢复”,并观察控件属性的默认值。
  5. 第 5、6 章用命令行启动程序,对照日志理解启动与退出的先后顺序。
  6. 每完成一章,就在自己的练习项目里增加一个页签。

示例项目位置

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

在 PowerShell 中运行:

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

如果使用 Visual Studio:

  1. 打开 WpfAdvancedBindingDemo.csproj。
  2. 确认启动项目是 WpfAdvancedBindingDemo。
  3. 按 F5 启动。
  4. 打开“输出”窗口,将“显示输出来源”切到“调试”。绑定失败时,诊断信息通常会出现在这里。

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 WpfAdvancedBindingPractice -o .\WpfAdvancedBindingPractice
cd .\WpfAdvancedBindingPractice
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="WpfAdvancedBindingPractice.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,看到六个空页签,说明跟做环境正常。

先准备一个会通知变化的 ViewModel

多重绑定要在来源属性变化时刷新,所以来源对象应实现 INotifyPropertyChanged。在项目中新建 ViewModels 文件夹,然后添加 ObservableObject.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
using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace WpfAdvancedBindingPractice.ViewModels;

public abstract class ObservableObject : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;

protected bool SetProperty<T>(
ref T field,
T value,
[CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return false;
}

field = value;
OnPropertyChanged(propertyName);
return true;
}

protected void OnPropertyChanged(
[CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(
this,
new PropertyChangedEventArgs(propertyName));
}
}

这段代码只做一件事:属性值改变时发出 PropertyChanged 通知。后面的示例都继承它,避免每个属性重复写通知代码。


1. 多重绑定基本使用

本节目标

把 LastName、FirstName、JobTitle 三个来源合成一句:

1
张三 · 产品工程师

这一节先不使用转换器,只用 MultiBinding 和 StringFormat。

先理解数据流

普通 Binding 是“一个来源 → 一个目标属性”。MultiBinding 则是“多个子 Binding → 一个目标属性”。

MultiBinding 的收集与合成流程

图 2:三个子 Binding 按声明顺序收集值,再由 StringFormat 合成目标文本。

可以把它想成一张有三个空格的表:

位置 对应子 Binding 当前值
{0} LastName 张
{1} FirstName 三
{2} JobTitle 产品工程师

格式字符串是:

1
{}{0}{1} · {2}

开头的 {} 不是输出内容。它是在告诉 XAML:后面的花括号属于格式字符串,不要把它当成 XAML 标记扩展。最终真正起作用的是 {0}{1} · {2}。

把这条链路再压缩成一句话:每个子 Binding 只负责取一个值;MultiBinding 负责把值排成数组;StringFormat 或转换器负责把数组变成最终文本。排错时也按这三层逐层检查。

跟做:在 ViewModel 里准备三个属性

在 ViewModels 文件夹添加 MainViewModel.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
namespace WpfAdvancedBindingPractice.ViewModels;

public sealed class MainViewModel : ObservableObject
{
private string _lastName = "张";
private string _firstName = "三";
private string _jobTitle = "产品工程师";

public string LastName
{
get => _lastName;
set => SetProperty(ref _lastName, value);
}

public string FirstName
{
get => _firstName;
set => SetProperty(ref _firstName, value);
}

public string JobTitle
{
get => _jobTitle;
set => SetProperty(ref _jobTitle, value);
}
}

在 MainWindow.xaml.cs 中设置 DataContext。这是第 1 章的版本;第 4 章会扩展它,第 5 章会用带参数的构造函数替换它:

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

namespace WpfAdvancedBindingPractice;

public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new MainViewModel();
}
}

跟做:写第一个 MultiBinding

把第 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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<TabItem Header="1  多重绑定">
<Grid Margin="28">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="360" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>

<StackPanel Margin="0,0,28,0">
<TextBlock Text="姓氏" Margin="0,0,0,6" />
<TextBox Padding="8"
Text="{Binding LastName,
UpdateSourceTrigger=PropertyChanged}" />

<TextBlock Text="名字" Margin="0,16,0,6" />
<TextBox Padding="8"
Text="{Binding FirstName,
UpdateSourceTrigger=PropertyChanged}" />

<TextBlock Text="职位" Margin="0,16,0,6" />
<TextBox Padding="8"
Text="{Binding JobTitle,
UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>

<Border Grid.Column="1"
Padding="24"
Background="White"
BorderBrush="#D7E1EF"
BorderThickness="1"
CornerRadius="10">
<StackPanel>
<TextBlock Text="合成结果"
Foreground="#617089" />
<TextBlock Margin="0,12,0,0"
FontSize="30"
FontWeight="Bold"
Foreground="#172033">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0}{1} · {2}">
<Binding Path="LastName" />
<Binding Path="FirstName" />
<Binding Path="JobTitle" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StackPanel>
</Border>
</Grid>
</TabItem>

注意:MultiBinding 不能直接写在 Text="..." 里,要用属性元素语法:

1
2
3
4
5
<TextBlock.Text>
<MultiBinding ...>
...
</MultiBinding>
</TextBlock.Text>

运行后的检查点

  1. 启动程序,默认显示“张三 · 产品工程师”。
  2. 把姓氏改成“李”,结果立即变成“李三 · 产品工程师”。
  3. 把名字改成“雷”,结果变成“李雷 · 产品工程师”。
  4. 把职位改成“测试工程师”,职位部分立即跟着变化。
  5. 把第一个和第二个子 Binding 对调,结果变成“三张 · 产品工程师”。这证明顺序就是取值顺序。

小练习

把格式改成下面两种,观察差别:

1
2
3
4
<MultiBinding StringFormat="{}{0} {1}">
<Binding Path="LastName" />
<Binding Path="FirstName" />
</MultiBinding>
1
2
3
4
5
<MultiBinding StringFormat="员工:{1}{0},岗位:{2}">
<Binding Path="LastName" />
<Binding Path="FirstName" />
<Binding Path="JobTitle" />
</MultiBinding>

第二个例子说明:占位符可以不按 {0}、{1}、{2} 的书写顺序出现,但编号仍然对应子 Binding 的声明位置。


2. 多重值转换器

本节目标

用单价、数量、折扣计算应付金额:

1
128 × 2 × 90% = 230.40

当合成规则不只是拼字符串,而是要做计算、判断或格式化时,就需要 IMultiValueConverter。

先理解 values 数组

IMultiValueConverter.Convert 只收到一个 object[] values。数组的元素顺序完全由 XAML 中子 Binding 的声明顺序决定。

IMultiValueConverter 的数组顺序

图 3:values[0]、values[1]、values[2] 必须与三个子 Binding 一一对应。

对应关系:

数组元素 XAML 子 Binding 含义
values[0] UnitPrice 单价
values[1] Quantity 数量
values[2] DiscountPercent 折扣百分比

转换器的职责是把数组变成一个结果。它不知道来源属性叫什么名字,只能按位置读取。所以以后调整 XAML 顺序时,必须同步检查转换器。

跟做:先加三个可写属性

在 MainViewModel.cs 中补上三个可写属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private decimal _unitPrice = 128m;
private decimal _quantity = 2m;
private decimal _discountPercent = 90m;

public decimal UnitPrice
{
get => _unitPrice;
set => SetProperty(ref _unitPrice, value);
}

public decimal Quantity
{
get => _quantity;
set => SetProperty(ref _quantity, value);
}

public decimal DiscountPercent
{
get => _discountPercent;
set => SetProperty(ref _discountPercent, value);
}

这里故意用 decimal,因为金额计算比 double 更适合用十进制类型。

跟做:写 OrderTotalConverter

新建 Converters 文件夹,添加 OrderTotalConverter.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
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
using System.Globalization;
using System.Windows;
using System.Windows.Data;

namespace WpfAdvancedBindingPractice.Converters;

public sealed class OrderTotalConverter : IMultiValueConverter
{
public object Convert(
object[] values,
Type targetType,
object? parameter,
CultureInfo culture)
{
if (values.Any(
value => value == DependencyProperty.UnsetValue))
{
return "输入还没有准备完整";
}

if (!TryToDecimal(values.ElementAtOrDefault(0), out var unitPrice)
|| !TryToDecimal(
values.ElementAtOrDefault(1),
out var quantity)
|| !TryToDecimal(
values.ElementAtOrDefault(2),
out var discountPercent))
{
return "请填写有效数字";
}

if (quantity < 0 || quantity != decimal.Truncate(quantity))
{
return "数量必须是非负整数";
}

if (discountPercent <= 0 || discountPercent > 100)
{
return "折扣必须在 0 到 100 之间";
}

var original = unitPrice * quantity;
var payable = original * discountPercent / 100m;

return $"应付 ¥ {payable:N2} 原价 ¥ {original:N2} 折扣 {discountPercent:N0}%";
}

public object[] ConvertBack(
object value,
Type[] targetTypes,
object? parameter,
CultureInfo culture)
{
throw new NotSupportedException(
"这个示例只做单向展示,不需要 ConvertBack。");
}

private static bool TryToDecimal(
object? value,
out decimal result)
{
return decimal.TryParse(
value?.ToString(),
NumberStyles.Number,
CultureInfo.CurrentCulture,
out result);
}
}

代码里有三道检查:

  1. UnsetValue 表示某个子绑定暂时没有值。
  2. 文本要能转换成 decimal。
  3. 数量和折扣必须落在合理范围内。

注意 ConvertBack 直接抛异常。一个“应付 230.40”无法唯一还原成“单价 128、数量 2、折扣 90%”,所以这类展示型转换器通常只实现 Convert。

跟做:在 XAML 里使用转换器

在 MainWindow.xaml 的 Window.Resources 中注册转换器。如果这个元素还没有,就放在 <Window ...> 和根布局之间;如果已经存在,就把这一行加进去:

1
2
3
<Window.Resources>
<local:OrderTotalConverter x:Key="OrderTotalConverter" />
</Window.Resources>

同时在窗口根元素的属性列表中加入命名空间。命名空间声明不是控件,不能写进 Window.Resources:

1
xmlns:local="clr-namespace:WpfAdvancedBindingPractice.Converters"

把第 2 个页签改成:

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
<TabItem Header="2  多重值转换器">
<Grid Margin="28">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="360" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>

<StackPanel Margin="0,0,28,0">
<TextBlock Text="单价" Margin="0,0,0,6" />
<TextBox Padding="8"
Text="{Binding UnitPrice,
UpdateSourceTrigger=PropertyChanged}" />

<TextBlock Text="数量" Margin="0,16,0,6" />
<TextBox Padding="8"
Text="{Binding Quantity,
UpdateSourceTrigger=PropertyChanged}" />

<TextBlock Text="折扣百分比" Margin="0,16,0,6" />
<TextBox Padding="8"
Text="{Binding DiscountPercent,
UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>

<Border Grid.Column="1"
Padding="24"
Background="#EFFAF4"
BorderBrush="#BFE5D2"
BorderThickness="1"
CornerRadius="10">
<StackPanel>
<TextBlock Text="应付金额" Foreground="#1B7E58" />
<TextBlock Margin="0,14,0,0"
FontSize="25"
FontWeight="Bold"
TextWrapping="Wrap"
Foreground="#172033">
<TextBlock.Text>
<MultiBinding
Converter="{StaticResource OrderTotalConverter}">
<Binding Path="UnitPrice" />
<Binding Path="Quantity" />
<Binding Path="DiscountPercent" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StackPanel>
</Border>
</Grid>
</TabItem>

如果命名空间前缀不写 local,也可以叫 conv,只要 XAML 中的声明和资源引用保持一致即可。

运行后的检查点

  1. 默认显示“应付 ¥ 230.40 原价 ¥ 256.00 折扣 90%”。
  2. 单价改成 100、数量改成 3、折扣改成 80,应得到“应付 ¥ 240.00”。
  3. 数量输入 1.5,应显示“数量必须是非负整数”。
  4. 折扣输入 0,应显示“折扣必须在 0 到 100 之间”。
  5. 把第三个子 Binding 移到最前面,金额可能算错。恢复顺序后再确认结果正确。

小练习

给金额输出增加“免运费”规则:应付金额满 300 时追加“ 免运费”,否则追加“ 运费 ¥ 12.00”。只修改 OrderTotalConverter,不要改 XAML 结构。


3. 优先级绑定

本节目标

实现一条报价选择链:

1
主价格 → 缓存价格 → 默认价格 → 暂无报价

PriorityBinding 不是求和,也不是把多个值拼在一起。它会从高优先级到低优先级依次检查,选择第一个能提供结果的值。

先理解选择链

PriorityBinding 的优先级选择链

图 4:主价可用就用主价;主价不可用才继续向下找。

这里最容易误解的是“空字符串会不会自动跳过”。在本示例中,空字符串仍然是一个普通字符串值,不会自动让 PriorityBinding 继续。因此示例先用一个转换器把空串变成 DependencyProperty.UnsetValue,明确告诉 WPF:“这一项不可用,请继续找下一项。”

换句话说,PriorityBinding 判断的是“这一项有没有返回可用结果”,而不是“这个字符串看起来是否为空”。只要子绑定返回普通字符串,它就算有结果。想让空白值让位,必须把它变成 UnsetValue,或者让绑定本身失败并配合 FallbackValue。

跟做:准备三个报价属性

在 MainViewModel.cs 中补充三个报价属性和字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private string _primaryPrice = "¥ 128.00";
private string _cachedPrice = "¥ 158.00";
private string _defaultPrice = "¥ 199.00";

public string PrimaryPrice
{
get => _primaryPrice;
set => SetProperty(ref _primaryPrice, value);
}

public string CachedPrice
{
get => _cachedPrice;
set => SetProperty(ref _cachedPrice, value);
}

public string DefaultPrice
{
get => _defaultPrice;
set => SetProperty(ref _defaultPrice, value);
}

跟做:写 EmptyStringToUnsetValueConverter

在 Converters 文件夹添加 EmptyStringToUnsetValueConverter.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
using System.Globalization;
using System.Windows;
using System.Windows.Data;

namespace WpfAdvancedBindingPractice.Converters;

public sealed class EmptyStringToUnsetValueConverter : IValueConverter
{
public object Convert(
object value,
Type targetType,
object parameter,
CultureInfo culture)
{
return string.IsNullOrWhiteSpace(value?.ToString())
? DependencyProperty.UnsetValue
: value;
}

public object ConvertBack(
object value,
Type targetType,
object parameter,
CultureInfo culture)
{
throw new NotSupportedException();
}
}

它的输入是普通字符串,输出要么是原值,要么是 UnsetValue。UnsetValue 是 WPF 依赖属性系统里的特殊标记,不是普通 null。

在 Window.Resources 中注册:

1
<local:EmptyStringToUnsetValueConverter x:Key="EmptyToUnset" />

跟做:写 PriorityBinding

第 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
50
51
52
<TabItem Header="3  优先级绑定">
<Grid Margin="28">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="360" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>

<StackPanel Margin="0,0,28,0">
<TextBlock Text="主价格" Margin="0,0,0,6" />
<TextBox Padding="8"
Text="{Binding PrimaryPrice,
UpdateSourceTrigger=PropertyChanged}" />

<TextBlock Text="缓存价格" Margin="0,16,0,6" />
<TextBox Padding="8"
Text="{Binding CachedPrice,
UpdateSourceTrigger=PropertyChanged}" />

<TextBlock Text="默认价格" Margin="0,16,0,6" />
<TextBox Padding="8"
Text="{Binding DefaultPrice,
UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>

<Border Grid.Column="1"
Padding="24"
Background="#FFF6E5"
BorderBrush="#F0D6A8"
BorderThickness="1"
CornerRadius="10">
<StackPanel>
<TextBlock Text="当前最终报价" Foreground="#B26B00" />
<TextBlock Margin="0,14,0,0"
FontSize="28"
FontWeight="Bold"
Foreground="#172033">
<TextBlock.Text>
<PriorityBinding>
<Binding Path="PrimaryPrice"
Converter="{StaticResource EmptyToUnset}" />
<Binding Path="CachedPrice"
Converter="{StaticResource EmptyToUnset}" />
<Binding Path="DefaultPrice"
Converter="{StaticResource EmptyToUnset}"
FallbackValue="暂无报价" />
</PriorityBinding>
</TextBlock.Text>
</TextBlock>
</StackPanel>
</Border>
</Grid>
</TabItem>

注意第三个子 Binding 上的 FallbackValue="暂无报价"。当所有子绑定都没有可用值时,界面最终会显示这段兜底文字。

运行后的检查点

  1. 默认主价格有值,显示“¥ 128.00”。
  2. 清空主价格,显示“¥ 158.00”。
  3. 再清空缓存价格,显示“¥ 199.00”。
  4. 再清空默认价格,显示“暂无报价”。
  5. 在主价格中输入“¥ 88.00”,界面立即回到主价格。

以上四步已经在随附示例中逐项验证。

小练习

把四个阶段改成“会员价、活动价、普通价、联系客服”。要求仍然只在最后一个子 Binding 上设置兜底值,并确保清空全部三个输入框后能显示“联系客服”。

MultiBinding 与 PriorityBinding 对照

对比项 MultiBinding PriorityBinding
主要用途 多个值合成一个结果 从多条绑定中选择第一个可用结果
是否同时使用所有值 是 不一定,命中高优先级后不再向下找
常见搭档 IMultiValueConverter、StringFormat 转换器、FallbackValue
典型场景 拼接姓名、计算总价 主数据、缓存数据、默认数据

4. 绑定关系清除

本节目标

学会使用:

1
2
3
BindingOperations.ClearBinding(...)
BindingOperations.ClearAllBindings(...)
BindingOperations.SetBinding(...)

并理解清除绑定以后,目标属性实际会发生什么。

先看实测结果

很多初学者会以为“清除绑定后,控件会保留上一次显示的文字”。随附示例的实测结果不是这样:

1
2
3
清除前:Text = 在线:服务已连接
清除后:Text = 空字符串
清除后:Foreground = 黑色

原因是清除绑定以后,Text、Foreground 等依赖属性会重新使用自己的默认值。原来显示的内容属于绑定结果,不是目标控件的持久值。

如果希望清除后仍保留某个显示值,必须在清除前手动把当前值写回目标属性。不过这会把它变成普通本地值,之后来源再变化也不会自动跟随。

ClearBinding 前后对比

图 5:清除绑定只断开关系,不会修改来源对象;目标属性回到默认值。

跟做:准备状态属性

在 MainViewModel.cs 中补充。using System.Windows.Media; 要放在文件顶部,不要塞进类里面:

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

private string _statusText = "在线:服务已连接";
private bool _isOnline = true;

public string StatusText
{
get => _statusText;
set => SetProperty(ref _statusText, value);
}

public bool IsOnline
{
get => _isOnline;
set
{
if (SetProperty(ref _isOnline, value))
{
StatusText = value
? "在线:服务已连接"
: "离线:正在等待重连";

OnPropertyChanged(nameof(StatusBrush));
}
}
}

public Brush StatusBrush => IsOnline
? new SolidColorBrush(Color.FromRgb(27, 126, 88))
: new SolidColorBrush(Color.FromRgb(176, 58, 58));

StatusBrush 是只读派生属性,所以 IsOnline 改变时要手动通知它。

跟做:在 XAML 里放两个观察目标

把第 4 个页签的内容替换成下面的结构。重点是两个带 x:Name 的 TextBlock:后面的 ClearBinding 和 SetBinding 都通过这两个名字找到目标控件:

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
<TabItem Header="4  清除绑定">
<StackPanel Margin="28">
<TextBlock Text="来源文本" Margin="0,0,0,6" />
<TextBox Padding="8"
Text="{Binding StatusText,
UpdateSourceTrigger=PropertyChanged}" />

<CheckBox Margin="0,16,0,0"
Content="来源切换为离线"
IsChecked="{Binding IsOnline}" />

<TextBlock Margin="0,24,0,6"
Text="只清除 Text 绑定"
Foreground="#617089" />
<TextBlock x:Name="SingleBindingTarget"
FontSize="24"
FontWeight="Bold" />

<TextBlock Margin="0,24,0,6"
Text="同时绑定 Text 和 Foreground"
Foreground="#617089" />
<TextBlock x:Name="AllBindingsTarget"
FontSize="24"
FontWeight="Bold" />

<WrapPanel Margin="0,24,0,0">
<Button Content="清除单属性绑定"
Padding="14,8"
Margin="0,0,10,0"
Click="OnClearSingleBindingClick" />
<Button Content="恢复单属性绑定"
Padding="14,8"
Margin="0,0,10,0"
Click="OnRestoreSingleBindingClick" />
<Button Content="清除全部绑定"
Padding="14,8"
Margin="0,0,10,0"
Click="OnClearAllBindingsClick" />
<Button Content="恢复全部绑定"
Padding="14,8"
Click="OnRestoreAllBindingsClick" />
</WrapPanel>
</StackPanel>
</TabItem>

跟做:在代码里保存并恢复 Binding

本节的按钮事件会用到下面的 using。文件顶部只保留一份即可,不要在每个代码块前重复粘贴:

1
2
3
4
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using WpfAdvancedBindingPractice.ViewModels;

跟做项目可以把第 1 到第 4 章的内容放在同一个 MainWindow.xaml.cs 中。若从前面章节一路写下来,文件里会同时存在 using WpfAdvancedBindingPractice.ViewModels; 和 using WpfAdvancedBindingPractice.Models;,这是正常现象。第 5 章会把这个无参构造函数整体替换掉,先不要额外保留第二份构造函数。

在 MainWindow.xaml.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
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
namespace WpfAdvancedBindingPractice;

public partial class MainWindow : Window
{
private readonly Binding _singleStatusBinding;
private readonly Binding _allStatusTextBinding;
private readonly Binding _allStatusBrushBinding;

public MainWindow()
{
InitializeComponent();

var viewModel = new MainViewModel();
DataContext = viewModel;

_singleStatusBinding = new Binding(
nameof(MainViewModel.StatusText))
{
Source = viewModel,
Mode = BindingMode.OneWay
};

_allStatusTextBinding = new Binding(
nameof(MainViewModel.StatusText))
{
Source = viewModel,
Mode = BindingMode.OneWay
};

_allStatusBrushBinding = new Binding(
nameof(MainViewModel.StatusBrush))
{
Source = viewModel,
Mode = BindingMode.OneWay
};

BindingOperations.SetBinding(
SingleBindingTarget,
TextBlock.TextProperty,
_singleStatusBinding);

BindingOperations.SetBinding(
AllBindingsTarget,
TextBlock.TextProperty,
_allStatusTextBinding);

BindingOperations.SetBinding(
AllBindingsTarget,
TextBlock.ForegroundProperty,
_allStatusBrushBinding);
}

private void OnClearSingleBindingClick(
object sender,
RoutedEventArgs e)
{
BindingOperations.ClearBinding(
SingleBindingTarget,
TextBlock.TextProperty);
}

private void OnRestoreSingleBindingClick(
object sender,
RoutedEventArgs e)
{
BindingOperations.SetBinding(
SingleBindingTarget,
TextBlock.TextProperty,
_singleStatusBinding);
}

private void OnClearAllBindingsClick(
object sender,
RoutedEventArgs e)
{
BindingOperations.ClearAllBindings(AllBindingsTarget);
}

private void OnRestoreAllBindingsClick(
object sender,
RoutedEventArgs e)
{
BindingOperations.SetBinding(
AllBindingsTarget,
TextBlock.TextProperty,
_allStatusTextBinding);

BindingOperations.SetBinding(
AllBindingsTarget,
TextBlock.ForegroundProperty,
_allStatusBrushBinding);
}
}

这里保存的是 Binding 对象,并在构造函数里主动装上去。清除以后,WPF 不会自动帮你把原绑定装回去,所以要恢复时必须重新调用 SetBinding。

跟做时容易漏掉构造函数里的三次 SetBinding。如果漏了,页面第一次显示时两个 TextBlock 会没有文字,直到点击“恢复”按钮才出现绑定。把这段安装代码放在构造函数里,能保证初始显示与恢复后的绑定完全一致。

这套写法可以概括为:

1
2
创建 Binding → 用 SetBinding 安装 → 需要时 ClearBinding
→ 需要恢复时,再用同一个 Binding 调用 SetBinding

跟做:观察表达式是否存在

如果想知道某个属性现在还有没有绑定,可以使用:

1
2
3
4
5
6
7
var expression = BindingOperations.GetBindingExpression(
SingleBindingTarget,
TextBlock.TextProperty);

MessageBox.Show(expression is null
? "当前没有绑定"
: "当前仍有绑定");

清除前不为 null,清除后为 null,恢复后再次不为 null。随附示例已实测这三步。

两个 API 的区别

API 作用范围 典型用途
ClearBinding(target, property) 只清除指定的一个目标属性 暂时停掉文本、颜色或宽度绑定
ClearAllBindings(target) 清除该元素上所有依赖属性的绑定 整块区域切换显示模式

运行后的检查点

  1. 点击“清除单属性绑定”,第一个 TextBlock 的文字变空。
  2. 修改上方来源文本框,第一个 TextBlock 不再变化。
  3. 点击“恢复单属性绑定”,它重新显示当前来源文字。
  4. 点击“清除全部绑定”,第二个 TextBlock 的文字和颜色都回到默认状态。
  5. 点击“恢复全部绑定”,文字和颜色同时恢复绑定。

小练习

增加一个“临时冻结显示”复选框。勾选时清除 SingleBindingTarget.Text 的绑定,取消勾选时用 SetBinding 恢复。要求来源对象的值在这个过程中始终不被修改。


5. 应用启动事件与启动参数

本节目标

理解 WPF 程序从进程启动到主窗口显示的过程,并学会在 App.OnStartup 中读取命令行参数。

先理解启动链路

启动参数从命令行到达界面的过程

图 6:启动参数先进入 e.Args,再由应用程序解析,最后交给窗口和 ViewModel。

普通 WPF 模板通常在 App.xaml 中写:

1
StartupUri="MainWindow.xaml"

这表示框架自动创建主窗口。示例为了让“参数解析”先发生,去掉了 StartupUri,改为在 App.OnStartup 中手动创建窗口。

这里有一个重要顺序:先进入 Application.Startup,再执行 App.OnStartup。如果使用 StartupUri,框架会在启动流程中自动创建窗口;一旦改用手动创建,就必须自己负责 Show(),否则进程可能直接结束,或者看起来“什么都没发生”。

跟做:准备 App.xaml

把 App.xaml 改成。删掉 StartupUri 后,程序就没有“自动创建窗口”的入口了,所以下一步必须在 OnStartup 里手动 new MainWindow(...):

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

重点是不要再写 StartupUri。如果保留它,WPF 会再自动创建一个窗口,容易出现两个主窗口。

跟做:写参数模型

新建 Models 文件夹,添加 StartupArguments.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
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
namespace WpfAdvancedBindingPractice.Models;

public sealed class StartupArguments
{
public static StartupArguments Empty { get; } = new();

private StartupArguments()
{
}

public bool DemoMode { get; init; }
public string UserName { get; init; } = "访客";
public string Role { get; init; } = "Guest";
public int? AutoExitCode { get; init; }
public string RawText { get; private init; } = "(没有启动参数)";

public static StartupArguments Parse(string[] args)
{
var values = new Dictionary<string, string?>(
StringComparer.OrdinalIgnoreCase);

for (var i = 0; i < args.Length; i++)
{
var token = args[i];
if (!token.StartsWith("--", StringComparison.Ordinal))
{
continue;
}

var body = token[2..];
var separator = body.IndexOf('=');

if (separator >= 0)
{
values[body[..separator]] = body[(separator + 1)..];
continue;
}

if (i + 1 < args.Length
&& !args[i + 1].StartsWith("--", StringComparison.Ordinal))
{
values[body] = args[++i];
}
else
{
values[body] = "true";
}
}

var demo = values.TryGetValue("demo", out var demoValue)
&& !string.Equals(
demoValue,
"false",
StringComparison.OrdinalIgnoreCase);

int? autoExit = null;
if (values.TryGetValue("auto-exit", out var exitValue)
&& int.TryParse(exitValue, out var parsedExit))
{
autoExit = parsedExit;
}

return new StartupArguments
{
DemoMode = demo,
UserName = values.GetValueOrDefault("name") ?? "访客",
Role = values.GetValueOrDefault("role") ?? "Guest",
AutoExitCode = autoExit,
RawText = args.Length == 0
? "(没有启动参数)"
: string.Join(
" ",
args.Select(argument =>
argument.Contains(' ')
? $"\"{argument}\""
: argument))
};
}
}

它支持两种写法:

1
2
--name=张三
--name 张三

也支持只有开关、没有值的参数:

1
--demo

跟做:在 OnStartup 中读取 e.Args

把 App.xaml.cs 改成。注意 StartupInfo 的初始值使用 StartupArguments.Empty,因为参数模型把无参构造设成了私有,不能让外部随便 new:

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

namespace WpfAdvancedBindingPractice;

public partial class App : Application
{
public StartupArguments StartupInfo { get; private set; }
= StartupArguments.Empty;

protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);

ShutdownMode = ShutdownMode.OnMainWindowClose;
StartupInfo = StartupArguments.Parse(e.Args);

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

这里做了三件事:

  1. 把 e.Args 交给解析器。
  2. 把解析结果保存到 StartupInfo。
  3. 手动创建并显示 MainWindow。

跟做:把参数交给 ViewModel

请用下面的构造函数替换第 4 章里的无参构造函数 MainWindow(),其他事件处理器保留。第 4 章创建的三个 Binding 字段也要保留,并在新构造函数里继续初始化,否则 readonly 字段没有赋值,项目无法编译。为了让你一次复制正确,下面把字段、ViewModel 属性和构造函数放在一起;如果文件里已经有这三个字段,就不要重复声明:

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
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using WpfAdvancedBindingPractice.Models;
using WpfAdvancedBindingPractice.ViewModels;

namespace WpfAdvancedBindingPractice;

public partial class MainWindow : Window
{
private readonly Binding _singleStatusBinding;
private readonly Binding _allStatusTextBinding;
private readonly Binding _allStatusBrushBinding;

public MainWindow(StartupArguments startup)
{
InitializeComponent();
ViewModel = new MainViewModel(startup);
DataContext = ViewModel;

_singleStatusBinding = new Binding(nameof(MainViewModel.StatusText))
{
Source = ViewModel,
Mode = BindingMode.OneWay
};

_allStatusTextBinding = new Binding(nameof(MainViewModel.StatusText))
{
Source = ViewModel,
Mode = BindingMode.OneWay
};

_allStatusBrushBinding = new Binding(nameof(MainViewModel.StatusBrush))
{
Source = ViewModel,
Mode = BindingMode.OneWay
};

BindingOperations.SetBinding(
SingleBindingTarget,
TextBlock.TextProperty,
_singleStatusBinding);

BindingOperations.SetBinding(
AllBindingsTarget,
TextBlock.TextProperty,
_allStatusTextBinding);

BindingOperations.SetBinding(
AllBindingsTarget,
TextBlock.ForegroundProperty,
_allStatusBrushBinding);
}

public MainViewModel ViewModel { get; }
}

在 MainViewModel 中保存并展示参数。下面的字段和构造函数用来替换第 1 章的无参构造函数,参数展示属性可以追加在类中。为了让日志预览在 ViewModel 构造后马上有内容,构造函数里调用了一次 RefreshEventLog();这个方法的代码在本节末尾给出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using WpfAdvancedBindingPractice.Models;

private readonly StartupArguments _startup;

public MainViewModel(StartupArguments startup)
{
_startup = startup;
RefreshEventLog();
}

public string StartupUserName => _startup.UserName;
public string StartupRole => _startup.Role;
public string StartupDemoMode => _startup.DemoMode ? "开启" : "关闭";
public string StartupAutoExit => _startup.AutoExitCode is int code
? $"启动后自动以退出码 {code} 结束"
: "不自动退出";
public string StartupArgumentSummary => _startup.RawText;

RefreshEventLog() 用来把日志文件内容读进 EventLogText。它依赖 App.EventLogPath,可以写成:

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

private string _eventLogText = string.Empty;

public string EventLogText
{
get => _eventLogText;
private set => SetProperty(ref _eventLogText, value);
}

public void RefreshEventLog()
{
EventLogText = File.Exists(App.EventLogPath)
? File.ReadAllText(App.EventLogPath)
: "事件日志还没有生成。";
}

App.EventLogPath 会在第 6 章的日志器中补上。跟做到这里时先保留这段代码,等第 6 章加完 EventLogPath 后项目才能整体编译。若不想跨章节依赖,也可以暂时删掉构造函数里的 RefreshEventLog(); 以及整段日志展示代码,等第 6 章再补回来。

第 5 个页签可以这样展示:

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
<TabItem Header="5  启动参数">
<StackPanel Margin="28">
<TextBlock Text="本次启动参数"
FontSize="22"
FontWeight="Bold" />

<Grid Margin="0,18,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="36" />
<RowDefinition Height="36" />
<RowDefinition Height="36" />
<RowDefinition Height="36" />
<RowDefinition Height="36" />
</Grid.RowDefinitions>

<TextBlock Text="姓名" Foreground="#617089" />
<TextBlock Grid.Column="1"
Text="{Binding StartupUserName}" />

<TextBlock Grid.Row="1"
Text="角色"
Foreground="#617089" />
<TextBlock Grid.Row="1"
Grid.Column="1"
Text="{Binding StartupRole}" />

<TextBlock Grid.Row="2"
Text="演示模式"
Foreground="#617089" />
<TextBlock Grid.Row="2"
Grid.Column="1"
Text="{Binding StartupDemoMode}" />

<TextBlock Grid.Row="3"
Text="自动退出"
Foreground="#617089" />
<TextBlock Grid.Row="3"
Grid.Column="1"
Text="{Binding StartupAutoExit}" />

<TextBlock Grid.Row="4"
Text="原始参数"
Foreground="#617089" />
<TextBlock Grid.Row="4"
Grid.Column="1"
Text="{Binding StartupArgumentSummary}" />
</Grid>
</StackPanel>
</TabItem>

跟做:从命令行运行

先编译,再带参数运行。这里的路径是随附示例;如果运行自己的 WpfAdvancedBindingPractice,把 cd 后面的目录换掉即可:

1
2
3
cd "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfAdvancedBindingDemo"
dotnet build
dotnet run -- --demo --name=张三 --role=Admin

-- 很重要。它告诉 dotnet run:后面的内容交给程序,不要当成 dotnet run 自己的选项。

随附示例还支持自动退出。这个功能只是测试辅助,不是 WPF 的必需代码。为了和后面的日志实验保持一致,在 App.xaml.cs 的 OnStartup 中、window.Show(); 后面补上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if (StartupInfo.AutoExitCode is int exitCode)
{
var timer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(1400)
};

timer.Tick += (_, _) =>
{
timer.Stop();
IsClosingForShutdown = true;
Shutdown(exitCode);
};

timer.Start();
}

同时在 App.xaml.cs 顶部加入:

1
using System.Windows.Threading;

这段代码让窗口有足够时间完成首次渲染,再主动结束应用,适合后面自动核对日志。IsClosingForShutdown 用来区分“用户关闭窗口”和“程序主动退出”;第 6 章会把它的作用补全。现在先照抄,编译时如果提示这个属性不存在,就跳到第 6 章把标记补上。正常发布程序时不需要保留自动退出逻辑。

1
dotnet run --no-build -- --demo --name=李雷 --role=Admin --auto-exit=7

程序启动约 1.4 秒后自动以退出码 7 结束,方便验证退出流程。

启动阶段观察表

阶段 可以做什么 本示例中的表现
进程启动 接收命令行 e.Args 中得到四个参数
App.OnStartup 解析参数、创建窗口 姓名、角色、演示模式被写入 ViewModel
Window.Loaded 窗口已经加载 写入 Window.Loaded 日志
Window.ContentRendered 界面首次渲染完成 写入 Window.ContentRendered 日志

运行后的检查点

  1. 从命令行启动后,第 5 个页签显示姓名“张三”、角色“Admin”、演示模式“开启”。
  2. 再启动一次,把 --name=张三 改成 --name 李雷,界面仍然能显示“李雷”。两种写法是等价的:= 连接和空格分隔都支持。
  3. 把命令改成 dotnet run -- --name=张三 --role=Admin,去掉 --demo 后,演示模式应显示“关闭”。
  4. 暂时在 dotnet run 后面漏写 --,观察参数没有进入程序;补回 -- 后恢复正确。

常见错误

现象 常见原因 处理
参数没有传进来 没写 --,参数被 dotnet run 吃掉 使用 dotnet run -- --name=张三
出现两个窗口 App.xaml 仍保留 StartupUri 删除 StartupUri
关闭窗口程序还在运行 ShutdownMode 不合适 设为 OnMainWindowClose 或显式调用 Shutdown
e.Args 顺序和输入不同 解析器按字典读取,不保证字典顺序 需要顺序时直接遍历 e.Args

小练习

给参数解析器增加一个 --theme=dark 或 --theme=light 选项。默认使用 light,并把最终主题显示在第 5 个页签。要求不认识的参数值不要让程序崩溃,而是回退到 light。


6. 应用退出事件

本节目标

分清四个阶段:

1
2
3
4
Window.Closing
Window.Closed
Application.Exit
进程退出码

并学会在 Closing 中取消关闭,在 OnExit 中读取退出码。

先理解关闭链路

窗口关闭与应用退出的事件顺序

图 7:先询问窗口能否关闭,再确认窗口已经关闭,最后应用退出。

三者的职责不同:

事件 触发时机 能否取消 常见用途
Window.Closing 窗口准备关闭 可以,设置 e.Cancel = true 提示保存、阻止误关
Window.Closed 窗口已经关闭 不能 释放窗口级资源、写日志
Application.Exit 应用准备退出 不能 保存应用级状态、读取退出码

可以把它们记成“询问、确认、收尾”:Closing 是询问,Closed 是窗口层面的确认,Application.Exit 是整个应用层面的收尾。事件名里带 Closing 的还在过程中,带 Closed 的已经完成,这个时态差别在排查顺序问题时很有用。

跟做:给窗口挂事件

在 MainWindow.xaml 的根元素上加入。Closing 和 Closed 都是窗口事件,不能写到 App.xaml 的 Application 元素上:

1
2
Closing="OnWindowClosing"
Closed="OnWindowClosed"

在代码隐藏文件中添加:

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

private void OnWindowClosing(
object? sender,
CancelEventArgs e)
{
var shouldPreventClose = true;

if (shouldPreventClose)
{
e.Cancel = true;
}
}

private void OnWindowClosed(
object? sender,
EventArgs e)
{
// 窗口已经关闭,不能在这里取消。
}

这里先固定为 true,用来观察“取消关闭”。真实项目中可以改成“有未保存内容”的判断。若只是验证 Closing → Closed → Application.Exit 的顺序,可以暂时把 shouldPreventClose 改成 false;否则窗口确实会被拦住,后续事件也不会继续发生。

再补一个“程序主动退出”的按钮,在 MainWindow.xaml 中加入:

1
2
3
4
5
<Button Content="以退出码 7 退出"
Margin="0,12,0,0"
Padding="12,7"
HorizontalAlignment="Left"
Click="OnShutdownWithCodeClick" />

事件处理器写成:

1
2
3
4
5
6
7
private void OnShutdownWithCodeClick(
object sender,
RoutedEventArgs e)
{
App.IsClosingForShutdown = true;
Application.Current.Shutdown(7);
}

如果按钮事件临时写成了 Application.Current.Shutdown(7) 而没有先设置标记,OnWindowClosing 会把它当成普通关闭,PreventClose 为 true 时仍然会被拦下。看到“点击退出按钮没反应”,先检查这两行的顺序。

App 中增加这个静态标记,并给 MainViewModel 补上 PreventClose 属性:

1
2
3
4
5
6
7
8
9
public static bool IsClosingForShutdown { get; set; }

private bool _preventClose;

public bool PreventClose
{
get => _preventClose;
set => SetProperty(ref _preventClose, value);
}

然后把 OnWindowClosing 改成同时判断来源:

1
2
3
4
5
6
7
8
9
10
private void OnWindowClosing(
object? sender,
CancelEventArgs e)
{
if (ViewModel.PreventClose
&& !App.IsClosingForShutdown)
{
e.Cancel = true;
}
}

这样用户点右上角关闭按钮时会被 PreventClose 拦住,而“以退出码 7 退出”仍然能顺利完成。这就是 IsClosingForShutdown 存在的意义。

点击右上角关闭按钮时,事件会先走 Window.Closing;只有这里没有取消,窗口才会真正关闭并触发 Window.Closed,最后应用退出并进入 Application.Exit。这也是为什么日志里的 Closing 一定排在 Closed 前面。

如果 PreventClose 为 true,上面的事件顺序只走到 Closing 就停下,不会继续产生 Closed 和 Application.Exit。这不是事件丢失,而是关闭被取消后的正常结果。

为了让按钮真的能控制 PreventClose,在第 6 个页签中放入复选框:

1
2
<CheckBox Content="阻止窗口关闭"
IsChecked="{Binding PreventClose}" />

跟做:在 App.OnExit 中读取退出码

给 App 增加:

1
2
3
4
5
6
7
8
protected override void OnExit(ExitEventArgs e)
{
var exitCode = e.ApplicationExitCode;

// 这里可以写日志或保存应用级状态。

base.OnExit(e);
}

主动指定退出码:

1
Application.Current.Shutdown(7);

如果调用的是不带参数的 Shutdown(),退出码通常是 0。退出码可以被 PowerShell、批处理或其他启动程序读取。

跟做:把 IsClosingForShutdown 接完整

第 5 章自动退出时用到了这个标记,第 6 章主动退出按钮也用了它。请在 App 中确认有:

1
public static bool IsClosingForShutdown { get; set; }

它不需要绑定到界面,只负责告诉窗口:“这次关闭来自程序主动 Shutdown,不要被普通的阻止关闭逻辑拦下。”每次进程启动时静态字段都会重新初始化为 false。如果你以后做“取消退出后继续使用程序”的复杂流程,记得在取消完成后手动把它复位为 false。

跟做:观察实际日志

为了亲眼看到顺序,给跟做项目加一个极简日志器。在 App.xaml.cs 顶部加入:

1
2
3
using System.IO;
using System.Text;
using System.Windows.Threading;

在 App 类中增加下面的日志方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static string EventLogPath { get; } =
Path.Combine(AppContext.BaseDirectory, "runtime-events.log");

public static void AppendEvent(string stage, string message)
{
var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} | {stage} | {message}";
File.AppendAllText(
EventLogPath,
line + Environment.NewLine,
new UTF8Encoding(false));
}

private static void ResetEventLog()
{
File.WriteAllText(
EventLogPath,
"WPF 应用程序生命周期实验日志" + Environment.NewLine,
new UTF8Encoding(false));
}

在 OnStartup 的 base.OnStartup(e); 后面调用 ResetEventLog();,并写入第一条记录:

1
2
3
4
ResetEventLog();
AppendEvent(
"Application.Startup",
$"参数={StartupInfo.RawText}");

在 MainWindow 构造函数末尾记录窗口阶段:

1
2
3
4
5
Loaded += (_, _) =>
App.AppendEvent("Window.Loaded", "主窗口已加载");

ContentRendered += (_, _) =>
App.AppendEvent("Window.ContentRendered", "界面首次渲染完成");

Window.Loaded 只表示元素已经进入可交互状态;Window.ContentRendered 才表示首帧已经绘制。两者顺序固定,但不要把二者当成同一个阶段。

在 OnWindowClosing 中记录。要把这行放在设置 e.Cancel 之后,才能记录到最终结果。它只负责记录,不会额外改变关闭结果:

1
2
3
App.AppendEvent(
"Window.Closing",
e.Cancel ? "已取消关闭" : "允许关闭");

OnWindowClosed 只记录一行:

1
App.AppendEvent("Window.Closed", "主窗口已关闭");

OnWindowClosed 已经无法再取消关闭,所以这里不要写 e.Cancel。它的参数是普通的 EventArgs,本来也没有 Cancel 属性。

最后在 OnExit 的 base.OnExit(e); 前面记录退出码:

1
2
3
App.AppendEvent(
"Application.Exit",
$"退出码={e.ApplicationExitCode}");

运行后,日志写到:

1
示例代码\WpfAdvancedBindingDemo\bin\Debug\net9.0-windows\runtime-events.log

使用自动退出参数运行后,实测日志顺序如下:

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

最后一次实测的退出码为 7。

跟做:从 PowerShell 读取退出码

下面的路径对应随附示例 WpfAdvancedBindingDemo。如果运行的是自己创建的 WpfAdvancedBindingPractice,把可执行文件名和输出目录中的项目名一起替换。

1
2
3
4
5
6
7
8
9
$exe = "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfAdvancedBindingDemo\bin\Debug\net9.0-windows\WpfAdvancedBindingDemo.exe"

$process = Start-Process `
-FilePath $exe `
-ArgumentList "--demo", "--name=李雷", "--role=Admin", "--auto-exit=7" `
-PassThru `
-Wait

$process.ExitCode

输出应为:

1
7

再查看日志:

1
2
3
Get-Content `
-LiteralPath "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfAdvancedBindingDemo\bin\Debug\net9.0-windows\runtime-events.log" `
-Encoding UTF8

关闭实验的检查点

  1. 勾选“阻止窗口关闭”,点击窗口右上角关闭按钮,窗口应当不关闭。
  2. 取消勾选,再关闭窗口,程序正常结束。
  3. 点击“以退出码 7 退出”,日志中出现 Application.Exit。
  4. 用 PowerShell 启动并读取 $process.ExitCode,结果应为 7。
  5. 对照日志确认 Closing 在 Closed 之前,Closed 在 Application.Exit 之前。

小练习

给“以退出码 7 退出”改成两个按钮,分别使用 0 和 2。再用 PowerShell 启动两次,比较 $process.ExitCode 和日志中的 Application.Exit 行。


完整项目结构

随附示例的核心文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
WpfAdvancedBindingDemo
├─ App.xaml
├─ App.xaml.cs
├─ MainWindow.xaml
├─ MainWindow.xaml.cs
├─ Models
│ └─ StartupArguments.cs
├─ Converters
│ ├─ OrderTotalConverter.cs
│ └─ EmptyStringToUnsetValueConverter.cs
└─ ViewModels
├─ ObservableObject.cs
└─ MainViewModel.cs

六个页签分别对应:

页签 关键 API 观察重点
1 MultiBinding、StringFormat 子 Binding 顺序与输出顺序
2 IMultiValueConverter values 数组顺序与计算
3 PriorityBinding 优先级选择与兜底值
4 BindingOperations 清除、恢复、属性默认值
5 App.OnStartup、e.Args 参数解析与窗口创建
6 Closing、Closed、OnExit 事件顺序与退出码

常见错误速查

现象 优先检查
MultiBinding 输出为空 子 Binding 的 Path 是否存在;DataContext 是否设置
输出顺序不对 子 Binding 的声明顺序;StringFormat 的编号
格式字符串报 XAML 错误 StringFormat 是否以 {} 开头
转换器没被调用 是否在资源中注册;Converter= 名称是否一致
values 顺序错 XAML 子 Binding 顺序与 values[0]、values[1] 的读取顺序
金额显示异常 来源是否是数字;decimal.TryParse 是否成功
PriorityBinding 不切换 空字符串是否已转换成 UnsetValue
兜底值不出现 FallbackValue 是否放在合适的子 Binding 上
清除绑定后不是旧值 这是正常现象;目标属性会回到默认值
恢复绑定失败 是否重新调用了 SetBinding;绑定对象是否仍有效
启动参数丢失 dotnet run 后面是否写了 --
出现两个窗口 App.xaml 是否还保留 StartupUri
无参构造函数报错 是否仍残留第 1 章的 MainWindow(),与第 5 章的新构造函数冲突
退出按钮没反应 是否先设置 App.IsClosingForShutdown = true,再调用 Shutdown(7)
退出码读不到 是否在 App.OnExit 的 ExitEventArgs 中读取

综合练习

把六章合成一个小工具:

  1. 第 1 页输入姓、名和岗位,实时生成员工名片标题。
  2. 第 2 页输入单价、数量、折扣,显示应付金额和原价。
  3. 第 3 页提供会员价、活动价、普通价,全部为空时显示“联系客服”。
  4. 第 4 页提供一个“冻结名片”复选框,勾选时清除标题绑定,取消时恢复。
  5. 启动时支持 --name=张三 --role=Admin,并把参数显示在标题区域。
  6. 关闭窗口时如果有未保存内容,弹出确认;退出时把退出码写入日志。

完成这个练习,说明你已经能把多值绑定、绑定生命周期和应用程序生命周期放进同一个可运行的小项目里。

结语

这一篇最重要的不是记住六个 API 的名字,而是建立四条判断:

1
2
3
4
多个值要合成一个结果时,先想 MultiBinding。
多个候选值要挑一个时,先想 PriorityBinding。
绑定关系要暂停时,用 BindingOperations 清除,需要时再 SetBinding。
程序启动先看 OnStartup 和 e.Args,退出先看 Closing、Closed 和 OnExit。

能把这四句话讲清楚,再回头看示例代码,就不会把“绑定表达式”“转换器”“窗口事件”和“应用事件”混在一起了。