在“创建新项目”的搜索框里敲下“Windows 窗体”几个字,列表里通常会跳出两条长得几乎一模一样的条目,一条叫“Windows 窗体应用 (.NET Framework)”,另一条就叫“Windows 窗体应用”。很多刚上手 Visual Studio 的人在这里随手点一个,然后就拿到了一份完全看不懂的工程:有人发现自己新建的项目里莫名其妙多了一个 Properties 文件夹和一堆 AssemblyInfo.cs,有人发现 csproj 文件短得只剩几行、连一个<Compile Include>都找不到,还有人把老项目里的ConfigurationManager.AppSettings拷过来直接报红。这两个模板的差别不是“新旧皮肤”那种无关痛痒的差别,它决定的是目标机器上要装什么运行时、发布出来的东西长什么样、能用哪些第三方控件、设计器什么时候会罢工。我做过不少“接手别人半路留下来的 WinForms 项目”的活,也帮人做过从 .NET Framework 到 .NET 8 的迁移评估,这篇就把这两个模板从工程文件、运行时模型、编码写法、设计器行为到选型决策完整拆一遍,新手可以当成选型指南,老手可以直接拿去对照排错。
1. 名字只差一个括号,工程结构差一个世代
1.1 在新建项目对话框里怎么一眼把它们分开
最可靠的辨认方式就是看后缀。带(.NET Framework)后缀的那一条,是继承自 .NET Framework 4.x 时代的老模板;不带后缀的那一条,走的是 .NET(也就是大家习惯叫的 .NET Core 之后那一套)技术栈。除了名字,还有几个地方可以交叉确认:语言下拉框里,两个模板都提供 C# 和 Visual Basic;但在“框架”下拉框里,带后缀的那条只会让你在4.6.2 到 4.8.1之间挑,不带后缀的那条则是.NET 6 / 7 / 8 / 9这样的选项。列表右侧的标签也不一样,老模板一般挂着 Windows 和 .NET Framework,新模板挂的是 Windows、Desktop 和 .NET。
还有一个容易踩的细节:控件库模板也是成对出现的,“Windows 窗体控件库”和“Windows 窗体控件库 (.NET Framework)”。这两个的差异和主模板完全一致,所以选控件库的时候同样要看清后缀,别一边用着新模板建主程序、一边用老模板建控件库,混在一起引用会带来框架兼容性上的麻烦。
另外顺手把一件事说清楚:这里的 VS 指的是 Visual Studio,不是 VS Code。VS Code 里没有 WinForms 的项目模板,也没有可视化窗体设计器,装了 C# 扩展之后你能做的只有编辑代码、跑dotnet build和dotnet run。用它来写 WinForms 不是不行,但拖控件、调属性、看设计器预览这些事它做不了,初学者很容易在这里绕冤枉路。
1.2 模板生成出来的工程骨架长什么样
新建完对比一下目录树,差异一目了然。老模板生成的工程大概是这个结构:
MyApp/ ├─ MyApp.sln └─ MyApp/ ├─ Program.cs ├─ Form1.cs ├─ Form1.Designer.cs ├─ Form1.resx ├─ App.config ├─ packages.config (用了旧式 NuGet 才会有) ├─ Properties/ │ ├─ AssemblyInfo.cs │ ├─ Resources.resx │ ├─ Resources.Designer.cs │ ├─ Settings.settings │ └─ Settings.Designer.cs └─ MyApp.csproj新模板生成的工程则干净得多:
MyApp/ ├─ MyApp.sln └─ MyApp/ ├─ Program.cs ├─ Form1.cs ├─ Form1.Designer.cs ├─ Form1.resx └─ MyApp.csproj新工程里没有 Properties 文件夹,没有 AssemblyInfo.cs,没有 App.config,也没有 Settings.settings。这不是模板偷懒,而是这些东西在新工程系统里被“隐式生成”或者“用别的方式替代”了:程序集版本、公司名、版权这些信息现在直接写在 csproj 的属性里,编译时由 SDK 自动生成等价于原来 AssemblyInfo.cs 的内容;配置数据则推荐走 appsettings.json 加配置类,而不是.config文件。
注意:正因为新工程是按文件名约定自动配对资源文件的,
Form1.cs/Form1.Designer.cs/Form1.resx这三个文件的名字必须保持一致的前缀。你要是随手把 Designer.cs 重命名成Form1.design.cs或者换个大写,设计器很可能就认不出这是同一个窗体的分部类,打开之后变成一片空白。
1.3 Visual Studio 2022 能选到的老框架版本已经不全了
很多十年前的老教程会让你把目标框架设成 .NET Framework 4.0 或者 4.5,然后你在 VS 2022 里翻半天也找不到这些选项,这不是环境装坏了。VS 2022 只支持以4.6.2 及以上作为编译目标,更低的版本要么得回去用旧版 IDE,要么就得先升级目标框架。相对的,这些老框架版本在客户机上往往还是能跑的,因为 .NET Framework 的 4.x 系列是就地升级关系:一台机器上装了 4.8,它并不是“同时拥有 4.0、4.5、4.8 三套运行时”,而是只有一套 4.8,早先针对 4.5 编译的程序会被 4.8 的运行时接管运行。这也解释了两个很常见的疑问:为什么装了 4.8.1 的机器再去安装 4.8 的离线包,安装程序会直接告诉你“这台计算机中已经安装了 .NET Framework 4.8 或版本更高的更新”然后退出;以及为什么有些只针对 .NET Framework 3.5 写的祖传程序在新系统上要先按需启用那个老组件才能跑——3.5 和 4.x 不是同一条产品线,彼此不覆盖。
2. csproj 文件对比:SDK 风格与老式工程文件到底差在哪
2.1 老式工程文件把每件事都写死了
老模板的 csproj 是一个完整的 MSBuild 文件,根节点带ToolsVersion和xmlns,开头要先导入公共属性,然后是一堆PropertyGroup和ItemGroup。它最显著的特征是所有参与编译的文件都要一条条列出来:
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> <PropertyGroup> <OutputType>WinExe</OutputType> <RootNamespace>MyApp</RootNamespace> <AssemblyName>MyApp</AssemblyName> <TargetFrameworkVersion>v4.8</TargetFrameworkVersion> <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> </PropertyGroup> <ItemGroup> <Compile Include="Form1.cs"><SubType>Form</SubType></Compile> <Compile Include="Form1.Designer.cs"><DependentUpon>Form1.cs</DependentUpon></Compile> <Compile Include="Program.cs" /> <Compile Include="Properties\AssemblyInfo.cs" /> </ItemGroup> </Project>这段结构里有几个值得记住的点。<SubType>Form</SubType>和<DependentUpon>元数据是老工程里用来告诉 IDE“这是一个窗体、这个 Designer 文件属于哪个窗体”的,缺了它设计器就打不开对应的可视化视图。AutoGenerateBindingRedirects是老 NuGet 时代解决程序集版本冲突的常规操作,原因就是 .NET Framework 的程序集大量来自全局程序集缓存(GAC),多个包依赖同一个程序集的不同版本时会打架。而TargetFrameworkVersion用的是v4.8这种带 v 前缀的写法,和后面要讲的新写法完全不是一个格式。
还有一个隐藏行为:老模板默认的 AnyCPU 可执行文件是带着Prefer32Bit=true的,也就是说在 64 位系统上它默认以 32 位进程跑。这个默认值曾经救过无数依赖 32 位原生 DLL 的老程序,但也是后来迁移时最容易被忘掉的一环。
2.2 SDK 风格工程文件为什么可以这么短
新模板的 csproj 短得让人怀疑人生:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net8.0-windows</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> <UseWindowsForms>true</UseWindowsForms> <ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode> </PropertyGroup> </Project>短短十来行之所以够用,是因为 SDK 属性包做了三件事:文件通配符自动包含(**/*.cs自动进编译列表,**/*.resx自动进资源列表,不需要一条条写)、隐式框架引用(UseWindowsForms=true加上net8.0-windows这个 TFM,SDK 就知道自动引用 WinForms 相关程序集,不用手工添加引用)、属性代替文件(程序集元数据写在 csproj 里,编译时生成)。程序集依赖也不再靠 GAC 和绑定重定向,而是靠deps.json描述,运行时按需加载本地目录里的 DLL。
TFM 的写法是另一个必须记住的细节:net8.0-windows里的-windows后缀不是装饰,它意味着这个项目只能在 Windows 上运行。WinForms 在 .NET 6 之后依然没有跨平台计划,这一点和“.NET 跨平台”这个印象是两回事——跨平台的是 .NET 运行时和类库,桌面 UI 框架各有各的平台限制。
2.3 属性对照表与“中间态”工程
把两边的关键差异列成表,迁移时照着改会省很多事:
| 关注点 | 老式工程 | SDK 风格工程 |
|---|---|---|
| 目标框架 | TargetFrameworkVersion= v4.8 | TargetFramework= net8.0-windows |
| UI 框架声明 | 手工引用程序集 | UseWindowsForms= true |
| 源码包含方式 | 显式Compile Include | 通配符自动包含 |
| 程序集元数据 | Properties/AssemblyInfo.cs | csproj 属性,编译时生成 |
| 配置文件 | App.config 编译成 xxx.exe.config | 无常规配置文件,推荐 appsettings.json |
| 包管理 | packages.config 或 PackageReference | PackageReference |
| 绑定重定向 | 需要AutoGenerateBindingRedirects | 不需要 |
| AnyCPU 行为 | 默认 32 位(Prefer32Bit) | 默认 64 位 |
| 输出主程序 | MyApp.exe 里就是你的 IL | 代码在 MyApp.dll,exe 只是启动器 |
表里最后一行是新手最容易懵的地方,后面第 3 节会展开。
这里还有一个很多人不知道的中间态做法:你可以用 SDK 风格的工程文件去编译 .NET Framework 4.8 的 WinForms 项目,也就是把TargetFramework写成net48,同时保留UseWindowsForms=true。这样做的价值在于,先把工程系统现代化(文件通配、PackageReference、属性化元数据),但运行时仍然是 4.8,客户机不用装任何新东西,回归测试的压力也小。等这一步稳定了,再改 TFM 去试新运行时,出问题的时候能快速判断是“工程系统改动”引起的还是“运行时替换”引起的。这个分两步走的方法,比一次性全改完再去大海捞针定位问题要轻松得多。
3. 运行时与部署模型:客户机上到底装了什么
3.1 “装了 4.8”和“装了 .NET 8”是两码事
.NET Framework 从 4.x 开始就是系统级组件,随机安装、就地升级、全机器共享,程序集装在 Windows 目录下,一部分在 GAC 里。对开发者来说这意味着部署极其省心:Win10 1903 之后的版本和 Win11 都自带 4.8 或 4.8.1,你编出来的 exe 拷过去就能双击运行,这也是大量企业内部工具至今还留在 .NET Framework 上的最直接原因。
想确认客户机上的版本,最快的办法是查注册表。PowerShell 一行就够:
(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full').Release常见的 Release 值对照大致是:461808对应 4.7.2,528040对应 4.8,533320对应 4.8.1。如果键不存在,那这台机器根本没装 4.x 运行时,程序双击会直接弹“需要安装 .NET Framework”的对话框。
.NET(现代那条线)走的是完全不同的路子:并排安装、按版本共存。不同大版本安装在C:\Program Files\dotnet\shared\下面,各占一个目录,互不干扰,各自独立更新和卸载。好处是升级不用怕动到别人的程序,代价就是客户机上不一定有,你得在部署时把它装上,或者干脆把运行时打包进发布目录。开发机上检查运行时和 SDK 的命令是:
dotnet --list-runtimes dotnet --list-sdks输出的清单里,桌面应用真正依赖的是Microsoft.WindowsDesktop.App这一行。这一点在打包机器上做 CI 的时候特别重要:只装了 SDK 没装对应桌面运行时的容器里,dotnet publish可能能过,但dotnet run会直接报找不到框架。
3.2 输出目录里的文件清单说明了一切
打开两个项目的bin\Debug\目录对比一下,很多问题不用解释就明白了。
老项目输出大致是:
MyApp.exe MyApp.exe.config MyApp.pdb新项目输出大致是:
MyApp.exe (apphost 启动器,通常只有一百多 KB) MyApp.dll (你的真实代码在这里) MyApp.deps.json (依赖清单) MyApp.runtimeconfig.json(运行时配置:TFM、是否自包含、回滚策略) MyApp.pdb我见过不止一个人在新项目发布之后,对着几十 KB 的 exe 发愁,以为发布失败或者代码被清了。事实是 exe 只是个壳,负责找到并加载对应版本的运行时,然后调用 dll 里的入口点。另外,runtimeconfig.json里还藏着滚动升级策略,如果你需要锁定某个具体的补丁版本,可以在 csproj 里通过RollForward之类的属性控制,这在交付给客户现场、对方 IT 部门会不定期更新运行时的场景里很有用。
还有一个容易被忽略的差别:老项目里如果把某个框架程序集的“复制到本地”设成 true,bin 目录会瞬间膨胀出一堆System.*.dll,共用同一个目录的两个程序还可能互相覆盖;新项目默认所有框架程序集都由共享运行时提供,bin 目录里基本只有你自己的东西和第三方包,干净很多。
3.3 单文件、自包含和体积取舍
在部署方式上,两个模板的能力差异非常实际。老项目这边,可选择的主要是 ClickOnce 或者自己写安装包,运行时依赖系统提供;新项目除了 ClickOnce,还多了两条路:框架依赖发布(体积小,客户机必须有对应运行时)和自包含发布(体积大,但什么都不用装)。
# 框架依赖 + 单文件,体积小,客户机要装 .NET 8 桌面运行时 dotnet publish -c Release -r win-x64 -p:PublishSingleFile=true --self-contained false # 自包含 + 单文件,客户机零依赖,发布目录通常几十到上百 MB dotnet publish -c Release -r win-x64 -p:PublishSingleFile=true --self-contained true这里有个坑要提前说:WinForms 项目不要开裁剪(PublishTrimmed)。WinForms 大量依赖反射和设计器生成的代码,裁剪器看不到这些动态引用,很容易把运行时需要的东西剪掉,程序在开发机上好好的,到客户机上一点按钮就崩,报的还是让人摸不着头脑的类型加载异常。如果嫌启动慢,用 ReadyToRun 更稳妥,它只是预编译成机器码,不会改变程序集内容。
4. 写代码时真正会撞上的差异
4.1 入口代码的写法已经完全不一样了
老模板生成的Program.cs是每个 WinForms 开发者都背下来的那几行:
using System; using System.Windows.Forms; namespace MyApp { static class Program { [STAThread] static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new Form1()); } } }新模板生成的入口短了一截,而且多了一个看起来“不存在”的方法:
namespace MyApp; static class Program { [STAThread] static void Main() { ApplicationConfiguration.Initialize(); Application.Run(new Form1()); } }ApplicationConfiguration.Initialize()既不是框架方法,也不在你的源码里,它是WinForms 源生成器根据 csproj 里的属性自动生成的一个静态类。你可以在obj\Debug\net8.0-windows\下面找到生成出来的文件,内容其实就是把原来的三行调用包了一层,并且把高 DPI 模式也从属性里读了出来。知道这一点很关键:当你想改高 DPI 行为、默认字体、是否启用视觉样式的时候,正确做法是改 csproj 属性,而不是像以前那样手写Application.SetHighDpiMode(...)——手写虽然也能生效,但会和生成代码打架,顺序问题会导致设置被覆盖。
顺带说,新模板默认打开了可空引用类型和隐式 using,所以你会发现using System;这类语句凭空消失了,string和string?也开始被编译器严格区分。对老项目迁移来说,可空引用类型要么一次开、一次改完,要么就明确关掉,写到一半开开关关,警告会多到没法看。
4.2 配置和设置:System.Configuration 不再白送
老项目里读配置最顺手的是ConfigurationManager.AppSettings["key"],System.Configuration是框架自带的。到了新项目,这个命名空间默认不在引用列表里,你得单独装System.Configuration.ConfigurationManager这个 NuGet 包才能用,而且它读的还是 app.config 风格的文件,和我们平时说的“新式配置”是两套东西。真正推荐的做法是用配置绑定:
// 需要 Microsoft.Extensions.Configuration.Json 等包 var config = new ConfigurationBuilder() .SetBasePath(AppContext.BaseDirectory) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .Build(); var db = config.GetSection("Database").Get<DatabaseOptions>();Properties/Settings.settings这个设计器支持的东西也值得单独提一句。它在老项目里是靠ApplicationSettingsBase加生成代码实现的,新项目早期版本里这个设计器不可用,后来逐步补齐了,但底层依赖的仍然是System.Configuration.ConfigurationManager包,行为和老框架内置的那套并不完全等价,尤其在部署路径、用户级设置存储位置上容易出意外。我个人的做法是:新项目里不要再用.settings这套东西,配置统一走 JSON 加一个强类型配置类,用户级偏好存到%AppData%下的自定义文件,逻辑清晰、可控、好排查。
4.3 语言特性和运行时 API 的边界
很多人以为换成新模板就"什么新语法都能用了",方向对但不够准确:C# 语言版本主要由编译器决定,但有一部分特性需要运行时配合,这部分在老框架上就是不可用的。整理一下实际会遇到的边界:
Span<T>、Memory<T>、Index、Range(arr[1..3]这种写法)在老框架上需要额外装System.Memory之类的兼容包,勉强能用但会带来包依赖;IAsyncEnumerable<T>(异步流)在老框架上需要Microsoft.Bcl.AsyncInterfaces;- 记录类型(record)和
init访问器需要IsExternalInit垫片,网上有现成写法,属于"能凑合"; - 默认接口实现(DIM)需要运行时支持,老框架上基本没有可行方案,这是硬边界。
反过来说,性能相关的 API 差距也不小。HTTP 客户端、字符串处理、JSON 序列化、异步 I/O 这些在新运行时上都有明显的性能改进和内存占用优化,尤其是高 DPI 下的 WinForms 渲染,新运行时的绘制路径优化是能肉眼感觉出来的。如果你的程序是数据量大、界面刷新频繁的类型,这个差距值得计入选型考虑。
4.4 平台目标:32 位依赖是最硬的一道墙
这是最容易被忽视、也最容易在迁移时翻车的一条。老项目默认 AnyCPU 加 Prefer32Bit,在 64 位系统上以 32 位运行;新项目没有 Prefer32Bit 这个概念,AnyCPU 就是 64 位。如果你的程序要调用某台仪器、某台打印机、某个读卡器厂商提供的 32 位原生 DLL,或者引用了只能在 32 位下注册的 COM 组件、ActiveX 控件,迁移后就会在运行时抛BadImageFormatException,报错信息通常只说"试图加载格式不正确的程序",不会告诉你真正的原因是位数不匹配。
解决方式是把平台目标显式锁到 x86:
<PropertyGroup> <TargetFramework>net8.0-windows</TargetFramework> <UseWindowsForms>true</UseWindowsForms> <PlatformTarget>x86</PlatformTarget> </PropertyGroup>发布的时候也要带上对应的 RID,比如-r win-x86。要注意,锁定 x86 之后,新运行时的部分内存和性能优势会打折扣,同时新的进程外设计器对 32 位项目的支持也有限制,可能影响可视化编辑体验。所以遇到这种依赖,先评估有没有 64 位版本的原生库或替代方案,实在没有,就老实评估是继续留在 .NET Framework(老框架对 32 位原生互操作的支持最成熟),还是接受新模板加 x86 的各种不便。这个判断没有标准答案,取决于那套原生依赖有多难替换。
5. 设计器行为差异:为什么你的窗体打不开了
5.1 进程内设计器和进程外设计器
老框架项目的窗体设计器是在 IDE 进程内运行的,它加载你编译出来的程序集,在同一个进程里渲染设计视图。新项目的设计器是进程外的,Visual Studio 会启动一个独立的设计器宿主进程来承载,你在任务管理器里能看到它。这个架构差异带来几个实际后果:设计器启动慢一点;设计器崩溃不会连累整个 IDE;但最重要的是——设计器依赖项目能成功编译并加载,编译不过、运行时缺失、目标 TFM 对应的运行时没装,设计器就是打不开。
5.2 设计器打不开的排查顺序
我在实际项目里总结出一套固定的排查顺序,基本能覆盖八成以上的"设计器空白/报错":
- 先编译整个项目。很多人一边写着一半的代码一边去开设计器,编译不过的时候设计器必然失败,这一步能省掉一半的无效排查。
- 确认目标运行时和 SDK 装齐了。新项目的 TFM 是
net8.0-windows,机器上就必须有对应的 .NET 8 SDK 和桌面运行时。升级 IDE 之后把目标框架改到更新版本,忘了装新 SDK,也会出现这个现象。老项目这边则要确认对应的 **.NET Framework 开发包(Developer Pack)**在装,只装运行时不具备编译和设计器所需的引用程序集。 - 检查平台目标是否和设计器兼容。项目锁了 x86 或依赖 32 位原生控件时,设计器加载控件类型可能失败。
- 清缓存重来。关闭 VS,删掉解决方案目录下的
.vs隐藏文件夹,再清理 IDE 自身的组件模型缓存目录(%LocalAppData%\Microsoft\VisualStudio\下对应版本号目录里的ComponentModelCache),然后重新打开工程。 - 逐个窗体排除。如果一个项目里有十几个窗体,只有一个打不开,通常问题就出在那个窗体的构造函数或者某个自定义控件上,而不是环境问题。
提示:如果某个自定义控件在构造函数里访问了数据库、读配置文件或者调用外部服务,在进程外设计器里这些操作可能失败,表现就是"设计器加载失败"。正确做法是在构造函数里判断当前是否处于设计时环境。
5.3 DesignMode 陷阱与高 DPI 配置的两种写法
DesignMode这个属性是个经典坑:在构造函数和InitializeComponent执行期间,DesignMode往往返回 false,于是你以为写对了的判空逻辑根本没生效,设计器一加载就抛异常。稳妥的写法是判断许可证使用模式:
if (LicenseManager.UsageMode == LicenseUsageMode.Designtime) return;或者用System.ComponentModel.DesignerProperties.GetIsInDesignMode这类方式。这个坑在老框架和新项目里都存在,只是新项目的进程外设计器在某些场景下表现和以前不同,所以别指望换个模板就自动好了。
高 DPI 的配置写法也是两种模板差异的典型代表。老框架 4.7 之后是在 app.config 里加配置节:
<configuration> <System.Windows.Forms.ApplicationConfigurationSection> <add key="DpiAwareness" value="PerMonitorV2" /> </System.Windows.Forms.ApplicationConfigurationSection> </configuration>新项目是在 csproj 里写属性,由源生成器读出来:
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>这里有个很容易漏的点:新模板生成的ApplicationConfiguration.Initialize()里默认给的是SystemAware,也就是不额外声明的话,多显示器拖拽时界面缩放会糊。想拿到逐显示器适配的效果,必须显式声明PerMonitorV2。声明完之后,建议去obj目录里翻一下生成的那个文件,亲眼确认最终生成的调用是哪个模式——配置文件里写了不等于运行时真的按这个模式跑了,中间被覆盖的情况我遇到过不止一次。
6. 到底该选哪个:把决策依据摆清楚
6.1 一张对照表看清全部差异
| 维度 | .NET Framework 模板 | 新模板(.NET 8 等) |
|---|---|---|
| 客户机预装情况 | Win10 1903+/Win11 自带 4.8/4.8.1 | 需要单独安装运行时或自包含发布 |
| 老系统支持 | 支持到 Windows 7 SP1 等较老系统 | 较新版本已不支持过老的系统 |
| 运行时部署模型 | 系统级、就地升级、全机器共享 | 并排安装、按版本共存 |
| 第三方控件兼容 | 老控件库基本都支持 | 看厂商支持矩阵,停更的库通常没有 |
| 32 位原生/COM 依赖 | 最省事,默认就是 32 位 | 需显式锁 x86,部分控件加载受限 |
| 单文件/自包含发布 | 不支持 | 支持 |
| 输出目录 | exe 里就是你的 IL | 代码在 dll,exe 是启动器 |
| 语言特性支持 | 部分需要垫片,部分不可用 | 全量可用 |
| 长期维护 | 4.8.1 是最后版本,只做安全修复 | 每年发新版,有 LTS 轨道 |
| 设计器成熟度 | 最稳,进程内 | 进程外,近几年已经很顺手,个别控件有坑 |
6.2 应该继续留在 .NET Framework 的真实场景
我不想给"新项目必须上新运行时"这种一刀切的建议,因为以下几种情况留在老框架上是完全理性的选择:
第一,客户现场完全不可控。给制造业车间、医院科室、政务窗口这类环境做工具,机器上装什么不由你决定,IT 部门的软件分发流程又长又慢,能利用系统自带运行时是巨大的优势,这时候新建项目直接选 .NET Framework 4.8,交付成本最低。
第二,依赖停更的控件库或 32 位原生 SDK。老版本的报表控件、图表控件、条形码控件,如果厂商早就不维护了,强行上新运行时的成本可能比重新写一个功能模块还高。
第三,需要和老系统长期共存。企业内部往往有一整套基于 .NET Framework 构建的公共库和基础设施,新项目插进去走完全不同的包管理和运行时模型,会在引用关系和版本冲突上持续产生摩擦,收益不明显而维护成本明显上升。
6.3 真要迁移的话,按这个顺序走
如果评估下来值得迁移,我建议按下面的顺序推进,每一步都留有退路:
- 先把业务逻辑和 UI 解耦。把计算、数据访问、协议解析这些抽到一个独立的类库,目标框架选
netstandard2.0,这样它同时能被老框架和新运行时引用。这一步做完,后面切换 UI 层的时候基本不用重写业务代码。 - 把老工程换成 SDK 风格但保留 net48。文件通配、PackageReference、属性化元数据都在这一步完成,运行时不变,测试压力最小。
- 装上升级辅助工具跑一遍扫描。它会标出不兼容的 API,比如
AppDomain相关调用、远程处理、部分配置 API、老式加密实现等,生成一份清单。重点看这份清单里有多少是你真正在用的。 - 逐个第三方依赖确认新版本。控件库、数据库驱动、日志库、序列化库,挨个查支持矩阵。这一步最容易卡住,也是决定迁移可行性的关键。
- 把 TFM 换到 net8.0-windows,编译,修错,回归测试。别指望一次过,重点测试文件路径、编码、并发、打印和报表导出这些老框架和新运行时行为有细微差别的地方。
顺便说一句关于"把 WinForms 工程挪到 Linux 上编译"的想法。新运行时的 SDK 本身是跨平台的,理论上可以用EnableWindowsTargeting属性在 Linux 上编译net8.0-windows的项目,适合放在 CI 里做编译验证。但这只解决了"编译",WinForms 程序在 Linux 上没有运行时支持,跑不起来,设计器也没有。真想让业务逻辑跨平台,出路是把逻辑层抽成普通的类库去跨平台,而不是指望整个窗体程序跨过去。
7. 那些实际踩过之后才记住的坑
7.1 找不到类型或命名空间
从老项目往新项目拷代码,大概率第一批报错就是ConfigurationManager、Registry、ManagementObjectSearcher找不到。前两个是命名空间没被隐式引用,装对应的 NuGet 包或者在文件顶部补 using 就行。这里要注意System.Management这类包在新运行时的行为和老框架并不完全一致,涉及硬件信息读取、打印机枚举这类功能的代码,迁移后务必在真实环境里验证一遍,不要只在开发机上跑通就发布。
7.2 引用路径写死的后果
老项目如果还在用 packages.config 管理包,csproj 里的引用会带HintPath,写的是..\packages\Xxx.1.0.0\lib\net45\Xxx.dll这种相对路径。把工程发给同事、或者换个目录层级,引用就找不到,编译报错但看不出所以然。解决办法就是在换 SDK 风格工程的同时切到 PackageReference,让包还原走统一的位置,这个改动本身风险很低,收益却很高。
7.3 其余零散但高频的坑
| 现象 | 真正原因 | 处理方式 |
|---|---|---|
| 新项目发布后 exe 只有一百多 KB | exe 是启动器,代码在 dll 里 | 发布整个目录,别只拷 exe |
| 运行时抛 BadImageFormatException | 位数不匹配,缺少 Prefer32Bit 的行为 | 显式设置 PlatformTarget 为 x86 |
| 设计器打开一片空白 | 项目编译失败或目标运行时未安装 | 先编译,再确认运行时和开发包 |
| 配置文件读不到 | 新项目里 App.config 不再是默认机制 | 改用 appsettings.json 加配置绑定 |
| 高 DPI 下界面仍然模糊 | 生成代码里默认是 SystemAware | 设置 ApplicationHighDpiMode 为 PerMonitorV2 |
| 程序到客户机上启动就崩 | 缺少对应版本的桌面运行时 | 改为自包含发布,或让客户机装运行时 |
| 界面里某些老控件在设计器里放不进去 | 进程外设计器不支持该类控件 | 改在代码里创建,或换等价控件 |
最后分享一个我自己的习惯:在项目根目录放一个README或者一个简单的说明文件,写清楚这个工程用的是哪个模板、目标框架是什么、客户机上需要预装什么、发布命令是哪一条。看着不起眼,但等半年后你或者同事再打开这个项目,或者要交接给别人的时候,这几行字能省下一整天的猜测。另外一个很实用的动作是,在 CI 脚本里加一步检查目标框架和已安装运行时的匹配关系,把"本机跑得好好的、到客户机就崩"这类问题尽量拦在发布之前,而不是等售后电话打过来才发现。