数据验证(ValidationRule + IDataErrorInfo)
大约 9 分钟约 2602 字
数据验证(ValidationRule + IDataErrorInfo)
简介
数据验证是表单交互的核心功能。WPF 提供了多种验证机制:ValidationRule(验证规则)、IDataErrorInfo(接口验证)、INotifyDataErrorInfo(异步验证)。掌握这三种方式可以实现从简单到复杂的数据校验,配合错误模板可以给用户清晰的错误提示。
特点
ValidationRule
验证规则
<!-- ValidationRule — XAML 中声明验证 -->
<Window.Resources>
<!-- 必填验证 -->
<local:RequiredRule x:Key="RequiredRule" ErrorMessage="此项不能为空"/>
<!-- 范围验证 -->
<local:RangeRule x:Key="AgeRule" Min="0" Max="150" ErrorMessage="年龄应在 0-150 之间"/>
</Window.Resources>
<StackPanel>
<TextBox x:Name="NameBox" Margin="5">
<TextBox.Text>
<Binding Path="Name" UpdateSourceTrigger="PropertyChanged">
<Binding.ValidationRules>
<local:RequiredRule ErrorMessage="姓名不能为空"/>
</Binding.ValidationRules>
</Binding>
</TextBox.Text>
</TextBox>
<TextBox x:Name="AgeBox" Margin="5">
<TextBox.Text>
<Binding Path="Age" UpdateSourceTrigger="PropertyChanged">
<Binding.ValidationRules>
<local:RangeRule Min="18" Max="120" ErrorMessage="年龄应在 18-120 之间"/>
</Binding.ValidationRules>
</Binding>
</TextBox.Text>
</TextBox>
</StackPanel>/// <summary>
/// 自定义 ValidationRule
/// </summary>
public class RequiredRule : ValidationRule
{
public string ErrorMessage { get; set; } = "此项不能为空";
public override ValidationResult Validate(object value, CultureInfo cultureInfo)
{
var text = value?.ToString()?.Trim();
return string.IsNullOrEmpty(text)
? new ValidationResult(false, ErrorMessage)
: ValidationResult.ValidResult;
}
}
public class RangeRule : ValidationRule
{
public double Min { get; set; }
public double Max { get; set; }
public string ErrorMessage { get; set; } = "值超出范围";
public override ValidationResult Validate(object value, CultureInfo cultureInfo)
{
if (double.TryParse(value?.ToString(), out double num))
{
return num >= Min && num <= Max
? ValidationResult.ValidResult
: new ValidationResult(false, ErrorMessage);
}
return new ValidationResult(false, "请输入有效数字");
}
}IDataErrorInfo
接口验证
/// <summary>
/// IDataErrorInfo — ViewModel 接口验证
/// </summary>
public class UserEditModel : IDataErrorInfo
{
public string Name { get; set; } = "";
public string Email { get; set; } = "";
public string Phone { get; set; } = "";
public int Age { get; set; }
// 索引器 — 按属性名返回错误信息
public string this[string columnName] => columnName switch
{
nameof(Name) => ValidateName(),
nameof(Email) => ValidateEmail(),
nameof(Phone) => ValidatePhone(),
nameof(Age) => ValidateAge(),
_ => ""
};
// 整体错误(一般不用)
public string Error => "";
private string ValidateName()
{
if (string.IsNullOrWhiteSpace(Name)) return "姓名不能为空";
if (Name.Length < 2) return "姓名至少2个字符";
if (Name.Length > 20) return "姓名不能超过20个字符";
return "";
}
private string ValidateEmail()
{
if (string.IsNullOrWhiteSpace(Email)) return "邮箱不能为空";
if (!Regex.IsMatch(Email, @"^[\w.-]+@[\w.-]+\.\w{2,}$"))
return "邮箱格式不正确";
return "";
}
private string ValidatePhone()
{
if (string.IsNullOrWhiteSpace(Phone)) return "";
if (!Regex.IsMatch(Phone, @"^1[3-9]\d{9}$"))
return "手机号格式不正确";
return "";
}
private string ValidateAge()
{
if (Age < 18) return "年龄不能小于18岁";
if (Age > 120) return "年龄不能超过120岁";
return "";
}
}<!-- 使用 IDataErrorInfo -->
<!-- 需要设置 ValidatesOnDataErrors=True -->
<StackPanel>
<TextBox Text="{Binding Name, UpdateSourceTrigger=PropertyChanged, ValidatesOnDataErrors=True}"
Margin="5"/>
<TextBox Text="{Binding Email, UpdateSourceTrigger=PropertyChanged, ValidatesOnDataErrors=True}"
Margin="5"/>
<TextBox Text="{Binding Phone, UpdateSourceTrigger=PropertyChanged, ValidatesOnDataErrors=True}"
Margin="5"/>
</StackPanel>INotifyDataErrorInfo
异步验证
/// <summary>
/// INotifyDataErrorInfo — 异步验证(推荐)
/// </summary>
public class RegisterViewModel : ObservableObject, INotifyDataErrorInfo
{
private readonly Dictionary<string, List<string>> _errors = new();
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
public bool HasErrors => _errors.Count > 0;
public IEnumerable GetErrors(string? propertyName)
{
return propertyName != null && _errors.ContainsKey(propertyName)
? _errors[propertyName]
: Enumerable.Empty<string>();
}
private void AddError(string propertyName, string error)
{
if (!_errors.ContainsKey(propertyName))
_errors[propertyName] = new List<string>();
if (!_errors[propertyName].Contains(error))
{
_errors[propertyName].Add(error);
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
}
}
private void ClearErrors(string propertyName)
{
if (_errors.Remove(propertyName))
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
}
// 属性验证
private string _username = "";
public string Username
{
get => _username;
set => SetProperty(ref _username, value, ValidateUsername);
}
private void ValidateUsername()
{
ClearErrors(nameof(Username));
if (string.IsNullOrWhiteSpace(Username))
AddError(nameof(Username), "用户名不能为空");
else if (Username.Length < 3)
AddError(nameof(Username), "用户名至少3个字符");
// 异步验证(如检查用户名是否已存在)
_ = CheckUsernameExistsAsync(Username);
}
private async Task CheckUsernameExistsAsync(string username)
{
if (string.IsNullOrEmpty(username)) return;
var exists = await _userService.ExistsAsync(username);
if (exists)
AddError(nameof(Username), "用户名已存在");
}
}多规则组合验证
组合验证规则
<!-- 多个 ValidationRule 组合使用 -->
<TextBox x:Name="EmailBox" Margin="5">
<TextBox.Text>
<Binding Path="Email" UpdateSourceTrigger="PropertyChanged">
<Binding.ValidationRules>
<!-- 先检查必填 -->
<local:RequiredRule ErrorMessage="邮箱不能为空"/>
<!-- 再检查格式 -->
<local:RegexRule Pattern="^[\w.-]+@[\w.-]+\.\w{2,}$"
ErrorMessage="邮箱格式不正确"/>
</Binding.ValidationRules>
</Binding>
</TextBox.Text>
</TextBox>/// <summary>
/// 正则表达式验证规则
/// </summary>
public class RegexRule : ValidationRule
{
public string Pattern { get; set; } = "";
public string ErrorMessage { get; set; } = "格式不正确";
public override ValidationResult Validate(object value, CultureInfo cultureInfo)
{
var text = value?.ToString()?.Trim();
if (string.IsNullOrEmpty(text))
return ValidationResult.ValidResult; // 空值交给 RequiredRule 处理
if (!string.IsNullOrEmpty(Pattern) && !Regex.IsMatch(text, Pattern))
return new ValidationResult(false, ErrorMessage);
return ValidationResult.ValidResult;
}
}
/// <summary>
/// 自定义验证规则 — 支持 ViewModel 上下文
/// </summary>
public class UniqueNameRule : ValidationRule
{
public ICollection<string> ExistingNames { get; set; } = Array.Empty<string>();
public string ErrorMessage { get; set; } = "名称已存在";
public override ValidationResult Validate(object value, CultureInfo cultureInfo)
{
var text = value?.ToString()?.Trim();
if (string.IsNullOrEmpty(text))
return ValidationResult.ValidResult;
if (ExistingNames.Contains(text))
return new ValidationResult(false, ErrorMessage);
return ValidationResult.ValidResult;
}
}跨字段验证
/// <summary>
/// 跨字段验证 — IDataErrorInfo 实现
/// </summary>
public class RegisterModel : IDataErrorInfo
{
public string Password { get; set; } = "";
public string ConfirmPassword { get; set; } = "";
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public string this[string columnName] => columnName switch
{
nameof(ConfirmPassword) => ValidateConfirmPassword(),
nameof(EndDate) => ValidateDateRange(),
_ => ""
};
private string ValidateConfirmPassword()
{
if (string.IsNullOrEmpty(ConfirmPassword))
return "请确认密码";
if (ConfirmPassword != Password)
return "两次密码不一致";
return "";
}
private string ValidateDateRange()
{
if (EndDate < StartDate)
return "结束日期不能早于开始日期";
if ((EndDate - StartDate).Days > 365)
return "日期范围不能超过一年";
return "";
}
public string Error => "";
}验证与命令联动
表单提交验证
/// <summary>
/// 验证整个表单后再提交
/// </summary>
public partial class OrderViewModel : ObservableValidator
{
[ObservableProperty]
[Required(ErrorMessage = "客户名称不能为空")]
[MinLength(2, ErrorMessage = "客户名称至少2个字符")]
private string _customerName = "";
[ObservableProperty]
[Required(ErrorMessage = "订单数量不能为空")]
[Range(1, 10000, ErrorMessage = "数量应在 1-10000 之间")]
private int _quantity;
[ObservableProperty]
[Required(ErrorMessage = "交货日期不能为空")]
private DateTime _deliveryDate;
// 提交前验证所有属性
[RelayCommand(CanExecute = nameof(CanSubmit))]
private async Task SubmitAsync()
{
// 二次确认
var errors = GetErrors(null).Cast<ValidationResult>().ToList();
if (errors.Any())
{
// 显示所有错误
var message = string.Join("\n", errors.Select(e => e.ErrorMessage));
MessageBox.Show(message, "请修正以下错误");
return;
}
// 执行提交
await _orderService.CreateAsync(new Order
{
CustomerName = CustomerName,
Quantity = Quantity,
DeliveryDate = DeliveryDate
});
}
private bool CanSubmit() => !HasErrors;
// 属性变更时刷新命令状态
partial void OnCustomerNameChanged(string value)
{
ValidateProperty(value, nameof(CustomerName));
SubmitCommand.NotifyCanExecuteChanged();
}
partial void OnQuantityChanged(int value)
{
ValidateProperty(value, nameof(Quantity));
SubmitCommand.NotifyCanExecuteChanged();
}
}XAML 验证状态绑定
<!-- 提交按钮根据表单验证状态启用/禁用 -->
<Button Content="提交订单"
Command="{Binding SubmitCommand}"
Margin="0,10,0,0"
Padding="20,8"/>
<!-- 显示字段级错误信息 -->
<StackPanel Margin="0,2,0,0">
<TextBox Text="{Binding CustomerName, UpdateSourceTrigger=PropertyChanged,
ValidatesOnDataErrors=True}"
Width="250" Height="28"/>
<!-- 绑定当前控件的第一个验证错误 -->
<ItemsControl ItemsSource="{Binding
ElementName=CustomerNameBox,
Path=(Validation.Errors)}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding ErrorContent}"
Foreground="Red" FontSize="11" Margin="2,0,0,0"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>验证规则复用
通过标记属性定义验证
/// <summary>
/// 自定义验证特性
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
public class IpAddressAttribute : ValidationAttribute
{
public IpAddressAttribute() : base("IP 地址格式不正确") { }
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
var str = value?.ToString();
if (string.IsNullOrEmpty(str)) return ValidationResult.Success;
return System.Net.IPAddress.TryParse(str, out _)
? ValidationResult.Success
: new ValidationResult(ErrorMessage);
}
}
[AttributeUsage(AttributeTargets.Property)]
public class PortAttribute : ValidationAttribute
{
public PortAttribute() : base("端口号应在 1-65535 之间") { }
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
if (value is int port)
{
return port >= 1 && port <= 65535
? ValidationResult.Success
: new ValidationResult(ErrorMessage);
}
return new ValidationResult("端口号必须是整数");
}
}错误模板
自定义错误显示
<!-- 全局默认错误模板 -->
<Style TargetType="TextBox">
<Setter Property="Validation.ErrorTemplate">
<Setter.Value>
<ControlTemplate>
<DockPanel>
<!-- 错误图标和提示 -->
<TextBlock DockPanel.Dock="Right"
Foreground="Red" FontSize="12" Margin="5,0,0,0"
Text="{Binding ElementName=ErrorAdorner,
Path=AdornedElement.(Validation.Errors)[0].ErrorContent}"/>
<!-- 红色边框 -->
<Border BorderBrush="Red" BorderThickness="1">
<AdornedElementPlaceholder x:Name="ErrorAdorner"/>
</Border>
</DockPanel>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="Validation.HasError" Value="True">
<Setter Property="ToolTip"
Value="{Binding RelativeSource={RelativeSource Self},
Path=(Validation.Errors)[0].ErrorContent}"/>
</Trigger>
</Style.Triggers>
</Style>优点
缺点
总结
WPF 验证推荐方案:简单验证用 ValidationRule(声明式),ViewModel 验证用 INotifyDataErrorInfo(支持异步)。IDataErrorInfo 适合简单同步场景。关键配置:ValidatesOnDataErrors=True、ValidatesOnNotifyDataErrors=True、UpdateSourceTrigger=PropertyChanged。
关键知识点
- 先分清主题属于界面层、ViewModel 层、线程模型还是设备接入层。
- WPF 文章真正的价值在于把 UI、数据、命令、线程和资源关系讲清楚。
- 上位机场景里,稳定性和异常恢复常常比界面花哨更重要。
- WPF 主题往往要同时理解依赖属性、绑定、可视树和命令系统。
项目落地视角
- 优先明确 DataContext、绑定路径、命令源和线程切换位置。
- 涉及设备时,补齐超时、重连、日志、告警和资源释放策略。
- 复杂控件最好提供最小可运行页面,便于后续复用和排障。
- 优先确认 DataContext、绑定路径、命令触发点和资源引用来源。
常见误区
- 把大量逻辑堆进 code-behind,导致页面难测难维护。
- 在后台线程直接操作 UI,或忽略 Dispatcher 切换。
- 只验证正常路径,不验证设备掉线、权限缺失和资源泄漏。
- 在 code-behind 塞太多状态与业务逻辑。
进阶路线
- 继续向 MVVM 工具链、控件可复用性、性能优化和视觉树诊断深入。
- 把主题放回真实设备流程,思考启动、连接、采集、显示、告警和恢复。
- 沉淀成控件库、通信层和诊断工具,提高整套客户端的复用度。
- 继续补齐控件库、主题系统、诊断工具和启动性能优化。
适用场景
- 当你准备把《数据验证(ValidationRule + IDataErrorInfo)》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合桌面业务系统、监控看板、工控上位机和设备交互界面。
- 当系统需要同时处理复杂 UI、后台任务和硬件通信时,这类主题尤为关键。
落地建议
- 优先明确 View、ViewModel、服务层和设备层边界,避免代码隐藏过重。
- 涉及实时刷新时,提前设计 UI 线程切换、节流和资源释放。
- 对硬件通信、日志、告警和异常恢复建立标准流程。
排错清单
- 先检查 DataContext、绑定路径、INotifyPropertyChanged 和命令状态是否正常。
- 排查 Dispatcher 调用、死锁、后台线程直接操作 UI 的问题。
- 出现内存增长时,优先检查事件订阅、图像资源和窗口生命周期。
复盘问题
- 如果把《数据验证(ValidationRule + IDataErrorInfo)》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《数据验证(ValidationRule + IDataErrorInfo)》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《数据验证(ValidationRule + IDataErrorInfo)》最大的收益和代价分别是什么?
