WPF UI 多目标框架(Multi-Targeting)架构决策:从 .NET Framework 4.6.2 到 .NET 10 的兼容性工程实践
【免费下载链接】wpfuiWPF UI provides the Fluent experience in your known and loved WPF framework. Intuitive design, themes, navigation and new immersive controls. All natively and effortlessly.项目地址: https://gitcode.com/GitHub_Trending/wp/wpfui
WPF UI(Wpf.Ui)作为一套提供 Fluent 设计体验的 WPF 控件库,其架构核心决策之一(ADR-001,状态:Accepted)是同时面向现代 .NET 与遗留 .NET Framework 构建多个目标框架(TFM)。本文以该 ADR 为骨架,结合仓库中Directory.Build.props、Directory.Packages.props、Directory.Build.targets及各.csproj的真实配置,完整还原 WPF UI 的多目标化工程方案,帮助读者理解如何在控件库中落地 CPM(中央包管理)、条件编译、PolySharp 源码生成器与统一 C# 语言版本,从而在最大化消费端兼容性的同时保持现代开发体验。
一、决策背景:为什么要同时支持 8 个以上框架
WPF UI 的定位决定了它必须兼容"新旧两代"消费者:既有 .NET Framework 时代遗留的企业级 WPF 应用,也有跑在 .NET 8/9/10 上的现代桌面项目。ADR-001 将支持面划分为三个梯队:
| 梯队 | 目标框架 | 定位 |
|---|---|---|
| 现代 .NET | .NET 10、9、8 | 可调用 Windows 专属 API 的现代实现 |
| 遗留框架 | .NET Framework 4.8.1、4.7.2、4.6.2 | 覆盖存量企业应用 |
| 抽象层 | .NET Standard 2.0、2.1 | 仅用于无 WPF 依赖的抽象库,最大化兼容面 |
这一决策背后有三条核心动因,在 ADR-001 中均有明确说明:
-windows后缀是硬性要求:面向 .NET 5+ 的 WPF 项目必须使用netX.0-windows形式的 TFM 后缀,这是 .NET 5 引入操作系统特定 TFM 机制后的强制约定;- 遗留兼容不是可选项:大量企业应用仍停留在 .NET Framework,控件库要进入这类项目必须直接产出对应程序集;
- 多版本并提供升级通道:同时面向多个 .NET 主版本,让消费者在升级运行时期间无需更换库的版本。
二、三大库的分层目标框架设计
2.1 核心库 Wpf.Ui:WPF 专属,六目标并行
仓库中 src/Wpf.Ui/Wpf.Ui.csproj 的真实配置为:
<TargetFrameworks>net10.0-windows;net9.0-windows;net8.0-windows;net481;net472;net462</TargetFrameworks>注意三个细节:
netX.0-windows后缀:凡是UseWPF=true的项目(Wpf.Ui、Wpf.Ui.Tray、Wpf.Ui.Gallery 等)一律采用带-windows的 TFM,而不是裸net10.0;EnableWindowsTargeting=true:允许在非 Windows 的构建主机(如 CI 的 Linux 容器)上编译 Windows 目标,这是多目标工程能在 GitHub Actions 等平台跑通的关键;.NET Framework三兄弟:net481;net472;net462依次对应 .NET Framework 4.8.1 / 4.7.2 / 4.6.2。
2.2 抽象层 Wpf.Ui.Abstractions:无 WPF 依赖,兼容面最广
src/Wpf.Ui.Abstractions/Wpf.Ui.Abstractions.csproj 的目标为:
<TargetFrameworks>net10.0;net9.0;net8.0;net481;net472;net462;netstandard2.1;netstandard2.0</TargetFrameworks>与 ADR 原文相比,当前仓库将net481;net472;net462一并纳入了抽象层目标,并与核心库保持同一组 .NET Framework 目标,体现了"同一 TFM 组内所有项目必须同步"的执行准则(见下文"强制规则")。该层只承载INavigationViewPageProvider、INavigationAware等接口与基类,不引用任何 WPF 程序集,因此可以打进 .NET Standard 2.0——这使得它可以在 .NET Framework 与 .NET Core/5+ 之间共享的类库中直接使用。
2.3 DI 集成层 Wpf.Ui.DependencyInjection:同一目标集,低版本依赖
src/Wpf.Ui.DependencyInjection/Wpf.Ui.DependencyInjection.csproj 采用与 Abstractions 完全相同的 TFM 集合,且只依赖一个包:
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" VersionOverride="3.1.0" />ADR 原文提到"版本 3.1.0 以获取广泛兼容性",仓库中的落地方式不是Version属性,而是 CPM 体系下的VersionOverride="3.1.0"——这是对该 ADR 决策更精确的实现细节。由于无 WPF 依赖,该层甚至可以被后台服务(background services)等非 UI 上下文复用。
三、Central Package Management:版本唯一真源
ADR 要求"所有包版本只允许出现在Directory.Packages.props"。仓库根目录的 Directory.Packages.props 正是这一决策的执行者:
<Project> <PropertyGroup> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled> <NuGetAudit>true</NuGetAudit> <NuGetAuditLevel>moderate</NuGetAuditLevel> </PropertyGroup> <ItemGroup> <PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.275" /> <PackageVersion Include="System.Memory" Version="4.6.3" /> <PackageVersion Include="PolySharp" Version="1.15.0" /> <!-- 其余 ~30 个包版本集中在此 --> </ItemGroup> </Project>使用 CPM 后,各项目引用包时不再写版本号,例如 src/Wpf.Ui/Wpf.Ui.csproj:
<PackageReference Include="Microsoft.Windows.CsWin32"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> <PackageReference Include="System.Memory" />ADR 文档阐述的三项收益在仓库中得到印证:
- 单一真源:所有
PackageVersion集中在 Directory.Packages.props 一处,升级版本只需改一个文件; - 防止版本冲突:整个解决方案(含
Wpf.Ui.Gallery及所有测试项目)共享同一版本表; - 简化依赖更新:配合
CentralPackageTransitivePinningEnabled=false与NuGetAudit(审计级别 moderate),版本治理可以集中管控。
当前仓库的版本号与 ADR 撰写时略有差异(如 CsWin32 由 0.3.242 升至 0.3.275),这正说明 CPM 的价值:版本演进只发生在单一文件内。
四、条件编译:用#if而非运行时判断
4.1 框架检测指令的规范写法
ADR 给出了一套标准模式,仓库源码中随处可见其落地:
#if NET5_0_OR_GREATER // 现代 .NET API(如 Environment.OSVersion) #else // .NET Framework 回退方案(如注册表) #endif #if NET6_0_OR_GREATER // 例如 CancellationTokenRegistration 的 DisposeAsync #endif #if NET8_0_OR_GREATER // 使用 .NET 8+ 的新特性 #endif实际代码印证:
- src/Wpf.Ui/Win32/Utilities.cs 用
#if NET5_0_OR_GREATER区分现代 .NET 与 .NET Framework 的 API 路径(第 154 行是#if !NET5_0_OR_GREATER的对称回退分支); - src/Wpf.Ui/Controls/MessageBox/MessageBox.cs 在
#if NET8_0_OR_GREATER内using System.Runtime.CompilerServices;,并分别在 L256、L333、L375-L382 多处按NET8_0_OR_GREATER/NET6_0_OR_GREATER分支实现; - src/Wpf.Ui/Controls/NavigationView/NavigationView.Navigation.cs 在
NET6_0_OR_GREATER下启用较新的导航 API; - 其余项目同样遵守该约定,如 src/Wpf.Ui.Tray/RoutedNotifyIconEvent.cs。
ADR 的核心纪律是:编译期能力差异一律用#if指令处理,绝不用运行时版本检查。因为运行时判断会拖慢路径且无法消除编译器对缺失 API 的报错,而#if能让每个 TFM 的编译在构建期就暴露 API 缺失问题。
4.2 框架专属依赖的条件引用
src/Wpf.Ui/Wpf.Ui.csproj 只对net462引入System.ValueTuple:
<ItemGroup Condition="'$(TargetFramework)' == 'net462'"> <PackageReference Include="System.ValueTuple" /> </ItemGroup>同理,src/Wpf.Ui.Tray/Wpf.Ui.Tray.csproj 反向使用条件——除net462外引入System.Drawing.Common(托盘图标Hicon.cs依赖其生成 HICON,见 src/Wpf.Ui.Tray/Hicon.cs 的 TODO 注释),而对net462只引System.ValueTuple:
<ItemGroup Condition="'$(TargetFramework)' != 'net462'"> <PackageReference Include="System.Drawing.Common" /> </ItemGroup> <ItemGroup Condition="'$(TargetFramework)' == 'net462'"> <PackageReference Include="System.ValueTuple" /> </ItemGroup>这种"按 TFM 条件裁剪依赖"的做法,避免了在 .NET 6+ 上引入不受支持的System.Drawing.Common(该包自 .NET 6 起仅支持 Windows),是多目标工程避免运行时陷阱的典型手法。
五、PolySharp:编译期生成 polyfill,现代语法贯通老框架
5.1 工作原理与仓库配置
PolySharp 是一个构建期源码生成器:它不会引入运行时依赖,而是在编译时按需生成 C# 语言特性所需的 polyfill 类型。ADR 文档中给出的配置示例(CompilerVisibleProperty+PolySharpExcludeGeneratedTypes)在仓库中落地为 src/Wpf.Ui/Wpf.Ui.csproj 的一行属性:
<PolySharpExcludeGeneratedTypes>System.Runtime.CompilerServices.OverloadResolutionPriorityAttribute;System.Diagnostics.CodeAnalysis.UnscopedRefAttribute</PolySharpExcludeGeneratedTypes>同时在 Directory.Build.targets 中,PolySharp 被按目标框架条件引入——只有旧框架(netstandard2.0、netstandard2.1、net481、net472、net462)才需要 polyfill,现代 .NET 无需加载:
<PackageReference Include="PolySharp" Condition="'$(TargetFramework)' == 'net462'"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>build; analyzers</IncludeAssets> </PackageReference> <!-- netstandard2.0 / netstandard2.1 / net472 / net481 同理 -->5.2 能力清单
ADR 明确 PolySharp 带来的核心收益,这些在现代 .NET 上是 BCL 内置能力、在旧框架上由生成器补齐:
- init-only 属性(
IsExternalInit):旧框架上让{ get; init; }可用; - required 成员:支持 C# 11 的
required关键字; - CallerArgumentExpression:让断言/校验库在旧框架也能拿到调用点表达式;
- C# 11 / C# 12+ 语法在旧框架上的落地:配合下文的语言版本设置,实现"一套语法、处处编译"。
5.3 纪律:禁止手写 polyfill
ADR 强调"绝不为语言特性手写 polyfill 类"。原因在于 PolySharp 的生成器在构建期自动产出符号,若开发者手动声明IsExternalInit等同名类型,会触发重复符号编译错误——机制本身即是防手写的最强校验。
六、统一语言版本:C# 14 与 preview 例外
所有项目共享 Directory.Build.props 中的统一配置:
<LangVersion>14.0</LangVersion>唯一的例外是核心库 src/Wpf.Ui/Wpf.Ui.csproj:
<LangVersion>preview</LangVersion>这是 ADR 中明确记录的唯一 override,用于在核心库中启用 C# 预览特性。C# 14 + PolySharp 的组合意味着:编写代码时可以使用最新 C# 语法,PolySharp 在编译期为旧 TFM 生成对应 polyfill,全程零运行时依赖。
使用提示:当 .NET 工具链与
LangVersion=14.0不匹配时,需要保证 SDK 版本支持 C# 14(对应 .NET 10 SDK);若仅想稳定编译,可将LangVersion调低,但会失去该仓库对全部 TFM 的统一语法基线。
七、构建全局属性与框架探测机制
7.1 Directory.Build.props:一次定义,处处生效
Directory.Build.props 集中管理所有项目的公共构建属性,除了上文提到的LangVersion,还包括:
<Version>4.3.0</Version> <LangVersion>14.0</LangVersion> <Nullable>enable</Nullable> <AllowUnsafeBlocks>true</AllowUnsafeBlocks> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <GenerateDocumentationFile>true</GenerateDocumentationFile>(注意:当前仓库实际版本为4.3.0,ADR 文档示例中的4.2.0是撰写时的快照。)
7.2 条件属性组:如何定义NET8_0_OR_GREATER
ADR 展示了用MSBuild::IsTargetFrameworkCompatible定义自定义常量的模式。仓库中 Directory.Build.props 采用等价的"白名单"实现:
<PropertyGroup> <IsBelowNet8 Condition="'$(TargetFramework)' == 'netstandard2.0' Or '$(TargetFramework)' == 'netstandard2.1' Or '$(TargetFramework)' == 'net462' Or '$(TargetFramework)' == 'net472' Or '$(TargetFramework)' == 'net481' Or '$(TargetFramework)' == 'net5.0' ... Or '$(TargetFramework)' == 'net10.0'" >True</IsBelowNet8> </PropertyGroup> <PropertyGroup Condition="'$(IsBelowNet8)' == 'false'"> <DefineConstants>$(DefineConstants);NET8_0_OR_GREATER</DefineConstants> </PropertyGroup>这套机制的价值在于:仓库在正式 SDK 预定义符号之外,显式注入NET8_0_OR_GREATER常量,让 src/Wpf.Ui/Controls/MessageBox/MessageBox.cs 等文件中#if NET8_0_OR_GREATER的语义完全由中央构建属性驱动,任何新增 TFM 只需维护一处列表。
7.3 Directory.Build.targets:打包、分析与裁剪的统一收口
Directory.Build.targets 补充了打包与分析阶段的能力:
- 统一打包元数据:
GenerateLibraryLayout、snupkg符号包、DeterministicSourcePaths,并在打包时附带 ThirdPartyNotices.txt 与 LICENSE.md; - 分析器全家桶:
Microsoft.CodeAnalysis.BannedApiAnalyzers(配合根目录 BannedSymbols.txt)、AsyncFixer、IDisposableAnalyzers、StyleCop.Analyzers全部按PrivateAssets=all引入; - 裁剪/AOT 分析器:对
net6.0及以上的 TFM 开启IsTrimmable、EnableTrimAnalyzer、EnableAotAnalyzer、EnableSingleFileAnalyzer(Directory.Build.targets)——这与 ADR 中"Abstractions 库 AOT 兼容"的目标相呼应; - SourceLink 特判:
WpfSourceLinkWorkaround目标针对UseWPF=true的项目绕过 WPF 与 SourceLink 的已知冲突(Directory.Build.targets)。
八、执行纪律:MUST / MUST NOT 清单与自校验机制
ADR 用强约束条款把多目标工程的可维护性固定下来:
必须遵循(MUST):
- 所有包版本只允许写在
Directory.Packages.props(CPM 唯一真源); - 有 WPF 依赖的项目必须使用
-windows后缀 TFM(如net10.0-windows,而不是net10.0); - 无 WPF/Windows 依赖的抽象包使用
netstandard2.0/netstandard2.1; - 框架专属代码一律用
#if NET{X}_0_OR_GREATER守护,禁用运行时版本检查; - 语言特性 polyfill 一律交给 PolySharp,禁止手写
IsExternalInit、CallerArgumentExpression等类型; LangVersion在Directory.Build.props统一设置,仅在有充分理由时在单个.csproj覆盖(当前仅Wpf.Ui.csproj使用preview)。
禁止事项(MUST NOT):
- 严禁在
.csproj的PackageReference上写Version属性(版本只能在Directory.Packages.props)——ManagePackageVersionsCentrally=true会让违规写法直接触发构建错误; - 严禁只给部分项目新增 TFM——同一 TFM 组内所有项目必须保持同步(对照上文,Abstractions 与 DI 层在仓库中确实共享同一 TFM 集合);
- 严禁使用带补丁号的
#if(如NET8_0_10),只允许_OR_GREATER后缀符号; - 严禁在未进行主版本升级时移除已发布包中的 TFM——移除 TFM 对仍停留在该框架的消费者是破坏性变更。
三层自校验机制:
- CPM 强制:
ManagePackageVersionsCentrally=true使任何带Version的引用直接编译失败; - TFM 验证:每次
dotnet build都会编译全部 TFM,缺失 API 立即以编译错误形式暴露——这是条件编译与多目标配合的天然安全网; - PolySharp 覆盖:生成器自动产出 polyfill,手写同类符号会触发重复定义错误。
九、决策后果:收益与代价
正面收益(ADR 原文 + 仓库印证):
- 广泛兼容:从 .NET Framework 4.6.2 到 .NET 10 的应用都能直接引用,且 Wpf.Ui.Abstractions 通过
netstandard2.0覆盖到无法升级运行时的存量类库; - 现代开发体验:C# 14(核心库 preview)语法配合 PolySharp 贯通全部 TFM,Directory.Build.props 一处设置全局生效;
- 维护简化:CPM 把版本治理收敛到 Directory.Packages.props 单文件;
- 清晰升级路径:消费者升级 .NET 版本时无需更换库版本;
- AOT/裁剪就绪:Directory.Build.targets 对 .NET 6+ 开启裁剪与 AOT 分析器,抽象层无 WPF 依赖天然适合 AOT 场景。
负面代价:
- 构建复杂度上升:每次提交都要编译 6+ 个框架变体(以 src/Wpf.Ui/Wpf.Ui.csproj 的六目标为例);
- 包体积增大:多目标 NuGet 包内含全部框架的程序集;
- 条件编译负担:框架差异代码必须接受
#if指令约束; - 测试成本:新特性应尽量在多个框架版本上验证——仓库在 tests 目录同时维护了 Wpf.Ui.UnitTests 与基于 FlaUI 的 Wpf.Ui.Gallery.IntegrationTests,正是对这一负担的工程回应。
十、实践要点速查
在多目标控件库(或任何需要兼容新旧框架的 .NET 库)中复刻这套方案时,按以下顺序落地:
- 根目录创建
Directory.Packages.props:设ManagePackageVersionsCentrally=true,把所有包版本集中管理; - 根目录创建
Directory.Build.props:统一LangVersion、Nullable、AllowUnsafeBlocks,并按 TFM 白名单注入NET8_0_OR_GREATER等自定义常量; - 根目录创建
Directory.Build.targets:按旧框架条件引入 PolySharp,对 .NET 6+ 开启裁剪/AOT 分析器; - 按分层设定 TFM:WPF 层用
net10.0-windows;...;net462并加EnableWindowsTargeting,抽象层追加netstandard2.1;netstandard2.0; - 源码中用
#if NET{X}_0_OR_GREATER守护框架差异,依赖用Condition="'$(TargetFramework)' == ..."裁剪; - 用
dotnet build全量验证:所有 TFM 在同一命令内编译,任何 API 缺失都会立即报错——这正是 ADR 选择"构建期验证"而非运行时探测的根本原因。
十一、相关文档延伸
- ADR 决策原文:docs/architecture/decisions/ADR-001-multi-target-framework.md
- 架构总览与推荐实践:docs/architecture/README.md、docs/architecture/RECOMMENDATIONS.md
- 控制库分层架构:docs/architecture/decisions/ADR-002-control-library-architecture.md
- Win32 互操作选型(依赖 CsWin32 版本管理):docs/architecture/decisions/ADR-003-win32-interop-via-cswin32.md
- 主题静态管理器(同样受多目标约束):docs/architecture/decisions/ADR-004-static-managers-for-theming.md
- 核心实现参考:src/Wpf.Ui/Wpf.Ui.csproj、src/Wpf.Ui.Abstractions/Wpf.Ui.Abstractions.csproj、src/Wpf.Ui.DependencyInjection/Wpf.Ui.DependencyInjection.csproj
- 工程化配置:Directory.Build.props、Directory.Packages.props、Directory.Build.targets
【免费下载链接】wpfuiWPF UI provides the Fluent experience in your known and loved WPF framework. Intuitive design, themes, navigation and new immersive controls. All natively and effortlessly.项目地址: https://gitcode.com/GitHub_Trending/wp/wpfui
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考