WPF项目为何没有Properties文件夹及迁移要点 新建一个 WPF 项目解决方案资源管理器里只有 App.xaml、MainWindow.xaml 和一个 csproj右键想找 Properties 展开改个版本号翻遍了也没这个文件夹——WPF .NET 框架里面默认没有 Properties 文件夹这个问题几乎每个从 .NET Framework 时代过来的人都会撞上一次。第一次遇到的时候我也怀疑过是不是装错了工作负载、模板被裁掉了甚至在项目文件里手动Compile IncludeProperties\AssemblyInfo.cs /结果编译报文件不存在。后来才明白这不是缺东西是项目文件格式换代之后模板的出厂配置变了Properties 文件夹里的那几样东西有的被搬进了 csproj有的被挪到了 obj 目录里自动生成还有的干脆在新框架下失去了原有的运行基础。这篇就把这件事从头捋一遍——它到底是什么、为什么会消失、哪些场景下你必须把它找回来、以及找回来之后怎么避免一堆重复定义的编译错误。不管你是刚接手一个老 WPF 项目要做迁移还是新建项目后发现改不了程序集信息下面的内容都能直接抄。1. 消失的不是文件夹是文件清单这套老规矩很多人把 Properties 当成 WPF 的某种固定组成部分其实它跟 WPF 本身没有半毛钱关系。它是一个约定俗成的目录名Visual Studio 的老模板习惯把程序集信息、资源、设置这几类非代码主体的东西塞进去仅此而已。真正决定它存在与否的是 csproj 这个文件的写法老式项目文件是一份逐文件登记的清单新式 SDK 风格项目文件是一份通配 约定的声明。搞清这个差别后面所有的现象都能自己推导出来。1.1 老 csproj 是清单每个文件都得点名.NET Framework 时代的 WPF 项目打开 csproj 你会看到密密麻麻的Compile Include... /、Page Include... /、EmbeddedResource Include... /。这意味着项目文件必须精确知道磁盘上有哪些文件参与编译任何新增的 .cs 文件都得在这里登记一笔。既然模板作者要往项目里放一个 AssemblyInfo.cs就必须在 csproj 里写上一行Compile IncludeProperties\AssemblyInfo.cs /为了让这行看起来不突兀顺手建个 Properties 目录是最自然的做法。这种模式的副作用很直接你在磁盘上新建一个类文件但忘了包含在项目中它就不参与编译反过来你在资源管理器里删了文件但没在项目里移除编译直接报找不到文件。用久了会形成一种肌肉记忆——加文件先右键添加新建项而不是在文件夹里拖。这套机制在单人小项目里没什么感觉一旦团队协作、分支合并频繁csproj 里的文件清单就成了冲突高发区两个人各加一个类合并时大概率要手动解冲突。1.2 SDK 风格改成通配模板就没必要再造文件了换成 SDK 风格之后csproj 顶部的Project SdkMicrosoft.NET.Sdk就意味着一整套默认规则被引入当前目录及子目录下所有**/*.cs自动参与编译所有**/*.xaml自动按 Page 处理所有**/*.resx自动按 EmbeddedResource 处理。项目文件从清单退化成了配置只剩下需要覆盖默认值的那部分内容。这一步变化带来的连锁反应是模板不再需要为了登记而创建文件。AssemblyInfo.cs 存在的唯一理由就是承载那些[assembly: ...]特性而 SDK 可以从 csproj 的属性里读出同样的信息并自动生成一份。既然能自动生成模板作者当然不会再塞一个需要人工维护的文件进去。所以你在 .NET 6/7/8 的 WPF 项目里看不到 Properties本质上是这个文件已经没有存在必要了而不是模板漏了。顺带一提这也是为什么新模板的 csproj 只有十几行老模板动辄上百行。我第一次把老项目的 csproj 整个替换成 SDK 风格时删掉了两百多行Compile Include编译一次通过那种感觉确实很爽——当然前提是你没漏掉那些藏在文件清单里的特殊配置项比如某个文件单独设了DependentUpon或者自定义生成工具这些是不会被自动推断出来的得手动补。1.3 哪些东西跟着一起没了Properties 目录下通常住着四类文件它们在新模板里的命运各不相同先做个总览后面几节分别展开。老文件作用新项目里的去向Properties/AssemblyInfo.cs程序集特性版本、公司、版权、ThemeInfo 等由 SDK 自动生成落在 obj 目录下Properties/Resources.resx界面字符串、图片等资源仍然支持但需要自己建目录和文件Properties/Resources.Designer.csresx 的强类型访问类不再随模板生成要配生成器Properties/Settings.settings应用级/用户级设置的强类型封装设计器不再支持需换方案App.config应用配置文件新模板不生成改用 appsettings.json 或保留 App.config这个表建议存下来迁移老项目的时候对着挨个确认。我见过太多次编译过了但运行时资源找不到的情况最后查出来都是资源文件没跟着搬编译期不报错运行时才炸排查成本高得多。2. 先搞清楚你缺的到底是文件夹还是特性在动手建目录之前有个问题必须先回答你需要的是Properties 这个目录还是往程序集上挂一段信息这两件事在老项目里是捆绑的在新项目里已经解耦了。分不清这一点很容易走弯路——比如有人为了改个版本号硬是把 GenerateAssemblyInfo 关掉手写全套 AssemblyInfo.cs结果后面每次加个特性都要去改文件纯属给自己找事。2.1 三种典型诉求对应三种做法我把实际遇到的诉求归成三类你可以直接对号入座。第一类是我只想改版本号、公司名、产品名。这类需求百分之百在 csproj 里解决一行 XML 搞定不需要建任何文件变更还能跟着代码一起走版本管理构建服务器上也能通过命令行参数覆盖dotnet build -p:Version1.2.3。这是最推荐的做法。第二类是我有一段自定义的 assembly 特性必须写比如日志框架的配置初始化特性、[assembly: InternalsVisibleTo(测试项目)]、[assembly: CLSCompliant(true)]或者某些 AOP 框架要求的注入点。这类需求需要真的有个 .cs 文件来写特性但不需要关掉自动生成建一个独立文件只写你自己的那几行就行SDK 生成的和手写的互不干扰只要特性名不重复。第三类是我要整体接管程序集信息通常出现在把一整批 .NET Framework 老代码平移过来的场景——老项目里那份 AssemblyInfo.cs 里可能堆积了十几条特性逐条翻译成 csproj 属性太费劲。这时候才值得关掉自动生成把老文件原样搬过来。2.2 csproj 属性和 AssemblyInfo 的完整映射如果你选第一类做法下面这张对照表基本能覆盖日常需要。左列是老写法右列是等价的 csproj 属性。AssemblyInfo 写法csproj 属性[assembly: AssemblyTitle(XXX)]AssemblyTitleXXX/AssemblyTitle[assembly: AssemblyDescription(...)]Description.../Description[assembly: AssemblyCompany(...)]Company.../Company[assembly: AssemblyProduct(...)]Product.../Product[assembly: AssemblyCopyright(...)]Copyright.../Copyright[assembly: AssemblyTrademark(...)]Trademark.../Trademark[assembly: AssemblyVersion(1.2.3.4)]AssemblyVersion1.2.3.4/AssemblyVersion[assembly: AssemblyFileVersion(1.2.3.4)]FileVersion1.2.3.4/FileVersion[assembly: AssemblyInformationalVersion(...)]InformationalVersion.../InformationalVersion[assembly: ComVisible(false)]ComVisiblefalse/ComVisible[assembly: NeutralResourcesLanguage(zh-CN)]NeutralLanguagezh-CN/NeutralLanguage有个容易踩的细节Version这个属性是总开关它会同时给 AssemblyVersion、FileVersion、InformationalVersion 提供默认值。如果你只写了Version2.1.0/Version那三者都会是 2.1.0AssemblyVersion 在缺省情况下会补成 2.1.0.0。但如果你同时写了Version和AssemblyVersion那么优先用后者。我一般建议显式写Version用于产品版本展示需要固定程序集版本比如强名称引用、插件热更新时再单独写AssemblyVersion两者分开控制思路清楚。还有AssemblyTitle和Title是两个不同属性前者对应程序集特性后者是包元数据别写混了。曾经有同事在Title里改了名字然后跑来说文件属性里看到的标题没变折腾半天。2.3 什么情况下必须真的动手建文件刚才说的第二类诉求有个前提你的自定义特性不能和 SDK 自动生成的撞名。一旦撞名编译直接给你 CS0579 重复特性错误。哪些特性 SDK 会自动生成粗略记一下AssemblyTitle、AssemblyDescription、AssemblyCompany、AssemblyProduct、AssemblyCopyright、AssemblyTrademark、AssemblyVersion、AssemblyFileVersion、AssemblyInformationalVersion、AssemblyConfiguration、TargetFramework、NeutralResourcesLanguage以及 WPF 项目特有的ThemeInfo。ThemeInfo 这条是 WPF 独有的值得单独说。它告诉 WPF 引擎去哪个程序集里找自定义控件的默认样式也就是 Generic.xaml。控件库通常需要ResourceDictionaryLocation.SourceAssembly应用程序模板一般是None。在 SDK 风格的 WPF 项目里这个特性由 SDK 在后台补上你在源码里看不到但编译后的程序集里有。这就带来一个很隐蔽的坑如果你为了搬老代码关掉了 GenerateAssemblyInfo又没在 AssemblyInfo.cs 里补 ThemeInfo编译一切正常运行时自定义控件的默认样式却静默失效——按钮长得跟普通按钮一模一样没有任何报错提示。提示判断 ThemeInfo 到底有没有生成别靠猜。编译一次去obj\Debug\net8.0-windows\下面翻那个项目名.AssemblyInfo.cs文件里面有什么一目了然。这个习惯能省掉大量玄学排查时间。3. 把 Properties/AssemblyInfo.cs 找回来的三种写法确认了需要手写特性之后具体怎么落地有三种路线从最小侵入到完全接管排列。我的建议顺序是能用路线 C 就别用路线 A除非你在做整体迁移。3.1 路线 A关掉自动生成完全自己写这是最直接也最容易出问题的做法。先建目录Properties在里面新建AssemblyInfo.cs然后在 csproj 的 PropertyGroup 里加一行Project SdkMicrosoft.NET.Sdk PropertyGroup OutputTypeWinExe/OutputType TargetFrameworknet8.0-windows/TargetFramework UseWPFtrue/UseWPF Nullableenable/Nullable ImplicitUsingsenable/ImplicitUsings !-- 关键关掉自动生成否则和下面的文件重复 -- GenerateAssemblyInfofalse/GenerateAssemblyInfo /PropertyGroup /Project文件内容参考这份注意 WPF 项目别漏了 ThemeInfousing System.Reflection; using System.Runtime.InteropServices; using System.Windows; [assembly: AssemblyTitle(MyWpfApp)] [assembly: AssemblyDescription(一个示例 WPF 应用)] [assembly: AssemblyCompany(示例组织)] [assembly: AssemblyProduct(MyWpfApp)] [assembly: AssemblyCopyright(Copyright © 2024)] [assembly: ComVisible(false)] [assembly: Guid(8f3b1c2e-4a5d-4e6f-9a7b-1c2d3e4f5a6b)] [assembly: AssemblyVersion(2.1.0.0)] [assembly: AssemblyFileVersion(2.1.0.0)] [assembly: AssemblyInformationalVersion(2.1.0)] // WPF 专用告诉引擎默认样式去哪里找 [assembly: ThemeInfo( ResourceDictionaryLocation.None, ResourceDictionaryLocation.SourceAssembly)]这里有个坑必须提前说关掉自动生成之后TargetFramework 特性和 NeutralResourcesLanguage 也不会自动生成了。前者一般不影响运行编译器会从 msbuild 属性里拿到目标框架后者会直接导致附属程序集的查找行为变化如果你有多语言资源运行时会因为默认语言判定不当而多走一次回退严重的会出现资源找不到。所以迁移时看到老 AssemblyInfo.cs 里有NeutralResourcesLanguage一定要原样带上。另一个坑是版本号。关掉自动生成但 AssemblyInfo.cs 里忘了写AssemblyVersion程序集版本会变成默认的 0.0.0.0 或 1.0.0.0某些依赖版本检查的插件加载逻辑会直接拒绝加载而且这种问题在开发机上完全看不出来一到部署环境就炸。3.2 路线 B细粒度开关只关掉冲突的那一条SDK 为每一条自动生成的特性都提供了独立的开关粒度细到GenerateAssemblyCompanyAttribute、GenerateAssemblyVersionAttribute这种级别。这意味着你可以保留 99% 的自动生成只把需要手写的那一条让出来PropertyGroup GenerateAssemblyCompanyAttributefalse/GenerateAssemblyCompanyAttribute /PropertyGroup这个写法在少数场景下很有用比如你想让 Company 属性由构建脚本动态注入而其他信息照旧走 csproj。但实际用起来比较啰嗦需要记住一整套属性名。我个人的经验是除非只在一条特性上纠结否则直接用路线 C 更省心。3.3 路线 C保留自动生成另建一个文件专门放假特性这是我目前所有项目都在用的做法也是最不容易出问题的不碰GenerateAssemblyInfo在Properties目录或者项目根目录建一个AssemblyInfo.Extra.cs里面只写 SDK 不会生成的那些特性。using System.Runtime.CompilerServices; // 只放自动生成覆盖不到的特性 [assembly: InternalsVisibleTo(MyWpfApp.Tests)] [assembly: InternalsVisibleTo(MyWpfApp.Benchmarks)]这个文件的存在完全不影响 SDK 自动生成的那一份两者在编译时会被合并。文件名叫什么其实无所谓只要不是AssemblyInfo.cs就行——叫AssemblyInfo.cs会让人误以为它是全套文件下次有人想改版本号就去里面找找不到又困惑。我一般叫AssemblyAttributes.cs或者GlobalAssemblyInfo.cs语义清楚。3.4 撞上 CS0579 之后怎么快速定位重复特性错误的完整报错大概长这样error CS0579: Duplicate System.Reflection.AssemblyVersionAttribute attribute看到这个错误按下面这个顺序查一般三步之内能定位。现象大概率原因处理方式报 AssemblyVersion 重复手写文件里有自动生成也开着二选一删掉手写那行或关掉对应的生成开关报 ThemeInfo 重复WPF SDK 已自动生成手写文件又写了一份删掉手写的那一条保留 SDK 生成的报 TargetFramework 重复手写文件里带了 TargetFramework 特性直接删这条交给 SDK报某个特性不存在关掉了 GenerateAssemblyInfo 但特性没跟上补齐缺失的特性或改回路线 C编译过了但版本号不对自动生成开着csproj 里的 Version 覆盖了手写值检查 csproj 是否有 Version 属性最后还有个大杀器直接打开obj目录下生成的项目名.AssemblyInfo.cs把自己写的和它生成的逐行对比重复的、缺失的一目了然。这个文件是编译中间产物很多人从来没打开过但它其实是排查程序集信息问题最快的一手资料。4. Settings.settings 在新框架下的替代方案Properties 目录里另一个高频被问到的就是 Settings.settings。老项目里双击打开设计器加几行配置项代码里Properties.Settings.Default.MySetting直接用改完调Save()持久化非常顺手。迁移到新框架之后你可能发现这个文件虽然能放进去但双击打不开设计器了或者在 SDK 风格项目里压根不识别它的自定义工具Settings.Designer.cs生成不出来老代码编译直接报找不到Properties.Settings。4.1 设计器为什么不能用了根子在配置系统本身。Settings.settings背后的单文件生成器是一套绑定在旧项目系统上的 Visual Studio 扩展它的输出Settings.Designer.cs依赖System.Configuration.ApplicationSettingsBase而这套 API 在 .NET Core 之后虽然通过System.Configuration.ConfigurationManager包保留了部分兼容但用户级设置User Scope的持久化路径和读写逻辑已经变了设计器没法再生成一份通用的、能在所有平台上工作的代码所以干脆不提供了。这里要区分清楚两个概念Application Scope 的设置是只读的、随程序走User Scope 的设置是每个用户一份、可写的、存在用户目录下。老实现把用户设置存在%LocalAppData%\公司名\产品名\版本号\user.config每个版本一个目录升级版本后旧设置不会自动带过来——这个特性当年就坑过不少人迁移时如果没有对应的迁移逻辑用户升级后会发现所有偏好设置都被重置了。4.2 用 appsettings.json 配强类型类如果你需要的是应用级只读配置最贴现代生态的做法是 json 配置文件加选项模式。先加包引用ItemGroup PackageReference IncludeMicrosoft.Extensions.Configuration Version8.0.0 / PackageReference IncludeMicrosoft.Extensions.Configuration.Json Version8.0.0 / PackageReference IncludeMicrosoft.Extensions.Configuration.Binder Version8.0.0 / /ItemGroup配置文件设为较新则复制ItemGroup None Updateappsettings.json CopyToOutputDirectoryPreserveNewest/CopyToOutputDirectory /None /ItemGroup读取代码大致是这样注意 WPF 里通常做成静态单例避免每次都重建配置对象public static class AppConfig { private static readonly IConfigurationRoot Root new ConfigurationBuilder() .SetBasePath(AppContext.BaseDirectory) .AddJsonFile(appsettings.json, optional: false, reloadOnChange: true) .Build(); public static AppOptions Options { get; } Root.GetSection(App).GetAppOptions() ?? new AppOptions(); } public class AppOptions { public string ServerAddress { get; set; } 127.0.0.1; public int TimeoutSeconds { get; set; } 30; public bool EnableLog { get; set; } true; }reloadOnChange: true这个参数值得开改完 json 不用重启程序就能生效调试时特别方便。但要注意它在某些环境比如文件被编辑器独占、或者程序装在只读目录下会抛异常生产环境如果配置文件放在 Program Files 下面建议设成 false避免不必要的文件监视开销。4.3 手搓一个 Settings 类让老代码少改如果迁移的老代码里到处是Properties.Settings.Default.XXX全局替换成新的配置访问方式工作量太大可以写一个仿制版直接顶上public sealed class Settings { private static readonly LazySettings Lazy new(() new Settings()); public static Settings Default Lazy.Value; private readonly string _filePath; private SettingsModel _model; private Settings() { var dir Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), 示例组织, MyWpfApp); Directory.CreateDirectory(dir); _filePath Path.Combine(dir, settings.json); _model Load(_filePath); } public string LastProjectPath { get _model.LastProjectPath; set { _model.LastProjectPath value; } } public double WindowWidth { get _model.WindowWidth; set { _model.WindowWidth value; } } public void Save() { var json JsonSerializer.Serialize(_model, new JsonSerializerOptions { WriteIndented true }); File.WriteAllText(_filePath, json); } private static SettingsModel Load(string path) { if (!File.Exists(path)) return new SettingsModel(); try { return JsonSerializer.DeserializeSettingsModel(File.ReadAllText(path)) ?? new SettingsModel(); } catch (JsonException) { // 配置文件损坏时不能让程序起不来 return new SettingsModel(); } } private sealed class SettingsModel { public string LastProjectPath { get; set; } string.Empty; public double WindowWidth { get; set; } 1024; } }这个类的接口和老写法几乎一致Settings.Default.LastProjectPath xxx; Settings.Default.Save();全局把Properties.Settings.Default替换成Settings.Default就行改动量可控。里面几个细节都是踩出来的LazyT保证单例线程安全Load里捕获JsonException而不是让异常冒出去是因为配置文件被用户手动编辑坏了的情况真的会发生程序起不来比丢设置严重得多Directory.CreateDirectory要放在读写文件之前第一次运行时目录不存在。4.4 用户设置的写入时机与几个注意点什么时候调Save()是个容易被忽略的问题。老代码里常见做法是在窗口的 Closing 事件里保存但如果用户强杀进程、或者系统关机、或者程序崩溃这次保存就丢了。我的做法是两段式重要设置改完立即保存比如用户点了确定按钮窗口尺寸这类高频变化的值只在 Closing 时保存兼顾性能和可靠性。还有几个零散但实打实会遇到的点保存路径不要用Environment.CurrentDirectory那个值在双击启动、从快捷方式启动、从命令行启动时可能完全不同用AppContext.BaseDirectory或者SpecialFolder.LocalApplicationData才稳。多开实例的并发写同一份配置文件会互相覆盖如果要支持多实例得加文件锁或者改成每个实例独立的临时文件。配置里如果存了敏感内容服务器地址、账号至少做一层简单混淆或者利用 Windows 提供的受保护存储接口。纯明文放在用户目录下稍微懂点的人都能改。5. Resources.resx 还能用但得自己点火资源文件是 Properties 目录里生命力最强的一个——resx 机制本身没被废弃.resx文件在新项目里仍然会被隐式包含为 EmbeddedResource。问题出在配套的Resources.Designer.cs上SDK 风格项目不会自动生成这个强类型访问类你新建一个 resx 文件代码里Resources.MyString直接编译不过。5.1 隐式包含的规则要知道SDK 项目的默认通配规则里**/*.resx会被自动纳入为EmbeddedResource。这意味着你把 resx 拖进项目目录它就已经参与编译了不需要任何配置。但参与编译和生成强类型类是两回事前者是把资源打进程序集后者是生成一个带静态属性的 C# 类方便你访问。这一步很多人会卡住因为在 Visual Studio 里选中 resx 文件、属性面板里有个自定义工具框老项目里会显示 ResXFileCodeGenerator新项目里可能是空的。手动填上PublicResXFileCodeGenerator或者ResXFileCodeGenerator然后右键运行自定义工具理论上就能生成 Designer.cs。但只有在这个文件被显式感知的时候才稳定更可靠的做法是直接在 csproj 里声明ItemGroup EmbeddedResource UpdateProperties\Resources.resx GeneratorPublicResXFileCodeGenerator/Generator LastGenOutputResources.Designer.cs/LastGenOutput /EmbeddedResource Compile UpdateProperties\Resources.Designer.cs DesignTimeTrue/DesignTime AutoGenTrue/AutoGen DependentUponResources.resx/DependentUpon /Compile /ItemGroup这段配置把生成器、输出文件名、依赖关系全都写死了团队成员拉下代码后打开项目就有一致的行为不依赖各自机器上的自定义工具状态。PublicResXFileCodeGenerator生成的是 public 类跨程序集能访问ResXFileCodeGenerator生成的是 internal 类只在当前程序集内可见。控件库一般用前者应用程序用后者够了。还有一个隐形坑资源类的完整命名空间由RootNamespace加上目录路径推导出来。资源放在Properties\Resources.resx默认命名空间是项目名.Properties.Resources。如果老代码里有硬编码的new ResourceManager(MyApp.Properties.Resources, assembly)而你迁移时改了 RootNamespace 或者把 resx 挪了位置运行时会抛MissingManifestResourceException。这类异常信息很含糊只会告诉你找不到资源不会告诉你正确的名字是什么。排查方法是编译后用反编译工具或者GetManifestResourceNames()把所有内嵌资源名打出来看看。5.2 图片、图标这类二进制资源要额外开一个开关resx 里如果只放字符串什么额外配置都不用。但一旦你往里面塞了图片、图标、音频这类非字符串资源编译时会报一个很绕的错误大意是无法序列化需要启用预序列化资源。解决办法是两个动作PropertyGroup GenerateResourceUsePreserializedResourcestrue/GenerateResourceUsePreserializedResources /PropertyGroup ItemGroup PackageReference IncludeSystem.Resources.Extensions Version8.0.0 / /ItemGroup这个开关的本质是让资源生成器用新的序列化方式而不是老的 BinaryFormatter 路径——后者在新框架里默认被禁用了。我第一次迁移带图标的资源文件时被这个错误卡了半小时报错信息完全没提加个包这件事全是内部类型名后来是搜报错里的关键类名才找到解法。5.3 多语言资源的配套设置如果项目有多语言需求老写法是建Resources.resx中性语言Resources.zh-CN.resxResources.en-US.resx然后依赖NeutralResourcesLanguage特性声明默认语言。迁移时这个特性一定要保留前面提过它现在由NeutralLanguage属性生成PropertyGroup NeutralLanguagezh-CN/NeutralLanguage /PropertyGroup这个设置的实际作用是性能优化加行为正确性声明之后运行时在查找资源时会先检查当前文化是否等于中性语言是的话直接查主程序集省掉一次附属程序集探测。少了这条最坏情况下中文环境下也会去zh-CN目录找一遍找不到再回退白白多一次 IO。更麻烦的是在某些边缘情况下回退链的判断会跟预期不一致导致本该显示中文的地方显示了英文。还有SatelliteResourceLanguages这个属性用来限制生成哪些语言的附属程序集。发布时如果你只想要中文和英文加上这个可以避免把一堆用不到的语言目录一起打包出去SatelliteResourceLanguageszh-CN;en-US/SatelliteResourceLanguages体积能省下不少尤其是引用了带多语言资源的第三方库时效果很明显。6. 从老框架迁到新框架Properties 相关的坑逐个过前面几节讲的是新建项目怎么补这一节专门说迁移。因为迁移场景下的问题往往不是缺文件而是文件在但行为变了排查起来更费劲。我经手过几个规模不小的 WPF 项目迁移下面这些坑基本每次都会撞上几个。6.1 程序集版本号莫名其妙变成 1.0.0.0这是最高频的问题。现象是老项目里 AssemblyInfo.cs 写着 3.2.1.0迁移后 csproj 里什么都没写编译出来的 dll 版本变成了 1.0.0.0。原因很简单你把 AssemblyInfo.cs 从项目里移除了或者没包含而 csproj 里也没补VersionSDK 就用默认值。处理方式二选一要么在 csproj 里补Version3.2.1/Version要么保留 AssemblyInfo.cs 并关掉自动生成。我更推荐前者因为版本号写在 csproj 里CI 流水线可以通过-p:Version直接覆盖不用改源码。顺带说个相关的如果项目用了强名称签名.snk文件迁移后记得检查SignAssemblytrue/SignAssembly和AssemblyOriginatorKeyFile有没有配上。这两项在老项目里也是写在 csproj 里的容易在重写 csproj 时漏掉漏了之后编译不报错但生成的 dll 没有签名引用它的其他程序集会加载失败。6.2 图标、清单文件这些项目级设置去哪了老 csproj 里有ApplicationIcon、ApplicationManifest、StartupObject这些节点它们不在 AssemblyInfo.cs 里而在 csproj 的 PropertyGroup 里。重写 csproj 的时候如果只关注程序集特性很容易把这几个漏掉结果就是编译出来的 exe 图标变成了默认的白纸图标或者 DPI 感知设置失效因为清单文件没被引用。迁移时建议做一次老 csproj 逐行扫描把老文件里所有PropertyGroup下的节点列出来逐个确认在新 csproj 里有没有对应项。常见的需要保留的有老节点新 csproj 写法漏掉的后果ApplicationIconapp.ico/ApplicationIcon同名节点保留exe 显示默认图标ApplicationManifestapp.manifest/ApplicationManifest同名节点保留DPI 感知、UAC 设置失效StartupObjectNs.Program/StartupObject同名节点保留多入口点项目编译报错PlatformTargetx64/PlatformTarget同名节点保留默认 AnyCPU可能加载不了 32 位原生依赖6.3 多目标框架下的条件编译如果项目要同时出 .NET Framework 和 .NET 8 两个版本渐进式迁移的常见做法Properties 里的东西就得区分对待。csproj 大概长这样PropertyGroup TargetFrameworksnet48;net8.0-windows/TargetFrameworks UseWPFtrue/UseWPF /PropertyGroup !-- 只在旧框架下关掉自动生成用老的 AssemblyInfo.cs -- PropertyGroup Condition$(TargetFramework) net48 GenerateAssemblyInfofalse/GenerateAssemblyInfo /PropertyGroup ItemGroup Condition$(TargetFramework) net48 Compile IncludeProperties\AssemblyInfo.Net48.cs / /ItemGroup这种写法能把两套程序集信息隔离开避免新框架下重复特性、旧框架下缺特性的两难。代价是维护成本翻倍所以我的建议是除非有明确的过渡期需求否则别搞多目标直接一步迁到新框架把 Properties 里的东西一次性梳理清楚。代码里的条件编译符号也要注意NET48和NET8_0_OR_GREATER这些符号是自动定义的可以直接用#if。但如果你在 AssemblyInfo.cs 里写了#if要注意这个文件是整文件参与编译的条件不满足时整个文件可能变成空的某些情况下会触发文件不包含任何可编译内容的警告虽然不影响构建但看着烦。6.4 迁移完成后的自检清单我一般会在迁移结束后跑一遍下面这几项能挡掉八成后续问题反编译或者用属性面板确认 dll 的版本号、公司名、产品名和老版本一致。检查obj目录下生成的 AssemblyInfo 文件确认 ThemeInfo 存在且参数正确。遍历程序集里所有[assembly: ...]特性和老版本做一次对比少了就补。如果有多语言资源切到英文系统区域跑一遍确认没有跑到中文资源。自定义控件在界面上显示正常没有变成默认样式这条是 ThemeInfo 的实战验证。配置文件读写路径正确用户设置能正常保存和加载。清单第 5 条看着不起眼但它是我遇到过的、最难排查的一类问题——不报错、不崩溃就是长得不对而且往往在测试后期才发现。7. 我个人在这件事上的几个习惯折腾了这么多次现在我的做法基本固定下来了新项目一律不建 Properties 目录程序集信息全走 csproj需要自定义特性的时候建一个AssemblyAttributes.cs放在项目根文件名带 Assembly 前缀方便搜索。这样做的好处是项目结构和 SDK 约定完全一致新同事拉下代码不用问为什么这里有个 Properties 目录但里面只有两个文件。迁移老项目的时候我会先把老 AssemblyInfo.cs 的内容整段复制到一个临时文本文件里逐条标注这条 csproj 能替代 / 这条必须手写 / 这条已经废弃标完之后再动手改代码。这个动作花不了十分钟但能避免反复编译试错。尤其是那些第三方框架要求的特性名字看起来很像内置的很容易被误删。另外提一个我踩过的坑有些老项目的 AssemblyInfo.cs 里会写[assembly: AssemblyVersion(1.0.*)]用通配符让编译器自动生成版本号。这个写法在 SDK 风格项目里是不支持的会直接编译报错。替代方案是用构建脚本或者 CI 在编译时传-p:Version把版本号生成挪到构建流程里比源码里的通配符可控得多。