C#获取Windows已安装软件清单:注册表、MSI与AppX三路采集实战 做软件资产管理、给客户做机器盘点、或者是自己写一个装机巡检工具最后都要落到同一个问题上怎么用C#把那台Windows上到底装了哪些软件给整明白。这个需求听着简单做起来坑不少——打开“应用和功能”面板你看到的是一回事用代码挨个遍历注册表拿到的是另一回事二者对不上号的情况时有发生。这篇文章我把实际项目里验证过的一套完整方案拆开讲清楚包含注册表读取、MSI枚举、AppX/UWP补充、去重合并和导出照着做基本能拿到一份干净、可用的已安装应用清单。有段时间我一直在维护一个公司内部的客户端巡检工具采集范围里有一项就是“本机安装的应用列表”数据要落到数据库里做资产统计。最初版本只读了注册表结果丢了一堆AppX应用还混进去了大量Windows更新补丁被后端的同事点名吐槽过好几轮。后来反复调把数据源拆成注册表、Windows Installer接口和包管理器三路再做合并和过滤才终于达到可用的程度。这篇文章算是把这条路上的经验和踩过的坑都记下来了。1. 方案选型为什么“注册表一条路”不够要多路采集1.1 获取应用清单的常见技术路线对比Windows平台上能拿到已安装应用信息的途径其实不少但每一条都有自己的脾气和适用范围。我常用的方案有四个方向先把它们的优劣势和适用场景摆出来对比一下。先说注册表法。大多数Win32软件的卸载信息都写在注册表里路径集中在HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall以及对应的WOW6432Node节点下用户级安装还会写到HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Uninstall。这个方法最直接大部分传统桌面软件都能覆盖读取速度也快。但它的缺点同样明显一部分较新的应用尤其是从商店或MSIX包安装的不在这里登记而且需要自己做过滤逻辑否则会混进大量系统更新和组件。然后是Windows Installer API也就是通过MsiEnumProducts这类接口来枚举通过MSI安装包安装的产品。这个数据源自带产品名称、版本、发布者等信息比较规范适合补全那些在注册表Uninstall节点里不够完整的记录。不过它只覆盖MSI安装的产品对绿色软件和AppX包无能为力。第三条路线是枚举AppX/UWP应用包用Windows.Management.Deployment.PackageManager可以拿到现代应用、商店应用以及系统自带应用的列表。这部分在Windows 10/11上尤其重要否则像“照片”“计算器”“Microsoft Store”这类应用会从清单里完全消失。还有一条路是WMI的Win32_Product类我建议你直接绕开它。这个API虽然能列出很多信息但调用时会对每个已安装产品触发重新配置检查慢不说有时还会把系统搞出问题。我亲眼见过有运维脚本跑完之后客户那边的一些程序启动变得极慢就是它惹的祸。数据源覆盖范围稳定性性能建议注册表 Uninstall 节点传统Win32软件、用户级安装高但噪音较多快主力方案Windows Installer APIMSI安装的产品高数据规范中等作为补充PackageManagerAppX/UWP应用高但需处理系统包中等必须补充Win32_Product名义上覆盖很全低会触发配置检查极慢不推荐1.2 我的选型结论与整体架构最终我在生产环境里采用的方案是“注册表为主、MSI和AppX补充”的三路采集架构。注册表负责广度保证绝大多数传统软件都能被捞到MSI接口负责规范化把登记的卸载信息修正补全PackageManager负责现代应用部分避免遗漏商店应用和系统预装应用。三路数据拿回来之后再统一做过滤、去重、排序最后落成一份结构化列表。这样做的好处是覆盖面足够广无论是给内部资产管理用还是给客户的“卸载清理工具”做数据源都不会说缺三少四。代价是代码量会多一些但整个采集过程一般都是离线遍历不涉及网络和权限升级跑一遍通常在一秒以内完全在可接受范围内。2. 注册表方案最直接但最容易被坑的路子2.1 先把注册表结构吃透为什么有两个Uninstall节点在写代码之前我觉得有必要先搞清楚注册表里这些节点的结构不然你根本不知道为什么同一段代码在这台机器上正常换一台机器就漏了一大半。在64位Windows系统上Uninstall信息存在两个物理位置一个在HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall存放的是64位应用和系统组件的卸载信息另一个在HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall存的是32位应用的卸载信息。这是WOW64机制造成的物理隔离目的是让32位程序读写注册表时自动重定向到WOW6432Node下避免应用错误地覆盖64位程序的配置。这里要特别注意很多人在代码里硬编码这两个路径这有隐患。如果你的程序编译成x86架构那么在64位系统上访问HKLM\SOFTWARE\...\Uninstall时系统会自动重定向到HKLM\SOFTWARE\WOW6432Node\...\Uninstall你再去访问第二个硬编码路径相当于访问了一个根本不存在的WOW6432Node\WOW6432Node路径。结果就是32位软件读了两遍64位软件一个也没读到还容易查半天查不出原因。我的做法是用RegistryView来显式指定视图。从RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view)打开基键之后代码里的注册表路径保持一致不需要关心WOW6432Node的存在系统会自动映射到正确的物理位置。这样无论程序编译成x86、x64还是AnyCPU结果都是准确的。2.2 读取注册表Uninstall节点的完整C#代码下面是一段可以直接拿去用的读取代码我用的是 .NET 8但这段逻辑在 .NET 6 或者 .NET Framework 4.7.2 上同样能跑只是目标框架不同而已。using Microsoft.Win32; public sealed class InstalledAppInfo { public string DisplayName { get; set; } public string DisplayVersion { get; set; } public string Publisher { get; set; } public string InstallLocation { get; set; } public string InstallDate { get; set; } public string UninstallString { get; set; } public string Source { get; set; } } public static class RegistryAppReader { public static ListInstalledAppInfo GetAppsFromRegistry() { var result new ListInstalledAppInfo(); RegistryView[] views Environment.Is64BitOperatingSystem ? new[] { RegistryView.Registry64, RegistryView.Registry32 } : new[] { RegistryView.Registry32 }; foreach (var view in views) { using var baseKey RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view); using var uninstallKey baseKey.OpenSubKey(SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall); if (uninstallKey null) continue; foreach (var subKeyName in uninstallKey.GetSubKeyNames()) { using var appKey uninstallKey.OpenSubKey(subKeyName); if (appKey null) continue; var displayName appKey.GetValue(DisplayName) as string; if (string.IsNullOrWhiteSpace(displayName)) continue; result.Add(new InstalledAppInfo { DisplayName displayName.Trim(), DisplayVersion appKey.GetValue(DisplayVersion) as string, Publisher appKey.GetValue(Publisher) as string, InstallLocation appKey.GetValue(InstallLocation) as string, InstallDate appKey.GetValue(InstallDate) as string, UninstallString appKey.GetValue(UninstallString) as string, Source $Registry:{view} }); } } return result; } }注意看我用Environment.Is64BitOperatingSystem做了平台判断然后在32位系统上只用RegistryView.Registry32。这是因为在32位系统上请求64位视图会直接抛异常而这个判断可以把运行环境兼容问题一次性解决。2.3 过滤系统组件和空DisplayName别让清单变垃圾堆如果你的程序只是自己调试用读取注册表并输出几条记录是很容易的但一旦用于生产你马上会发现两个大问题。第一很多Windows更新补丁、驱动、系统组件也注册在Uninstall节点下它们的DisplayName往往以KB开头你肯定不想让它们混进“已安装软件”列表。第二有些卸载项的DisplayName是空的很可能是卸载残留或异常安装这些也应该被跳过。我的过滤策略是优先处理三个关键标记。第一个是SystemComponent值如果它等于1说明这是一个系统组件而不是用户能理解的常规应用。第二个是ParentKeyName如果它不为空通常是某些大软件内部更新的子条目比如Office或Visual Studio的授权更新这类记录会影响清单的干净程度。第三个是ReleaseType当它等于Security Update、Update Rollup或Hotfix时基本可以确定是补丁类条目直接丢掉。不过也提醒一句过滤规则不要写太死不同的业务场景对“干净”的定义不一样。比如你在做IT资产盘点可能确实需要把补丁也纳入统计。所以我一般会把过滤逻辑抽成一个谓词函数做成可配置策略方便在不同项目里复用。public static bool ShouldSkip(RegistryKey appKey, string displayName) { if (string.IsNullOrWhiteSpace(displayName)) return true; var systemComponent appKey.GetValue(SystemComponent) as int?; if (systemComponent 1) return true; if (!string.IsNullOrEmpty(appKey.GetValue(ParentKeyName) as string)) return true; var releaseType appKey.GetValue(ReleaseType) as string; if (releaseType is Security Update or Update Rollup or Hotfix) return true; return false; }2.4 别忘了HKCU用户级安装软件很容易被漏掉除了HKLM还有一类软件的卸载信息只写在当前用户注册表下路径是HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Uninstall。这类软件通常不需要管理员权限就能安装比如一些绿色工具、用户空间的Chrome、部分企业软件的按用户安装组件。如果你的程序是以当前登录用户身份运行的建议把这个节点也纳入读取范围。读取HKCU时同样注意视图问题最稳的写法还是通过RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, view)而不是直接Registry.CurrentUser.OpenSubKey。否则在64位系统上32位进程读到的还是重定向后的视图两个视图的数据永远凑不齐。3. 补全另外两路数据源才能叫“完整清单”3.1 用Windows Installer API枚举MSI产品注册表方案虽然覆盖面广但它依赖软件厂商在注册表里写卸载信息。有些MSI安装包的卸载项在注册表里只有GUIDDisplayName字段是空的或者卸载字符串指向MsiExec.exe /I{GUID}靠注册表枚举很难拼出完整的信息。这时候Windows Installer API就是很好的补充。核心是两个API函数MsiEnumProducts用来遍历所有已安装的MSI产品MsiGetProductInfo用来查询每个产品的属性比如产品名、版本、发布者、安装目录等。通过P/Invoke调用即可不需要额外引入依赖。using System.Runtime.InteropServices; using System.Text; public static class MsiAppReader { [DllImport(msi.dll, CharSet CharSet.Unicode)] private static extern int MsiEnumProducts(uint productIndex, StringBuilder productCode); [DllImport(msi.dll, CharSet CharSet.Unicode)] private static extern uint MsiGetProductInfo( string productCode, string property, StringBuilder valueBuffer, ref uint bufferSize); private const int ERROR_SUCCESS 0; private const int ERROR_NO_MORE_ITEMS 259; public static ListInstalledAppInfo GetAppsFromMsi() { var result new ListInstalledAppInfo(); uint index 0; var productCodeBuffer new StringBuilder(39); while (MsiEnumProducts(index, productCodeBuffer) ERROR_SUCCESS) { string productCode productCodeBuffer.ToString(); var name GetProductProperty(productCode, ProductName); if (string.IsNullOrWhiteSpace(name)) { index; continue; } result.Add(new InstalledAppInfo { DisplayName name, DisplayVersion GetProductProperty(productCode, VersionString), Publisher GetProductProperty(productCode, Publisher), InstallLocation GetProductProperty(productCode, InstallLocation), Source MSI }); index; } return result; } private static string GetProductProperty(string productCode, string property) { uint size 0; MsiGetProductInfo(productCode, property, null, ref size); var buffer new StringBuilder((int)size 1); uint ret MsiGetProductInfo(productCode, property, buffer, ref size); return ret ERROR_SUCCESS ? buffer.ToString() : string.Empty; } }这里有个细节容易忽略调用MsiGetProductInfo时第一次用null作为字符缓冲区函数会把需要的缓冲区大小写入size参数然后你再次调用传入足够大的StringBuilder。如果像读取固定长度那样一次性传256字节的缓冲区某些产品的长字符串会被截断信息就不完整了。还有一个心理预期要提前建立MSI枚举出来的产品和注册表里读到的结果是会高度重合的因为MSI产品在安装时本来就会在Uninstall节点写卸载信息。但重合并不代表可以省掉这一路——部分MSI写注册表时漏掉了DisplayName或者被用户手动改过通过MSI接口能拿回最原始、最规范的数据两路一交叉能解决很多“注册表里显示一堆乱码GUID”的问题。3.2 用PackageManager枚举AppX/UWP应用到了Windows 10/11时代现代应用都是通过AppX/MSIX包分发的它们根本不往传统的Uninstall注册表节点里写信息。如果只用注册表方案你会发现在“应用和功能”面板里明明有一堆应用代码输出却什么都没有。正确做法是使用Windows.Management.Deployment.PackageManager类。这个类在 .NET 5 中需要引用Microsoft.Windows.SDK.ContractsNuGet包才能使用目标框架是Windows时才可用.NET Framework则可以直接引用System.Runtime.WindowsRuntime。下面是一段在Windows平台上运行的代码。using Windows.Management.Deployment; public static ListInstalledAppInfo GetAppsFromAppx() { var result new ListInstalledAppInfo(); try { var packageManager new PackageManager(); var packages packageManager.FindPackagesForUser(); foreach (var package in packages) { // 跳过框架包、资源包和开发模式部署的包这些不是用户能感知的“应用” if (package.IsFramework) continue; if (package.IsBundle) continue; var displayName string.IsNullOrWhiteSpace(package.DisplayName) ? package.Id.Name : package.DisplayName; result.Add(new InstalledAppInfo { DisplayName displayName, DisplayVersion package.Id.Version.ToString(), Publisher package.PublisherDisplayName ?? package.Id.Publisher, InstallLocation package.InstallLocation, Source AppX }); } } catch (Exception ex) { // 处理权限不足、平台不支持等情况 // 建议在这里接入日志系统不要静默吞掉 } return result; }这里要提醒一个问题PackageManager.FindPackagesForUser()的空字符串参数表示“当前登录用户”它拿到的包列表是跟着用户走的不同登录用户看到的结果可能不一样。如果你的程序以SYSTEM权限运行可能需要显式传入目标用户SID才能枚举到该用户上下文里安装的AppX包这一块要根据你的实际巡检场景决定怎么处理。AppX包还有一个特性是里面混着大量系统组件比如framework包、系统驱动包、windows.immersivecontrolpanel这类底层组件。IsFramework属性可以过滤掉框架包IsBundle可以过滤掉依赖包但一些系统预装应用仍然会出现在结果里。我的建议是不要做绝对过滤而是给每条记录打上Source标记让使用方自行决定哪些保留、哪些丢弃。4. 多路合并、去重与数据验证4.1 合并多数据源的正确姿势三路数据都拿回来之后摆在面前的新问题是如何合并且不产生大量重复。经典的重复情形有几种同一个64位应用注册表和MSI各返回一条同一个商店应用AppX里有一条注册表里还有一条同一个产品重复注册了多个子项比如“Microsoft Edge”可能同时出现在{GUID}子键和 “Microsoft Edge” 子键下面。我的处理策略是先按“DisplayName Publisher”做分组去重然后把来源优先级排好。注册表数据最全保留它MSI数据作为注册表缺失字段的补充如果分组里同时有注册表和MSI两条以注册表为主用MSI填充注册表里为空的字段如果只有AppX一条就完整保留AppX那一条。排序方面我习惯按DisplayName不区分大小写排序方便人眼扫读。去重代码不用写特别复杂一个字典加一个合并方法就够了。关键是理解为什么要以注册表为主它能提供的字段最丰富包括InstallDate、UninstallString、EstimatedSize等业务上很关心的信息。MSI接口能拿到的属性相对有限AppX虽然字段不少但应用卸载入口跟Win32传统方式完全不同混在同一个“UninstallString”字段里没有意义。4.2 和系统“应用和功能”面板对比验证我每次改完采集逻辑都会做一次交叉验证拿我自己采集到的清单去和系统设置里的“应用和功能”面板做逐项对比。Windows的这个面板本身也是多个来源拼出来的对比之后能发现不少问题。对比时我会重点关注三个维度。第一是数量差如果面板里明显有几十个应用我这边却只有十几个那基本就是AppX或HKCU漏了。第二是特征项比如某一行只有GUID没有名字那通常意味着DisplayName为空或过滤掉了不该过滤的条目。第三是版本号有些软件会在注册表里写DisplayVersion但MSI接口返回的VersionString和它不一样需要决定业务上以哪个为准。比较稳妥的办法是输出成CSV文件在Excel里和面板截图做标注对比。虽然听起来土但这是最直观、最不容易漏项的方法。4.3 导出为CSV或JSON方便后续对接采集到的数据最终是要交给下游系统用的我一般同时输出两种格式CSV给人看、JSON给程序对接。CSV注意用带BOM的UTF-8编码否则Excel打开会乱码JSON用System.Text.Json序列化即可不需要引入第三方依赖。输出时建议把“数据源”字段一起带上方便你事后排查某条记录是从哪里来的。var json System.Text.Json.JsonSerializer.Serialize(appList, new System.Text.Json.JsonSerializerOptions { WriteIndented true }); File.WriteAllText(installed_apps.json, json, System.Text.Encoding.UTF8);如果你想输出成Excel用我自己常用的方式是生成CSV指定分隔符为逗号字段顺序固定为“DisplayName, Publisher, DisplayVersion, InstallDate, InstallLocation, Source”。随后在Excel里做透视表按Publisher统计软件数量非常顺手。5. 避坑经验与常见问题速查5.1 我实际踩过的几个坑第一个坑是32位程序读注册表被重定向。当时程序编译成x86硬编码了WOW6432Node路径结果在公司巡检机上跑出来一片空白而我自己开发机是x64一直没问题。排查了半天才意识到是WOW64重定向在捣鬼。从那以后凡是涉及注册表遍历的代码我都用RegistryView显式指定视图再没出过事。第二个坑是Win32_Product。早期版本我图省事直接用WMI查询Win32_Product因为MSDN上说它能拿到所有已安装产品。结果第一次在客户环境跑完客户反馈说所有软件都变慢了后来一查发现就是这个类引起的连锁反应。微软官方其实也不推荐在生产环境用这个类做查询大家看到网上的老博客推荐这条路的话多留个心眼。第三个坑是AppX的DisplayName不是总能拿到。许多系统预装应用在PackageManager里的DisplayName是空的要用Package.Id.Name兜底否则会出现一批没有名字的记录。我当时没有兜底处理导出的JSON里一堆Package对象是空名字下游JavaScript报表直接渲染成空白行费了不少劲才定位到是这里。第四个坑是过滤规则把正常软件误杀了。早期我把SystemComponent 1和ReleaseType的过滤写得很激进结果把一些老牌企业软件过滤掉了因为它们在注册表里确实标记成了SystemComponent。后来我改成只过滤“DisplayName以KB开头且ReleaseType是Security Update”这种强特征条目误杀率才降下来。所以我的建议是过滤规则一定要做在白名单和黑名单之间取平衡并留可以关闭过滤的开关。第五个坑是HKCU视图问题。某次领导让我统计同一台机器上两个登录用户的软件差异我只读了当前用户HKCU另一个用户这边完全没数据。后来才意识到HKCU本身就是按用户隔离的要在不切换用户的情况下读其他用户的HKCU得加载对应用户的注册表hive文件这个操作复杂多了。对于一般巡检场景我建议明确“本清单仅反映当前登录用户视角”不要假装能做到全量多用户覆盖。5.2 常见问题排查表症状可能原因解决办法清单里缺少商店应用没有采集AppX数据源引入 PackageManager 枚举清单里出现大量KB开头补丁没有过滤系统更新按 ReleaseType 或 SystemComponent 过滤32位程序读64位系统漏软件未考虑WOW64重定向使用 RegistryView 显式区分32/64视图某些记录DisplayName为空注册表项写得不规范用MSI接口交叉补全清单出现大量重复多数据源未去重按 DisplayNamePublisher 分组去重调用WMI查询特别慢使用了 Win32_Product弃用改用注册表MSIAppXAppX枚举抛权限异常目标用户上下文不对传入目标用户SID或提升权限运行5.3 关于性能表现我拿一台安装了200多个应用、一堆AppX包的Windows 11测试机跑过完整三路采集整个流程大概在700到900毫秒之间其中PackageManager占了大部分时间注册表遍历和MSI枚举都很快。如果业务对性能有更高要求可以把AppX枚举放到后台线程避免阻塞UI线程如果应用数量特别多注册表遍历也建议用Parallel.ForEach多线程处理但要注意注册表键的打开和释放必须用using包裹否则会句柄泄漏。这段代码还有一种扩展用途如果你想给一个离线环境做装机镜像审核可以把它编译成一个小命令行工具加上--json、--csv参数直接扔到目标机器上跑拿回结果做分析非常方便。后续还可以加上Hash校验、按Publisher分组统计等功能按需调整即可。我个人在实际项目里这套采集逻辑稳定跑了一年多前后服务了数千台终端的软件盘点没有再出现过大的数据源断层。最值钱的经验无非是三条多数据源交叉验证、视图重定向别踩坑、过滤规则可配置。照着这个思路做你也能写出一份顶用的已安装应用清单工具。