☰
UWP获取版本信息零依赖指南:系统版本、应用包与契约检测
2026/10/23 3:55:31 网站建设 项目流程

在 UWP 项目里,拿“版本信息”这件事,看起来比登天还简单,做起来却经常让人抓狂。我说的是这种场景:你要根据当前系统是 Windows 10 1809 还是 2004 来切换功能,或者写崩溃日志的时候想顺带记录一下系统 Build 号,又或者只是想在自己的“关于”页面上显示应用包版本。你搜一下,会发现一堆人建议你去 NuGet 装一个包,甚至引一个完整 SDK。但实际上,版本信息是系统级的基础能力,UWP 应用本身就自带这些东西。用 Windows.System.Profile 里的 AnalyticsInfo、Windows.ApplicationModel 里的 Package 和 Windows.Foundation.Metadata 里的 ApiInformation,不需要任何额外的 NuGet 包或 SDK,就能把系统版本、应用包版本、设备家族和能力契约全部拿到手。这篇文章就把这三条路一次讲清楚,代码直接复制可用,同时把我实际踩过的坑一并列出来。不管你是刚接触 UWP 的新人,还是正在清理老项目依赖关系的老手,都可以往下看。

1. 思路先聊透:你想要的到底是哪个“版本”

我经常在社区里看到有人问“怎么获取 UWP 版本”,但细聊下去,每个人要的东西其实都不一样。这种歧义最耽误时间,所以先把目标拆开。版本信息不是一个点,而是一个家族,至少包含四个互不相干的成员:设备当前系统版本、应用包版本、API 契约版本、清单里声明的最小系统版本。你要做的是先确认自己要哪一个,再选择对应的 API,而不是拿一个包把所有信息都塞进来。

1.1 版本号是一个家族,四项对号入座

你听到的说法真实含义获取入口典型值
系统版本 / OS Build设备当前运行的操作系统版本AnalyticsInfo.VersionInfo.DeviceFamilyVersion10.0.19041.0
应用包版本打包进 appx / 提交到商店的版本号Package.Current.Id.Version1.0.2.0
API 契约版本系统承诺支持的 API 能力面版本ApiInformation.IsApiContractPresentUniversalApiContract 8
目标平台最小版本清单里声明的 MinVersion,即最低运行门槛Package.appxmanifest 的 TargetDeviceFamily10.0.17763.0

先说系统版本,这就是大家平时说的“这台设备跑的是哪个 Windows”,比如 Windows 10 2004 对应 Build 19041,Windows 11 21H2 对应 Build 22000。这个数字在设备整个生命周期内固定不变,适合写日志、做统计、显示在关于页面里。

再说应用包版本,它是你自己发布的包的版本,和系统版本没有关系。你改了代码、重新打包、提交商店,这个数字就会变。很多新人把Package.Current.Id.Version当成系统版本来用,结果拿到的永远是“1.0.0.0”这种自家项目版本号,然后一脸懵。

API 契约版本则比较特殊,它是 UWP 用来管理 API 演进的一套机制。系统不同 Build 对应不同的契约号,契约号越高,可用的 API 越多。后面我会专门讲,在功能开关场景里,判断契约往往比判断 Build 号更可靠。

目标平台最小版本最容易被忽略,它写在 Package.appxmanifest 的<TargetDeviceFamily>节点里,代表你承诺这个应用至少能在哪个版本上运行,跟运行时拿到的实际版本完全不是一回事。

所以当你问“Get UWP Version”的时候,我默认你指的是第一项,也就是系统版本。如果你其实是在做商店打包、想知道自己应用包的版本,那去看第二项。这篇文章都会覆盖到,但接下来的重点会放在系统版本和契约检测上。

1.2 这个需求为什么用不着引 NuGet 包或 SDK

往项目里引第三方包的代价,很多人会低估。首先是体积和依赖链问题:一个只为了读版本号的包,往往还会拉进来日志库、序列化库、生命周期辅助类等一系列依赖,最后为了一个字符串存进一堆程序集。其次是 UWP 特有的问题:UWP 在发布时可以选择 .NET Native 编译,这个编译过程对反射和动态代码的支持比较严格,某些第三方包用反射解析类型或读取资源,打包后可能直接抛 MissingMetadata 异常。

更隐蔽的坑在系统更新之后。版本信息的解析逻辑一旦写进第三方包,就跟着那个包的维护节奏走。系统升级后 Build 号变了,如果包作者没有及时更新解析规则,你的功能开关就会“穿越”。而系统内置 API 不一样,它们是平台自己维护的,系统更新时反而会保证这些入口始终返回正确数据。

还有一点很多人没意识到:那些第三方包本质上就是对你接下来要看的这些内置 API 做了一层薄封装,甚至有的为了拿到真实版本号直接去 P/Invoke 一些非公开接口。为了一层薄封装去承担额外的维护风险和商店审核风险,怎么算都不划算。所以这个需求的正解,就是直接用系统内置的三个命名空间。

2. 三个内置入口逐个拆解

在开始封装之前,先把每个入口的定位和用法讲清楚。这三个入口分别解决三类问题:Windows.System.Profile拿设备信息,Windows.ApplicationModel拿应用包信息,Windows.Foundation.Metadata拿能力检测信息。它们都是 UWP 框架的基础组成部分,项目创建出来就自带,不需要引用任何额外的程序集。

2.1 系统版本:AnalyticsInfo 的 DeviceFamilyVersion 与位运算解析

真正的系统版本藏在Windows.System.Profile.AnalyticsInfo.VersionInfo.DeviceFamilyVersion里,但这个属性有个很别扭的设计:它返回的不是“10.0.19041.0”这样的直观字符串,而是一个编码后的超长整数。我第一次用的时候也愣了一下,以为拿错了。

示例代码如下:

using Windows.System.Profile; string raw = AnalyticsInfo.VersionInfo.DeviceFamilyVersion; ulong value = ulong.Parse(raw); int major = (int)((value >> 48) & 0xFFFF); int minor = (int)((value >> 32) & 0xFFFF); int build = (int)((value >> 16) & 0xFFFF); int revision = (int)(value & 0xFFFF); var osVersion = new Version(major, minor, build, revision);

解析原理其实很简单:这个属性返回的数值是一个 64 位整数,里面塞了四个 16 位字段。从左到右依次是主版本、次版本、Build 号、修订号。以 Windows 10 2004 为例,主版本是 10(0x000A),次版本是 0,Build 是 19041(0x4A61),修订号是 0,拼出来的十六进制就是0x000A00004A610000。

字段右移位数掩码说明
Major480xFFFF主版本号,Windows 10 为 10
Minor320xFFFF次版本号,通常为 0
Build160xFFFF编译号,如 19041
Revision00xFFFF修订号,一般补丁版本用

这就是为什么解析时优先用ulong而不是int——四个 16 位字段拼出来的 64 位数值很容易超过int的范围,直接int.Parse会在运行时溢出。另外要注意,这个属性返回的是一段纯数字字符串,不是带点号的版本串,不能直接拿new Version(raw)去构造。

2.2 设备家族:DeviceFamily 能告诉你什么

和版本号同在一个VersionInfo类里的还有DeviceFamily属性,它返回的是设备家族标识,典型值是Windows.Desktop。在老版本系统上还有Windows.Mobile、Windows.Xbox、Windows.IoT这些值,分别对应手机、游戏主机和物联网设备。

当初 Windows 10 Mobile 还活着的时候,开发者经常用它区分手机和桌面,因为两边的 UI 布局和功能差异很大。现在这个属性在日常业务里的存在感越来越低,因为绝大多数设备都会返回Windows.Desktop,包括各种平板和翻转本,它们的系统内核本身就是桌面版。不过如果你在做一个跨设备的通用应用,或者要兼容老设备,还是可以在初始化时顺手把这个值记录下来,至少写日志的时候能分辨出用户是在什么形态的设备上运行的。

string family = AnalyticsInfo.VersionInfo.DeviceFamily;

这段代码在什么时候需要,取决于你项目支持的设备范围。如果你是纯桌面 UWP 应用,这一步可以省略;如果你要兼容手机或者 Xbox,则建议保留。

2.3 应用包版本:Package.Current.Id.Version 的正确用法

获取应用包版本比获取系统版本直观得多,走Windows.ApplicationModel.Package就行:

using Windows.ApplicationModel; var packageVersion = Package.Current.Id.Version; string appVersion = $"{packageVersion.Major}.{packageVersion.Minor}.{packageVersion.Build}.{packageVersion.Revision}";

这个结构体里的 Major、Minor、Build、Revision 都是 16 位无符号整数,直接拼接输出就是你在商店里展示给用户看的版本号。它的值来自打包配置,和系统版本毫无关系,别指望它反映用户的系统环境。

这里有个务实的小建议:Package.Current在完整应用上下文里一定能用,但在设计器、单元测试宿主或某些特殊进程中可能会出现异常。如果你把版本号用在一个被多处调用的公共方法里,最好在外层包一层 try/catch,避免一个无关紧要的日志功能把启动流程带崩。

2.4 能力检测:ApiInformation 是比版本号更稳的尺子

很多时候你想要版本号,其实真正关心的是“这台设备支不支持某个 API”。这种情况下直接判断 Build 号不是最优解,正确的姿势是用Windows.Foundation.Metadata.ApiInformation做能力检测。UWP 把 API 按“契约(Contract)”分组,每个契约有一个名字和一个主版本号,系统支持到哪个契约版本,就代表它承诺提供哪些 API。

最常用的判断方式是检查契约版本:

using Windows.Foundation.Metadata; bool supportsContractV8 = ApiInformation.IsApiContractPresent( "Windows.Foundation.UniversalApiContract", 8);

除了契约检查,还有几个更细粒度的判断方法。IsTypePresent用来判断某个类型是否存在,IsMethodPresent用来判断某个类里有没有某个方法,IsEventPresent和IsEnumNamedValuePresent也可以按需使用。举例来说,你想判断当前环境能不能使用某个控制器的 API,可以这样写:

bool hasGpio = ApiInformation.IsTypePresent("Windows.Devices.Gpio.GpioController");

为什么说这比判断 Build 号更稳?因为 Build 号是产品版本,契约号是能力版本,两者虽然有关联但并不是严格一一对应。同一个 Build 在专业版、企业版、教育版上表现一致,但不同 Build 之间,契约号的变化才是你真正要关心的“能力边界”。我在实际项目中见过有人用Build >= 18362判断功能,结果 1903 和 1909 的契约版本都是 8,有些能力表现却不完全一样,用契约判断反而不会出岔子。

下面是一张常见系统的契约版本速查表,写代码时可以做个参考:

Windows 10 版本BuildUniversalApiContract
1507102401
1511105862
1607143933
1703150634
1709162995
1803171346
1809177637
1903183628
1909183638
2004190419

后面还陆续有更新,具体以官方契约文档为准。这张表我建议只拿来快速估算,不要写死在业务逻辑里,真要做功能开关,直接调IsApiContractPresent才是标准做法。

3. 实操:封装一个零依赖的 VersionHelper

前面把每个入口都拆开了,接下来把它们整合成一个可以直接复用的静态类。这样项目里所有需要版本信息的地方都走同一个入口,以后系统再升级、代码再维护,只需要改一个文件。

3.1 完整代码,可直接复制进项目

using System; using Windows.ApplicationModel; using Windows.Foundation.Metadata; using Windows.System.Profile; namespace YourApp.Helpers { public static class VersionHelper { private static Version _osVersion; private static bool _osVersionLoaded; private static string _deviceFamily; public static Version OsVersion { get { if (!_osVersionLoaded) { string raw = AnalyticsInfo.VersionInfo.DeviceFamilyVersion; ulong value = ulong.Parse(raw); int major = (int)((value >> 48) & 0xFFFF); int minor = (int)((value >> 32) & 0xFFFF); int build = (int)((value >> 16) & 0xFFFF); int revision = (int)(value & 0xFFFF); _osVersion = new Version(major, minor, build, revision); _osVersionLoaded = true; } return _osVersion; } } public static string DeviceFamily { get { if (_deviceFamily == null) { _deviceFamily = AnalyticsInfo.VersionInfo.DeviceFamily; } return _deviceFamily; } } public static Version AppVersion { get { var packageVersion = Package.Current.Id.Version; return new Version(packageVersion.Major, packageVersion.Minor, packageVersion.Build, packageVersion.Revision); } } public static bool HasUniversalApiContract(ushort majorVersion) { return ApiInformation.IsApiContractPresent( "Windows.Foundation.UniversalApiContract", majorVersion); } } }

这段代码有几个刻意的设计。第一,所有解析结果都做了缓存,版本号在进程生命周期内不会变,没必要每次调用都重新解析一遍字符串和位运算。第二,OsVersion使用懒加载,只有真正被调用时才执行解析,避免在应用一启动就做一些不必要的计算。第三,HasUniversalApiContract把最常用的契约判断抽成一个方法,业务代码里调用时语义清晰,别人读代码一眼就能看懂。

3.2 在自己的页面里怎么用

接进项目很简单,把上面这个类放到 Helpers 目录下,然后在需要的地方直接调用。比如我想在设置页面的“关于”栏显示版本信息:

System.Diagnostics.Debug.WriteLine($"OS : {VersionHelper.OsVersion}"); System.Diagnostics.Debug.WriteLine($"Family : {VersionHelper.DeviceFamily}"); System.Diagnostics.Debug.WriteLine($"App : {VersionHelper.AppVersion}"); System.Diagnostics.Debug.WriteLine($"Contract >= 8: {VersionHelper.HasUniversalApiContract(8)}");

再比如你想做一个功能开关,只在支持某个能力的设备上展示某个按钮,正确姿势是判断契约而不是判断 Build:

if (VersionHelper.HasUniversalApiContract(8)) { // 展示新功能入口 } else { // 使用旧布局 }

把这段逻辑放在页面加载或 ViewModel 初始化里都行,静态类没有异步操作,也没有线程问题,不用像调用某些系统服务那样担心必须在 UI 线程上执行。

3.3 几个值得留意的工程细节

封装类虽然小,但有几个细节值得留意。第一,Version是 .NET 自带的类,支持ToString()直接输出“主版本.次版本.Build.修订号”,不需要自己拼字符串。第二,Package.Current在部分测试框架里会抛异常,如果这个 Helper 被日志系统调用,建议给AppVersion也加上异常保护,返回一个默认版本号而不是让调用方崩掉。第三,不要把这个类放在和Windows命名空间冲突的位置,避免写代码时出现歧义。我用过最常见的命名是VersionHelper,放在自己的应用命名空间下,和系统类型完全隔离。

还有一个容易被忽略的点:这个类不需要做成实例类,也不要为它单独初始化。静态属性天然适合“进程内全局唯一且不变”的信息,如果搞成单例或者依赖注入,反而是过度设计。

4. 实战中踩过的坑与排查清单

版本信息这些 API 本身不难,难的是周边那些看起来更“简单”的替代方案。下面这几条坑,每一个我都见过真实项目往里跳,列出来给你避雷。

4.1 Environment.OSVersion:看着最直接,实际最坑

最容易被带偏的写法是直接用Environment.OSVersion。这个属性在 .NET Framework 的经典桌面写法里很自然,放到 UWP 里就变了味。UWP 应用默认运行在系统兼容层之上,很多经典 API 拿到的版本号并不是设备真实 Build,而是兼容层报告出来的值。不同系统版本、不同应用目标版本下,这个返回值可能完全不同,甚至可能固定返回一个初始版本号。

我见过有人拿Environment.OSVersion的 Build 号做功能开关,结果某次系统更新之后,开关逻辑直接“穿越”,要么新系统上走了老分支,要么老系统上误进了新分支。这类问题最麻烦的地方是它不报错,日志里也是正常的版本号,排查起来非常困难。所以结论很简单:在 UWP 里,版本判断一律走AnalyticsInfo,别用这个属性。

4.2 别绕道 P/Invoke,那只是个看起来高级的捷径

还有一部分开发者会从老 Win32 代码里搬来 P/Invoke 方案,比如调用GetVersionEx或者RtlGetVersion。先说GetVersionEx,这个老接口本身就有一套兼容性策略,在清单里没有显式声明支持的系统版本时,它返回的就是一个“伪装版本号”,和Environment.OSVersion一样不可靠。RtlGetVersion虽然能拿到真实版本,但它是靠系统内部接口绕路,并不是官方推荐的公共 API,在 UWP 沙箱环境下能不能稳定调用完全看运气。

而且这两个方案有一个共同的问题:它们拿到的版本号是 Win32 视角的版本,不是 UWP 契约视角的能力,拿来做功能开关依然会踩到 4.4 说的坑。所以我建议彻底放弃这个方向,不要把 P/Invoke 版本写进新代码。官方给AnalyticsInfo就是为了让 UWP 应用体面地拿版本,学一下位运算解析比绕路靠谱得多。

4.3 字符串解析的格式兼容处理

虽然正常情况下DeviceFamilyVersion返回的是纯数字串,但为了稳妥,我在实际项目里还是会做一层兼容,防止在模拟器、老版本系统或者特殊设备上拿到意外格式。给你一个更健壮的解析函数:

private static Version ParseFamilyVersion(string raw) { if (string.IsNullOrEmpty(raw)) { return new Version(0, 0, 0, 0); } // 标准情况:64 位整数编码 if (raw.IndexOf('.') < 0 && ulong.TryParse(raw, out ulong value)) { int major = (int)((value >> 48) & 0xFFFF); int minor = (int)((value >> 32) & 0xFFFF); int build = (int)((value >> 16) & 0xFFFF); int revision = (int)(value & 0xFFFF); return new Version(major, minor, build, revision); } // 兜底:点分格式,按字符串拆 string[] parts = raw.Split('.'); if (parts.Length >= 3 && int.TryParse(parts[0], out int v1) && int.TryParse(parts[1], out int v2) && int.TryParse(parts[2], out int v3)) { int v4 = 0; if (parts.Length >= 4) { int.TryParse(parts[3], out v4); } return new Version(v1, v2, v3, v4); } return new Version(0, 0, 0, 0); }

这里先把标准格式解析掉,遇到点分格式再做字符串兜底,实在解析不了就返回全零版本号。这样即使某个设备的环境比较特殊,最多是日志里显示一个 0.0.0.0,不会因为Parse异常把整个应用带崩。

4.4 版本号一致并不代表能力一致

这是版本判断里最隐蔽的逻辑错误。Build 号只是产品版本,它和 API 能力并不严格一一对应。比如 Windows 10 1903 和 1909,Build 分别是 18362 和 18363,两者非常接近,契约版本也都是 8,但严格来说并不代表它们暴露的能力集合完全一致。反过来,同一个 Windows 10 2004 设备上,应用包版本不同,能够调用的 API 也可能因为契约版本而不同。

所以我的经验法则很简单:只要你是在判断“能不能用某个 API”,一律用ApiInformation检查契约或类型,不要自己去给 Build 号建立能力映射表。版本号留给日志、统计、关于页面这种给人看的场景。

4.5 常见问题速查表

现象可能原因处理方式
拿到的版本号一直不变用了Environment.OSVersion或GetVersionEx,命中兼容层逻辑改用AnalyticsInfo.VersionInfo.DeviceFamilyVersion
ulong.Parse抛出格式异常返回值混入非数字字符,或环境返回了点分格式先TryParse,再按点分格式拆字符串兜底
用 Build 号做功能开关后失效把产品版本当成了能力清单改用契约检测或 API 类型检测
Package.Current在测试环境抛异常该 API 只在完整应用包上下文可用加异常保护,返回默认版本号
模拟器上报版本和宿主机不一致模拟器自带独立的系统镜像以模拟器镜像报告的版本为准,别拿宿主机版本对照

这张表基本覆盖了我日常答疑时遇到的高频问题。前两行是坑,后三行是边界条件,都处理好了,版本信息这块就算稳了。

5. 我的一点使用体会

最后聊几句实际体会。我早期写 UWP 时也犯过“为了拿版本号引包”的错,一个本来几行代码就能解决的问题,最后变成了项目里一个需要定期跟进升级的第三方依赖。后来把版本信息全部收敛到内置 API 之后,代码量反而少了,排查问题也更直观——不会再有“这个版本号到底是包解析的还是系统告诉我的”这种疑问。

如果你现在正要写这个功能,我建议把规则定成这样:凡是判断能力,一律用ApiInformation契约检测;凡是显示版本号给用户或写日志,用AnalyticsInfo解析出来的系统版本;凡是打包上架维护应用自身版本,用Package.Current.Id.Version。三个入口各司其职,互不混淆。这样几年后回头看,你会庆幸当初没有为了这个几行代码的小需求,给项目塞进去一个可有可无的 NuGet 包。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询