Day 4 - 表单验证
建议用时:240-270 分钟
你将学会什么
- 知道表单验证为什么不能只写在界面上。
- 会在 ViewModel 里写名称必填校验。
- 会在 ViewModel 里写价格数字和范围校验。
- 会把字段错误显示到输入框下面。
- 会让保存按钮根据
CanSave自动启用或禁用。 - 会在保存时再次执行完整校验,避免漏掉错误。
本页不用复杂验证框架,先手写最直接的验证流程。先把“错误从哪里来、显示到哪里、什么时候阻止保存”搞清楚。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
学习衔接
上一页学习的是“命令机制”,今天继续学习“表单验证”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
今天的最低通过线
第一次学习不要求背完整页。完成下面 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
价格校验有两步:
- 能不能转成数字。
- 数字是否大于 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 的作用
保存按钮是否能点,取决于 ProductName 和 PriceText。
当这两个属性变化时,要通知 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>先用一句话理解
输入框改的是 ProductName 和 PriceText,字段变化时更新错误消息,保存按钮用 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 "";
}分别传入 -1m、20m、200000m。
训练 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 商品表单验证页面:
- 名称不能为空。
- 价格必须是数字。
- 价格必须大于 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>必须验证的输入
| 名称 | 价格 | 应该看到 |
|---|---|---|
| 空 | 199 | 名称下方显示 商品名称不能为空。,保存按钮不可点击 |
Keyboard | abc | 价格下方显示 价格必须是数字。,保存按钮不可点击 |
Keyboard | 0 | 价格下方显示 价格必须大于 0。,保存按钮不可点击 |
Keyboard | 199 | 保存按钮可点击,点击后显示保存成功 |
如果报错,按这个顺序检查
- 如果错误文字不刷新,检查
OnProductNameChanged或OnPriceTextChanged是否写对。 - 如果保存按钮状态不刷新,检查两个字段上有没有
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]。 - 如果价格输入
abc后程序异常,检查是否用了decimal.TryParse。 - 如果
SaveCommand找不到,检查保存方法是否写了[RelayCommand(CanExecute = nameof(CanSave))]。 - 如果绑定不显示,检查
x:DataType和App.axaml.cs的DataContext。
今天真正要掌握的闭环
用户输入
-> ObservableProperty 更新 ViewModel 属性
-> On属性Changed 更新字段错误
-> NotifyCanExecuteChangedFor 刷新按钮可执行状态
-> 点击保存
-> ValidateAll 再次检查全部字段
-> 合格才显示保存成功这个闭环掌握后,后面换成更高级的验证框架也不会乱,因为原理仍然是字段、规则、错误、保存前总校验。