跳到主要内容

1.3 从 ASP.NET Core 到 WPF

学习目标​

本篇帮助你在熟悉的 .NET 知识上建立新认识。读完后,应该能解释桌面状态为什么要通知、窗口与请求的生命周期有什么差别,以及 Service 层可以怎样继续复用。

先理解应用如何运转​

在 ASP.NET Core 中,一次 HTTP 请求进入路由,执行处理逻辑,再返回响应。即使浏览器保持打开,服务端的一次处理通常已经结束,下一次交互会产生新请求。

WPF 应用启动后创建窗口,进入消息循环。输入、点击、重绘和完成的异步操作会不断进入 UI 调度队列。窗口对象和它持有的界面状态可能存在几个小时,你的程序需要持续描述“现在界面是什么状态”。

两个不同的生命周期
Web:请求 → 路由 / 处理器 → Service → 响应 → 本次请求结束
WPF:启动 → 窗口与状态 → 输入 / 命令 / 更新 → … → 关闭
↓
Service → API

它们可以共用业务知识和服务接口,但界面的运行方式不同。

后端概念如何对应​

熟悉的概念WPF 中的相关概念需要注意的边界
Controller / Endpoint命令触发的操作ViewModel 保存界面状态,没有 HTTP 路由职责
DTO传输模型、界面模型DTO 不会自动发送属性变更通知
请求 scope窗口或业务操作 scopeWPF 不自动创建和释放请求作用域
模型校验绑定校验、界面错误提示客户端检查不能代替服务端校验
中间件与异常处理应用入口、操作边界的异常处理UI 错误需要提示并恢复交互状态
async/await异步命令、UI 同步上下文避免阻塞 UI,也避免跨线程更新控件
返回 JSON修改可通知的状态界面更新依赖绑定和通知机制

这些只是帮助理解的对应关系。尤其不要直接把 Controller 改名为 ViewModel:一个 ViewModel 往往同时持有选择项、输入文本、命令、忙碌状态和提示文字,比单次请求处理器承担更多交互协调职责。

DTO 为什么不能直接当作所有界面状态​

你在 API 中可能返回一个不可变记录:

传输契约示例
public record TodoDto(int Id, string Title, bool IsCompleted);

WPF 可以读出这个对象的属性,并在绑定初次建立时显示。如果用户操作之后需要原地改变某个属性,绑定引擎还需要知道它已经变化。

INotifyPropertyChanged 表达的是“某个属性已改变”;ObservableCollection 表达的是“集合增加、删除或重置”。它们与 JSON 序列化无关。对象被反序列化出来,不代表对象具备界面通知能力。

对简单只读列表,可以在请求后替换整个集合,或替换指定元素;复杂编辑界面通常会映射到可通知的行模型。实战选择明确的刷新与元素替换,先保证数据流容易追踪。

一个操作不仅有结果,还有过程​

后端接口往往用状态码和响应体表达成功或失败。桌面界面还需要表达操作期间的状态:

刷新操作的状态变化
空闲 → 发起请求 / 显示忙碌 / 禁用冲突操作
├─ 成功:显示列表与结果提示
├─ 失败:显示可理解的错误
└─ 取消:保留可解释的状态
→ 恢复按钮 → 回到空闲

例如下面是解释机制的伪代码,不是可直接加入项目的完整文件:

异步操作边界示意
IsBusy = true;
try
{
var result = await service.GetTodosAsync(cancellationToken);
ReplaceItems(result);
StatusMessage = "刷新成功";
}
catch (OperationCanceledException)
{
StatusMessage = "请求已取消或超时";
}
finally
{
IsBusy = false;
}

重点是 finally 负责恢复交互,以及 ViewModel 显式表达状态。正式实战会进一步区分用户取消与超时,并处理 HTTP 和数据格式错误。

DI 可以继续用,但作用域需要自己设计​

Microsoft.Extensions.DependencyInjection 和 Generic Host 可以用于 WPF,配置与日志也可以复用。区别在于桌面框架不会自动给每次点击创建 scope。

如果把 DbContext 注册为 Scoped,却一直从根容器解析一个长寿命 ViewModel,可能导致生命周期不匹配。更容易理解的办法是:客户端通过 HTTP 调用后端;需要直接访问本地数据库时,在明确的操作边界创建 scope,或使用适合短操作的工厂,每次操作及时释放资源。

窗口注册成 Transient 也不等于关闭后立即回收:事件订阅、容器捕获的 Disposable 实例、后台任务和缓存仍可能持有它。生命周期不仅看注册名称,还要看实际引用关系。

UI 线程是新的重点​

ASP.NET Core 通常没有与某个请求绑定的 UI 同步上下文。WPF 有 UI 线程和 Dispatcher,多数控件要求由创建它们的线程访问。绑定集合的更新也应在 UI 线程完成。

HTTP 是 I/O 操作,直接 await 即可。Task.Run 常用于昂贵的 CPU 计算,而不是为了把所有异步代码包到“后台”。从 UI 线程发起的 await 默认可以回到原来的上下文;如果显式切换到后台线程,就需要通过 Dispatcher 把结果提交给界面。

:::warning 常见卡顿原因 在 UI 线程执行 .Result、.Wait() 或长循环,会阻塞消息处理;某些情况下还会死锁。后端能够运行的同步等待方式,搬到窗口里可能立即造成冻结。 :::

动手练习​

把“新增任务”拆成三个角色:View 获取输入并绑定状态;ViewModel 校验交互条件、调用服务、更新提示;Service 处理 HTTP 与传输契约。写下每个角色应该拥有哪些属性或方法。

验收: 业务服务不知道 TextBox 或 Window;ViewModel 不承担 API 路由;你能够说出按钮从可点击到忙碌再恢复的过程。

参考思路

View 绑定输入框和按钮;ViewModel 保存 NewTitle、IsBusy、StatusMessage,并暴露 AddCommand;Service 暴露接收标题和 CancellationToken 的异步新增方法。服务端继续执行最终校验和授权。

接下来创建第一个窗口。后续遇到线程和生命周期问题,可以回看异步与 UI 线程和依赖注入。

延伸阅读​