项目越做越大,解决方案里的程序集越来越多,引用关系像一张乱麻——改一个底层库,上层十几个项目跟着报错;NuGet 包版本冲突让构建失败,排查半天才发现是某个间接依赖在作怪;发布时 DLL 文件一堆,不知道哪些是必要的,哪些是冗余的。
这些问题在 Winform 项目里尤为常见,因为桌面应用往往历史包袱重,多年积累下来的程序集管理问题会在某一天集中爆发。
读完本文,你将掌握:

在中大型 Winform 项目里,程序集管理混乱通常不是某一次决策失误造成的,而是长期"随手加引用"积累的结果。某个功能需要 JSON 序列化,就加了 Newtonsoft.Json;另一个模块需要日志,又加了 log4net;后来迁移到 .NET 6,顺手又引入了 Microsoft.Extensions.Logging——两套日志框架并存,谁也没人清理。
这种现象背后有两个核心问题:
其一,缺乏分层意识。 很多 Winform 项目只有一个主工程,所有逻辑——UI、业务、数据访问、工具类——全部堆在一起。这意味着每一个引用都是全局可见的,任何地方都可以直接 new 出数据库连接,引用关系没有任何约束。
其二,NuGet 的间接依赖问题被忽视。 当你引入包 A,包 A 依赖包 B 的 1.0 版本,而你另一个模块直接依赖包 B 的 2.0 版本,就会产生版本冲突。在 .NET Framework 时代这个问题通过 bindingRedirect 勉强解决,到了 .NET 8 的 SDK 风格项目,规则变了,很多老项目迁移时在这里栽跟头。
.NET 8 沿用了 .NET Core 的 AssemblyLoadContext(ALC)机制,与 .NET Framework 的 AppDomain 有本质区别。每个 ALC 都有独立的加载上下文,这意味着同一个程序集可以在不同上下文中以不同版本共存——这是插件化架构的基础,也是理解程序集隔离的关键。
对于普通 Winform 应用,默认的 AssemblyLoadContext.Default 足够使用,但如果你的应用需要支持插件热加载(比如模块化的工业软件),就必须为每个插件创建独立的 ALC,否则卸载插件时内存无法释放。
.NET 8 的 .csproj 文件采用 SDK 风格,相比老式项目文件简洁得多。一个关键特性是**传递性依赖(Transitive Dependencies)**的自动处理——你不需要在每个项目里都显式引用底层依赖,NuGet 会自动解析依赖树。
但这把双刃剑也带来了隐患:传递性依赖的版本可能不受你控制。解决方案是在解决方案根目录使用 Directory.Build.props 统一管理版本,这是 .NET 8 项目中最被低估的实践之一。
一个清晰的 Winform 解决方案应该按职责划分项目,而不是按技术类型。以下是一个经过验证的分层结构:
MyApp.sln ├── src/ │ ├── MyApp.UI # Winform 主工程,只负责界面与交互 │ ├── MyApp.Application # 应用层:用例、命令、查询 │ ├── MyApp.Domain # 领域层:业务实体、规则(零外部依赖) │ ├── MyApp.Infrastructure # 基础设施层:数据库、文件、网络 │ └── MyApp.Shared # 共享层:通用工具、扩展方法、常量 ├── tests/ │ ├── MyApp.Application.Tests │ └── MyApp.Domain.Tests └── Directory.Build.props # 全局版本管理
核心原则是依赖方向单一:UI 层依赖 Application 层,Application 层依赖 Domain 层,Infrastructure 层实现 Application 层定义的接口。Domain 层不依赖任何外部程序集,这样它的单元测试不需要任何 Mock 框架就能运行。
xml<!-- MyApp.UI.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<!-- 只引用应用层,不直接引用基础设施层 -->
<ProjectReference Include="..\MyApp.Application\MyApp.Application.csproj" />
<!-- 组合根:UI 层负责注册依赖,因此需要引用基础设施层 -->
<ProjectReference Include="..\MyApp.Infrastructure\MyApp.Infrastructure.csproj" />
</ItemGroup>
</Project>
xml<!-- MyApp.Domain.csproj:零外部 NuGet 依赖 -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<!-- 注意:领域层不需要 -windows 后缀,保持跨平台能力 -->
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<!-- 无任何 PackageReference,这是刻意为之 -->
</Project>
这是我在多个项目中推行后效果最显著的实践。在解决方案根目录创建 Directory.Build.props,所有项目自动继承其中的配置:
xml<Project>
<PropertyGroup>
<!-- 全局编译选项 -->
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<!-- 集中管理所有 NuGet 包版本 -->
<ItemGroup>
<!-- 日志 -->
<PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageVersion Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" />
<!-- 依赖注入 -->
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<!-- 数据访问 -->
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.0" />
<PackageVersion Include="Dapper" Version="2.1.28" />
<!-- 序列化 -->
<PackageVersion Include="System.Text.Json" Version="8.0.0" />
</ItemGroup>
</Project>

配合 Directory.Packages.props(.NET 的中央包管理特性),在各子项目中引用包时不再需要指定版本号:
xml<!-- MyApp.Infrastructure.csproj -->
<ItemGroup>
<!-- 版本由 Directory.Build.props 统一管理,这里不写 Version -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Dapper" />
<PackageReference Include="Serilog.Extensions.Hosting" />
</ItemGroup>
实际效果:在一个有 8 个子项目的解决方案中,升级 Microsoft.Extensions.* 系列包时,只需修改 Directory.Build.props 中的一行版本号,全部项目同步更新,彻底消除版本不一致导致的 NU1605 警告。
当出现依赖冲突时,先用命令行诊断,而不是盲目猜测:
bash# 查看完整依赖树,找出冲突来源
dotnet list package --include-transitive
# 检查是否存在已弃用或有漏洞的包
dotnet list package --deprecated
dotnet list package --vulnerable
如果某个间接依赖版本不符合要求,可以在项目文件中显式覆盖:
xml<!-- 强制将间接依赖的版本提升到安全版本 -->
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="8.0.5" />
</ItemGroup>
.NET 8 的 Microsoft.Extensions.DependencyInjection 不是 ASP.NET Core 专属的,Winform 同样可以用,而且效果很好。关键是在 Program.cs 建立组合根:
csharpusing Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
namespace AppNuGet
{
internal static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
static void Main()
{
// 配置 Serilog
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.File("logs/app-.txt", rollingInterval: RollingInterval.Day)
.CreateLogger();
ApplicationConfiguration.Initialize();
// 构建 DI 容器
var services = new ServiceCollection();
ConfigureServices(services);
using var serviceProvider = services.BuildServiceProvider();
// 从容器中解析主窗体,而不是直接 new
var form1 = serviceProvider.GetRequiredService<Form1>();
Application.Run(form1);
}
static void ConfigureServices(IServiceCollection services)
{
// 日志
services.AddLogging(builder =>
{
builder.ClearProviders();
builder.AddSerilog();
});
//// 注册各层服务
//services.AddInfrastructureServices(); // 扩展方法,定义在 Infrastructure 层
//services.AddApplicationServices(); // 扩展方法,定义在 Application 层
//// 注册所有 Form(Transient:每次打开新窗口都是新实例)
//services.AddTransient<Form2>();
//services.AddTransient<FrmSetting>();
//services.AddTransient<FrmAbout>();
}
}
}
csharp// Infrastructure/ServiceExtensions.cs
// 基础设施层的服务注册,封装在扩展方法中,UI 层无需了解实现细节
public static class InfrastructureServiceExtensions
{
public static IServiceCollection AddInfrastructureServices(
this IServiceCollection services)
{
services.AddScoped<IUserRepository, SqliteUserRepository>();
services.AddScoped<IConfigRepository, JsonConfigRepository>();
services.AddSingleton<IFileStorageService, LocalFileStorageService>();
return services;
}
}
当 Form 数量增多,逐一手动注册会很繁琐。可以利用反射自动扫描程序集中的所有 Form:
这个在我以前文章有详细讲解
csharp// 自动注册当前程序集中所有继承自 Form 的类型
static void RegisterAllForms(IServiceCollection services, Assembly assembly)
{
var formTypes = assembly
.GetTypes()
.Where(t => t.IsSubclassOf(typeof(Form))
&& !t.IsAbstract
&& t.IsPublic);
foreach (var formType in formTypes)
{
// 注册为 Transient,每次从容器获取都是新实例
services.AddTransient(formType);
}
}
踩坑预警:Form 的构造函数注入依赖时,设计器(Designer)会尝试调用无参构造函数。如果你的 Form 只有带参构造函数,设计器会报错。解决方法是保留一个无参构造函数供设计器使用,同时提供带参构造函数供 DI 容器使用:
csharppublic partial class Form1 : Form
{
private readonly IConfigRepository _configRepo;
private readonly ILogger<Form1> _logger;
// 供设计器使用(不会在运行时被 DI 调用)
public Form1() : this(null!, null!) { }
// 供 DI 容器使用
public Form1(
IConfigRepository configRepo,
ILogger<Form1> logger)
{
_configRepo = configRepo;
_logger = logger;
InitializeComponent();
}
}
这块变化比较大,其实实际Winform项目中这么干看上去不一定好
.NET 8 支持将 Winform 应用发布为单文件,但 Winform 目前不支持 Native AOT(因为依赖反射的 Designer 机制)。对于大多数场景,单文件发布 + 自包含部署是最实用的选择:
xml<!-- MyApp.UI.csproj 发布配置 -->
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<!-- 自包含发布,不依赖目标机器安装 .NET 运行时 -->
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<!-- 单文件发布 -->
<PublishSingleFile>true</PublishSingleFile>
<!-- 裁剪未使用的程序集(谨慎使用,需充分测试) -->
<PublishTrimmed>false</PublishTrimmed>
<!-- 压缩单文件体积 -->
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
</PropertyGroup>
发布命令:
bashdotnet publish -c Release -r win-x64 --self-contained true
关于 PublishTrimmed:裁剪可以显著减小发布体积,但 Winform 应用大量使用反射(设计器、数据绑定、资源加载),裁剪容易误删必要程序集,导致运行时报 TypeLoadException。建议先在测试环境充分验证后再启用。
Directory.Build.props 一处定义,全局生效,是消除版本冲突最低成本的方式。程序集管理问题的本质是架构问题,而不是工具问题。工具(NuGet、DI 容器、SDK 项目格式)只是手段,清晰的分层意识和依赖原则才是核心。
迁移到 .NET 8 是一次难得的重构机会——趁着迁移,把历史遗留的引用混乱一并整理,建立 Directory.Build.props,拆分单体项目,引入 DI 容器。这些投入在项目初期看起来是额外工作,但在后续维护中会持续节省时间。
欢迎在评论区分享你在 Winform 项目迁移 .NET 8 过程中遇到的程序集管理问题,以及你的解决思路。
#C#开发 #WinForms #dotNET8 #程序集管理 #依赖注入 #NuGet #架构设计
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!