引用本文(DOI: 10.5281/zenodo.21615516)
本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。
小村 豪(2026)。《Windows 应用的单文件分发 - 单一可执行文件与系统依赖的界限》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615516 https://comcomponent.com/zh-CN/blog/2026/03/19/003-windows-single-binary-and-os-dependencies/
- DOI(最新版本)
- 10.5281/zenodo.21615516
- DOI(此版本)
- 10.5281/zenodo.22282037
这篇文章源自一场闲聊,话题是“在 Windows 上,到底能把哪些情况称为单一可执行文件”。引出这个话题的帖子如下。即使跳过这一段,后面的内容也可以单独阅读。
在 Windows 上,“如果可以,希望只用一个文件分发”是相当普遍的需求。对于内部工具、设备联动工具、监控端、离线环境,以及尽量想避开安装程序的现场,做成单一可执行文件都很有吸引力。
不过,这个话题如果不先分清楚,讨论多半会在中途就对不上。因为在 Windows 上说“想做成单一二进制文件”时,其实很容易混进 4 种不同的需求。
- 想让发布物只有 1 个
- 想不用事先安装 .NET 或 Visual C++ 的运行时
- 想不用安装程序、不需要管理员权限,放着就能跑
- 想不受目标 Windows 差异的影响
这 4 件事并不相同。从实务角度看,这样理解最不容易跑偏。
把发布物收拢成 1 个 EXE,是相当可行的。 但要把对目标 Windows 的依赖降到零,是做不到的。
本文就从 Windows 应用实务的角度,把这条界线梳理清楚。
flowchart TB
accTitle: 即使做成1个EXE也无法消除系统依赖
accDescr: 想做成单一可执行文件这一需求中,容易混杂分发层面的诉求和依赖层面的诉求;把发布物收拢成1个EXE相当可行,但把对目标Windows的依赖降到零做不到。
a0["“想做成单一可执行文件”"] --> a1["分发层面:想只有1个、想放着就能跑"]
a0 --> a2["依赖层面:不需要运行时、不依赖系统"]
a1 --> a3["收拢成1个EXE相当可行"]
a2 --> a4["系统依赖无法降到零"]
图1:“想只用一个文件分发”里,混杂着能做到的事和做不到的事。
1. 先说结论
先把结论列出来。
- 普通的桌面 EXE,可以做到相当高程度的 single binary 化
- 但能做成 1 个 EXE和不依赖目标 Windows,是两件不同的事
- Shell 扩展、Windows 服务、驱动、WebView2、部分 WinUI 3 场景,重点往往不在文件数量,而在于要向系统注册什么、以什么为前提
- 实务上最重要的是分开决定:到底想做 single binary、想去掉安装程序,还是想降低系统依赖
换句话说,在 Windows 上这条界线是这样的。
- 把发布物收拢成 1 个:相当可行
- 把额外运行时一并带上:相当可行
- 做到 xcopy 分发:取决于应用类型
- 消除目标 Windows 侧的依赖:不可能
flowchart TB
accTitle: Windows上的界线
accDescr: 把发布物收拢成1个以及随附额外运行时都相当可行,能否做到xcopy分发取决于应用类型,而消除目标Windows侧的依赖则不可能。
b1["把发布物收拢成1个"] --> b2["相当可行"]
b3["随附额外运行时"] --> b2
b4["做到xcopy分发"] --> b5["取决于应用类型"]
b6["消除系统侧的依赖"] --> b7["不可能"]
图2:能做到的、取决于类型的、做不到的,三者的界线。
1.1 本文使用的术语
先把后文反复出现的词整理一下。
| 术语 | 读法与全称 | 本文中的含义 |
|---|---|---|
| UCRT | Universal C Runtime | Visual Studio 2015 拆分 C 运行时后形成的标准 C 库部分。在 Windows 10 及以后,它作为系统的组成部分随系统提供 |
| VC++ 可再发行组件包 | Visual C++ Redistributable | 用于把 UCRT 之外的 Visual C++ 运行时 DLL 装到目标机上的安装程序 |
| framework-dependent | 依赖目标环境 .NET 的发布 | 以目标机上已安装 .NET 运行时为前提的发布形态 |
| self-contained | 随附 .NET 运行时 | 由应用侧把整套 .NET 运行时一并带上分发的形态 |
| single-file | 单文件发布 | 把发布物合并成 1 个 EXE 的 .NET 发布选项 |
| Native AOT | Native Ahead-Of-Time | 在发布时把 IL 预先编译为本机代码的发布形态。运行时不使用 JIT |
| app-local 分发 | 应用相邻分发 | 把 DLL 放在与 EXE 相同的文件夹中,只供该应用使用的分发方式 |
| xcopy 分发 | 只需复制的分发 | 不使用安装程序也不需要管理员权限,整个文件夹复制过去就能运行的分发方式 |
| SCM | Service Control Manager,服务控制管理器 | 管理 Windows 服务注册、启动、停止的系统机制 |
| UAC | User Account Control,用户账户控制 | 控制提升到管理员权限的 Windows 安全机制 |
| Shell 扩展 | 外壳扩展 | 被加载进资源管理器等进程中运行的 COM 组件 |
| Evergreen | 持续更新方式 | 把 WebView2 Runtime 交给一份自动更新的共享实例的分发模式 |
| Fixed Version | 版本固定方式 | 把特定版本的 WebView2 Runtime 随附到自己应用中的分发模式 |
| arch | architecture,CPU 架构 | 指 x86 / x64 / Arm64 |
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 22 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
2. 把“单一可执行文件”拆成 4 个层次
先把这几个层次画成图,是这样的。自下而上越来越难,最上面一层在 Windows 上无法达到。
flowchart BT
A["层次 A:发布物只有 1 个<br/>相当可行"]
B["层次 B:无需预先安装语言运行时<br/>相当可行"]
C["层次 C:无需安装或注册<br/>取决于应用类型"]
D["层次 D:不依赖目标 Windows<br/>在 Windows 上无法达到"]
A --> B
B --> C
C --> D
图3:单一可执行文件的4个层次。自下而上越来越难,层次D无法达到。
“想做成单一可执行文件”这句话,指的是这 4 层中的哪一层,需要做的工作完全不同。
2.1 层次 A:发布物只有 1 个
最表面的一层是这样的。
- 用邮件发 1 个附件就能寄出
- U 盘里放 1 个文件就够
- 目标位置上只放一个
app.exe
这是发布单位外观层面的事。实际上,即使启动时会临时解压,即使依赖系统侧的 DLL,仅就这一条件而言也是能满足的。
2.2 层次 B:无需预先安装语言运行时
接下来是无需事先在目标机器上安装 .NET 运行时或 VC++ 可再发行组件包就能运行的状态。
- C/C++ 的静态链接
- .NET 的 self-contained
- .NET 的 single-file
- .NET Native AOT
到了这一层,“可以单独带走”的感觉就会明显强很多。
2.3 层次 C:无需安装或注册
从这里开始会突然变难。
单纯的 EXE 可能放着就能跑。但下面这些属于另一类。
- Shell 扩展
- Windows 服务
- 自定义 URL scheme 或文件关联
- 驱动
- 会被资源管理器、Office 等其他进程加载的组件
这一类光靠把文件放上去是不够的,需要向系统注册,或者与宿主侧对接。
flowchart TB
accTitle: 光放文件不够的领域
accDescr: Shell扩展、Windows服务、文件关联、驱动、会被其他进程加载的组件,光把文件放上去不够,需要向系统注册或与宿主侧对接。
c1["Shell扩展、服务"] --> c4["光放文件不够"]
c2["文件关联、驱动"] --> c4
c3["会被其他进程加载的组件"] --> c4
c4 --> c5["需要向系统注册、与宿主对接"]
图4:层次C之所以突然变难,是因为它属于需要向系统注册的领域。
2.4 层次 D:不依赖目标 Windows
这一层在 Windows 上做不到。
因为 Windows 应用最终仍然运行在 Windows 的 API、加载器、安全模型、设备栈之上。single binary 化能覆盖的,只到应用自身的责任范围,并不是把操作系统本身也一起带走。
3. 相当容易收拢成 1 个 EXE 的领域
在 Windows 上,也有相对容易收拢成 1 个 EXE 的应用。
- 单独启动的桌面工具
- EXE 自身同时承担 UI 和处理逻辑的业务应用
- 通信、文件处理、日志收集、监控、设备控制这类工具
- 不需要与资源管理器或 Office 做宿主整合的应用
- 不以 Web 运行时为前提的 UI
这一类应用中,容易纳入应用本体的内容很多。
- 自有代码
- 资源文件
- manifest
- 默认配置
- 模板数据
- 部分第三方库
- 语言运行时本体
此外,即使不把 DLL 完全嵌入 EXE 内部,把 DLL 放在 EXE 旁边的 app-local 分发在 Windows 上也是相当实用的常规做法。实务中,
- 单独一个
app.exe - 或者
app.exe加上少量相邻 DLL - 但无需安装程序、无需管理员权限,可以直接 xcopy 分发
这种形式往往比硬压缩成 1 个 EXE 更容易维护。
flowchart TB
accTitle: app-local分发这个折中点
accDescr: 即使不把DLL完全嵌入EXE,只要做到把DLL放在EXE旁边、无需安装程序、无需管理员权限、可以xcopy分发,往往比硬压缩成1个EXE更容易维护。
d1["app.exe加上少量相邻DLL"] --> d2["无需安装程序、无需管理员权限"]
d2 --> d3["可以直接xcopy分发"]
d3 -.-> d4["有时比硬压缩成1个EXE更容易维护"]
图5:不必执着于1个EXE,用app-local分发常常也能满足目的。
4. 即使做成 1 个 EXE 也无法消除的 Windows 依赖
如果以为“只要 EXE 只有 1 个,就不依赖目标 Windows”,就会在这里出问题。实际上,即使做成 1 个 EXE,仍有依赖会留下来。
4.1 操作系统版本依赖
Windows API 各自都有最低支持的系统版本。x64 与 Arm64 之间也有差异。也就是说,即使做成单个 EXE,下面这些也需要一开始就定下来。
- 是否要支持到 Windows 10
- 是否以 Windows 11 为前提
- 是否也要在 Windows Server 上运行
- 目标是 x86 / x64 / Arm64 中的哪一种
4.2 系统 DLL 依赖
即便自己觉得已经做成了 1 个 EXE,运行时当然仍在使用系统提供的组件。
kernel32.dlluser32.dlladvapi32.dll- COM 基础设施
- 服务控制基础设施
这些属于 Windows 侧的责任范围。
4.3 安全模型依赖
- UAC
- 文件 ACL
- 服务控制管理器
- 注册表
- 驱动签名策略
这些东西,应用无法独自承担。
flowchart TB
accTitle: 做成1个EXE后仍然留下的依赖
accDescr: 即使做成1个EXE,对Windows API最低支持系统版本与架构、kernel32.dll等系统DLL与COM基础设施、UAC与ACL、驱动签名策略等安全模型的依赖仍然存在。
e0["1个EXE的应用"] --> e1["系统版本与arch"]
e0 --> e2["系统DLL、COM基础设施"]
e0 --> e3["安全模型"]
e1 --> e4["这些都不是应用侧能承担的"]
e2 --> e4
e3 --> e4
图6:即使把发布物合并成一个,对Windows侧责任范围的依赖仍然留下。
4.4 宿主与运行时依赖
如果不是单独启动的 EXE,而是要挂在某个宿主之上的设计,依赖会一下子增多。
- 使用 WebView2:需要 WebView2 Runtime
- 使用 WinUI 3 / Windows App SDK:需要理清分发模式
- 制作 Shell 扩展:需要向资源管理器侧注册
也就是说,UI 与整合方式的选择,往往直接变成分发的难度。
flowchart TB
accTitle: UI与整合的选择直接变成分发难度
accDescr: 使用WebView2就需要WebView2 Runtime,使用WinUI 3就需要理清分发模式,制作Shell扩展就需要向资源管理器侧注册,宿主与运行时的选择直接变成分发的难度。
f1["使用WebView2"] --> f2["需要Runtime"]
f3["使用WinUI 3"] --> f4["需要理清分发模式"]
f5["制作Shell扩展"] --> f6["需要向资源管理器注册"]
图7:离单独启动的EXE越远,依赖增长得越快。
5. 按技术类型看现实的落地点
5.1 原生 C/C++
原生 C/C++ 在 single binary 化上自由度较高。它有选择静态链接的余地,如果是单独启动的 EXE,相当容易收拢。
不过,比起把所有内容都塞进 1 个文件,下面这些在实务上更重要。
- UCRT 和 VC++ 运行时怎么处理
- 第三方 DLL 是否放到 app-local
- 目标 CPU / 系统要收窄到什么范围
入口:/MT 与 /MD 的区别
在 MSVC 中,决定“要不要把运行时一并带上”的,实际上就是这个编译器选项。
| 选项 | 链接的内容 | 目标机上需要的东西 |
|---|---|---|
/MD |
ucrt.lib 和 vcruntime.lib 这两个供 DLL 使用的导入库。运行时使用 ucrtbase.dll 和 vcruntime<版本>.dll |
UCRT 和 VC++ 运行时 |
/MT |
静态链接 libucrt.lib、libvcruntime.lib、libcmt.lib |
不需要额外运行时 |
最小的试法,在 Developer Command Prompt 里只要这一行。
cl /std:c++17 /EHsc /O2 /MT app.cpp /Fe:app.exe
这里有 3 点需要注意。
- UCRT 是在 Visual Studio 2015 拆分 C 运行时时成为 Windows 的组成部分的,Windows 10 及以后作为系统的一部分随系统提供。如果要面向更早的 Windows,就需要通过
vcredist再发行。 - 不推荐用
/MT构建 DLL。静态链接的 CRT 会把状态封闭在该 DLL 内部,于是 EXE 与 DLL 之间在内存分配、区域设置、_set_se_translator的生效范围上会出现错位。如果笼统地统一成“EXE 用/MT,随附的 DLL 也用/MT”,跨边界的分配与释放就会出问题。 /clr和/MT不能同时使用。如果混有 C++/CLI,就只能走/MD这一侧。
flowchart TB
accTitle: 静态链接的3点注意
accDescr: UCRT在Windows 10及以后随系统提供但更早的Windows需要用vcredist再发行、用/MT构建DLL会把CRT状态封闭在DLL内导致跨边界分配与释放出问题、/clr与/MT不能同时使用。
g0["用/MT做静态链接"] --> g1["较早的Windows需要vcredist"]
g0 --> g2["不推荐DLL侧使用/MT"]
g0 --> g3["不能与/clr同时使用"]
g2 -.-> g4["跨边界的分配与释放会出问题"]
图8:/MT很强,但要注意UCRT的前提、DLL边界、C++/CLI这3点。
5.2 .NET
.NET 有 single-file、self-contained、Native AOT,表面上的发布单位可以做得相当小。
但还是要区分清楚。
- framework-dependent:依赖目标环境的 .NET
- self-contained:把 .NET 运行时一并带上
- single-file:把发布物收拢成一个
- Native AOT:能进一步减少启动期依赖,但也有功能限制
“因为是 single-file,所以系统依赖会变少”并不成立。变少的主要是应用发布物的集合数量。
flowchart TB
accTitle: single-file减少的是发布物的集合数量
accDescr: framework-dependent依赖目标环境的.NET,self-contained把运行时一并带上,single-file只是把发布物收拢成一个,并不是因为用了single-file系统依赖就变少。
h1["framework-dependent"] --> h2["依赖目标环境的.NET"]
h3["self-contained"] --> h4["把.NET运行时一并带上"]
h5["single-file"] --> h6["把发布物收拢成一个"]
h6 -.-> h7["并不因此减少系统依赖"]
图9:.NET的每种发布形态,减少的东西和留下的东西都不一样。
入口:dotnet publish 的 3 种模式
如果想先跑起来看看区别,下面这 3 条命令就够了。
rem 1. framework-dependent + single-file: 目标机上需要 .NET 运行时
dotnet publish -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true
rem 2. self-contained + single-file: 把 .NET 运行时一并带上
dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true
rem 3. Native AOT: 先在 csproj 中写上 PublishAot 再发布
dotnet publish -c Release -r win-x64
官方推荐把 PublishSingleFile 和 PublishAot 写在 csproj 里,而不是写在命令行上。这样构建过程中的兼容性分析会生效,减少发布之后才发现问题的情况。
<PropertyGroup>
<PublishSingleFile>true</PublishSingleFile>
<PublishAot>true</PublishAot>
</PropertyGroup>
另外,指定 RuntimeIdentifier 后 SelfContained 默认变为 true。想要 framework-dependent 时,请显式写上 --self-contained false。要在 Windows 上发布 Native AOT,需要安装 Visual Studio 的使用 C++ 的桌面开发工作负载。
权衡:“合并成 1 个”不是免费的
这里如果只写“能做到”就会出问题,所以把代价也一并列出来。
single-file 的启动时解压
- 默认情况下,被打包进去的只有托管 DLL。它们在启动时被读入内存,不会解压到文件夹。运行时本体的本机二进制文件仍然是独立文件。
- 如果想把本机二进制文件也一并放进 1 个文件,用
IncludeNativeLibrariesForSelfExtract;如果想让全部内容在运行前解压出来,用IncludeAllContentForSelfExtract。两者都会变成“先解压再启动”的行为,在 Windows 上会解压到%TEMP%\.net之下。可以用DOTNET_BUNDLE_EXTRACT_BASE_DIR更改位置,但不要把它设成权限不同的用户或服务可以写入的位置。 - 启用
EnableCompressionInSingleFile后 EXE 会小很多,但启动时要在内存中解压,启动会变慢。官方的说法也是“使用之前请同时测量体积和启动开销”。影响程度因应用而异,差别很大。
single-file 的 API 不兼容
把发布物合并成一个之后,以文件路径为前提的代码会悄悄失效。
| API | 在 single-file 下的行为 |
|---|---|
Assembly.Location |
返回空字符串 |
Assembly.CodeBase |
PlatformNotSupportedException |
Assembly.GetFile |
IOException |
Module.Name |
返回 <Unknown> 这个字符串 |
如果要访问 EXE 旁边的文件,改用 AppContext.BaseDirectory;如果需要可执行文件的路径,改用 Environment.ProcessPath。第三方库内部也可能在用这些 API,所以做成 single-file 之后,至少要在真机上启动确认一次。
flowchart TB
accTitle: 以路径为前提的代码会悄悄失效
accDescr: 把发布物合并成一个之后Assembly.Location会返回空字符串等,以文件路径为前提的API行为会改变,因此EXE旁边的文件改用AppContext.BaseDirectory,可执行文件路径改用Environment.ProcessPath,并至少在真机上启动确认一次。
i1["做成single-file"] --> i2["以路径为前提的API行为改变"]
i2 --> i3["旁边的文件用AppContext.BaseDirectory"]
i2 --> i4["可执行文件用Environment.ProcessPath"]
i3 --> i5["至少在真机上启动确认一次"]
i4 --> i5
图10:做成single-file后,要替换取路径的方式并在真机上确认。
Native AOT 的功能限制
作为“启动快、不需要运行时”的代价,下面这些将无法使用。
Assembly.LoadFile这类动态加载System.Reflection.Emit这类运行时代码生成- C++/CLI
- 在 Windows 上是内置 COM
- 由于必须做剪裁,剪裁本身的限制也会原样带上
- 由于实质上就是 single-file,上面的 API 不兼容也会原样带上
System.Linq.Expressions会始终以解释方式运行,比运行时生成的代码慢
目标平台也是写死的。在 .NET 8 中 Windows 支持 x64 和 Arm64,.NET 9 及以后加上了 x86。“用 AnyCPU 做成 1 个”这种想法从一开始就不成立。
flowchart TB
accTitle: Native AOT的代价
accDescr: Native AOT能减少启动期依赖,代价是无法使用动态加载和运行时代码生成、C++/CLI、Windows的内置COM,剪裁和single-file的限制也会带上,目标平台同样是写死的。
j0["Native AOT"] --> j1["能减少启动期依赖"]
j0 --> j2["无法动态加载、运行时生成"]
j0 --> j3["无法用C++/CLI、内置COM"]
j2 -.-> j4["剪裁和single-file的限制也带上"]
j3 -.-> j5["目标系统与arch写死"]
图11:看Native AOT的好处时,要连同失去的功能一起看。
5.3 WebView2
一旦采用 WebView2,single binary 的难点会完全改变。这里真正的主题不是 EXE 的数量,而是如何处理 WebView2 Runtime。
比起“能否做成 1 个 EXE”,有些问题应该先想清楚。
- 是否以目标环境已有 Runtime 为前提
- 是否使用 Evergreen
- 是否随附 Fixed Version
- 离线分发的责任要扛到什么程度
Evergreen 与 Fixed Version 的区别,大致是这样。
| Evergreen | Fixed Version | |
|---|---|---|
| 由谁更新 | Microsoft,自动更新 | 自己,随应用更新一起替换 |
| 机器上的实体 | 所有应用共享一份 | 每个应用各自随附 |
| 分发体积 | 几乎不增加 | 超过 250 MB |
| 安装方式 | Bootstrapper 约 2 MB,按需下载所需内容。离线场景则随附 Standalone Installer | 把解压出来的整套二进制文件与应用一起分发 |
| 既有约束 | 需要在启动前确认机器上是否已安装 | 无法从网络路径或 UNC 路径运行 |
Evergreen Runtime 在 Windows 11 上作为系统的一部分预装。但 Windows 10 那一侧仍有未安装的机器,因此 Microsoft 自己也写道“即使选择 Evergreen,也最好把 Runtime 一并分发”。也就是说,无论选哪一种,由应用侧确认是否已安装、不足就补装的流程都是必要的。Fixed Version 的代价是发布物会增加几百 MB,换来的是“更新时机由自己掌握”。这一点必须先于 1 个 EXE 的话题定下来。
flowchart TB
accTitle: WebView2要先决定分发方式
accDescr: Evergreen是由Microsoft自动更新的共享实例但机器上可能未安装,Fixed Version能自己掌握更新时机但发布物增加几百MB,无论选哪一种都需要由应用侧确认是否已安装、不足就补装。
k0["采用WebView2"] --> k1["Evergreen:共享一份并自动更新"]
k0 --> k2["Fixed Version:自己随附并更新"]
k1 --> k3["确认是否已安装,不足就补装"]
k2 -.-> k4["发布物增加几百MB"]
图12:WebView2的主题不是EXE的数量,而是Runtime的处理方式。
5.4 WinUI 3 / Windows App SDK
WinUI 3 同样在采用的那一刻就改变了分发要求。UI 技术的选择,本身就等同于分发方式的选择。
具体来说,要决定的有 2 个维度。
- packaging:packaged(MSIX)、指向外部位置的 packaged,还是 unpackaged
- runtime:framework-dependent 还是 self-contained
而从 single binary 的角度看,真正起作用的是下面这些组合约束。
- 能使用
PublishSingleFile的,只有 unpackaged 且 self-contained 的 WinUI 3 应用,并且需要 Windows App SDK 1.5 及以后。 - packaged 的应用,以及指向外部位置的 packaged 应用,都无法使用
PublishSingleFile。 - 一旦改为 unpackaged 就会失去 package identity,于是通知、后台任务、文件关联、上下文菜单扩展这些以 package identity 为前提的 Windows 功能都无法使用。
也就是说,在 WinUI 3 上,“做成 1 个 EXE”和“使用 Windows 的扩展功能”是正面冲突的。如果把 single binary 放在最优先,先重新审视 UI 技术的前提往往更快。
flowchart TB
accTitle: 1个EXE与package identity的冲突
accDescr: 在WinUI 3上能使用PublishSingleFile的只有unpackaged且self-contained的组合,而改为unpackaged就会失去package identity,通知和文件关联等功能都用不了,因此1个EXE与Windows的扩展功能正面冲突。
m1["想使用PublishSingleFile"] --> m2["只有unpackaged加self-contained"]
m2 --> m3["失去package identity"]
m3 --> m4["通知、文件关联等无法使用"]
m4 -.-> m5["1个EXE与扩展功能正面冲突"]
图13:在WinUI 3上,选择1个EXE就意味着舍弃package identity。
6. 本质上就需要“注册与依赖”的领域
6.1 Shell 扩展
被资源管理器加载的 Shell 扩展,与单纯“放着就能跑的 EXE”是两回事。这里的主题不是文件数量,而是如何向资源管理器注册。
6.2 Windows 服务
即便服务本体的 exe 能做成一个文件,分发仍是另一个问题。需要考虑
- 向 SCM 注册
- 权限
- 启动账户
- 恢复设置
这些内容。也就是说,服务属于“如何安装”比“做成 1 个 EXE”更需要打磨的领域。
6.3 驱动
驱动更加明确。它要连同 INF、签名、安装流程一起才能成立,因此从一开始就很难站上 single binary 的擂台。
flowchart TB
accTitle: 主题落在注册与签名上的领域
accDescr: Shell扩展需要向资源管理器注册,Windows服务需要向SCM注册并设计权限和启动账户,驱动要连同INF和签名一起才能成立,因此主题落在注册与依赖的设计上而不是文件数量。
n1["Shell扩展"] --> n2["向资源管理器注册"]
n3["服务"] --> n4["SCM注册、权限、账户"]
n5["驱动"] --> n6["INF与签名"]
n4 -.-> n7["比起文件数量,注册的设计才是主题"]
图14:在这3个领域里,要打磨的是“如何注册”而不是“做成1个EXE”。
7. 实务判断表
想做粗略判断时,这张表比较好用。
| 想做的东西 | 做成 1 个 EXE 的现实程度 | 应先考虑的事 |
|---|---|---|
| 单独启动的 Win32 / C++ 工具 | 高 | 静态链接、目标系统 / arch |
| 单独启动的 WinForms / WPF 工具 | 高 | self-contained、single-file、Native AOT 是否适用 |
| WinUI 3 / Windows App SDK 应用 | 中 | 分发模式、额外依赖 |
| 基于 WebView2 的桌面 UI | 低到中 | Runtime 的分发方式 |
| 资源管理器右键扩展或预览 | 低 | COM / 注册表注册 |
| Windows 服务 | 中 | SCM 注册、权限、更新流程 |
| 附带驱动的应用 | 低 | INF、签名、安装 |
这张表里最重要的是让人明白:“二进制文件的数量”和“分发的责任范围”是两回事。
8. 分发设计中应先决定的事
想让 single binary 化成功,有些事情比实现更值得提前决定。
8.1 先决定想把什么合并成 1 个
- 是想让发布物只有 1 个
- 是想省掉运行时的预先安装
- 是想去掉安装程序
- 是想让离线更新更简单
答案不同,选择的技术也会不同。
8.2 一开始就固定最低支持的 Windows 版本和 arch
single-file 和 Native AOT 基本上都是 OS / architecture specific 的。如果这一点还含糊不清就一路推进“总之先做成一个文件”,最后往往会被 API 缺失或 runtime 不匹配卡住。
8.3 把“随应用一起分发的内容”和“交给 Windows 处理的内容”写清楚
实务中,只要把下面这张表提前写出来,出问题的情况就会明显减少。
- 随应用一起分发的内容
- 本体 exe
- 自有 DLL
- 配置模板
- self-contained runtime
- 交给 Windows 处理的内容
- 系统 DLL
- 系统 API
- SCM / 注册表 / 资源管理器
- 驱动基础设施
- 另外作为前提的内容
- WebView2 Runtime
- VC++ Redistributable
- Office / Excel
- 专用驱动
flowchart TB
accTitle: 把责任范围的3种分类写清楚
accDescr: 只要把随应用一起分发的内容、交给Windows处理的内容、另外作为前提的内容这3种分类写下来,分发环节出问题的情况就会明显减少。
p0["发布物的责任范围"] --> p1["随应用一起分发的内容"]
p0 --> p2["交给Windows处理的内容"]
p0 --> p3["另外作为前提的内容"]
p3 -.-> p4["只要写下来问题就会减少"]
图15:把随附、交给系统、另外作为前提这3种分类提前写清楚。
8.4 如果优先考虑 single binary,就减少宿主整合
这一点相当有效。
- 放弃 Shell 扩展,改用普通 EXE
- 不做成服务,改用任务计划程序或显式启动
- 不使用 WebView2,改用原生 UI
- 把 COM 封闭在自己的进程内
归根结底,越是减少“让系统加载”“向系统注册”这类设计,就越接近 single binary。
flowchart TB
accTitle: 越减少宿主整合就越接近
accDescr: 放弃Shell扩展改用普通EXE、不做成服务改用任务计划程序或显式启动、不使用WebView2改用原生UI,越是减少让系统加载和向系统注册的设计,就越接近single binary。
q1["放弃Shell扩展改用普通EXE"] --> q4["减少向系统注册的设计"]
q2["不做成服务改用显式启动"] --> q4
q3["不用WebView2改用原生UI"] --> q4
q4 --> q5["越来越接近single binary"]
图16:如果优先考虑single binary,就要减少宿主整合本身。
9. 总结
在 Windows 上做 single binary 化,能做到的程度相当高。但最终落脚点就是这一句话。
可以把应用做成 1 个 EXE。 但这个应用所依赖的 Windows,无法一并做进这 1 个 EXE。
特别值得记住的有 5 点。
- 单独启动的普通 EXE,可以相当接近单文件分发
- C/C++ 的静态链接、.NET single-file、Native AOT 都是有力选项
- 但对系统版本、arch、系统 DLL、安全模型的依赖不会消失
- Shell 扩展、服务、驱动、WebView2、部分 WinUI 3 场景,主体会变成系统注册和额外运行时的问题
- single binary 成败的关键,在于一开始就分清楚“想把什么合并成 1 个”
如果非常看重 single binary,在技术选型阶段就朝着降低与系统的耦合度的方向去设计,成功率会高得多。
flowchart TB
accTitle: 成功的关键在于与系统的耦合度
accDescr: 可以把应用做成1个EXE,但无法把它依赖的Windows也做进这1个EXE,因此如果非常看重single binary,就应在技术选型阶段朝着降低与系统耦合度的方向设计。
r1["把应用做成1个EXE"] --> r2["做得到"]
r3["把依赖的Windows也做进去"] --> r4["做不到"]
r4 -.-> r5["所以在技术选型时降低与系统的耦合度"]
图17:能否做成1个EXE,取决于最初对与系统耦合度的设计。
10. 参考资料
- Microsoft Learn, Create a single file for application deployment
- Microsoft Learn, Native AOT deployment overview
- Microsoft Learn, C runtime (CRT) and C++ standard library (STL) lib files
- Microsoft Learn, Dynamic-link library search order
- Microsoft Learn, Targeting your application for Windows
- Microsoft Learn, Creating Registration-Free COM Objects
- Microsoft Learn, Registering Shell Extension Handlers
- Microsoft Learn, CreateServiceW function
- Microsoft Learn, Overview of INF Files
- Microsoft Learn, Windows driver signing tutorial
- Microsoft Learn, Distribute your app and the WebView2 Runtime
- Microsoft Learn, Windows App SDK deployment guide for self-contained apps
- Microsoft Learn, Package and deploy Windows apps overview
- Microsoft Learn, Packaging overview - Windows apps
- Microsoft Learn, Evergreen vs. fixed version of the WebView2 Runtime
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
Time Travel Debugging ── 把长期运行中不复现的缺陷“录下来”再倒回去
一个月才出一次的缺陷,崩溃转储只拍得到结果。本文讲解如何用 WinDbg 的 Time Travel Debugging(TTD) 录制执行并倒回,涵盖 TTD.exe 的录制设计、环形缓冲区、TTD.Calls 查询,以及与转储的分工。
参数为什么会坏掉 ── Windows 命令行参数的规则
在 Windows 上并不存在参数的数组,传给 CreateProcess 的是一条字符串,切分由接收方完成。本文讲解 CommandLineToArgvW・CRT・.NET 的切分规则,以及 .NET 的 ArgumentList 与 C++ 中正确的组装方法。
IE 模式之后换成 WebView2 就够了吗 ── ActiveX 无法运行的限制与现实的迁移设计
本文从内部系统的角度,梳理 WebView2 的基本结构、Evergreen 与 Fixed Version 两种分发策略、用户数据文件夹的陷阱、原生与 Web 的联动方式,并在「ActiveX 无法运行」这一限制的前提下,整理出摆脱 IE 模式依赖系统的现实迁移顺序。
委托开发 Windows 应用程序前该梳理的事项
在委托外包开发 Windows 应用程序之前,梳理现有软件改造、设备联动、COM/ActiveX、发布与更新、维护等需要注意的要点。
Windows 应用安全处理子进程的检查清单
在 Windows 应用中安全处理子进程,比起选择启动 API,更重要的是设计进程树的所有权和结束流程。本文整理 Job Object、退出传播、标准输入输出与 watchdog。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
Windows 应用程序开发
Windows 应用的分发,如果能把 single-file 化、运行时随附、是否采用 WebView2 或 WinUI、要不要做成服务一并纳入设计,就能减少返工。
技术咨询 & 设计评审
“想做成 1 个 EXE”这类需求,只要把分发单位、系统依赖、是否需要注册、更新责任分开梳理,就更容易判断。
常见问题
汇总了咨询这一主题时常见的问题。
- Windows 应用可以只用一个 EXE 文件分发吗?
- 如果是单独启动的桌面工具,可以做到相当高的程度。使用 C/C++ 的静态链接、.NET 的 self-contained 或 single-file、Native AOT,都能把发布物合并成一个 EXE。但能做成 1 个 EXE 和不依赖目标 Windows 是两件不同的事,对系统版本、架构、系统 DLL、安全模型的依赖并不会消失。
- .NET 的 single-file 是不是就意味着不再依赖系统?
- 不是。single-file 主要减少的是应用发布物的集合数量,而不是对系统的依赖。framework-dependent 依赖目标环境已安装的 .NET,self-contained 会把 .NET 运行时也一并带上,Native AOT 能进一步减少启动期依赖,但会带来功能上的限制。single-file 和 Native AOT 基本上都与系统版本和架构相关,因此需要先确定最低支持的 Windows 版本和目标架构。
- 哪些应用类型很难做成单个 EXE?
- Shell 扩展、Windows 服务、驱动、基于 WebView2 的 UI、部分 WinUI 3 场景都比较困难。这些应用的重点不在文件数量,而在于需要向系统注册什么,或依赖哪些额外运行时:Shell 扩展需要向 Explorer 注册,服务需要向 SCM 注册并设计权限和启动账户,驱动需要 INF 文件和签名才能成立,因此只是把文件放上去并不成立。WebView2 则需要先决定 WebView2 Runtime 的分发方式。
- 有没有比硬塞进单个 EXE 更好的分发方式?
- 把 DLL 放在 EXE 旁边的 app-local 分发是很有力的选择。哪怕是 app.exe 加上少量相邻 DLL 这种形式,只要能做到无需安装程序、无需管理员权限、可以直接 xcopy 分发,往往比硬压缩成 1 个 EXE 更容易维护。关键是要先分清楚:到底是想把发布物合并成一个,还是想省掉运行时的预先安装,还是想去掉安装程序。