7.2 实现任务管理客户端
学习目标
- 找到窗口启动、绑定对象和 HTTP 服务的连接点。
- 用可执行性通知和统一忙碌门禁管理四个异步命令。
- 解释取消、异常和窗口关闭时的完整清理流程。
先连接启动与绑定
打开本地 examples/TaskDesk/TaskDesk.Client。项目使用 .NET 10 WPF,引用 CommunityToolkit.Mvvm 8.4.0、Hosting 和 Http 10.0.0。App.xaml 删除 StartupUri,设置 ShutdownMode="OnExplicitShutdown";App 创建并启动 Host,再从容器解析主窗口。
下面是 App.xaml.cs 的注册片段,前提是已经读取并校验 baseUri,且导入 DI、Hosting、服务和 ViewModel 命名空间;完整启动与异常处理请使用本地成套源码。
builder.Services.AddHttpClient<ITodoService, TodoService>(client =>
{
client.BaseAddress = baseUri;
client.Timeout = TimeSpan.FromSeconds(15);
});
builder.Services.AddTransient<MainViewModel>();
builder.Services.AddTransient<MainWindow>();
容器创建窗口时注入 MainViewModel。窗口调用 InitializeComponent() 后,把同一个实例设为 DataContext,不是在 XAML 再创建第二个对象。Host 的内容根目录是程序目录,appsettings.json 配置为构建及发布时复制,因此从不同工作目录启动也能读到 API 地址。
把状态变成绑定源
MainViewModel 是继承 ObservableObject 的 partial 类。源生成器将字段生成公开属性,例如 newTitle 生成 NewTitle,同时发送属性通知。下面片段是这个类中的成员,不能脱离类声明和 Toolkit 引用单独复制。
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AddCommand))]
private string newTitle = string.Empty;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(CompleteCommand))]
[NotifyCanExecuteChangedFor(nameof(DeleteCommand))]
private TodoDto? selectedTodo;
IsBusy 的字段对刷新、新增、完成、删除、取消五个命令分别声明通知。CanAdd() 检查标题去空格后有效且未忙碌;CanComplete() 检查选中任务尚未完成。属性变化后重新计算 CanExecute,按钮才会立即更新,不能只写判断方法却不通知命令。
窗口绑定输入时指定按字符更新,选择使用双向绑定;以下片段展示已有控件的核心属性,完整布局在 MainWindow.xaml。
<TextBox Text="{Binding NewTitle, UpdateSourceTrigger=PropertyChanged}" MaxLength="120" />
<Button Content="新增" Command="{Binding AddCommand}" />
<DataGrid ItemsSource="{Binding Todos}" SelectedItem="{Binding SelectedTodo, Mode=TwoWay}"
AutoGenerateColumns="False" IsReadOnly="True" CanUserAddRows="False" />
Todos 是 ObservableCollection<TodoDto>,负责行增删通知。DTO 本身不可变,完成时用服务端返回的对象替换集合元素并恢复选择。只读表格中的勾选列展示结果,点击它不会偷偷改变服务端。
让四个命令共享请求规则
RefreshAsync、AddAsync、CompleteAsync、DeleteAsync 都标记 RelayCommand,生成去掉 Async 后缀的命令名称。参数中的 CancellationToken 让生成的异步命令支持取消,并经服务接口传到 HTTP 与 JSON 读取。
四个方法调用同一个 RunOperationAsync:再次检查 IsBusy,立即设置忙碌与状态文本,等待 I/O,分类处理异常,最后在 finally 中恢复忙碌。单个异步命令的默认防重入只保护自身,这个门禁还保护其他三个命令。新增和完成在等待前捕获标题、选中任务,避免用户随后改变输入导致请求目标漂移。
ViewModel 的 await 保留 UI 上下文,之后才能安全修改绑定集合。服务内部可以用 ConfigureAwait(false),但不接触 UI。这里无需用 Task.Run 包装 HTTP;可复习 异步与 Dispatcher。
取消与关闭
CancelCommand 调用 CancelCurrentOperation(),对四个命令逐一调用 Cancel();当前运行的命令把取消传下去。异常筛选先检查命令 token 是否取消,显示用户取消;没有该信号的取消异常按 HTTP 超时处理。取消和超时都不能撤销已提交的写操作,提示用户刷新确认。
窗口关闭先取消,并等待各命令的 ExecutionTask,然后真正关闭。App 在 Closed 后停止 Host、释放资源、显式退出。这样的顺序防止仍在运行的命令访问已经释放的服务;生命周期回调中的 async void 不应推广成普通业务方法。
常见问题
按钮一直禁用: 检查输入是否实际更新 NewTitle、选择是否更新 SelectedTodo,再检查通知特性和 IsBusy 的 finally。
绑定有数据却不显示: 在调试输出中查看绑定路径,确认窗口的 DataContext 正是容器创建的对象。更多排查见 常见问题。
练习与验收
运行示例并观察五个按钮:空输入时新增禁用;输入有效标题后可用;选择已完成任务时完成禁用;任意请求期间四个操作按钮禁用,取消可用;操作结束后恢复。解释每个变化分别由哪一个属性通知触发。慢请求测试继续阅读 联调、验收与扩展。