Skip to Content
Week 07Day 4 - 表单验证

Day 4 - 表单验证

建议用时:240-270 分钟

你将学会什么

  • 知道表单验证为什么不能只写在界面上。
  • 会在 ViewModel 里写名称必填校验。
  • 会在 ViewModel 里写价格数字和范围校验。
  • 会把字段错误显示到输入框下面。
  • 会让保存按钮根据 CanSave 自动启用或禁用。
  • 会在保存时再次执行完整校验,避免漏掉错误。

本页不用复杂验证框架,先手写最直接的验证流程。先把“错误从哪里来、显示到哪里、什么时候阻止保存”搞清楚。

本页固定顺序

  1. 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
  2. 再学第二部分:把刚学的知识组合成一个完整例子。
  3. 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
  4. 最后做第四部分:先独立完成作业,再用完整答案检查。

学习衔接

上一页学习的是“命令机制”,今天继续学习“表单验证”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。

今天的最低通过线

第一次学习不要求背完整页。完成下面 3 项,就可以继续:

  • 能用自己的话说明“表单验证”解决什么问题。
  • 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
  • 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。

第一部分:先学原理和最小知识

这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。表单验证不是为了写更多代码,而是为了防止错误数据进入后续流程。

1. 表单验证解决什么问题

用户输入永远不能直接相信。

商品表单里常见错误:

字段错误输入问题
名称空字符串不知道保存的是什么商品
名称全是空格看起来填了,实际没有有效内容
价格abc不能转换成数字
价格0不符合大于 0 的规则
价格-5不符合大于 0 的规则

验证就是在保存之前把这些问题拦住。

2. 字段错误是什么

字段错误是某一个输入项自己的错误。

本页有两个字段错误:

字段错误属性例子
商品名称NameError商品名称不能为空。
商品价格PriceError价格必须是数字。

XAML 里把错误显示在输入框下面:

<TextBlock Text="{Binding NameError}" Foreground="Red" />

这样用户能知道具体是哪一项有问题。

3. 为什么价格用 PriceText 而不是 Price

输入框里拿到的永远是文本。用户可能输入 abc,这不是数字。

如果 ViewModel 里直接用:

public decimal Price { get; set; }

你就很难表示“用户正在输入一个还不是数字的内容”。

所以本页先用:

private string priceText = "199";

等验证通过后再转成真正的 decimal price

4. 为什么要写 GetNameError

验证逻辑不要散落在很多地方。把名称校验集中成一个方法:

private static string GetNameError(string value) { return string.IsNullOrWhiteSpace(value) ? "商品名称不能为空。" : ""; }

返回空字符串表示没有错误,返回错误文字表示有错误。

这种写法直接、清楚,适合刚开始做表单验证。

5. 为什么要写 GetPriceError

价格校验有两步:

  1. 能不能转成数字。
  2. 数字是否大于 0。
private static string GetPriceError(string value) { if (!decimal.TryParse(value?.Trim(), out decimal price)) { return "价格必须是数字。"; } return price <= 0 ? "价格必须大于 0。" : ""; }

TryParse 的好处是:输入错误不会让程序崩掉,而是让你显示错误消息。

6. 字段变化时为什么要更新错误

用户一边输入,错误提示应该一边变化。

partial void OnProductNameChanged(string value) { NameError = GetNameError(value); }

这是 [ObservableProperty] 生成的变化回调。ProductName 改变时,这个方法会自动执行。

价格同理:

partial void OnPriceTextChanged(string value) { PriceError = GetPriceError(value); }

7. CanSave 和 ValidateAll 的区别

这两个都和验证有关,但职责不同。

方法什么时候用作用
CanSave按钮判断是否可点击控制保存按钮启用或禁用
ValidateAll真正保存前更新所有错误并决定是否继续保存

不要只写 CanSave。保存动作里最好再调用 ValidateAll,防止以后从别的地方触发保存时绕过按钮状态。

8. NotifyCanExecuteChangedFor 的作用

保存按钮是否能点,取决于 ProductNamePriceText

当这两个属性变化时,要通知 SaveCommand 重新判断。

[NotifyCanExecuteChangedFor(nameof(SaveCommand))]

如果少了它,输入已经合法了,但按钮可能还是禁用状态。

9. 为什么保存失败不清空输入

保存失败时不要清空用户已经输入的内容。

错误做法:

ProductName = ""; PriceText = "";

用户会失去刚才输入的内容。

正确做法是:

Message = "请先修正表单错误。";

保留输入,让用户按错误提示修正。

表单验证常用写法速查

需求写法说明
判断空文本string.IsNullOrWhiteSpace(Name)必填字段
转金额decimal.TryParse(PriceText, out decimal price)价格输入
转整数int.TryParse(StockText, out int stock)库存输入
错误提示ErrorMessage = "..."绑定到界面
成功提示Message = "保存成功"绑定到界面
停止保存return校验失败提前结束
清空输入Name = ""; PriceText = "";保存成功后

验证顺序:

先判断必填 -> 再转换类型 -> 再判断业务范围 -> 最后保存

第二部分:把知识组合成完整例子

今天要做一个 MVVM 商品表单:

  • 名称不能为空。
  • 价格必须是数字。
  • 价格必须大于 0。
  • 输入错误显示在对应字段下面。
  • 输入不合法时,保存按钮不可点击。
  • 点击保存时仍然再校验一次。

ViewModels/MainWindowViewModel.cs

using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ProductApp.ViewModels; public partial class MainWindowViewModel : ViewModelBase { [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SaveCommand))] private string productName = "Keyboard"; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SaveCommand))] private string priceText = "199"; [ObservableProperty] private string nameError = ""; [ObservableProperty] private string priceError = ""; [ObservableProperty] private string message = "请填写商品信息。"; partial void OnProductNameChanged(string value) { NameError = GetNameError(value); } partial void OnPriceTextChanged(string value) { PriceError = GetPriceError(value); } private bool CanSave() { return GetNameError(ProductName).Length == 0 && GetPriceError(PriceText).Length == 0; } [RelayCommand(CanExecute = nameof(CanSave))] private void Save() { if (!ValidateAll()) { Message = "请先修正表单错误。"; return; } decimal price = decimal.Parse(PriceText.Trim()); Message = $"保存成功:{ProductName.Trim()},价格 {price}"; } private bool ValidateAll() { NameError = GetNameError(ProductName); PriceError = GetPriceError(PriceText); return NameError.Length == 0 && PriceError.Length == 0; } private static string GetNameError(string value) { return string.IsNullOrWhiteSpace(value) ? "商品名称不能为空。" : ""; } private static string GetPriceError(string value) { if (!decimal.TryParse(value?.Trim(), out decimal price)) { return "价格必须是数字。"; } return price <= 0 ? "价格必须大于 0。" : ""; } }

Views/MainWindow.axaml

<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ProductApp.ViewModels" x:Class="ProductApp.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Width="560" Height="400" Title="表单验证"> <StackPanel Margin="20" Spacing="8"> <TextBlock Text="商品名称" /> <TextBox Text="{Binding ProductName, Mode=TwoWay}" PlaceholderText="请输入商品名称" /> <TextBlock Text="{Binding NameError}" Foreground="Red" TextWrapping="Wrap" /> <TextBlock Text="商品价格" /> <TextBox Text="{Binding PriceText, Mode=TwoWay}" PlaceholderText="请输入价格,例如 199" /> <TextBlock Text="{Binding PriceError}" Foreground="Red" TextWrapping="Wrap" /> <Button Content="保存" Command="{Binding SaveCommand}" /> <TextBlock Text="{Binding Message}" TextWrapping="Wrap" /> </StackPanel> </Window>

先用一句话理解

输入框改的是 ProductNamePriceText,字段变化时更新错误消息,保存按钮用 CanSave 判断能不能点,真正保存前再调用 ValidateAll

第三部分:跟着敲代码

从这里开始动手。每个例子都按真实文件来写。

例子 1:只验证名称

ViewModels/MainWindowViewModel.cs

using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ProductApp.ViewModels; public partial class MainWindowViewModel : ViewModelBase { [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SaveCommand))] private string productName = ""; [ObservableProperty] private string nameError = ""; [ObservableProperty] private string message = "请输入商品名称。"; partial void OnProductNameChanged(string value) { NameError = string.IsNullOrWhiteSpace(value) ? "商品名称不能为空。" : ""; } private bool CanSave() { return !string.IsNullOrWhiteSpace(ProductName); } [RelayCommand(CanExecute = nameof(CanSave))] private void Save() { Message = $"保存成功:{ProductName.Trim()}"; } }

Views/MainWindow.axaml

<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ProductApp.ViewModels" x:Class="ProductApp.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Width="520" Height="300" Title="名称验证"> <StackPanel Margin="20" Spacing="8"> <TextBlock Text="商品名称" /> <TextBox Text="{Binding ProductName, Mode=TwoWay}" PlaceholderText="请输入商品名称" /> <TextBlock Text="{Binding NameError}" Foreground="Red" /> <Button Content="保存" Command="{Binding SaveCommand}" /> <TextBlock Text="{Binding Message}" /> </StackPanel> </Window>

例子 2:只验证价格

ViewModels/MainWindowViewModel.cs

using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ProductApp.ViewModels; public partial class MainWindowViewModel : ViewModelBase { [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SaveCommand))] private string priceText = "199"; [ObservableProperty] private string priceError = ""; [ObservableProperty] private string message = "请输入价格。"; partial void OnPriceTextChanged(string value) { PriceError = GetPriceError(value); } private bool CanSave() { return GetPriceError(PriceText).Length == 0; } [RelayCommand(CanExecute = nameof(CanSave))] private void Save() { decimal price = decimal.Parse(PriceText.Trim()); Message = $"价格验证通过:{price}"; } private static string GetPriceError(string value) { if (!decimal.TryParse(value?.Trim(), out decimal price)) { return "价格必须是数字。"; } return price <= 0 ? "价格必须大于 0。" : ""; } }

Views/MainWindow.axaml

<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ProductApp.ViewModels" x:Class="ProductApp.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Width="520" Height="300" Title="价格验证"> <StackPanel Margin="20" Spacing="8"> <TextBlock Text="商品价格" /> <TextBox Text="{Binding PriceText, Mode=TwoWay}" PlaceholderText="请输入价格" /> <TextBlock Text="{Binding PriceError}" Foreground="Red" /> <Button Content="保存" Command="{Binding SaveCommand}" /> <TextBlock Text="{Binding Message}" /> </StackPanel> </Window>

例子 3:完整表单验证

ViewModels/MainWindowViewModel.cs

using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ProductApp.ViewModels; public partial class MainWindowViewModel : ViewModelBase { [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SaveCommand))] private string productName = "Keyboard"; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SaveCommand))] private string priceText = "199"; [ObservableProperty] private string nameError = ""; [ObservableProperty] private string priceError = ""; [ObservableProperty] private string message = "请填写商品信息。"; partial void OnProductNameChanged(string value) { NameError = GetNameError(value); } partial void OnPriceTextChanged(string value) { PriceError = GetPriceError(value); } private bool CanSave() { return GetNameError(ProductName).Length == 0 && GetPriceError(PriceText).Length == 0; } [RelayCommand(CanExecute = nameof(CanSave))] private void Save() { if (!ValidateAll()) { Message = "请先修正表单错误。"; return; } decimal price = decimal.Parse(PriceText.Trim()); Message = $"保存成功:{ProductName.Trim()},价格 {price}"; } private bool ValidateAll() { NameError = GetNameError(ProductName); PriceError = GetPriceError(PriceText); return NameError.Length == 0 && PriceError.Length == 0; } private static string GetNameError(string value) { return string.IsNullOrWhiteSpace(value) ? "商品名称不能为空。" : ""; } private static string GetPriceError(string value) { if (!decimal.TryParse(value?.Trim(), out decimal price)) { return "价格必须是数字。"; } return price <= 0 ? "价格必须大于 0。" : ""; } }

Views/MainWindow.axaml

<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ProductApp.ViewModels" x:Class="ProductApp.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Width="560" Height="400" Title="表单验证"> <StackPanel Margin="20" Spacing="8"> <TextBlock Text="商品名称" /> <TextBox Text="{Binding ProductName, Mode=TwoWay}" PlaceholderText="请输入商品名称" /> <TextBlock Text="{Binding NameError}" Foreground="Red" TextWrapping="Wrap" /> <TextBlock Text="商品价格" /> <TextBox Text="{Binding PriceText, Mode=TwoWay}" PlaceholderText="请输入价格,例如 199" /> <TextBlock Text="{Binding PriceError}" Foreground="Red" TextWrapping="Wrap" /> <Button Content="保存" Command="{Binding SaveCommand}" /> <TextBlock Text="{Binding Message}" TextWrapping="Wrap" /> </StackPanel> </Window>

小白重复敲写训练

验证规则一次只加一条,先看错误出现,再看错误消失。

训练 1:名称不能为空

private string ValidateName(string? name) { return string.IsNullOrWhiteSpace(name) ? "名称不能为空" : ""; } Console.WriteLine(ValidateName("")); Console.WriteLine(ValidateName("Keyboard"));

训练 2:价格范围

private string ValidatePrice(decimal price) { if (price < 0) return "价格不能小于 0"; if (price > 100000) return "价格过大"; return ""; }

分别传入 -1m20m200000m

训练 3:界面显示错误

<TextBox Text="{Binding Name}" /> <TextBlock Text="{Binding NameError}" Foreground="Red" IsVisible="{Binding HasNameError}" />

第三遍把价格错误也显示在价格输入框下方。

每日小测

做完本页后,用这 5 题检查是否真的掌握。

1. 判断题

本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。

答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。

2. 填空题

本页主题是:表单验证。今天至少要掌握的 3 个点是:

1. 知道表单验证为什么不能只写在界面上。 2. 会在 ViewModel 里写名称必填校验。 3. 会在 ViewModel 里写价格数字和范围校验。

答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。

3. 流程题

遇到本页相关功能时,先按什么顺序处理?

答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。

4. 找错误题

如果本页代码运行失败,第一步应该做什么?

答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。

5. 改需求题

在本页完整例子跑通后,至少改一个小需求。

可选改法:

  • 改一个字段名称。
  • 多加一个校验条件。
  • 多输出一行结果。
  • 把固定数据改成用户输入。
  • 把一次处理改成多条数据处理。

答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:知道表单验证为什么不能只写在界面上。。

上位机专项练习

设备配置必须在保存前验证,让操作者立即看到字段哪里不对。

下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。

专项例子 1:验证设备名不能为空

[ObservableProperty] [NotifyDataErrorInfo] [Required(ErrorMessage = "请输入设备名称")] private string deviceName = "";

运行结果或界面效果:

空设备名产生验证错误

改动任务: 增加最大长度 30。

专项例子 2:验证端口范围

[Range(1, 65535, ErrorMessage = "端口必须在 1 到 65535 之间")] public int Port { get; set; } = 502;

运行结果或界面效果:

0 和 70000 都不能通过

改动任务: 测试端口 502。

专项例子 3:界面显示验证错误

<TextBox Text="{Binding DeviceName, Mode=TwoWay}" /> <TextBlock Foreground="Red" Text="{Binding (DataValidationErrors.Errors)[0].ErrorContent, RelativeSource={RelativeSource PreviousData}}" />

运行结果或界面效果:

输入无效时显示红色提示

改动任务: 让保存命令在 HasErrors=true 时不可用。

第四部分:作业完整答案

这一部分给出当天作业的完整答案。先照第三部分敲一遍,再用这里检查结果。

作业要求

做一个 MVVM 商品表单验证页面:

  1. 名称不能为空。
  2. 价格必须是数字。
  3. 价格必须大于 0。
  4. 名称错误显示在名称输入框下方。
  5. 价格错误显示在价格输入框下方。
  6. 表单不合法时,保存按钮不可点击。
  7. 保存时再次验证全部字段。

ViewModels/MainWindowViewModel.cs 完整答案

using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ProductApp.ViewModels; public partial class MainWindowViewModel : ViewModelBase { [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SaveCommand))] private string productName = "Keyboard"; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SaveCommand))] private string priceText = "199"; [ObservableProperty] private string nameError = ""; [ObservableProperty] private string priceError = ""; [ObservableProperty] private string message = "请填写商品信息。"; partial void OnProductNameChanged(string value) { NameError = GetNameError(value); } partial void OnPriceTextChanged(string value) { PriceError = GetPriceError(value); } private bool CanSave() { return GetNameError(ProductName).Length == 0 && GetPriceError(PriceText).Length == 0; } [RelayCommand(CanExecute = nameof(CanSave))] private void Save() { if (!ValidateAll()) { Message = "请先修正表单错误。"; return; } decimal price = decimal.Parse(PriceText.Trim()); Message = $"保存成功:{ProductName.Trim()},价格 {price}"; } private bool ValidateAll() { NameError = GetNameError(ProductName); PriceError = GetPriceError(PriceText); return NameError.Length == 0 && PriceError.Length == 0; } private static string GetNameError(string value) { return string.IsNullOrWhiteSpace(value) ? "商品名称不能为空。" : ""; } private static string GetPriceError(string value) { if (!decimal.TryParse(value?.Trim(), out decimal price)) { return "价格必须是数字。"; } return price <= 0 ? "价格必须大于 0。" : ""; } }

Views/MainWindow.axaml 完整答案

<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ProductApp.ViewModels" x:Class="ProductApp.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Width="560" Height="400" Title="表单验证"> <StackPanel Margin="20" Spacing="8"> <TextBlock Text="商品名称" /> <TextBox Text="{Binding ProductName, Mode=TwoWay}" PlaceholderText="请输入商品名称" /> <TextBlock Text="{Binding NameError}" Foreground="Red" TextWrapping="Wrap" /> <TextBlock Text="商品价格" /> <TextBox Text="{Binding PriceText, Mode=TwoWay}" PlaceholderText="请输入价格,例如 199" /> <TextBlock Text="{Binding PriceError}" Foreground="Red" TextWrapping="Wrap" /> <Button Content="保存" Command="{Binding SaveCommand}" /> <TextBlock Text="{Binding Message}" TextWrapping="Wrap" /> </StackPanel> </Window>

必须验证的输入

名称价格应该看到
199名称下方显示 商品名称不能为空。,保存按钮不可点击
Keyboardabc价格下方显示 价格必须是数字。,保存按钮不可点击
Keyboard0价格下方显示 价格必须大于 0。,保存按钮不可点击
Keyboard199保存按钮可点击,点击后显示保存成功

如果报错,按这个顺序检查

  1. 如果错误文字不刷新,检查 OnProductNameChangedOnPriceTextChanged 是否写对。
  2. 如果保存按钮状态不刷新,检查两个字段上有没有 [NotifyCanExecuteChangedFor(nameof(SaveCommand))]
  3. 如果价格输入 abc 后程序异常,检查是否用了 decimal.TryParse
  4. 如果 SaveCommand 找不到,检查保存方法是否写了 [RelayCommand(CanExecute = nameof(CanSave))]
  5. 如果绑定不显示,检查 x:DataTypeApp.axaml.csDataContext

今天真正要掌握的闭环

用户输入 -> ObservableProperty 更新 ViewModel 属性 -> On属性Changed 更新字段错误 -> NotifyCanExecuteChangedFor 刷新按钮可执行状态 -> 点击保存 -> ValidateAll 再次检查全部字段 -> 合格才显示保存成功

这个闭环掌握后,后面换成更高级的验证框架也不会乱,因为原理仍然是字段、规则、错误、保存前总校验。