面向已经会写绑定、写过转换器的读者,这一篇把「输入错了怎么办」这件事从头讲清楚。
推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,以便在已安装 .NET 9 SDK 的电脑上直接编译;用到的验证机制在 .NET 9 和 .NET 10 中没有变化。
最后更新:2026-10-20

目录

  1. 基于 IDataErrorInfo 的数据验证
  2. IDataErrorInfo 中的属性判断
  3. 结合特性的属性验证过程
  4. 基于正则表达式的数据验证
  5. 结合异常信息提示的输入框模板

写在前面

这份笔记解决什么问题

上一篇笔记讲的是转换器和 ValidationRule,重点在「怎么把值翻译过去」和「怎么拦住不合法的值」。这一篇换个角度,专门讲数据对象自己怎么报告错误,以及界面怎么把这些错误显示出来。

具体回答六个问题:

1
2
3
4
5
6
模型实现 IDataErrorInfo 之后,WPF 到底在什么时候调用它?
索引器里的 columnName 是什么?为什么有时校验根本不触发?
属性上写 [Required]、[Range] 这些特性,WPF 会自动认吗?
正则校验为什么容易「看起来对、其实漏了」?
错误提示为什么默认只有红边,没有文字?
怎么做一个既显示红边、又显示错误文字的输入框模板?

这六个问题串起来,是一条完整的链路:

数据对象报告错误 → 绑定收集错误 → 控件上的 Validation 附加属性保存错误 → 模板把错误画出来。

数据验证的完整链路

图 1:从数据对象到界面提示,错误信息要经过四站。

建议的学习顺序

  1. 先运行随附示例,把六个页签点一遍,重点看「输入非法值后界面怎么变」。
  2. 第 1、2 章连着学。第 1 章讲机制,第 2 章讲索引器里的属性名判断,两者是一体的。
  3. 第 3 章讲特性校验。这里有一个最容易误解的点:WPF 不会自动读特性。
  4. 第 4 章讲正则。重点不是背正则,而是知道什么时候该用、什么时候不该用。
  5. 第 5 章讲模板。前四章的错误都只有红边,这一章把文字也显示出来。

示例项目位置

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

命令行运行:

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

2026 年的目标框架

这一篇用到的东西都在 WPF 基础库里,没有版本相关的坑。示例工程用 net9.0-windows,你在自己的项目里换成 net10.0-windows 也一样。

1
2
3
4
5
6
7
8
9
10
11
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>

</Project>

先建一个自己的跟做项目

在 Visual Studio 里新建「WPF 应用程序」,项目名建议 WpfValidationPractice,框架选 net9.0-windows 或 net10.0-windows。

本篇正文的代码统一使用 WpfValidationPractice 这个跟做项目名;随附的完整示例项目叫 WpfValidationDemo,两者结构相同,只是命名空间不同。跟做时只认正文里的 WpfValidationPractice,不要一边复制正文、一边又粘贴示例命名空间。

建好后新建这几个文件夹:

1
2
3
4
WpfValidationPractice
├── Models
├── Converters
└── ViewModels

先准备好属性通知基类

本篇所有模型都要在属性变化时通知界面,所以先写一个最薄的基类。后面每个模型都继承它,SetProperty 只写一次。

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
// Models/ObservableObject.cs
using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace WpfValidationPractice.Models;

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));
}

先准备好员工模型

这是第 1、2 章的主角。它自己实现 IDataErrorInfo,校验逻辑全部写在索引器里。

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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
// Models/Employee.cs
using System.ComponentModel;

namespace WpfValidationPractice.Models;

public class Employee : ObservableObject, IDataErrorInfo
{
private string _name = "张三";
private int _age = 28;
private string _phone = "13800138000";
private decimal _salary = 12000m;
private string _email = "zhangsan@example.com";

public string Name
{
get => _name;
set
{
if (SetProperty(ref _name, value))
{
// Error 的值依赖 Name,所以 Name 变了也要通知 Error。
OnPropertyChanged(nameof(Error));
}
}
}

public int Age
{
get => _age;
set => SetProperty(ref _age, value);
}

public string Phone
{
get => _phone;
set => SetProperty(ref _phone, value);
}

public decimal Salary
{
get => _salary;
set => SetProperty(ref _salary, value);
}

public string Email
{
get => _email;
set => SetProperty(ref _email, value);
}

// 整对象错误。平时返回空串,姓名缺失时给一条整体提示。
public string Error
{
get
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(Name))
{
errors.Add("员工信息不完整:姓名不能为空");
}

return string.Join(";", errors);
}
}

// 索引器:WPF 写回某个属性时会调用它。
public string this[string columnName]
{
get
{
return columnName switch
{
nameof(Name) => ValidateName(),
nameof(Age) => ValidateAge(),
nameof(Phone) => ValidatePhone(),
nameof(Salary) => ValidateSalary(),
nameof(Email) => ValidateEmail(),
_ => string.Empty,
};
}
}

private string ValidateName()
{
if (string.IsNullOrWhiteSpace(Name))
{
return "姓名不能为空";
}

if (Name.Trim().Length < 2)
{
return "姓名至少 2 个字";
}

if (Name.Trim().Length > 10)
{
return "姓名最多 10 个字";
}

return string.Empty;
}

private string ValidateAge()
{
if (Age < 18)
{
return "年龄不能小于 18";
}

if (Age > 60)
{
return "年龄不能大于 60";
}

return string.Empty;
}

private string ValidatePhone()
{
if (string.IsNullOrWhiteSpace(Phone))
{
return "手机号不能为空";
}

if (Phone.Length != 11 || !Phone.All(char.IsDigit))
{
return "手机号必须是 11 位数字";
}

if (Phone[0] != '1' || Phone[1] < '3' || Phone[1] > '9')
{
return "手机号应以 13 到 19 开头";
}

return string.Empty;
}

private string ValidateSalary()
{
if (Salary < 3000m)
{
return "工资不能低于 3000";
}

if (Salary > 200000m)
{
return "工资不能高于 200000";
}

return string.Empty;
}

private string ValidateEmail()
{
if (string.IsNullOrWhiteSpace(Email))
{
return "邮箱不能为空";
}

// 这里故意写得宽松一点,第 4 章再用正则收紧。
if (!Email.Contains('@') || !Email.Contains('.'))
{
return "邮箱格式不正确";
}

return string.Empty;
}
}

先准备好特性模型

第 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
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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
// Models/ValidatedEmployee.cs
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Reflection;

namespace WpfValidationPractice.Models;

public class ValidatedEmployee : ObservableObject, IDataErrorInfo
{
private readonly Dictionary<string, string> _errors = new();

private string _name = "张三";
private int _age = 28;
private string _phone = "13800138000";
private string _email = "zhangsan@example.com";
private decimal _salary = 12000m;
private DateTime _entryDate = new(2026, 10, 20);

[Display(Name = "姓名")]
[Required(ErrorMessage = "{0}不能为空")]
[StringLength(10, MinimumLength = 2, ErrorMessage = "{0}长度必须在 {2} 到 {1} 之间")]
public string Name
{
get => _name;
set
{
if (SetProperty(ref _name, value))
{
ValidateProperty(value, nameof(Name));
}
}
}

[Display(Name = "年龄")]
[Range(18, 60, ErrorMessage = "{0}必须在 {1} 到 {2} 之间")]
public int Age
{
get => _age;
set
{
if (SetProperty(ref _age, value))
{
ValidateProperty(value, nameof(Age));
}
}
}

[Display(Name = "手机号")]
[RegularExpression(@"^1[3-9]\d{9}$", ErrorMessage = "{0}格式不正确")]
public string Phone
{
get => _phone;
set
{
if (SetProperty(ref _phone, value))
{
ValidateProperty(value, nameof(Phone));
}
}
}

[Display(Name = "邮箱")]
[EmailAddress(ErrorMessage = "{0}格式不正确")]
public string Email
{
get => _email;
set
{
if (SetProperty(ref _email, value))
{
ValidateProperty(value, nameof(Email));
}
}
}

[Display(Name = "工资")]
[Range(typeof(decimal), "3000", "200000", ErrorMessage = "{0}必须在 {1} 到 {2} 之间")]
public decimal Salary
{
get => _salary;
set
{
if (SetProperty(ref _salary, value))
{
ValidateProperty(value, nameof(Salary));
}
}
}

[Display(Name = "入职日期")]
[PastDate(ErrorMessage = "{0}不能晚于今天")]
public DateTime EntryDate
{
get => _entryDate;
set
{
if (SetProperty(ref _entryDate, value))
{
ValidateProperty(value, nameof(EntryDate));
}
}
}

public string Error => string.Join(";", _errors.Values);

public string this[string columnName]
=> _errors.TryGetValue(columnName, out var message) ? message : string.Empty;

private void ValidateProperty(object? value, string propertyName)
{
var context = new ValidationContext(this) { MemberName = propertyName };
var results = new List<ValidationResult>();

if (Validator.TryValidateProperty(value, context, results))
{
_errors.Remove(propertyName);
return;
}

var first = results.FirstOrDefault(r => !string.IsNullOrWhiteSpace(r.ErrorMessage));
if (first is not null)
{
_errors[propertyName] = first.ErrorMessage!;
}
}

public string ValidateAll()
{
_errors.Clear();
var context = new ValidationContext(this);
var results = new List<ValidationResult>();

if (Validator.TryValidateObject(this, context, results, validateAllProperties: true))
{
return "全部通过";
}

foreach (var result in results)
{
foreach (var member in result.MemberNames)
{
_errors[member] = result.ErrorMessage ?? "校验失败";
}
}

return string.Join(";", results.Select(r => r.ErrorMessage));
}

public static string DescribeRules(string propertyName)
{
var property = typeof(ValidatedEmployee).GetProperty(propertyName);
if (property is null)
{
return "找不到属性";
}

var attributes = property.GetCustomAttributes<ValidationAttribute>().ToArray();
if (attributes.Length == 0)
{
return "没有校验特性";
}

return string.Join(" / ", attributes.Select(a => a.GetType().Name));
}
}

public sealed class PastDateAttribute : ValidationAttribute
{
public override bool IsValid(object? value)
{
if (value is not DateTime date)
{
return true;
}

return date.Date <= DateTime.Today;
}
}

先准备好正则模型

第 4 章的主角。注意每个正则都带超时,这是生产代码里该有的习惯。

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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
// Models/Registration.cs
using System.ComponentModel;
using System.Text.RegularExpressions;

namespace WpfValidationPractice.Models;

public class Registration : ObservableObject, IDataErrorInfo
{
private static readonly Regex PhoneRegex = new(
@"^1[3-9]\d{9}$", RegexOptions.Compiled, TimeSpan.FromMilliseconds(200));

private static readonly Regex EmailRegex = new(
@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.Compiled, TimeSpan.FromMilliseconds(200));

private static readonly Regex IdCardRegex = new(
@"^\d{17}[\dXx]$", RegexOptions.Compiled, TimeSpan.FromMilliseconds(200));

private static readonly Regex PostalCodeRegex = new(
@"^\d{6}$", RegexOptions.Compiled, TimeSpan.FromMilliseconds(200));

private string _phone = "13800138000";
private string _email = "zhangsan@example.com";
private string _idCard = "110101199003074512";
private string _postalCode = "100000";
private string _userName = "zhang_san";

public string Phone
{
get => _phone;
set => SetProperty(ref _phone, value);
}

public string Email
{
get => _email;
set => SetProperty(ref _email, value);
}

public string IdCard
{
get => _idCard;
set => SetProperty(ref _idCard, value);
}

public string PostalCode
{
get => _postalCode;
set => SetProperty(ref _postalCode, value);
}

public string UserName
{
get => _userName;
set => SetProperty(ref _userName, value);
}

public string Error => string.Empty;

public string this[string columnName]
{
get
{
return columnName switch
{
nameof(Phone) => Match(PhoneRegex, Phone, "手机号必须是 11 位,且以 13 到 19 开头"),
nameof(Email) => Match(EmailRegex, Email, "邮箱格式不正确,例如 name@example.com"),
nameof(IdCard) => Match(IdCardRegex, IdCard, "身份证必须是 18 位,最后一位可以是 X"),
nameof(PostalCode) => Match(PostalCodeRegex, PostalCode, "邮编必须是 6 位数字"),
nameof(UserName) => ValidateUserName(),
_ => string.Empty,
};
}
}

private static string Match(Regex regex, string? input, string message)
{
if (string.IsNullOrWhiteSpace(input))
{
return "不能为空";
}

try
{
return regex.IsMatch(input) ? string.Empty : message;
}
catch (RegexMatchTimeoutException)
{
return "校验超时,请检查输入";
}
}

private string ValidateUserName()
{
if (string.IsNullOrWhiteSpace(UserName))
{
return "用户名不能为空";
}

if (UserName.Length < 4 || UserName.Length > 16)
{
return "用户名长度必须是 4 到 16 位";
}

if (!char.IsLetter(UserName[0]))
{
return "用户名必须以字母开头";
}

foreach (var ch in UserName)
{
if (!char.IsLetterOrDigit(ch) && ch != '_')
{
return "用户名只能包含字母、数字和下划线";
}
}

return string.Empty;
}

public static bool TestPattern(string pattern, string input, out string message)
{
try
{
var regex = new Regex(pattern, RegexOptions.None, TimeSpan.FromMilliseconds(200));
var ok = regex.IsMatch(input);
message = ok ? "匹配成功" : "匹配失败";
return ok;
}
catch (ArgumentException ex)
{
message = "正则本身写错了:" + ex.Message;
return false;
}
catch (RegexMatchTimeoutException)
{
message = "匹配超时";
return false;
}
}
}

先准备好视图模型

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
// ViewModels/MainViewModel.cs
using WpfValidationPractice.Models;

namespace WpfValidationPractice.ViewModels;

public class MainViewModel : ObservableObject
{
private string _log = string.Empty;
private string _ruleDescription = string.Empty;

public Employee Employee { get; } = new();
public ValidatedEmployee ValidatedEmployee { get; } = new();
public Registration Registration { get; } = new();

public string RuleDescription
{
get => _ruleDescription;
set => SetProperty(ref _ruleDescription, value);
}

public string Log
{
get => _log;
set => SetProperty(ref _log, value);
}

public void AppendLog(string message)
{
Log = string.IsNullOrEmpty(Log) ? message : Log + Environment.NewLine + message;
}
}

先准备好两个转换器

第一个转换器第 5 章的模板要用,负责把错误内容从集合里取出来。第二个把布尔值转成可见性,适合在模板外做普通绑定;第 5 章模板内部仍用 Trigger,不需要它。

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
// Converters/ErrorContentToStringConverter.cs
using System.Globalization;
using System.Windows.Controls;
using System.Windows.Data;

namespace WpfValidationPractice.Converters;

public class ErrorContentToStringConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is not System.Collections.IEnumerable errors)
{
return string.Empty;
}

foreach (var item in errors)
{
if (item is ValidationError error && error.ErrorContent is not null)
{
return error.ErrorContent.ToString() ?? string.Empty;
}
}

return string.Empty;
}

public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Converters/HasErrorToVisibilityConverter.cs
using System.Globalization;
using System.Windows;
using System.Windows.Data;

namespace WpfValidationPractice.Converters;

public class HasErrorToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is true ? Visibility.Visible : Visibility.Collapsed;

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

示例文件分工

1
2
3
4
5
6
7
8
9
10
11
12
13
WpfValidationDemo
├── MainWindow.xaml 六个页签
├── MainWindow.xaml.cs 按钮事件
├── Models
│ ├── ObservableObject.cs 属性通知基类
│ ├── Employee.cs 第 1、2 章
│ ├── ValidatedEmployee.cs 第 3 章
│ └── Registration.cs 第 4 章
├── Converters
│ ├── ErrorContentToStringConverter.cs
│ └── HasErrorToVisibilityConverter.cs
└── ViewModels
└── MainViewModel.cs

1. 基于 IDataErrorInfo 的数据验证

本节目标

  • 知道 IDataErrorInfo 的两个成员各自负责什么。
  • 知道 WPF 在什么时机调用索引器,以及为什么有时根本不调用。
  • 分清「属性级错误」和「整对象错误」。
  • 用 ValidatesOnDataErrors="True" 让错误显示出来。

1.1 它只是一个接口,不是什么框架

IDataErrorInfo 的完整定义只有两个成员:

1
2
3
4
5
6
7
8
public interface IDataErrorInfo
{
// 整个对象的错误
string Error { get; }

// 某个属性的错误,columnName 就是属性名
string this[string columnName] { get; }
}

它的约定非常朴素:返回空字符串表示这个属性没问题,返回非空字符串表示有问题,那段文字就是错误消息。

注意这里没有布尔返回值。判断「有没有错」靠的是「字符串是不是空的」。这个设计现在看有点简陋,但在 WPF 刚出现的年代很常见。

1.2 绑定上的那个开关

模型实现了接口还不够,绑定上要显式打开开关:

1
2
3
4
5
6
7
8
<TextBox>
<TextBox.Text>
<Binding Path="Employee.Age"
Mode="TwoWay"
UpdateSourceTrigger="PropertyChanged"
ValidatesOnDataErrors="True" />
</TextBox.Text>
</TextBox>

四个属性里,ValidatesOnDataErrors="True" 是这一章的关键。少了它,模型写得再对,界面也不会有任何反应。

实测对照:

配置 输入 10(年龄下限 18) 界面上能看到什么
不写 ValidatesOnDataErrors 来源变成 10 没有红边,没有任何提示
写 ValidatesOnDataErrors="True" 来源变成 10 红边出现,错误内容是「年龄不能小于 18」

注意第二行:即使校验失败,来源值也已经变成 10 了。 这一点很多人会误解。IDataErrorInfo 是「事后报告」,不是「事前拦截」。值已经写进去了,接口只是补充一句「这个值不合适」。

想真正做到「不合法就不写进去」,得用上一篇的 ValidationRule,或者在属性的 setter 里拒绝赋值。这是两种不同的思路,后面「两种验证方式对照表」会专门对比。

1.3 什么时候调用索引器

这是本章最需要弄清楚的一件事。实测下来,调用时机可以归纳成三条:

第一条:绑定建立时会调用。

控件第一次挂上绑定时,WPF 会拿当前值问一次索引器。所以如果初始值本身就不合法,界面一打开就是红边。实测:初始 Age = 5,绑定后立刻有 1 条错误,索引器被调用 1 次。

第二条:每次写回来源时会被调用,但次数不固定,至少一次。

UpdateSourceTrigger="PropertyChanged" 时,每敲一个字符触发一次写回,也就触发一轮校验。LostFocus 时,要等失去焦点才写回,才校验。实测同一轮写回里,WPF 可能调用索引器 1 次,也可能调用 2 次,所以校验逻辑要允许重复执行。

实测:LostFocus 模式下把文本改成 10 但不移开焦点,索引器调用 0 次,界面上也没有红边。

第三条:值写不回去的时候,不调用。

这一条最容易被忽略。年龄是 int,你在文本框里输入 abc,类型转换就失败了,值根本没到达来源属性,索引器自然不会被调用。

实测:输入 abc 后,错误数是 1,但那条错误是上一次(输入 10)留下的,索引器这次调用 0 次。界面上看起来「有红边」,但错误消息还是旧的。

索引器的三个调用时机

图 2:绑定建立、写回成功、写回失败,三种情况索引器的调用次数不同。

1.4 Error 和索引器不是一回事

Error 属性描述的是整个对象的问题,索引器描述的是单个属性的问题。它们的用途完全不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 整对象:姓名缺失时,这条记录不完整
public string Error
{
get
{
if (string.IsNullOrWhiteSpace(Name))
{
return "员工信息不完整:姓名不能为空";
}

return string.Empty;
}
}

实测发现一个细节:​Error​​ ** 属性非空,并不会让某个输入框变红。** 因为输入框绑定的是某个具体属性,WPF 只关心那个属性的索引器结果。Error 更适合在界面上单独显示,比如页面顶部的「这条记录有问题」提示条。

示例程序的第 2 个页签就把它单独显示在右侧,方便观察两者的区别。

1.5 返回值的四种情况

索引器返回的字符串,WPF 会按这几种情况处理:

返回值 结果 说明
string.Empty 通过 这是「没有错误」的标准写法
null 不推荐 接口声明允许,但示例没有依赖它;请统一返回 string.Empty
" "(三个空格) 算错误 非空就是错误,空格也算内容
"年龄不能小于 18" 错误 正常情况

第二行和第三行放在一起看很有意思:空串表示通过,三个空格却算错误。原因是 WPF 判断的是「字符串是不是空的」,空格不算空。

null 虽然从接口类型上看得通,但不同绑定路径下的表现不值得初学阶段依赖。想表达「通过」,就老老实实返回 string.Empty,不要用 string.IsNullOrWhiteSpace 的直觉去理解这个返回值。

1.6 索引器抛异常会怎样

这一条也实测了,因为直觉上会觉得「校验代码出错应该报出来」。

实测结果:索引器里抛出的异常被 WPF 吞掉了,界面上没有错误,也没有异常冒到调用方。 错误数 0,看起来就像校验通过了。

这是个很危险的坑:你的校验逻辑里如果有空引用、越界之类的问题,界面会安静地显示「没问题」,你完全不知道校验已经失效了。

所以索引器里要写得足够防御:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public string this[string columnName]
{
get
{
try
{
return columnName switch
{
nameof(Age) => ValidateAge(),
_ => string.Empty,
};
}
catch (Exception)
{
// 校验本身出错时,宁可提示「无法校验」,也不要静默通过。
return "校验时发生错误,请检查数据";
}
}
}

初学阶段可以不加这个 try,但要知道这个行为的存在。等你的校验逻辑开始变复杂(读数据库、查字典、算金额),这层保护就很有必要了。

1.7 跟做:搭出第 1 章页签

在 MainWindow.xaml 里放一个 TabControl,第一个页签左边放五个输入框,右边放来源值展示。

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

<StackPanel Grid.Column="0" Margin="0,0,28,0">
<TextBlock Text="姓名" Foreground="#5D6B82" Margin="0,0,0,6" />
<TextBox Height="34" Padding="8,0" VerticalContentAlignment="Center">
<TextBox.Text>
<Binding Path="Employee.Name"
Mode="TwoWay"
UpdateSourceTrigger="PropertyChanged"
ValidatesOnDataErrors="True" />
</TextBox.Text>
</TextBox>

<TextBlock Text="年龄" Foreground="#5D6B82" Margin="0,16,0,6" />
<TextBox Height="34" Padding="8,0" VerticalContentAlignment="Center">
<TextBox.Text>
<Binding Path="Employee.Age"
Mode="TwoWay"
UpdateSourceTrigger="PropertyChanged"
ValidatesOnDataErrors="True" />
</TextBox.Text>
</TextBox>

<TextBlock Text="手机号" Foreground="#5D6B82" Margin="0,16,0,6" />
<TextBox Height="34" Padding="8,0" VerticalContentAlignment="Center">
<TextBox.Text>
<Binding Path="Employee.Phone"
Mode="TwoWay"
UpdateSourceTrigger="PropertyChanged"
ValidatesOnDataErrors="True" />
</TextBox.Text>
</TextBox>
</StackPanel>

<Border Grid.Column="1" Background="#F7F9FD" BorderBrush="#DCE5F2"
BorderThickness="1" CornerRadius="6" Padding="18">
<StackPanel>
<TextBlock Text="当前来源值" FontWeight="Bold" />
<TextBlock Margin="0,10,0,0" Text="{Binding Employee.Name, StringFormat=姓名:{0}}" />
<TextBlock Margin="0,6,0,0" Text="{Binding Employee.Age, StringFormat=年龄:{0}}" />
<TextBlock Margin="0,6,0,0" Text="{Binding Employee.Phone, StringFormat=手机号:{0}}" />
</StackPanel>
</Border>
</Grid>
</TabItem>

窗口的构造函数里把 DataContext 设好:

1
2
3
4
5
6
public MainWindow()
{
InitializeComponent();
ViewModel = new MainViewModel();
DataContext = ViewModel;
}

1.8 跟做检查点

启动程序,逐条确认:

  1. 五个输入框初始都没有红边,因为初始值都合法。
  2. 把年龄改成 10,输入框出现红边,右侧「年龄」那一行也变成 10。这就是 IDataErrorInfo 的「事后报告」:值已经写进去了,模型再补一句不合法。
  3. 把年龄改回 28,红边消失。
  4. 把姓名清空,红边出现。第 1 章默认模板只画红边,不显示文字;想看到「姓名不能为空」,可到第 5 页用带气泡的模板验证。
  5. 在年龄框里输入 abc,右侧年龄不变,红边保持上一次的状态。来源对象仍是合法值时,界面上可能仍然留着旧红边,这是类型转换失败后没有重新执行属性校验造成的。
  6. 把 ValidatesOnDataErrors="True" 临时删掉,再重复第 2 步,红边不再出现。

第 5 步是最容易困惑的。它不是 bug,而是「类型转换失败,值没写回,索引器没被调用」的直接结果。

1.9 小练习

练习一:加一个「部门」字段。 规则是:部门名不能为空,且不能超过 10 个字。只改 Employee 和界面,不碰其他地方。

练习二:让初始值故意不合法。 把 Age 的初始值改成 10,启动程序,观察「一打开就有红边」。然后想一想:如果你希望启动时不报错,该在哪里处理?

练习三:把索引器里的 ​​nameof(Age)​​ ** 改成字符串 **​ ​"age"​​ 。 重新运行,输入 10,看看还会不会报错。这个练习是为了让你体会「属性名大小写」这件事——下一章会详细讲。


2. IDataErrorInfo 中的属性判断

本节目标

  • 知道索引器里的 columnName 到底是什么。
  • 学会用 switch 或 nameof 做属性分派。
  • 知道嵌套路径、集合索引路径下索引器拿不到属性名。
  • 避开「属性名写错,校验静默失效」这个坑。

2.1 columnName 就是属性名

先把这个说清楚:WPF 调用索引器时,传进来的 columnName 是绑定的路径里最后那一段属性名。

实测结果:

绑定的 Path 索引器收到的 columnName
Employee.Name Name
Employee.Age Age

注意,绑定写的是 Employee.Name,但传进来的是 Name,前面的 Employee. 被剥掉了。因为 WPF 是对「员工对象」调用索引器,它只关心这个对象上的哪个属性出了问题。

2.2 用 switch 做分派

最清楚的写法是 switch 表达式,每个分支调用一个独立的校验方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public string this[string columnName]
{
get
{
return columnName switch
{
nameof(Name) => ValidateName(),
nameof(Age) => ValidateAge(),
nameof(Phone) => ValidatePhone(),
nameof(Salary) => ValidateSalary(),
nameof(Email) => ValidateEmail(),
_ => string.Empty,
};
}
}

三个细节:

第一,用 ​​nameof​​ ** 而不是字符串字面量。** 写 nameof(Age) 的好处是:将来把属性改名,编译器会在这里报错,你能立刻发现。写 "Age" 的话,改名后这里会安静地失效。

第二,​ ​_​​ ** 分支返回空串。** 意思是「这个属性我没写校验规则,当作通过」。这很重要,因为一个对象上可能有很多属性,你不需要每个都校验。

第三,每个校验方法只关心自己的属性。 不要在一个方法里同时判断好几个属性,那样会越来越乱。

2.3 属性名大小写是敏感的

这一条实测过,结论很明确:大小写敏感。

把索引器里的 nameof(Age) 改成 "age",然后输入 10:

写法 结果
nameof(Age) 命中分支,返回「年龄不能小于 18」,界面报错
"age" 不命中,走到 _ 分支返回空串,界面不报错

实测里我还专门试了「索引器对小写 age 返回错误」这种情况:WPF 照样会把这条错误显示出来,因为它只是把索引器返回的字符串原样使用,并不会去核对属性名。

换句话说,WPF 不检查你返回的错误是不是对应正确的属性。写错了属性名,要么静默失效,要么把错误显示在错误的属性上,两种都很麻烦。 这就是为什么强烈建议用 nameof。

2.4 嵌套路径:索引器拿不到名字

如果绑定路径是 Department.Name,也就是「员工的部门对象的名称」,情况就变了。

实测结果:写回时,Employee 这一层的索引器收到的调用次数是 0。因为 WPF 把值写到了 Department 对象上,校验应该由 Department 自己去报告。

这带来两个结论:

第一,每个对象只对自己的属性负责。 员工的索引器里写 nameof(Name),管的是员工自己的 Name,管不到部门的 Name。

第二,嵌套对象如果也实现了 ​​IDataErrorInfo​​ ,它的错误会被正确收集。 实测:让 Department 实现接口并返回错误,绑定 Department.Name 的输入框立刻出现红边,错误内容就是部门对象给的。

1
2
3
4
5
6
7
8
9
10
11
public class Department : IDataErrorInfo
{
public string Name { get; set; } = "研发部";

public string Error => string.Empty;

public string this[string columnName]
=> columnName == nameof(Name) && string.IsNullOrWhiteSpace(Name)
? "部门名称不能为空"
: string.Empty;
}

2.5 集合索引路径也一样

绑定 Items[0] 时,写回发生在 Items 集合的第 0 个元素上。实测:外层对象的索引器调用次数同样是 0。

原因和嵌套路径一样:值不属于外层对象,校验责任也不在外层对象。

2.6 整对象错误用空字符串

有一种特殊情况:索引器收到空字符串 ""。按接口约定,这表示「整个对象」的校验。

不过实测下来,在普通的属性绑定里,这个分支基本不会被触发。我专门让索引器对 "" 返回错误,结果那个输入框的错误数是 0。

所以实践建议是:整对象错误就写在 ​​Error​​ ** 属性里,不要在索引器的 ​ ​""​​ ** 分支上花心思。 后者行为不稳定,也不是常用路径。

2.7 一个属性改了,别的属性要不要跟着校验

这是实际项目里一定会遇到的问题。比如「起始日期」和「结束日期」,结束日期不能早于起始日期。改了起始日期,结束日期也应该重新校验。

IDataErrorInfo 本身不解决这件事,因为 WPF 只在「某个属性被写回」时校验那个属性。你需要自己触发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private DateTime _startDate;
private DateTime _endDate;

public DateTime StartDate
{
get => _startDate;
set
{
if (SetProperty(ref _startDate, value))
{
// 起始日期变了,结束日期的校验结果也可能变,通知界面重新读。
OnPropertyChanged(nameof(EndDate));
}
}
}

注意这里通知的是 EndDate 的属性变化。界面收到通知后会重新从来源取值,但这并不会自动重新触发校验。要重新校验,还得让绑定重新写回一次,或者改用 INotifyDataErrorInfo(它能主动推送错误变化)。

这个话题超出本篇范围,这里先记住结论:跨属性校验,​​IDataErrorInfo​​ ** 做起来很别扭。**

2.8 跟做:搭出第 2 章页签

第二个页签的结构和第 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
<TabItem Header="2  属性判断">
<Grid Margin="24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="420" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>

<StackPanel Grid.Column="0" Margin="0,0,28,0">
<TextBlock Text="姓名(2 到 10 字)" Foreground="#5D6B82" Margin="0,0,0,6" />
<TextBox Height="34" Padding="8,0" VerticalContentAlignment="Center">
<TextBox.Text>
<Binding Path="Employee.Name" Mode="TwoWay"
UpdateSourceTrigger="PropertyChanged"
ValidatesOnDataErrors="True" />
</TextBox.Text>
</TextBox>

<TextBlock Text="年龄(18 到 60)" Foreground="#5D6B82" Margin="0,16,0,6" />
<TextBox Height="34" Padding="8,0" VerticalContentAlignment="Center">
<TextBox.Text>
<Binding Path="Employee.Age" Mode="TwoWay"
UpdateSourceTrigger="PropertyChanged"
ValidatesOnDataErrors="True" />
</TextBox.Text>
</TextBox>

<Button Content="清空姓名,观察整对象错误"
Margin="0,22,0,0" Padding="16,7"
HorizontalAlignment="Left"
Background="#2F6FED" Foreground="White" BorderThickness="0"
Click="OnClearNameClick" />
</StackPanel>

<StackPanel Grid.Column="1">
<Border Background="#F7F9FD" BorderBrush="#DCE5F2"
BorderThickness="1" CornerRadius="6" Padding="18">
<StackPanel>
<TextBlock Text="整对象错误(Error 属性)" FontWeight="Bold" />
<TextBlock Margin="0,10,0,0" TextWrapping="Wrap"
Foreground="#B03A3A"
Text="{Binding Employee.Error, TargetNullValue=(空)}" />
</StackPanel>
</Border>
</StackPanel>
</Grid>
</TabItem>

按钮事件:

1
2
3
4
private void OnClearNameClick(object sender, RoutedEventArgs e)
{
ViewModel.Employee.Name = string.Empty;
}

注意上面 Name 的 setter:除了通知 Name,还额外通知了 Error。原因是 Error 的值依赖姓名,如果只通知 Name,右侧绑定 Employee.Error 的文本不会刷新。这个小坑正好说明:IDataErrorInfo 的字符串属性也需要 INotifyPropertyChanged 配合。

2.9 跟做检查点

  1. 姓名输入 张,出现红边。默认模板只画红边,错误文字用第 5 页的气泡模板查看。
  2. 姓名输入 张三,红边消失。
  3. 姓名清空,红边出现,右侧「整对象错误」同时出现文字。
  4. 年龄输入 70,出现红边;第 5 页的年龄框也用同一个来源,切过去就能看到「年龄不能大于 60」。
  5. 把索引器里的 nameof(Age) 改成 "age",重新运行,输入 10 不再报错。改回来。

第 5 步就是「属性名写错,校验静默失效」的真实样子。改的时候注意:Visual Studio 不会给你任何提示,因为 "age" 只是一个普通字符串。

2.10 小练习

练习一:给 ​​Employee​​ ** 加一个「部门」属性。** 校验规则:不能为空。然后用 switch 加一个分支。

练习二:把 ​​switch​​ ** 改成 ​​if / else if​**​ 。 对比一下两种写法的可读性,想一想属性多到十几个时哪种更好维护。

练习三:做一个「起始日期 / 结束日期」的小例子。 先只用 IDataErrorInfo,看看改起始日期时结束日期会不会重新校验。这个练习会让你直观感受到 2.7 节说的问题。


3. 结合特性的属性验证过程

本节目标

  • 知道 DataAnnotations 提供了哪些常用特性。
  • 弄明白一件关键的事:WPF 不会自动读取特性。
  • 学会在 IDataErrorInfo 里调用 Validator,把特性接进绑定。
  • 知道 ErrorMessage 里 {0}、{1}、{2} 各自代表什么。
  • 会写一个自定义特性。

3.1 特性只是贴在属性上的说明

System.ComponentModel.DataAnnotations 里有一组现成的校验特性,最常用的六个:

特性 作用 例子
[Required] 不能为空 姓名必填
[StringLength] 字符串长度范围 姓名 2 到 10 字
[Range] 数值或日期范围 年龄 18 到 60
[RegularExpression] 正则匹配 手机号格式
[EmailAddress] 邮箱格式 联系邮箱
[Compare] 两个属性相等 确认密码

写法很直观:

1
2
3
[Display(Name = "年龄")]
[Range(18, 60, ErrorMessage = "{0}必须在 {1} 到 {2} 之间")]
public int Age { get; set; } = 28;

[Display(Name = "年龄")] 的作用是给这个属性一个「人话名字」,下面 ErrorMessage 里的 {0} 就会替换成它。

3.2 最容易误解的一点:WPF 不会自动读特性

这一条必须单独说,因为网上的示例经常省略这一步,让人以为「写上特性就生效了」。

实测:给 ValidatedEmployee.Age 挂上 [Range(18, 60)],直接在 XAML 里绑定它,输入 10,错误数是 0,界面没有任何反应。

原因很简单:特性只是编译进程序集的元数据,WPF 的绑定引擎根本不知道要去看它。 绑定只认三样东西:

  1. IDataErrorInfo(ValidatesOnDataErrors)
  2. INotifyDataErrorInfo(ValidatesOnNotifyDataErrors)
  3. ValidationRule 集合

特性不在这个名单里。所以必须有人「翻译」一下,把特性的结论转成上面三种形式之一。

特性校验的桥接过程

图 3:特性本身不会生效,需要中间那层把结论交给 WPF。

3.3 用 IDataErrorInfo 当那座桥

最省事的做法:让模型同时实现 IDataErrorInfo,在索引器里调用 Validator.TryValidateProperty。

1
2
3
4
5
6
private readonly Dictionary<string, string> _errors = new();

public string Error => string.Join(";", _errors.Values);

public string this[string columnName]
=> _errors.TryGetValue(columnName, out var message) ? message : string.Empty;

索引器不再自己算,只是把缓存里的错误读出来。真正的校验发生在属性的 setter 里:

1
2
3
4
5
6
7
8
9
10
11
public int Age
{
get => _age;
set
{
if (SetProperty(ref _age, value))
{
ValidateProperty(value, nameof(Age));
}
}
}

ValidateProperty 是核心:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void ValidateProperty(object? value, string propertyName)
{
// MemberName 告诉 Validator:这次只校验这个属性。
var context = new ValidationContext(this) { MemberName = propertyName };
var results = new List<ValidationResult>();

if (Validator.TryValidateProperty(value, context, results))
{
_errors.Remove(propertyName);
return;
}

var first = results.FirstOrDefault(r => !string.IsNullOrWhiteSpace(r.ErrorMessage));
if (first is not null)
{
_errors[propertyName] = first.ErrorMessage!;
}
}

这段代码有三个要点:

第一,​​MemberName​​ ** 必须写。** 不写的话,Validator 不知道要校验哪个属性,会返回「没有错误」。

第二,​​TryValidateProperty​​ ** 只校验一个属性。** 它不会去检查其他属性,这正是我们需要的:改年龄时不必重新校验姓名。

第三,通过时要主动删除缓存。 只加不删,错误会永远留着,界面上的红边消不掉。

3.4 为什么 setter 里校验,而不是索引器里校验

你可能会想:既然索引器会被 WPF 调用,为什么不直接在索引器里调用 Validator?

技术上可以,但有两个问题:

问题一:索引器没有 ​​value​​ ** 参数。** TryValidateProperty 需要知道「要校验的值」,索引器里只能读到属性当前的 get 值。在写回流程里,这个顺序不一定是你想要的。

问题二:校验会重复执行。 WPF 可能在一次写回里调用索引器多次(实测 1 次或 2 次都有),每次都跑一遍 Validator 是浪费。

在 setter 里校验一次、缓存结果,索引器只负责读缓存,是更清晰的分工。

3.5 ErrorMessage 里的占位符

这是 DataAnnotations 里最容易记错的部分。实测确认了每个占位符的含义:

特性 {0} {1} {2}
[Range(min, max)] Display 名称 最小值 最大值
[StringLength(max, MinimumLength = min)] Display 名称 最大值 最小值
[RegularExpression] Display 名称 正则本身 —

注意 StringLength 这一行:{1} 是最大值,{2} 才是最小值,跟直觉相反。写消息时按这个顺序填,别凭感觉。

所以姓名的写法是:

1
[StringLength(10, MinimumLength = 2, ErrorMessage = "{0}长度必须在 {2} 到 {1} 之间")]

渲染出来是「姓名长度必须在 2 到 10 之间」。如果写反成 {1} 到 {2},就会变成「姓名长度必须在 10 到 2 之间」,读起来很怪。

[Range] 就正常一些,{1} 是最小值、{2} 是最大值:

1
2
[Range(18, 60, ErrorMessage = "{0}必须在 {1} 到 {2} 之间")]
// 年龄必须在 18 到 60 之间

3.6 decimal 的 Range 要换一个重载

[Range(3000, 200000)] 用的是 int 重载。如果属性是 decimal,要写成:

1
2
[Range(typeof(decimal), "3000", "200000", ErrorMessage = "{0}必须在 {1} 到 {2} 之间")]
public decimal Salary { get; set; }

边界值用字符串传,这是这个重载的设计。给 decimal 属性套用整数版 [Range(3000, 200000)] 时,边界类型和属性类型不一致,容易在运行时出现校验异常。建议直接用带 typeof 的版本。

3.7 几个特性的边界行为

实测得到的几个容易踩的点:

情况 结果
[Required] 遇到 " "(三个空格) 不通过
[Required] 遇到 "\t" 不通过
[StringLength(2,10)] 遇到 null 通过
[Range(18,60)] 遇到 18 和 60 通过(边界包含)
自定义特性遇到类型不符的值 由你决定,通常返回 true

第一行很重要:[Required] 会把纯空白当成空。这一点比很多人想的严格。

第三行也值得注意:[StringLength] 不管 null,null 交给 [Required] 管。所以「必填 + 长度限制」通常要两个特性一起写。

3.8 写一个自定义特性

内置特性不够用时,继承 ValidationAttribute 重写 IsValid 就行。比如「入职日期不能晚于今天」:

1
2
3
4
5
6
7
8
9
10
11
12
13
public sealed class PastDateAttribute : ValidationAttribute
{
public override bool IsValid(object? value)
{
// 类型不对时返回 true,交给类型转换去处理,不要在这里报「格式错误」。
if (value is not DateTime date)
{
return true;
}

return date.Date <= DateTime.Today;
}
}

用法:

1
2
3
[Display(Name = "入职日期")]
[PastDate(ErrorMessage = "{0}不能晚于今天")]
public DateTime EntryDate { get; set; }

两个约定:

约定一:类型不符时返回 ​​true​​ 。 因为「值不是日期」是类型转换的问题,不该由日期范围特性来报错。返回 true 让校验通过,转换失败时绑定自己会处理。

约定二:只比较 ​​Date​​ ** 部分。** DateTime.Today 带时间 00:00:00,如果直接比较完整时间,今天下午的日期会被判成「晚于今天」。

3.9 跟做:搭出第 3 章页签

把 ValidatedEmployee 的五个属性各绑一个输入框,右侧显示「特性描述」和「校验日志」。

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
<TabItem Header="3  特性校验">
<Grid Margin="24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="440" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>

<StackPanel Grid.Column="0" Margin="0,0,28,0">
<TextBlock Text="姓名(必填,2 到 10 字)" Foreground="#5D6B82" Margin="0,0,0,6" />
<TextBox Height="34" Padding="8,0" VerticalContentAlignment="Center">
<TextBox.Text>
<Binding Path="ValidatedEmployee.Name" Mode="TwoWay"
UpdateSourceTrigger="PropertyChanged"
ValidatesOnDataErrors="True" />
</TextBox.Text>
</TextBox>

<TextBlock Text="年龄(18 到 60)" Foreground="#5D6B82" Margin="0,16,0,6" />
<TextBox Height="34" Padding="8,0" VerticalContentAlignment="Center">
<TextBox.Text>
<Binding Path="ValidatedEmployee.Age" Mode="TwoWay"
UpdateSourceTrigger="PropertyChanged"
ValidatesOnDataErrors="True" />
</TextBox.Text>
</TextBox>

<Button Content="校验全部属性"
Margin="0,22,0,0" Padding="16,7"
HorizontalAlignment="Left"
Background="#2F6FED" Foreground="White" BorderThickness="0"
Click="OnValidateAllClick" />
</StackPanel>

<StackPanel Grid.Column="1">
<Border Background="#F7F9FD" BorderBrush="#DCE5F2"
BorderThickness="1" CornerRadius="6" Padding="18">
<StackPanel>
<TextBlock Text="特性描述" FontWeight="Bold" />
<TextBlock Margin="0,10,0,0" TextWrapping="Wrap"
Text="{Binding RuleDescription}" />
</StackPanel>
</Border>
</StackPanel>
</Grid>
</TabItem>

按钮事件里做整对象校验:

1
2
3
4
5
private void OnValidateAllClick(object sender, RoutedEventArgs e)
{
var result = ViewModel.ValidatedEmployee.ValidateAll();
ViewModel.AppendLog("整对象校验结果:" + result);
}

ValidateAll 用 Validator.TryValidateObject,注意最后的 validateAllProperties 要传 true,否则默认只跑对象级和必填类规则,属性上的长度、范围不一定都会检查:

1
Validator.TryValidateObject(this, context, results, validateAllProperties: true);

这个参数很容易漏。漏了之后,整对象校验可能只覆盖对象级和必填类规则,属性上的长度、范围不一定都会检查。

3.10 跟做检查点

  1. 姓名清空,出现红边。
  2. 姓名输入 张,仍为红边。
  3. 年龄输入 17,出现红边。
  4. 年龄输入 61,同样出现红边。
  5. 手机号输入 12800138000,出现红边。
  6. 工资输入 2999,出现红边。
  7. 点「校验全部属性」,日志里能看到所有不通过的项。
  8. 在练习副本里把 TryValidateObject 的 validateAllProperties 改成 false,再点按钮,观察哪些属性级规则不再参与整对象校验。这个开关影响的是整对象校验,不改变每次输入写回时的单属性校验。

这一页使用的是默认错误模板,所以先看红边是否出现。具体文字可点「校验全部属性」看日志,或把第 5 章的 ValidatedBox 样式套过来观察气泡。

3.11 小练习

练习一:加一个「确认邮箱」属性。 用 [Compare(nameof(Email))] 校验两次输入一致。

**练习二:把 **​ ​[StringLength]​​ ** 的 **​ ​{1}​​ ** 和 ​ ​{2}​​ ** 对调。 看看提示文字变成什么样,理解占位符顺序。

**练习三:写一个 ​ ​[FutureDate]​​ ** 特性。 规则是日期必须晚于今天,用在「合同到期日」上。


4. 基于正则表达式的数据验证

本节目标

  • 知道正则适合校验什么、不适合校验什么。
  • 记住四个最常用的正则,并知道每一段的意思。
  • 弄明白为什么必须写 ^ 和 $。
  • 学会给正则加超时,避免界面卡死。
  • 知道什么时候不该用正则。

4.1 正则管「形状」,不管「含义」

先划清边界。正则擅长判断一串字符的结构:

  • 是不是 11 位数字?
  • 是不是以 1 开头?
  • 有没有 @ 和 .?

正则不擅长判断含义:

  • 这个手机号是不是真的存在?
  • 这个身份证号的校验位对不对?
  • 这个邮箱能不能收到信?

所以正则适合做「第一道过滤」,把明显不对的输入挡在外面。真正的业务校验(查重、验真)还得靠服务端或专门的算法。

4.2 四个常用正则,逐段拆开

手机号:​ ​^1[3-9]\d{9}$​

片段 含义
^ 从字符串开头开始匹配
1 第一位必须是 1
[3-9] 第二位是 3 到 9 之一
\d{9} 后面正好 9 位数字
$ 必须匹配到字符串结尾

合起来就是「1 开头,第二位 3 到 9,总共 11 位」。

邮箱:​ ​^[^@\s]+@[^@\s]+\.[^@\s]+$​

片段 含义
[^@\s]+ 一段非 @、非空白的字符,至少一个
@ 一个 @ 符号
[^@\s]+ 域名部分,同样不能有 @ 和空白
\. 一个点号(转义,因为 . 本身是通配符)
[^@\s]+ 顶级域名部分

这个正则故意写得宽松。完整的邮箱规则非常复杂,追求「完全符合 RFC」往往得不偿失。

身份证:​ ​^\d{17}[\dXx]$​

17 位数字,最后一位是数字或 X/x。注意这不校验校验位,只校验形状。

邮编:​ ​^\d{6}$​

正好 6 位数字。

4.3 为什么必须写 ^ 和 $

这是正则校验里最常见的漏洞。实测对比:

正则 输入 结果
\d{11} 13800138000 匹配
\d{11} a13800138000b 匹配
\d{11} 138001380001(12 位) 匹配
\d{11} 1380013800(10 位) 不匹配

第二行和第三行就是问题所在。没有 ^ 和 $,正则只要求「字符串里存在一段 11 位数字」,前后多什么它都不管。

加上锚点之后:

1
^1[3-9]\d{9}$

a13800138000b 和 138001380001 都会被拒绝,因为整串必须正好符合。

锚点前后的匹配范围

图 4:没有锚点时,正则只匹配「其中一段」;加上锚点才要求整串符合。

**记忆方法:做「格式校验」的正则,一律加 **​ ​^​​ ** 和 **​ ​$​​ 。 除非你确实想做「包含」判断。

4.4 空值要先处理

Regex.IsMatch("") 对大多数正则会返回 false,然后你就会得到「格式不正确」这种提示。但用户看到空白输入框时,更希望看到「不能为空」。

所以判断顺序应该是:

1
2
3
4
5
6
7
8
9
10
private static string Match(Regex regex, string? input, string message)
{
// 先判断空,给一个准确的提示。
if (string.IsNullOrWhiteSpace(input))
{
return "不能为空";
}

return regex.IsMatch(input) ? string.Empty : message;
}

4.5 正则一定要加超时

这一条很多教程不讲,但在真实项目里很重要。

某些正则(尤其是嵌套量词,比如 ^(a+)+$)遇到特定输入时会产生「灾难性回溯」,CPU 会飙到 100%,界面直接卡死。

实测:^(a+)+$ 匹配 40 个 a 加一个 !,在没设超时的情况下会卡很久;设了 50 毫秒超时后,会抛出 RegexMatchTimeoutException。

正确的写法:

1
2
3
4
private static readonly Regex PhoneRegex = new(
@"^1[3-9]\d{9}$",
RegexOptions.Compiled,
TimeSpan.FromMilliseconds(200));

第三个参数就是超时时间。匹配时用 try/catch 接住超时:

1
2
3
4
5
6
7
8
try
{
return regex.IsMatch(input) ? string.Empty : message;
}
catch (RegexMatchTimeoutException)
{
return "校验超时,请检查输入";
}

RegexOptions.Compiled 的作用是把正则预编译成 IL,重复匹配更快。适合「一个正则用很多次」的场景,比如列表里每一行都要校验。

4.6 正则写错了会怎样

正则本身语法错误时,new Regex(...) 会抛异常。实测:

1
2
new Regex("[0-9")
→ RegexParseException: Invalid pattern '[0-9' at offset 4. Unterminated [] set.

注意它抛的是 RegexParseException,这个类型继承自 ArgumentException。所以捕获 ArgumentException 就能接住。

如果正则写在静态字段里,这个异常会在类型初始化时抛出,通常表现为 TypeInitializationException,排查起来更麻烦。所以写正则时最好先用一个小工具(或者示例程序第 4 个页签的测试器)验证一遍。

4.7 什么时候不该用正则

三种情况要谨慎:

第一种:需要理解语义的。 比如「身份证校验位对不对」,这是算法,不是形状。

第二种:规则会频繁变的。 正则是硬编码,改一次要重新发布。如果规则来自配置或数据库,考虑把模式字符串放进配置。

第三种:输入很长的。 正则处理超长文本时性能不可控。几百 KB 以上的文本,用字符串 API 更稳。

4.8 跟做:搭出第 4 章页签

左边五个输入框,右边一个「正则测试器」。

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
<TabItem Header="4  正则校验">
<Grid Margin="24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="440" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>

<StackPanel Grid.Column="0" Margin="0,0,28,0">
<TextBlock Text="手机号" Foreground="#5D6B82" Margin="0,0,0,6" />
<TextBox Height="34" Padding="8,0" VerticalContentAlignment="Center">
<TextBox.Text>
<Binding Path="Registration.Phone" Mode="TwoWay"
UpdateSourceTrigger="PropertyChanged"
ValidatesOnDataErrors="True" />
</TextBox.Text>
</TextBox>

<TextBlock Text="邮箱" Foreground="#5D6B82" Margin="0,16,0,6" />
<TextBox Height="34" Padding="8,0" VerticalContentAlignment="Center">
<TextBox.Text>
<Binding Path="Registration.Email" Mode="TwoWay"
UpdateSourceTrigger="PropertyChanged"
ValidatesOnDataErrors="True" />
</TextBox.Text>
</TextBox>
</StackPanel>

<StackPanel Grid.Column="1">
<TextBlock Text="模式" Foreground="#5D6B82" />
<TextBox x:Name="PatternBox" Height="34" Margin="0,6,0,0"
Padding="8,0" VerticalContentAlignment="Center"
Text="^1[3-9]\d{9}$" />

<TextBlock Text="输入" Foreground="#5D6B82" Margin="0,12,0,0" />
<TextBox x:Name="PatternInputBox" Height="34" Margin="0,6,0,0"
Padding="8,0" VerticalContentAlignment="Center"
Text="13800138000" />

<Button Content="测试" Margin="0,14,0,0" Padding="16,7"
HorizontalAlignment="Left"
Background="#2F6FED" Foreground="White" BorderThickness="0"
Click="OnTestPatternClick" />

<TextBlock x:Name="PatternResult" Margin="0,12,0,0" TextWrapping="Wrap" />
</StackPanel>
</Grid>
</TabItem>

按钮事件:

1
2
3
4
5
private void OnTestPatternClick(object sender, RoutedEventArgs e)
{
var ok = Registration.TestPattern(PatternBox.Text, PatternInputBox.Text, out var message);
PatternResult.Text = (ok ? "匹配成功:" : "匹配失败:") + message;
}

4.9 跟做检查点

  1. 手机号输入 1380013800(10 位),提示格式不正确。
  2. 输入 12800138000,提示格式不正确(第二位不是 3 到 9)。
  3. 输入 138001380001(12 位),提示格式不正确。
  4. 邮箱输入 abc,提示格式不正确。
  5. 邮箱输入 a@b.com,通过。
  6. 在测试器里把模式改成 \d{11},输入 a13800138000b,会显示「匹配成功」。这说明锚点的重要性。
  7. 把模式改成 [0-9,点测试,提示「正则本身写错了」。

4.10 小练习

练习一:加一个「车牌号」正则。 先用一个宽松版本(省份简称 + 字母 + 5 位),跑通再收紧。

练习二:给用户名写正则版。 规则:字母开头,4 到 16 位,只允许字母数字下划线。写完和第 4 章里的「逐字符判断」版本对比,看哪个更易读。

练习三:把超时改成 10 毫秒。 用 ^(a+)+$ 匹配 40 个 a,观察是否稳定触发超时提示。


5. 结合异常信息提示的输入框模板

本节目标

  • 知道 Validation 的四个附加属性各自是什么。
  • 弄明白为什么默认只有红边、没有文字。
  • 学会用自定义 ControlTemplate 做一个「红边 + 气泡」的输入框。
  • 知道为什么输入框模板里必须有 PART_ContentHost。
  • 避开几个常见的模板坑。

5.1 错误最后都放在哪

前四章讲的校验,不管用哪种方式,结果最终都汇聚到控件上的四个附加属性:

附加属性 类型 含义
Validation.HasError bool 这个控件当前有没有错误
Validation.Errors ReadOnlyObservableCollection<ValidationError> 所有错误
Validation.ErrorTemplate ControlTemplate 错误时画什么
Validation.ErrorContent object 第一条错误的内容(常用)

它们都挂在控件上,不是挂在数据对象上。这一点很关键:同一个数据对象绑到两个控件上,两个控件各自维护自己的错误状态。

在代码里读:

1
2
3
var hasError = Validation.GetHasError(textBox);
var errors = Validation.GetErrors(textBox);
var first = errors.FirstOrDefault()?.ErrorContent;

实测:校验失败时,HasError 是 true,Errors 里有 1 条,ErrorContent 是字符串(就是你返回的那句提示),BindingInError 是 BindingExpression 对象。

5.2 默认只有红边

WPF 给所有控件准备了一个默认的 ErrorTemplate,效果是:在控件外面套一圈红色边框。

就这一圈红边,没有文字。所以很多人的第一反应是「校验是不是没生效」——其实生效了,只是错误内容没地方显示。

想看到文字,有两条路:

路线一:用 ToolTip。 鼠标悬停时显示,改动最小。

1
2
<TextBox ToolTip="{Binding RelativeSource={RelativeSource Self},
Path=(Validation.Errors)[0].ErrorContent}" />

路线二:换输入框自己的 ​​ControlTemplate​​ 。 把红边和文字都画出来,效果更好,但要写模板。

这一章走路线二。

5.3 为什么 ToolTip 不够用

ToolTip 有三个问题:

  1. 要悬停才看得到。 用户填表时不会去悬停每个输入框。
  2. 样式难统一。 ToolTip 是独立窗口,跟页面风格经常对不上。
  3. ​(Validation.Errors)[0]​​ ** 在没错误时会绑定失败。** 虽然界面上看不出来,但调试输出里会有一堆绑定错误。

第三个问题可以用转换器解决,但既然都要写转换器了,不如直接做模板。

5.4 先分清两种模板

这里最容易混的是两个长得很像、用途完全不同的模板:

模板 放在哪里 负责什么
控件自己的 Template Style 的 Setter 画出输入框本体,必须包含 PART_ContentHost
Validation.ErrorTemplate Style 或控件的附加属性 在控件外面套一层错误装饰,里面要有 AdornedElementPlaceholder

本篇示例选择第一种:自定义 ​​TextBox​​ ** 的 ​​ControlTemplate​**​ 。 因为错误气泡要参与布局,让下面的内容自动让位,控件模板比装饰器更直观。

顺便认识一下第二种。Validation.ErrorTemplate 也是一个 ControlTemplate,但它的作用对象不是控件本身,而是控件外面的一层装饰器(Adorner) 。

理解这一点很重要:

  • 它画在控件上面,不参与控件的布局。
  • 它里面必须有一个 AdornedElementPlaceholder,用来给原控件「留位置」。
  • 它的 DataContext 不是你的 ViewModel,所以绑定路径要写全。

一个最小的错误装饰模板长这样:

1
2
3
4
5
<ControlTemplate x:Key="MinimalErrorTemplate">
<Border BorderBrush="Red" BorderThickness="1">
<AdornedElementPlaceholder />
</Border>
</ControlTemplate>

效果就是给控件套一个红框,跟默认模板差不多。它的好处是不用改控件模板本身;缺点是装饰层不参与布局,放不下会把内容撑开的气泡。

输入框模板的结构

图 5:自定义控件模板里,输入区、错误气泡和触发状态之间的关系。

5.5 做一个带气泡的模板

目标效果:错误时控件有红边,下方出现一个浅红底的气泡,里面是错误文字。

先解决一个问题:Validation.Errors 是一个集合,不能直接显示。需要一个转换器把第一条的 ErrorContent 取出来。

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 ErrorContentToStringConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is not System.Collections.IEnumerable errors)
{
return string.Empty;
}

foreach (var item in errors)
{
if (item is ValidationError error && error.ErrorContent is not null)
{
return error.ErrorContent.ToString() ?? string.Empty;
}
}

return string.Empty;
}

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

然后写输入框模板。注意第一层不是 AdornedElementPlaceholder,而是输入框真正的内容宿主 PART_ContentHost:

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
<ControlTemplate x:Key="ValidatedTextBoxTemplate" TargetType="TextBox">
<StackPanel>
<!-- 第一层:输入框本体。PART_ContentHost 不能改名,也不能省略。 -->
<Border x:Name="InputBorder"
MinHeight="34"
Padding="{TemplateBinding Padding}"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="3"
SnapsToDevicePixels="True">
<ScrollViewer x:Name="PART_ContentHost"
Focusable="False"
HorizontalScrollBarVisibility="Hidden"
VerticalScrollBarVisibility="Hidden"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
</Border>

<!-- 第二层:气泡提示 -->
<Border x:Name="ErrorTip"
Margin="0,4,0,0"
Padding="8,4"
Background="#FDEEEE"
BorderBrush="#F0C9C9"
BorderThickness="1"
CornerRadius="4"
Visibility="Collapsed">
<TextBlock Foreground="#B03A3A"
FontSize="12"
TextWrapping="Wrap"
Text="{Binding RelativeSource={RelativeSource TemplatedParent},
Path=(Validation.Errors),
Converter={StaticResource ErrorContentToString}}" />
</Border>
</StackPanel>

<ControlTemplate.Triggers>
<Trigger Property="Validation.HasError" Value="True">
<Setter TargetName="InputBorder"
Property="BorderBrush"
Value="#B03A3A" />
<Setter TargetName="InputBorder"
Property="BorderThickness"
Value="2" />
<Setter TargetName="ErrorTip" Property="Visibility" Value="Visible" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>

把它套到输入框上:

1
2
3
4
5
6
7
8
9
<Style x:Key="ValidatedBox" TargetType="TextBox">
<Setter Property="MinHeight" Value="34" />
<Setter Property="Padding" Value="8,0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="BorderBrush" Value="#DCE5F2" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Background" Value="White" />
<Setter Property="Template" Value="{StaticResource ValidatedTextBoxTemplate}" />
</Style>

5.6 模板里的四个关键点

关键点一:​​PART_ContentHost​​ ** 必须有。**

TextBox 不是自己直接画文字的,它靠模板里一个名字固定为 PART_ContentHost 的 ScrollViewer 来承载编辑区。少了它,输入框可能能显示边框,却不能正常输入,或者光标、选区全部失灵。

只有写 Validation.ErrorTemplate 时,才需要 AdornedElementPlaceholder。两种模板不能混着套。

关键点二:输入区要绑定控件的样式属性。

Padding、Background、BorderBrush、BorderThickness 都用 TemplateBinding 接回 TextBox。这样 Style 里设置的浅灰边框和白色背景才会真正作用到模板里的 Border。如果直接写死颜色,以后换主题就改不动了。

关键点三:绑定的相对源是 ​​TemplatedParent​​ 。

模板内部不能直接用 {Binding Validation.Errors},因为模板的 DataContext 已经被换成了控件。要读控件自己的附加属性,得写 RelativeSource TemplatedParent,路径用括号包起来表示附加属性:(Validation.Errors)。

关键点四:气泡放在 ​​StackPanel​​ ** 里,而不是叠在控件上。**

如果把气泡做成绝对定位叠在控件下方,它会盖住下面的输入框。放在 StackPanel 里,气泡出现时会把布局撑开,下面的内容自动下移,不会遮挡。

代价是:错误出现和消失时,布局会跳一下。这个体验可以接受,比遮挡好得多。

5.7 为什么用 Trigger 而不是转换器控制可见性

我一开始用的是「把 Validation.HasError 绑到 Visibility,再配一个转换器」。能跑,但有两个问题:

  • 模板里的 Trigger 更直观,一眼能看出「有错误就显示」。
  • Trigger 不需要额外的转换器,少一个资源。

所以模板内部优先用 Trigger,转换器留给「确实需要转换值」的场景。

不过前面准备好的 HasErrorToVisibilityConverter 也不是白写的:如果你选择在控件模板外单独放提示块,并手动绑定 Validation.HasError,它就能派上用场。

5.8 五个常见的模板坑

坑一:把 ​​AdornedElementPlaceholder​​ ** 放进控件模板。**

AdornedElementPlaceholder 只属于 Validation.ErrorTemplate 的装饰器模板。放进普通 ControlTemplate 会导致运行时不显示或模板加载失败。控件模板里要写的是 PART_ContentHost。

坑二:​​TargetType​​ ** 写错。**

模板的 TargetType 必须和控件类型一致。给 TextBox 写的模板,TargetType 就得是 TextBox。写成 Control 或者不写,运行时可能报「模板与控件类型不匹配」。

坑三:错误提示用了绝对定位。

把气泡叠在输入框上会挡住输入内容,也会盖住下面的控件。把输入区和气泡放进同一个 StackPanel,气泡出现时布局自然向下让位。

坑四:把 ​​Height​​ ** 写死在模板里。**

模板里写死高度,用户改字体大小或者系统缩放时会显示不全。示例在 Style 里设置 MinHeight,让内容变高时还能继续撑开。

坑五:只写 ​​PART_ContentHost​​ ,忘了把控件属性接进模板。

这时输入框虽然能用,但 Style 里设置的边框、背景、内边距不会跟着变化。检查 TemplateBinding,尤其是 Padding、Background、BorderBrush、BorderThickness。

5.9 跟做:搭出第 5 章页签

把模板和样式放进 Window.Resources,然后四个输入框都用 ValidatedBox 样式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<Window.Resources>
<conv:ErrorContentToStringConverter x:Key="ErrorContentToString" />

<ControlTemplate x:Key="ValidatedTextBoxTemplate" TargetType="TextBox">
<!-- 上面 5.5 节的完整模板 -->
</ControlTemplate>

<Style x:Key="ValidatedBox" TargetType="TextBox">
<Setter Property="MinHeight" Value="34" />
<Setter Property="Padding" Value="8,0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="BorderBrush" Value="#DCE5F2" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Background" Value="White" />
<Setter Property="Template" Value="{StaticResource ValidatedTextBoxTemplate}" />
</Style>
</Window.Resources>

使用:

1
2
3
4
5
6
7
8
9
<TextBlock Text="姓名" Foreground="#5D6B82" Margin="0,0,0,6" />
<TextBox Style="{StaticResource ValidatedBox}">
<TextBox.Text>
<Binding Path="Employee.Name"
Mode="TwoWay"
UpdateSourceTrigger="PropertyChanged"
ValidatesOnDataErrors="True" />
</TextBox.Text>
</TextBox>

四个输入框的间距要留够,因为气泡出现时会占一行高度。示例里用的是 Margin="0,20,0,6"。另外,第 5 页直接绑 Employee.Age,输入 10 就能看到气泡;第 1 页和第 5 页共用同一个来源对象,所以测试后要记得改回合法值。

5.10 跟做检查点

  1. 启动程序,切到第 5 页,四个输入框都没有气泡,高度正常。
  2. 姓名清空,输入框出现红边,下方出现浅红气泡,文字是「姓名不能为空」。
  3. 姓名改回 张三,红边和气泡都消失,下面的输入框上移回来。
  4. 年龄输入 10,气泡显示「年龄不能小于 18」,此时输入框仍能正常获得焦点和编辑。
  5. 鼠标点进出现气泡的输入框,能正常编辑,说明边框没有挡住鼠标。
  6. 把窗口拉窄,气泡里的文字自动换行,不会溢出。

第 6 步能验证 TextWrapping="Wrap" 有没有生效。错误消息长短不一,不换行的话长消息会被截断。

5.11 小练习

练习一:把气泡改成「图标 + 文字」。 在 ErrorTip 里放一个 Grid,左边一个红色感叹号(可以用 TextBlock 写 !),右边放错误文字。

练习二:让错误时的边框变粗。 把 BorderThickness 从 1 改成 2,观察视觉效果。再试试只改颜色不改粗细,哪个更明显?

练习三:做一个「只在有错误时显示计数器」的版本。 用 Validation.Errors.Count 显示「共 N 个问题」。这个练习需要再写一个转换器。


两种验证方式对照表

到这里,IDataErrorInfo 和 ValidationRule 两条路都走过了。放在一起对比:

对比项 IDataErrorInfo ValidationRule
写在哪 数据模型上 XAML 绑定上
校验时机 写回之后 写回之前(可配置步骤)
校验失败时来源值 已经改变 保持不变
能否跨属性 困难 可以(规则里能拿到整个对象)
复用性 跟着模型走 一个规则类可以配到多个绑定
适合场景 模型自己有完整业务规则 同一模型在不同界面有不同规则

最关键的一行是第三行:​IDataErrorInfo​​ ** 是事后报告,​​ValidationRule​​ ** 是事前拦截。 如果你的业务要求「不合法就不能进模型」,用 ValidationRule;如果模型本身就是权威,允许先写进去再标记,用 IDataErrorInfo。

常见错误对照表

现象 原因 解决
模型实现了接口,界面没反应 绑定没写 ValidatesOnDataErrors 加上这个属性
校验只在失焦时触发 默认 UpdateSourceTrigger 是 LostFocus 改成 PropertyChanged
输入字母时没有新提示 类型转换失败,值没写回,索引器没被调用 这是正常行为,需要额外处理
改了属性名,校验失效 索引器里用了字符串字面量 改用 nameof
特性写了但不生效 WPF 不会自动读特性 在 IDataErrorInfo 里调 Validator
整对象校验漏掉范围、长度规则 TryValidateObject 的 validateAllProperties 传了 false 改成 true
正则漏掉了前后多余字符 没写 ^ 和 $ 加上锚点
界面卡死 正则灾难性回溯 给 Regex 构造加超时
校验失败但没文字 默认 ErrorTemplate 只有红边 自定义模板或加 ToolTip
气泡挡住下面的控件 气泡用了绝对定位 放进 StackPanel 让布局撑开
输入框能显示却不能输入 控件模板漏了 PART_ContentHost 补上固定命名的 ScrollViewer
自定义错误装饰不显示 把 AdornedElementPlaceholder 放进了控件模板 控件模板用 PART_ContentHost,错误装饰模板才用占位符

完整示例结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
WpfValidationDemo
├── App.xaml
├── App.xaml.cs
├── MainWindow.xaml 六个页签
├── MainWindow.xaml.cs 按钮事件
├── Models
│ ├── ObservableObject.cs 属性通知基类
│ ├── Employee.cs 第 1、2 章
│ ├── ValidatedEmployee.cs 第 3 章
│ └── Registration.cs 第 4 章
├── Converters
│ ├── ErrorContentToStringConverter.cs
│ └── HasErrorToVisibilityConverter.cs
└── ViewModels
└── MainViewModel.cs

运行检查表

启动示例程序,按顺序确认:

  1. 第 1 页签:五个输入框初始都没有红边。
  2. 第 1 页签:年龄改成 10,红边出现,右侧来源值也变成 10。
  3. 第 1 页签:年龄改回 28,红边消失。
  4. 第 1 页签:年龄输入 abc,来源值不变,红边状态保持。
  5. 第 2 页签:姓名输入 张,出现红边;切到第 5 页,同名字段的提示气泡会显示「姓名至少 2 个字」。
  6. 第 2 页签:点「清空姓名」,右侧「整对象错误」出现文字;切换一次页签后再看属性输入框,红边会更新。
  7. 第 3 页签:姓名清空,出现红边。默认模板不显示文字,可点「校验全部属性」查看日志。
  8. 第 3 页签:姓名输入 张,仍为红边,说明 StringLength 规则不通过。
  9. 第 3 页签:年龄输入 61,出现红边;点「校验全部属性」后日志会列出具体消息。
  10. 第 3 页签:点「校验全部属性」,日志里出现当前所有不通过的项。
  11. 第 4 页签:手机号输入 10 位,提示格式不正确。
  12. 第 4 页签:测试器里模式改成 \d{11},输入 a13800138000b,显示匹配成功。
  13. 第 4 页签:模式改成 [0-9,显示「正则本身写错了」。
  14. 第 5 页签:年龄输入 10,出现红边 + 气泡「年龄不能小于 18」,输入框仍可正常编辑。
  15. 第 5 页签:年龄改回 28,气泡消失,布局上移。
  16. 第 6 页签:把一个框改成 10,再把另一个框也改成 10,观察两边红边不同。关键不是输入动作,而是绑定有没有开 ValidatesOnDataErrors。

第 16 步是整篇的收尾对照,能一次性说明「开关」的作用。


综合练习

练习一:做一个带校验的注册表单

要求:

  • 用户名、密码、确认密码、邮箱、手机号五个字段。
  • 用户名用 [Required] + [StringLength]。
  • 密码用自定义特性,要求至少 8 位且包含数字。
  • 确认密码用 [Compare]。
  • 邮箱和手机号用正则。
  • 所有输入框都用第 5 章的模板。

做完后问自己:哪些规则放在模型里,哪些放在 ValidationRule 里更合适?

练习二:给列表做一个可编辑的等级列

用 DataGrid 显示三个员工,Age 列可编辑。要求:

  • 每行的年龄都做 18 到 60 的校验。
  • 校验失败时,那一格显示红边。
  • 顶部显示「当前有 N 行不合法」。

这个练习会逼你处理「多个对象、多个控件」的错误聚合,是实际项目里很常见的需求。

练习三:把三种校验方式放在一起对比

做一个页面,三列分别用 IDataErrorInfo、ValidationRule、INotifyDataErrorInfo 校验同一个「年龄 18 到 60」的规则。输入 10,观察:

  • 三列是不是都变红?
  • 来源值是不是都被改了?
  • 改成 30 之后,错误是不是都能清掉?

做完这个练习,你对三种方式的差别会有很直观的认识。


结语

这一篇的主线其实只有一句:数据对象报告错误,绑定收集错误,控件保存错误,模板显示错误。

四个环节里,最容易出问题的是第一个和最后一个:

  • 第一个环节:模型写了校验,但绑定没开开关,或者索引器属性名写错,或者类型转换失败根本没走到校验。
  • 最后一个环节:错误确实产生了,但默认模板只有红边,用户不知道错在哪。

中间的绑定和控件部分,WPF 已经帮你做得很好了。你要做的是把两头接对。

学完这一篇,建议回头看一眼上一篇的 ValidationRule。两种方式不是对立的,实际项目里经常混用:模型层用 IDataErrorInfo 保证数据自身完整,界面层用 ValidationRule 做针对当前表单的额外约束。

再往后走,可以了解 INotifyDataErrorInfo(支持异步校验、跨属性错误推送)和 MVVM 框架里的验证封装。但那些都是在这一套基础上的扩展,底子稳了,学起来会快很多。

祝学习顺利。