跳到主要内容

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 服务。尤其不要让应用级单例持有短期数据库上下文,把后端请求生命周期直接移植到桌面。

依赖与配置文件​

在客户端项目目录安装:

安装 Generic Host 与 HTTP 工厂
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。

appsettings.json
{
"Api": {
"BaseUrl": "http://localhost:5080/"
}
}

在项目文件的 Project 内增加以下片段,保证配置随构建和发布复制。配置内容根目录设为 AppContext.BaseDirectory,不依赖快捷方式的当前工作目录。

TaskDesk.Client.csproj:Project 内片段
<ItemGroup>
<None Update="appsettings.json"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>

唯一的窗口启动入口​

替换 App.xaml,移除模板的 StartupUri。否则 WPF 会另外创建主窗口,与容器解析出的窗口重复。这里采用 OnExplicitShutdown,先完成 Host 停止,再主动退出;它也意味着窗口关闭不会自己结束进程。

App.xaml
<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。

App.xaml.cs
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();
}
}
}

窗口已有代码后置的构造函数改为:

MainWindow.xaml.cs:构造函数片段
public MainWindow(MainViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}

示例所有正常退出入口应调用主窗口的 Close(),走统一停止流程。async void 只出现在 WPF 框架要求的生命周期和事件签名中,并在边界捕获异常。不要把异步 StopAsync 放进 OnExit 后就期待框架等待它,也不要用阻塞等待制造退出死锁。若不使用异步停止,可选择 OnMainWindowClose,但仍要设计后台服务与资源的清理路径。

常见错误​

把容器注册完却仍 new MainViewModel(),会绕过配置与依赖;在每个方法中随处解析服务,会隐藏构造依赖。UI 窗口要在 UI 线程创建;注册进容器不会自动改变线程。后台服务应响应停止令牌,五秒超时只是停止预算,不会强行杀死忽略取消的任务。

练习与验收​

更改配置中的端口,确认请求目标跟随变化;再注册一个能响应取消的 BackgroundService。验收:只有一个主窗口;关闭时后台服务收到停止信号,进程随后结束;从其他工作目录启动也能读取配置。打开第二个编辑窗口前,先写下它的 ViewModel 是复用还是独立实例。

官方资料​