6.2 依赖注入与配置
学习目标
你将把窗口、ViewModel、API 服务和配置放入启动组合入口,并解释关闭窗口时如何停止 Host。前提:独立的 .NET 10 WPF 练习项目使用命名空间 TaskDesk.Client,已安装 Toolkit;服务 TodoApiClient 在 下一节 定义。配套实战采用 ITodoService 与 TaskDesk.Client.ViewModels.MainViewModel,组合入口见 实战实现。
桌面没有自动请求作用域
ASP.NET Core 每次请求自动创建并释放 scope;WPF 的 DI 容器没有“每点击一次按钮一个 scope”的规则。Singleton 适合应用级配置、会话与无状态协调服务;Transient 表示每次解析新建对象,但不会自动跟随窗口关闭释放;Scoped 需要自己创建、持有和释放作用域。
本节只有一个主窗口,其 ViewModel 也持续存在,因此使用 Singleton。多窗口编辑时,应为每个窗口建立独立状态;若采用 scope,关闭窗口时释放它,不要从根容器永久解析 Scoped 服务。尤其不要让应用级单例持有短期数据库上下文,把后端请求生命周期直接移植到桌面。
依赖与配置文件
在客户端项目目录安装:
dotnet add package Microsoft.Extensions.Hosting --version 10.0.0
dotnet add package Microsoft.Extensions.Http --version 10.0.0
新增完整配置文件。它含可公开的本地 API 地址,不含服务端密钥。Generic Host 默认读取配置、环境变量等来源;部署时可通过环境变量 Api__BaseUrl 覆盖 JSON。
{
"Api": {
"BaseUrl": "http://localhost:5080/"
}
}
在项目文件的 Project 内增加以下片段,保证配置随构建和发布复制。配置内容根目录设为 AppContext.BaseDirectory,不依赖快捷方式的当前工作目录。
<ItemGroup>
<None Update="appsettings.json"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
唯一的窗口启动入口
替换 App.xaml,移除模板的 StartupUri。否则 WPF 会另外创建主窗口,与容器解析出的窗口重复。这里采用 OnExplicitShutdown,先完成 Host 停止,再主动退出;它也意味着窗口关闭不会自己结束进程。
<Application x:Class="TaskDesk.Client.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
ShutdownMode="OnExplicitShutdown">
<Application.Resources />
</Application>
下列为完整启动文件。前提:项目已有 MainViewModel(TodoApiClient api) 构造函数;MainWindow 构造函数接受 ViewModel 并设置 DataContext。命名 HTTP 客户端由长期存活的 API 服务按请求创建,避免单例长期捕获短期的 typed client。
using System;
using System.ComponentModel;
using System.Windows;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace TaskDesk.Client;
public partial class App : Application
{
private IHost? _host;
private bool _isStopping;
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
try
{
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
{
ContentRootPath = AppContext.BaseDirectory
});
builder.Services.AddHttpClient("TaskDeskApi", (services, client) =>
{
var config = services.GetRequiredService<IConfiguration>();
client.BaseAddress = new Uri(config["Api:BaseUrl"]
?? "http://localhost:5080/");
client.Timeout = TimeSpan.FromSeconds(20);
});
builder.Services.AddSingleton<TodoApiClient>();
builder.Services.AddSingleton<MainViewModel>();
builder.Services.AddSingleton<MainWindow>();
_host = builder.Build();
await _host.StartAsync();
MainWindow = _host.Services.GetRequiredService<MainWindow>();
MainWindow.Closing += OnMainWindowClosing;
MainWindow.Show();
}
catch (Exception ex)
{
MessageBox.Show($"启动失败:{ex.Message}");
try
{
if (_host is not null)
await _host.StopAsync(TimeSpan.FromSeconds(5));
}
catch (Exception stopError)
{
MessageBox.Show($"启动清理失败:{stopError.Message}");
}
finally
{
_host?.Dispose();
Shutdown(1);
}
}
}
private async void OnMainWindowClosing(object? sender, CancelEventArgs e)
{
e.Cancel = true;
if (_isStopping) return;
_isStopping = true;
try
{
if (_host is not null)
await _host.StopAsync(TimeSpan.FromSeconds(5));
}
catch (Exception ex)
{
MessageBox.Show($"停止后台服务时出错:{ex.Message}");
}
finally
{
_host?.Dispose();
Shutdown();
}
}
}
窗口已有代码后置的构造函数改为:
public MainWindow(MainViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
示例所有正常退出入口应调用主窗口的 Close(),走统一停止流程。async void 只出现在 WPF 框架要求的生命周期和事件签名中,并在边界捕获异常。不要把异步 StopAsync 放进 OnExit 后就期待框架等待它,也不要用阻塞等待制造退出死锁。若不使用异步停止,可选择 OnMainWindowClose,但仍要设计后台服务与资源的清理路径。
常见错误
把容器注册完却仍 new MainViewModel(),会绕过配置与依赖;在每个方法中随处解析服务,会隐藏构造依赖。UI 窗口要在 UI 线程创建;注册进容器不会自动改变线程。后台服务应响应停止令牌,五秒超时只是停止预算,不会强行杀死忽略取消的任务。
练习与验收
更改配置中的端口,确认请求目标跟随变化;再注册一个能响应取消的 BackgroundService。验收:只有一个主窗口;关闭时后台服务收到停止信号,进程随后结束;从其他工作目录启动也能读取配置。打开第二个编辑窗口前,先写下它的 ViewModel 是复用还是独立实例。