Skip to Content
Week 12Day 2 - 体验优化

Day 2 - 体验优化

建议用时:180-210 分钟

你将学会什么

  • 用“状态”提升界面可用性
  • 让保存过程有反馈,不让用户重复点击
  • 处理空数据、输入错误、保存中、保存成功这几种常见情况
  • 不靠自定义样式,也能把功能做得清楚

本页不做颜色主题、不做按钮样式、不写资源样式。今天只做一件事:让用户清楚知道“现在是什么状态、下一步能做什么、失败后怎么改”。

本页固定顺序

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

今天只抓住 3 件事

  1. 体验优化先做状态,不是先做样式。
  2. 每一种状态都要有变量承接,例如 IsSavingErrorMessageIsEmpty
  3. 按钮能不能点、提示显示什么,都应该由 ViewModel 状态决定。

学习衔接

上一页学习的是“边界与异常”,今天继续学习“体验优化”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。

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

这一部分只读,不敲代码。先把“为什么要这样写”讲清楚,再进入完整代码。

为什么体验优化先做状态

一个功能能运行,只代表程序完成了计算;一个功能好用,必须让使用者知道程序现在发生了什么。

比如点击“保存”后,如果页面没有任何变化,使用者会产生三个问题:

  • 我刚才点到了吗?
  • 程序是不是卡住了?
  • 我要不要再点一次?

如果重复点击保存,可能会出现重复数据。所以体验优化不是装饰,它是在保护业务流程。

今天要掌握的 5 个状态

状态直接解释页面上应该出现什么
普通状态页面可以正常输入和点击输入框、保存按钮正常可用
空状态当前没有数据显示“暂无商品,请先新增”
错误状态输入不符合规则显示具体错误,例如“价格必须是大于 0 的数字”
保存中状态程序正在处理保存显示“正在保存”,按钮暂时不可点
成功状态保存已经完成显示“保存成功”,列表出现新商品

状态和变量的关系

界面状态本质上就是变量。

变量类型负责什么
ProductNamestring商品名称输入框的内容
PriceTextstring价格输入框的内容,先用字符串接收
IsSavingbool是否正在保存
CanSavebool按钮现在能不能点
Messagestring普通提示
ErrorMessagestring错误提示
IsEmptybool列表是不是空的
ProductsObservableCollection<ProductItem>商品列表

这里有一个重要细节:价格输入框用 string 接收,而不是直接用 decimal

原因是用户输入过程可能是临时非法的,比如刚输入一个空字符串、一个点号、一个字母。界面先把内容当字符串接住,再用 decimal.TryParse 判断能不能变成数字,这样更稳定。

为什么保存中要禁用按钮

保存通常不是瞬间完成的,真实项目里可能要写文件、写数据库、请求接口。

如果保存中按钮还可以点,就可能发生:

  • 同一个商品被保存两次。
  • 前一次还没完成,后一次又开始了。
  • 成功提示和失败提示互相覆盖。

所以保存开始时设置 IsSaving = true,保存结束后再设置 IsSaving = false。按钮绑定 CanSave,当 IsSavingtrue 时按钮不可点。

为什么错误提示要具体

错误提示不能只写“保存失败”,因为这句话没有告诉用户怎么改。

更好的提示是:

  • 商品名称不能为空。
  • 价格必须是大于 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.cs
  • Views/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 的执行顺序

保存按钮点击后,代码按这个顺序走:

  1. 如果正在保存,直接 return,防止重复保存。
  2. 清空上一次错误:ErrorMessage = ""
  3. 读取并修剪商品名称:ProductName.Trim()
  4. 名称为空就显示错误,并停止保存。
  5. 价格不能转成数字,或者小于等于 0,就显示错误,并停止保存。
  6. 设置 IsSaving = true,界面按钮不可点。
  7. 模拟保存耗时:await Task.Delay(500)
  8. 把商品加入 Products
  9. 清空输入框,更新空状态,显示保存成功。
  10. 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}" 的意思是:

  • CanSavetrue,按钮可以点。
  • CanSavefalse,按钮不可点。
  • CanSave 的结果来自 !IsSaving

IsVisible="{Binding IsEmpty}" 的意思是:

  • IsEmptytrue,显示“暂无商品,请先新增”。
  • IsEmptyfalse,隐藏这句提示。

第 5 步:运行并检查

运行项目:

dotnet run

按下面顺序检查:

  1. 刚打开窗口时,能看到“暂无商品,请先新增”。
  2. 不输入商品名称,直接点保存,能看到“商品名称不能为空”。
  3. 输入商品名称,价格输入 abc,点保存,能看到“价格必须是大于 0 的数字”。
  4. 输入商品名称 Keyboard,价格输入 199,点保存,按钮会短暂不可点。
  5. 保存完成后,列表出现 Keyboard 199,输入框被清空。

体验状态常用操作速查

需求写法作用
保存中状态IsSaving = true防止重复点击
保存完成IsSaving = false恢复按钮和输入
普通提示Message = "保存成功"告诉当前结果
错误提示ErrorMessage = "价格必须大于 0"告诉具体修正点
空列表判断Products.Count == 0决定是否显示空状态
按钮可用条件CanSave => !IsSaving控制按钮是否允许执行
异步等待await Task.Delay(500)模拟耗时保存
收尾处理finally { IsSaving = false; }保证异常时状态恢复

常见错误和修法

错误为什么错修法
点击保存后界面没变化没有设置保存中或提示状态保存开始设置 IsSavingMessage
保存失败后按钮一直不可点异常后没有恢复状态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 时,不会保存,并显示库存错误。
  • 保存中按钮不可点,避免重复保存。
  • 保存成功后,列表出现新商品,三个输入框都被清空。