Day 2 - 体验优化
建议用时:180-210 分钟
你将学会什么
- 用“状态”提升界面可用性
- 让保存过程有反馈,不让用户重复点击
- 处理空数据、输入错误、保存中、保存成功这几种常见情况
- 不靠自定义样式,也能把功能做得清楚
本页不做颜色主题、不做按钮样式、不写资源样式。今天只做一件事:让用户清楚知道“现在是什么状态、下一步能做什么、失败后怎么改”。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
今天只抓住 3 件事
- 体验优化先做状态,不是先做样式。
- 每一种状态都要有变量承接,例如
IsSaving、ErrorMessage、IsEmpty。 - 按钮能不能点、提示显示什么,都应该由 ViewModel 状态决定。
学习衔接
上一页学习的是“边界与异常”,今天继续学习“体验优化”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
第一部分:先学原理和最小知识
这一部分只读,不敲代码。先把“为什么要这样写”讲清楚,再进入完整代码。
为什么体验优化先做状态
一个功能能运行,只代表程序完成了计算;一个功能好用,必须让使用者知道程序现在发生了什么。
比如点击“保存”后,如果页面没有任何变化,使用者会产生三个问题:
- 我刚才点到了吗?
- 程序是不是卡住了?
- 我要不要再点一次?
如果重复点击保存,可能会出现重复数据。所以体验优化不是装饰,它是在保护业务流程。
今天要掌握的 5 个状态
| 状态 | 直接解释 | 页面上应该出现什么 |
|---|---|---|
| 普通状态 | 页面可以正常输入和点击 | 输入框、保存按钮正常可用 |
| 空状态 | 当前没有数据 | 显示“暂无商品,请先新增” |
| 错误状态 | 输入不符合规则 | 显示具体错误,例如“价格必须是大于 0 的数字” |
| 保存中状态 | 程序正在处理保存 | 显示“正在保存”,按钮暂时不可点 |
| 成功状态 | 保存已经完成 | 显示“保存成功”,列表出现新商品 |
状态和变量的关系
界面状态本质上就是变量。
| 变量 | 类型 | 负责什么 |
|---|---|---|
ProductName | string | 商品名称输入框的内容 |
PriceText | string | 价格输入框的内容,先用字符串接收 |
IsSaving | bool | 是否正在保存 |
CanSave | bool | 按钮现在能不能点 |
Message | string | 普通提示 |
ErrorMessage | string | 错误提示 |
IsEmpty | bool | 列表是不是空的 |
Products | ObservableCollection<ProductItem> | 商品列表 |
这里有一个重要细节:价格输入框用 string 接收,而不是直接用 decimal。
原因是用户输入过程可能是临时非法的,比如刚输入一个空字符串、一个点号、一个字母。界面先把内容当字符串接住,再用 decimal.TryParse 判断能不能变成数字,这样更稳定。
为什么保存中要禁用按钮
保存通常不是瞬间完成的,真实项目里可能要写文件、写数据库、请求接口。
如果保存中按钮还可以点,就可能发生:
- 同一个商品被保存两次。
- 前一次还没完成,后一次又开始了。
- 成功提示和失败提示互相覆盖。
所以保存开始时设置 IsSaving = true,保存结束后再设置 IsSaving = false。按钮绑定 CanSave,当 IsSaving 为 true 时按钮不可点。
为什么错误提示要具体
错误提示不能只写“保存失败”,因为这句话没有告诉用户怎么改。
更好的提示是:
- 商品名称不能为空。
- 价格必须是大于 0 的数字。
- 保存失败:文件被占用。
具体错误能让用户立刻知道下一步该改哪里。
第二部分:把知识组合成完整例子
今天做一个“新增商品”的小页面。页面不追求好看,重点是状态清楚。
用户会看到这些行为:
- 页面刚打开时,列表没有商品,界面显示“暂无商品,请先新增”。
- 商品名称为空时点击保存,界面提示“商品名称不能为空”。
- 价格不是数字,或者价格小于等于 0,界面提示“价格必须是大于 0 的数字”。
- 正在保存时,保存按钮和清空按钮暂时不可点。
- 保存成功后,商品进入列表,输入框清空,界面显示保存成功。
这就是体验优化的最小闭环:不是给界面加颜色,而是让每一步都能被看见、能被理解、能继续操作。
先看核心状态
bool isSaving = false;
string message = "请输入商品后保存。";
string errorMessage = "";
bool isEmpty = true;
Console.WriteLine(message);
isSaving = true;
message = "正在保存,请稍等。";
Console.WriteLine(message);
isSaving = false;
isEmpty = false;
message = "保存成功:Keyboard";
Console.WriteLine(message);这段代码没有界面,但它已经说明了今天的核心思想:
isSaving表示当前是不是正在保存。message给用户看普通提示。errorMessage给用户看错误原因。isEmpty决定没有数据时是否显示空状态。
界面只是把这些状态显示出来。
第三部分:跟着敲代码
从这里开始动手。今天需要改两个文件:
ViewModels/MainWindowViewModel.csViews/MainWindow.axaml
如果你的项目命名空间不是 ProductExperience,把下面代码里的 ProductExperience 改成你的项目名称。例如项目叫 MyApp,就改成 MyApp.ViewModels。
第 1 步:写 ViewModel
打开 ViewModels/MainWindowViewModel.cs,替换成下面完整代码。
using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ProductExperience.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
{
public ObservableCollection<ProductItem> Products { get; } = new();
[ObservableProperty]
private string productName = "";
[ObservableProperty]
private string priceText = "";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanSave))]
private bool isSaving;
[ObservableProperty]
private string message = "请输入商品后保存。";
[ObservableProperty]
private string errorMessage = "";
[ObservableProperty]
private bool isEmpty = true;
public bool CanSave => !IsSaving;
[RelayCommand]
private async Task SaveAsync()
{
if (IsSaving)
{
return;
}
ErrorMessage = "";
string name = ProductName.Trim();
if (string.IsNullOrWhiteSpace(name))
{
ErrorMessage = "商品名称不能为空。";
Message = "请修正输入后再保存。";
return;
}
if (!decimal.TryParse(PriceText, out decimal price) || price <= 0)
{
ErrorMessage = "价格必须是大于 0 的数字。";
Message = "请修正输入后再保存。";
return;
}
try
{
IsSaving = true;
Message = "正在保存,请稍等。";
await Task.Delay(500);
Products.Add(new ProductItem
{
Name = name,
Price = price
});
ProductName = "";
PriceText = "";
IsEmpty = Products.Count == 0;
Message = $"保存成功:{name}";
}
catch (Exception ex)
{
ErrorMessage = $"保存失败:{ex.Message}";
Message = "保存没有完成。";
}
finally
{
IsSaving = false;
}
}
[RelayCommand]
private void Clear()
{
if (IsSaving)
{
return;
}
ProductName = "";
PriceText = "";
ErrorMessage = "";
Message = "已清空输入。";
}
}
public sealed class ProductItem
{
public string Name { get; set; } = "";
public decimal Price { get; set; }
}第 2 步:看懂 ViewModel 的执行顺序
保存按钮点击后,代码按这个顺序走:
- 如果正在保存,直接
return,防止重复保存。 - 清空上一次错误:
ErrorMessage = ""。 - 读取并修剪商品名称:
ProductName.Trim()。 - 名称为空就显示错误,并停止保存。
- 价格不能转成数字,或者小于等于 0,就显示错误,并停止保存。
- 设置
IsSaving = true,界面按钮不可点。 - 模拟保存耗时:
await Task.Delay(500)。 - 把商品加入
Products。 - 清空输入框,更新空状态,显示保存成功。
finally里设置IsSaving = false,保证按钮恢复可点。
第 3 步:写窗口 XAML
打开 Views/MainWindow.axaml,替换成下面完整代码。
<Window
x:Class="ProductExperience.Views.MainWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ProductExperience.ViewModels"
x:DataType="vm:MainWindowViewModel"
Title="ProductExperience">
<StackPanel Margin="20" Spacing="10">
<TextBlock Text="体验优化:保存商品" />
<TextBlock Text="{Binding Message}" />
<TextBlock Text="商品名称" />
<TextBox
Text="{Binding ProductName, Mode=TwoWay}"
PlaceholderText="例如 Keyboard" />
<TextBlock Text="价格" />
<TextBox
Text="{Binding PriceText, Mode=TwoWay}"
PlaceholderText="例如 199" />
<TextBlock Text="{Binding ErrorMessage}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button
Content="保存"
Command="{Binding SaveCommand}"
IsEnabled="{Binding CanSave}" />
<Button
Content="清空"
Command="{Binding ClearCommand}"
IsEnabled="{Binding CanSave}" />
</StackPanel>
<TextBlock Text="商品列表" />
<TextBlock
Text="暂无商品,请先新增。"
IsVisible="{Binding IsEmpty}" />
<ListBox ItemsSource="{Binding Products}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ProductItem">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Name}" />
<TextBlock Text="{Binding Price}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
</Window>第 4 步:看懂 XAML 和 ViewModel 怎么连起来
TextBox Text="{Binding ProductName, Mode=TwoWay}" 的意思是:
- 输入框显示
ProductName的值。 - 用户输入内容后,
ProductName也会跟着变。 - ViewModel 清空
ProductName后,输入框也会变空。
Button Command="{Binding SaveCommand}" 的意思是:
- 按钮点击后,调用 ViewModel 里的
SaveAsync。 SaveAsync上面有[RelayCommand]。- CommunityToolkit 会自动生成
SaveCommand。
IsEnabled="{Binding CanSave}" 的意思是:
CanSave为true,按钮可以点。CanSave为false,按钮不可点。CanSave的结果来自!IsSaving。
IsVisible="{Binding IsEmpty}" 的意思是:
IsEmpty为true,显示“暂无商品,请先新增”。IsEmpty为false,隐藏这句提示。
第 5 步:运行并检查
运行项目:
dotnet run按下面顺序检查:
- 刚打开窗口时,能看到“暂无商品,请先新增”。
- 不输入商品名称,直接点保存,能看到“商品名称不能为空”。
- 输入商品名称,价格输入
abc,点保存,能看到“价格必须是大于 0 的数字”。 - 输入商品名称
Keyboard,价格输入199,点保存,按钮会短暂不可点。 - 保存完成后,列表出现
Keyboard 199,输入框被清空。
体验状态常用操作速查
| 需求 | 写法 | 作用 |
|---|---|---|
| 保存中状态 | IsSaving = true | 防止重复点击 |
| 保存完成 | IsSaving = false | 恢复按钮和输入 |
| 普通提示 | Message = "保存成功" | 告诉当前结果 |
| 错误提示 | ErrorMessage = "价格必须大于 0" | 告诉具体修正点 |
| 空列表判断 | Products.Count == 0 | 决定是否显示空状态 |
| 按钮可用条件 | CanSave => !IsSaving | 控制按钮是否允许执行 |
| 异步等待 | await Task.Delay(500) | 模拟耗时保存 |
| 收尾处理 | finally { IsSaving = false; } | 保证异常时状态恢复 |
常见错误和修法
| 错误 | 为什么错 | 修法 |
|---|---|---|
| 点击保存后界面没变化 | 没有设置保存中或提示状态 | 保存开始设置 IsSaving 和 Message |
| 保存失败后按钮一直不可点 | 异常后没有恢复状态 | 把 IsSaving = false 放进 finally |
| 错误提示太模糊 | 使用者不知道该改什么 | 写清字段和规则 |
价格输入直接绑定 decimal | 输入过程可能临时非法 | 输入框先用 string,保存时 TryParse |
| 空列表没有提示 | 界面看起来像坏了 | 增加 IsEmpty 并绑定空状态文字 |
小白重复敲写训练
体验优化要让用户始终知道“现在发生了什么”。
训练 1:加载状态
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private string _statusMessage = "";
private async Task LoadAsync()
{
IsLoading = true;
StatusMessage = "正在加载...";
try
{
await _service.LoadAsync();
StatusMessage = "加载完成";
}
finally
{
IsLoading = false;
}
}训练 2:空状态
public bool IsEmpty => Products.Count == 0 && !IsLoading;<TextBlock Text="暂无商品" IsVisible="{Binding IsEmpty}" />
<ProgressBar IsIndeterminate="True" IsVisible="{Binding IsLoading}" />训练 3:操作结果反馈
[RelayCommand]
private async Task SaveAsync()
{
StatusMessage = "正在保存...";
await _service.SaveAsync();
StatusMessage = "保存成功";
}第三遍增加失败时的红色错误信息。
每日小测
做完本页后,用这 5 题检查是否真的掌握。
1. 判断题
本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。
答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。
2. 填空题
本页主题是:体验优化。今天至少要掌握的 3 个点是:
1. 用“状态”提升界面可用性
2. 让保存过程有反馈,不让用户重复点击
3. 处理空数据、输入错误、保存中、保存成功这几种常见情况答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。
3. 流程题
遇到本页相关功能时,先按什么顺序处理?
答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。
4. 找错误题
如果本页代码运行失败,第一步应该做什么?
答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。
5. 改需求题
在本页完整例子跑通后,至少改一个小需求。
可选改法:
- 改一个字段名称。
- 多加一个校验条件。
- 多输出一行结果。
- 把固定数据改成用户输入。
- 把一次处理改成多条数据处理。
答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:用“状态”提升界面可用性。
上位机专项练习
最终 UX 优化只围绕现场可用性:读得懂、点得准、出错知道怎么办。
下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。
专项例子 1:危险操作确认
if (!await dialog.ConfirmAsync("确定停止全部设备采集吗?"))
{
return;
}
await pollingService.StopAsync();运行结果或界面效果:
停止全部采集前必须明确确认改动任务: 为删除设备增加确认。
专项例子 2:错误提示给下一步
<StackPanel>
<TextBlock Text="PLC-01 连接失败" FontWeight="Bold" />
<TextBlock Text="请检查网线、IP 地址和端口 502" />
<Button Content="重新连接" Command="{Binding ReconnectCommand}" />
</StackPanel>运行结果或界面效果:
用户知道原因范围和下一步操作改动任务: 增加打开设备设置按钮。
专项例子 3:键盘和焦点检查
<TextBox Watermark="搜索设备" TabIndex="0" />
<Button Content="清空" TabIndex="1" />
<DataGrid TabIndex="2" />运行结果或界面效果:
按 Tab 的顺序符合操作流程改动任务: 不用鼠标完成一次搜索和清空。
第四部分:作业完整答案
作业要求
在今天页面基础上,再加一个“库存”输入框,规则如下:
- 库存必须是整数。
- 库存不能小于 0。
- 保存成功后,列表里同时显示名称、价格、库存。
- 保存成功后,名称、价格、库存三个输入框都要清空。
完整 ViewModel 答案
using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ProductExperience.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
{
public ObservableCollection<ProductItem> Products { get; } = new();
[ObservableProperty]
private string productName = "";
[ObservableProperty]
private string priceText = "";
[ObservableProperty]
private string stockText = "";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanSave))]
private bool isSaving;
[ObservableProperty]
private string message = "请输入商品后保存。";
[ObservableProperty]
private string errorMessage = "";
[ObservableProperty]
private bool isEmpty = true;
public bool CanSave => !IsSaving;
[RelayCommand]
private async Task SaveAsync()
{
if (IsSaving)
{
return;
}
ErrorMessage = "";
string name = ProductName.Trim();
if (string.IsNullOrWhiteSpace(name))
{
ErrorMessage = "商品名称不能为空。";
Message = "请修正输入后再保存。";
return;
}
if (!decimal.TryParse(PriceText, out decimal price) || price <= 0)
{
ErrorMessage = "价格必须是大于 0 的数字。";
Message = "请修正输入后再保存。";
return;
}
if (!int.TryParse(StockText, out int stock) || stock < 0)
{
ErrorMessage = "库存必须是大于或等于 0 的整数。";
Message = "请修正输入后再保存。";
return;
}
try
{
IsSaving = true;
Message = "正在保存,请稍等。";
await Task.Delay(500);
Products.Add(new ProductItem
{
Name = name,
Price = price,
Stock = stock
});
ProductName = "";
PriceText = "";
StockText = "";
IsEmpty = Products.Count == 0;
Message = $"保存成功:{name}";
}
catch (Exception ex)
{
ErrorMessage = $"保存失败:{ex.Message}";
Message = "保存没有完成。";
}
finally
{
IsSaving = false;
}
}
[RelayCommand]
private void Clear()
{
if (IsSaving)
{
return;
}
ProductName = "";
PriceText = "";
StockText = "";
ErrorMessage = "";
Message = "已清空输入。";
}
}
public sealed class ProductItem
{
public string Name { get; set; } = "";
public decimal Price { get; set; }
public int Stock { get; set; }
}完整 XAML 答案
<Window
x:Class="ProductExperience.Views.MainWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ProductExperience.ViewModels"
x:DataType="vm:MainWindowViewModel"
Title="ProductExperience">
<StackPanel Margin="20" Spacing="10">
<TextBlock Text="体验优化:保存商品" />
<TextBlock Text="{Binding Message}" />
<TextBlock Text="商品名称" />
<TextBox
Text="{Binding ProductName, Mode=TwoWay}"
PlaceholderText="例如 Keyboard" />
<TextBlock Text="价格" />
<TextBox
Text="{Binding PriceText, Mode=TwoWay}"
PlaceholderText="例如 199" />
<TextBlock Text="库存" />
<TextBox
Text="{Binding StockText, Mode=TwoWay}"
PlaceholderText="例如 10" />
<TextBlock Text="{Binding ErrorMessage}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button
Content="保存"
Command="{Binding SaveCommand}"
IsEnabled="{Binding CanSave}" />
<Button
Content="清空"
Command="{Binding ClearCommand}"
IsEnabled="{Binding CanSave}" />
</StackPanel>
<TextBlock Text="商品列表" />
<TextBlock
Text="暂无商品,请先新增。"
IsVisible="{Binding IsEmpty}" />
<ListBox ItemsSource="{Binding Products}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ProductItem">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Name}" />
<TextBlock Text="{Binding Price}" />
<TextBlock Text="{Binding Stock}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
</Window>正确结果
- 名称为空时,不会保存,并显示名称错误。
- 价格不是大于 0 的数字时,不会保存,并显示价格错误。
- 库存不是整数,或者库存小于 0 时,不会保存,并显示库存错误。
- 保存中按钮不可点,避免重复保存。
- 保存成功后,列表出现新商品,三个输入框都被清空。