Windows 應用程式的單一檔案散發 - 單一執行檔與 OS 相依性的界線

· 更新日期: · · Windows, 散發, 單一執行檔, .NET, C++, WebView2, WinUI

更新紀錄(2 筆,最後更新 2026年09月04日)

本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。

已將繁體中文版改寫為日文原文的完整翻譯。先前的繁體中文版只譯出日文原文的一部分,遺漏了章節、表格、Mermaid 圖、圖說與 FAQ。本次依日文原文將這些內容全部補回,並新增本文的知識地圖章節。技術主張與日文版一致。 查看更新前的版本 (DOI: 10.5281/zenodo.22279476)
補上了日文原文中已有的諮詢引導(consultation_services)。內文沒有改動。 查看更新前的版本 (DOI: 10.5281/zenodo.21616339)
初次發布
引用本文(DOI: 10.5281/zenodo.21616338)

本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。

Go Komura(2026)。〈Windows 應用程式的單一檔案散發 - 單一執行檔與 OS 相依性的界線〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616338 https://comcomponent.com/zh-TW/blog/2026/03/19/003-windows-single-binary-and-os-dependencies/

DOI(最新版本)
10.5281/zenodo.21616338
DOI(此版本)
10.5281/zenodo.22297177

這篇文章的起點,是一段「在 Windows 上,到哪裡為止還能稱為 單一執行檔」的閒聊。引發討論的貼文如下。就算跳過這一段,後面的內容也寫成可以單獨讀完的形式。

在 Windows 上,「可以的話想用 1 個檔案散發」是相當普通的需求。公司內部工具、設備介接工具、監控用電腦、離線環境,以及盡量不想用安裝程式的實務現場,做成單一執行檔 都很有吸引力。

不過這個題目如果不在一開始就釐清,討論到中途大多會兜不攏。因為在 Windows 上說「想做成單一執行檔」時,其實很容易混進 4 件不同的事。

  • 想讓散發物只有 1 個
  • 想讓 .NET 或 Visual C++ 的執行階段不必事先安裝
  • 想不用安裝程式、不用系統管理員權限,放著就能跑
  • 不想受目標 Windows 的差異影響

這 4 件事並不相同。以實務來說,這樣想最不會偏。

把散發物收攏成 1 個 EXE,相當程度做得到。 但要把對目標 Windows 的依賴降到零,做不到。

本文就以 Windows 應用程式的實務角度,把這條界線梳理清楚。

就算收成 1 個 EXE 也消不掉 OS 相依性想做成單一執行檔的需求裡容易混進散發的議題與相依性的議題,把散發物收攏成 1 個 EXE 相當程度做得到,但無法把對目標 Windows 的依賴降到零的圖。「想做成單一執行檔」散發的議題:只要 1 個、放著就能跑相依性的議題:不需執行階段、不依賴 OS收攏成 1 個 EXE 相當可行無法把 OS 相依性降到零

圖 1: 「想用 1 個檔案散發」裡,混著做得到的事與做不到的事。

1. 先講結論

先把結論整理出來,就是這樣。

  • 一般的桌面 EXE,single binary 化可以做到相當高的程度
  • 但是,能收成 1 個 EXE 與 不依賴目標 Windows 是兩回事
  • Shell 擴充、Windows 服務、驅動程式、WebView2、WinUI 3 的一部分,比起檔案數量,要向 OS 註冊什麼、以什麼為前提 更容易成為真正的題目
  • 實務上最重要的,是把 要做 single binary、要免安裝程式、還是要降低 OS 相依性 分開來決定

換句話說,在 Windows 上的界線是這樣劃的。

  • 把散發物收攏成 1 個:相當可行
  • 把額外的執行階段包進來:相當可行
  • 做到 xcopy 散發:視應用程式種類而定
  • 消除目標 Windows 這側的相依性:不可能
Windows 上的界線把散發物收攏成 1 個與同捆額外的執行階段都相當可行,能不能做到 xcopy 散發要看應用程式種類,消除目標 Windows 這側的相依性則不可能的圖。把散發物收攏成 1 個相當可行同捆額外的執行階段做到 xcopy 散發視應用程式種類而定消除 OS 這側的相依性不可能

圖 2: 做得到、視種類而定、做不到的界線。

1.1 本文使用的術語

先把後面會反覆出現的詞整理一下。

術語 全稱/說明 在本文中的意思
UCRT Universal C Runtime Visual Studio 2015 把 C 執行階段拆分之後留下的標準 C 函式庫部分。Windows 10 以後已經當成 OS 的組成元件一起附帶
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 服務註冊、啟動、停止的 OS 機制
UAC User Account Control,使用者帳戶控制 控制提升到系統管理員權限的 Windows 安全機制
Shell 擴充 shell extension 被載入檔案總管等處理程序內執行的 COM 元件
Evergreen 持續更新模式 把 WebView2 Runtime 交給自動更新的共用單一份的散發模式
Fixed Version 版本固定模式 把特定版本的 WebView2 Runtime 同捆進自己應用程式的散發模式
arch architecture,CPU 架構 指 x86 / x64 / Arm64

圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 22 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle

2. 把「單一執行檔」拆成 4 個階段來想

先把階段畫成圖,就是下面這樣。從下往上依序變難,最上面那一層在 Windows 上到不了。

層級 A:散發物只有 1 個相當可行層級 B:不必事先安裝語言執行階段相當可行層級 C:不需安裝或註冊視應用程式種類而定層級 D:不依賴目標 Windows在 Windows 上到不了

圖 3: 單一執行檔的 4 個階段。從下往上依序變難,層級 D 到不了。

「想做成單一執行檔」這句話,指的是這 4 層裡的哪一層,需要做的事完全不同。

2.1 層級 A:散發物只有 1 個

最表面的一層就是這個。

  • 用郵件寄 1 個附件就好
  • USB 裡放 1 個檔案就好
  • 部署的位置只放一個 app.exe

這談的是 散發單位看起來的樣子。實際上就算啟動時會先暫時解壓縮,就算依賴 OS 這側的 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 或檔案關聯
  • 驅動程式
  • 會被 Explorer、Office 等其他處理程序載入的元件

這個領域光是 把檔案放上去 是不夠的,還需要向 OS 註冊,或是跟宿主端接上線。

光把檔案放上去不夠的領域Shell 擴充、Windows 服務、檔案關聯、驅動程式,以及會被其他處理程序載入的元件,光把檔案放上去並不夠,還需要向 OS 註冊或跟宿主端接上線的圖。Shell 擴充、服務光把檔案放上去不夠檔案關聯、驅動程式會被其他處理程序載入的東西需要向 OS 註冊、跟宿主接上線

圖 4: 層級 C 之所以突然變難,是因為進到了需要向 OS 註冊的領域。

2.4 層級 D:不依賴目標 Windows

在 Windows 上做不到。

因為 Windows 應用程式最後都跑在 Windows 的 API、載入器、安全模型、裝置堆疊之上。能做到 single binary 化的,只到應用程式這側的職責範圍,並不是連 OS 本身都一起帶走。

3. 相當容易收成 1 個 EXE 的領域

在 Windows 上,也有比較容易收攏成 1 個 EXE 的應用程式。

  • 單獨啟動的桌面工具
  • EXE 本身同時具備 UI 與處理邏輯的業務應用程式
  • 通訊、檔案處理、日誌收集、監控、裝置控制這類工具
  • 不需要與 Explorer 或 Office 做宿主整合的
  • 不以 Web 執行階段為前提的 UI

這一類應用程式,能放進本體的東西很多。

  • 自己寫的程式碼
  • 資源
  • 資訊清單(manifest)
  • 預設設定
  • 範本資料
  • 一部分第三方函式庫
  • 語言執行階段本體

而且,就算沒有把 DLL 完全嵌進 EXE 裡,把 DLL 放在 EXE 旁邊的 app-local 散發 在 Windows 上本來就很有力。實務上,

  • 只有 1 個 app.exe
  • 或是 app.exe 加上幾個相鄰的 DLL
  • 但不需要安裝程式、不需要系統管理員權限,用 xcopy 就能散發

這種形式比硬壓成 1 個 EXE 更容易維護的情況並不少見。

app-local 散發這個折衷點就算沒有把 DLL 完全嵌進 EXE,只要把 DLL 放在 EXE 旁邊,不需安裝程式與系統管理員權限就能用 xcopy 散發,這種形式比硬壓成 1 個 EXE 更容易維護的情況並不少見的圖。app.exe 加上幾個相鄰 DLL不需安裝程式、不需系統管理員權限用 xcopy 就能散發有時比硬壓成 1 個 EXE 更容易維護

圖 5: 不必執著於 1 個 EXE,用 app-local 散發往往就能達成目的。

4. 就算收成 1 個 EXE 也消不掉的 Windows 相依性

要是以為「只要 EXE 只有 1 個,就不依賴目標 Windows」,就會在這裡出事。實際上,就算收成 1 個 EXE,還是有相依性會留下來。

4.1 OS 版本相依性

Windows API 各自都有最低支援的 OS,x64 與 Arm64 之間也有差異。也就是說,就算做成單一 EXE,下列項目

  • 要支援到 Windows 10 嗎
  • 是以 Windows 11 為前提嗎
  • Windows Server 上也要跑嗎
  • x86 / x64 / Arm64 要以哪一個為目標

都必須在一開始就固定下來。

4.2 系統 DLL 相依性

就算自己覺得已經收成 1 個 EXE,程式跑起來當然還是會用到 OS 提供的元件。

  • kernel32.dll
  • user32.dll
  • advapi32.dll
  • COM 基礎架構
  • 服務控制基礎架構

這一塊是 Windows 這側的職責範圍。

4.3 安全模型相依性

  • UAC
  • 檔案 ACL
  • 服務控制管理員
  • 登錄檔
  • 驅動程式簽署原則

這些東西,應用程式沒辦法自己一個人扛下來。

收成 1 個 EXE 之後仍留下的相依性就算收成 1 個 EXE,Windows API 的最低支援 OS 與架構、kernel32.dll 等系統 DLL 與 COM 基礎架構,以及 UAC、ACL、驅動程式簽署原則這些安全模型上的相依性都會留下來的圖。1 個 EXE 的應用程式OS 版本與 arch系統 DLL、COM 基礎架構安全模型這些都不是應用程式扛得下來的

圖 6: 就算把散發物收成 1 個,對 Windows 職責範圍的依賴仍然存在。

4.4 宿主與執行階段相依性

如果不是單獨啟動的 EXE,而是要掛在某個宿主上的設計,相依性會一口氣增加。

  • 使用 WebView2:需要 WebView2 Runtime
  • 使用 WinUI 3 / Windows App SDK:需要先把散發模式梳理清楚
  • 做 Shell 擴充:需要向 Explorer 這側註冊

也就是說,UI 與整合方式的選擇,往往直接變成散發的難度。

UI 與整合的選擇直接變成散發難度使用 WebView2 就需要 WebView2 Runtime,使用 WinUI 3 就需要梳理散發模式,做 Shell 擴充就需要向 Explorer 這側註冊,宿主與執行階段的選擇直接變成散發難度的圖。使用 WebView2需要 Runtime使用 WinUI 3需要梳理散發模式做 Shell 擴充需要向 Explorer 註冊

圖 7: 離單獨啟動的 EXE 越遠,相依性增加得越快。

5. 依技術別看現實的落地點

5.1 原生 C/C++

原生 C/C++ 在 single binary 化上的自由度屬於比較高的一側。有選擇靜態連結的空間,單獨啟動的 EXE 相當容易收攏。

不過,比起把全部塞進 1 個檔案,

  • UCRT 與 VC++ 執行階段要怎麼處理
  • 第三方 DLL 要不要放成 app-local
  • 目標 CPU / OS 要收窄到什麼程度

這些在實務上更重要。

入門:/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 以後已經當成 OS 的一部分一起附帶。如果要以更早的 Windows 為目標,就需要用 vcredist 轉散發。
  • 不建議用 /MT 建置 DLL 這一側。 靜態連結的 CRT 會把狀態封在那個 DLL 內部,所以 EXE 與 DLL 之間的記憶體配置、地區設定、_set_se_translator 的作用範圍都會對不上。隨手把「EXE 用 /MT,同捆的 DLL 也用 /MT」湊成一致,就會在跨越邊界的配置與釋放上出事。
  • /clr 與 /MT 不能併用。只要混進 C++/CLI,就得走 /MD 這一側。
靜態連結的 3 個注意事項UCRT 在 Windows 10 以後由 OS 附帶但更早的版本需要用 vcredist 轉散發,用 /MT 建置 DLL 會把 CRT 的狀態封在 DLL 內而在跨邊界的配置與釋放上出事,以及 /clr 與 /MT 不能併用的圖。用 /MT 靜態連結舊版 Windows 需要 vcredist不建議在 DLL 這側用 /MT不能與 /clr 併用跨邊界的配置與釋放會出事

圖 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:把散發物收攏成 1 個
  • Native AOT:可以進一步減少啟動時的相依性,但也有功能上的限制

並不是「因為用了 single-file,OS 相依性就會減少」。減少的主要是 應用程式散發物的零散程度。

single-file 減少的是散發物的零散程度framework-dependent 依賴目標環境的 .NET,self-contained 把執行階段包進來,single-file 只是把散發物收攏成 1 個,並不是用了 single-file 就會減少 OS 相依性的圖。framework-dependent依賴目標環境的 .NETself-contained把 .NET 執行階段包進來single-file把散發物收攏成 1 個並不會因此減少 OS 相依性

圖 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 的啟動時解壓縮

  • 預設只會把 managed DLL 打包進去。啟動時載入到記憶體上,不會解壓縮到資料夾裡。執行階段本體的原生二進位檔仍然是分開的檔案。
  • 想連原生二進位檔都收進 1 個檔案,就用 IncludeNativeLibrariesForSelfExtract;想在執行前把全部都解壓縮出來,就用 IncludeAllContentForSelfExtract。兩者都會變成「先解壓縮再啟動」的行為,在 Windows 上會解壓縮到 %TEMP%\.net 底下。位置可以用 DOTNET_BUNDLE_EXTRACT_BASE_DIR 更改,但不要指定成權限不同的使用者或服務也寫得進去的位置。
  • 啟用 EnableCompressionInSingleFile 之後 EXE 會小很多,但啟動時要在記憶體上解壓縮,啟動就會變慢。官方文件也寫成「使用之前先把大小與啟動成本兩邊都量過」。影響會因應用程式而有很大差異。

single-file 的 API 不相容

把散發物收成 1 個之後,以檔案路徑為前提的程式碼會悄悄壞掉。

API 在 single-file 下的行為
Assembly.Location 傳回空字串
Assembly.CodeBase PlatformNotSupportedException
Assembly.GetFile IOException
Module.Name 傳回 <Unknown> 這個字串

要碰 EXE 旁邊的檔案就改用 AppContext.BaseDirectory,需要執行檔的路徑就改用 Environment.ProcessPath。第三方函式庫內部也可能用到這些 API,所以做成 single-file 之後,一定要在實機上啟動確認一次。

以路徑為前提的程式碼會悄悄壞掉把散發物收成 1 個之後 Assembly.Location 會傳回空字串,以檔案路徑為前提的 API 行為都會改變,所以 EXE 旁邊的檔案改用 AppContext.BaseDirectory,執行檔的路徑改用 Environment.ProcessPath,並且一定要在實機上啟動確認一次的圖。做成 single-file以路徑為前提的 API 行為改變旁邊的檔案用 AppContext.BaseDirectory執行檔用 Environment.ProcessPath在實機上啟動確認一次

圖 10: 做成 single-file 之後,把取得路徑的方式換掉,並在實機上確認。

Native AOT 的功能限制

換來「啟動快、不需執行階段」的代價,是下面這些都不能用了。

  • 像 Assembly.LoadFile 這種 動態載入
  • 像 System.Reflection.Emit 這種 執行時產生程式碼
  • C++/CLI
  • 在 Windows 上是 內建 COM
  • 因為必須做修剪(trimming),修剪的限制也會原封不動地跟著上來
  • 實質上就是 single-file,所以上面的 API 不相容也會原封不動地跟著上來
  • System.Linq.Expressions 一律會變成直譯執行,比執行時產生的程式碼慢

目標平台也是寫死的。.NET 8 在 Windows 上是 x64 與 Arm64,.NET 9 以後才加上 x86。「用 AnyCPU 做成 1 個」這種想法從一開始就不成立。

Native AOT 的代價Native AOT 可以減少啟動時的相依性,代價是不能用動態載入與執行時產生程式碼、不能用 C++/CLI 與 Windows 的內建 COM,修剪與 single-file 的限制也會跟著上來,目標平台同樣是寫死的圖。Native AOT可以減少啟動時的相依性不能動態載入、不能執行時產生程式碼不能用 C++/CLI、內建 COM修剪與 single-file 的限制也跟著上來目標 OS 與 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,會自動更新 自己,配合應用程式的更新一起換掉
電腦上的實體 所有應用程式共用 1 份 每個應用程式各自同捆
散發大小 幾乎不會增加 超過 250 MB
安裝方式 Bootstrapper 約 2 MB,需要多少就下載多少。離線時要同捆 Standalone Installer 把解壓縮後的整套二進位檔跟應用程式一起散發
既有的限制 必須在啟動前檢查電腦上是否已經裝好 無法從網路路徑或 UNC 路徑執行

Evergreen Runtime 在 Windows 11 上是當成 OS 的一部分預先安裝好的。不過 Windows 10 這側還是有沒裝的電腦,所以 Microsoft 自己也寫成「就算選 Evergreen,Runtime 還是散發出去比較好」。也就是說,由應用程式這側檢查有沒有,不夠就裝上去 的步驟,不管選哪一種都需要。Fixed Version 換來的是「更新時機可以自己掌控」,代價是散發物會增加數百 MB。這件事跟 1 個 EXE 的討論是分開的,必須先決定。

WebView2 要先決定散發方式Evergreen 是由 Microsoft 自動更新的共用單一份但電腦上也可能沒裝,Fixed Version 換來自己掌控更新但散發物會增加數百 MB,所以不論選哪一種都需要由應用程式這側檢查有沒有、不夠就裝上去的步驟的圖。採用 WebView2Evergreen:共用的 1 份自動更新Fixed Version:自己同捆與更新檢查有沒有,不夠就裝上去散發物增加數百 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 技術的前提 往往比較快。

1 個 EXE 與 package identity 的衝突WinUI 3 能用 PublishSingleFile 的只有 unpackaged 而且 self-contained 的組態,改成 unpackaged 就會失去 package identity 而不能用通知與檔案關聯等功能,所以 1 個 EXE 與 Windows 的擴充功能正面衝突的圖。想使用 PublishSingleFile只有 unpackaged+self-contained失去 package identity不能用通知、檔案關聯等功能1 個 EXE 與擴充功能正面衝突

圖 13: 在 WinUI 3 上,選擇 1 個 EXE 就等於放棄 package identity。

6. 本質上就需要「註冊與相依」的領域

6.1 Shell 擴充

會被 Explorer 載入的 Shell 擴充,跟單純的「放著就能跑的 EXE」是兩種東西。這裡真正的題目不是檔案數量,而是 要怎麼向 Explorer 註冊。

6.2 Windows 服務

就算服務本體的 exe 可以做成 1 個檔案,散發還是另一個問題。

  • 向 SCM 註冊
  • 權限
  • 啟動帳戶
  • 修復設定

這些都必須先想清楚。也就是說,服務屬於比起「收成 1 個 EXE」,更該把「要怎麼安裝」敲定細節的領域。

6.3 驅動程式

驅動程式更明確。要連 INF、簽署、安裝步驟都齊備才算成立,所以從一開始就很難站上 single binary 的擂台。

註冊與簽署才是真正題目的領域Shell 擴充要向 Explorer 註冊,Windows 服務要向 SCM 註冊並設計權限與啟動帳戶,驅動程式要連 INF 與簽署都齊備才成立,所以比起檔案數量,註冊與相依的設計才是真正的題目的圖。Shell 擴充向 Explorer 註冊服務SCM 註冊、權限、帳戶驅動程式INF 與簽署比起檔案數量,註冊的設計才是題目

圖 14: 這 3 個領域要敲定的不是「收成 1 個 EXE」,而是「要怎麼註冊」。

7. 實務上的判斷表

想粗略判斷的話,這張表很好用。

想做的東西 收成 1 個 EXE 的現實度 該先想的事
單獨啟動的 Win32 / C++ 工具 高 靜態連結、目標 OS / arch
單獨啟動的 WinForms / WPF 工具 高 self-contained、single-file、Native AOT 的適用性
WinUI 3 / Windows App SDK 應用程式 中 散發模式、額外的相依性
以 WebView2 為基礎的桌面 UI 低到中 Runtime 的散發方式
Explorer 右鍵擴充或預覽 低 COM/登錄檔註冊
Windows 服務 中 SCM 註冊、權限、更新步驟
同捆驅動程式的應用程式 低 INF、簽署、安裝

這張表最重要的一點,是讓人看清 「二進位檔的數量」與「散發的職責範圍」是兩回事。

8. 散發設計上該先決定的事

想讓 single binary 化成功,有些事最好在實作之前就先決定。

8.1 決定想把什麼收成 1 個

  • 是想把散發物收成 1 個
  • 是想省掉事先安裝執行階段
  • 是想不用安裝程式
  • 還是想讓離線更新更簡單

答案不同,要選的技術也不同。

8.2 一開始就固定最低支援的 Windows 與 arch

single-file 與 Native AOT 基本上都是 OS / architecture specific。這一點含糊不清就一路推「總之做成 1 個檔案」,最後會被 API 不足或 runtime 不一致卡住。

8.3 把「要同捆的」與「交給 Windows 的」白紙黑字寫下來

實務上,光是把這張表寫下來,事故就會少很多。

  • 要同捆進應用程式的
    • 本體 exe
    • 自製 DLL
    • 設定範本
    • self-contained runtime
  • 交給 Windows 的
    • 系統 DLL
    • OS API
    • SCM/登錄檔/Explorer
    • 驅動程式基礎架構
  • 另外以前提存在的
    • WebView2 Runtime
    • VC++ Redistributable
    • Office / Excel
    • 專用驅動程式
把職責範圍的 3 種分類白紙黑字寫下來光是把要同捆進應用程式的、交給 Windows 的、另外以前提存在的這 3 種分類寫下來,散發時的事故就會少很多的圖。散發物的職責範圍要同捆進應用程式的交給 Windows 的另外以前提存在的光是寫下來事故就會減少

圖 15: 同捆、交給 OS、另外以前提存在的 3 種分類,要先白紙黑字寫下來。

8.4 想優先做到 single binary,就減少宿主整合

這一招相當管用。

  • 放棄 Shell 擴充,改回普通的 EXE
  • 不做成服務,改用工作排程器或明確啟動就好
  • 不用 WebView2,改用原生 UI
  • COM 收在自己的處理程序內

說到底,越是減少讓 OS「載入」「註冊」的設計,就越靠近 single binary。

越減少宿主整合就越靠近放棄 Shell 擴充改回普通 EXE,不做成服務而改用工作排程器或明確啟動,不用 WebView2 而改用原生 UI,越是減少讓 OS 載入與註冊的設計就越靠近 single binary 的圖。放棄 Shell 擴充改回普通 EXE減少向 OS 註冊的設計不做成服務改為明確啟動不用 WebView2 改用原生 UI靠近 single binary

圖 16: 想優先做到 single binary,就要減少宿主整合本身。

9. 總結

在 Windows 上做 single binary 化,可以做到相當高的程度。不過最後歸結起來就是這一句。

把應用程式收成 1 個 EXE,做得到。 但要連那個應用程式所依賴的 Windows 也收成 1 個 EXE,做不到。

特別想記住的有 5 點。

  • 單獨啟動的一般 EXE,可以相當接近 1 個檔案的散發
  • C/C++ 的靜態連結、.NET single-file、Native AOT 都很有力
  • 但 OS 版本、arch、系統 DLL、安全模型上的相依性不會消失
  • Shell 擴充、服務、驅動程式、WebView2、WinUI 3 的一部分,主角會變成 OS 註冊與額外執行階段的問題
  • single binary 的成敗,取決於一開始有沒有把「想把什麼收成 1 個」釐清

如果要把 single binary 放在很優先的位置,在技術選型階段就朝著 降低與 OS 的耦合度 去設計,成功率會高很多。

成功的關鍵在與 OS 的耦合度就算能把應用程式收成 1 個 EXE,也無法連它所依賴的 Windows 一起收成 1 個 EXE,所以要把 single binary 放在很優先的位置時,在技術選型階段就朝著降低與 OS 的耦合度去設計會比較容易成功的圖。把應用程式收成 1 個 EXE做得到連依賴的 Windows 也收成 1 個 EXE做不到所以在技術選型時降低與 OS 的耦合度

圖 17: 1 個 EXE 化的成敗,取決於一開始與 OS 的耦合度設計。

10. 參考資料

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

Windows 應用程式可以只用 1 個 EXE 檔案散發嗎?
如果是單獨啟動的桌面工具,可以做到相當高的程度。用 C/C++ 的靜態連結、.NET 的 self-contained 或 single-file、Native AOT,都能把散發物收攏成 1 個 EXE。但是「能收成 1 個 EXE」與「不依賴目標 Windows」是兩回事,對 OS 版本、CPU 架構、系統 DLL、安全模型的相依性並不會消失。
改用 .NET 的 single-file 之後,OS 相依性就會消失嗎?
不會。single-file 減少的主要是應用程式散發物的零散程度,並不是減少 OS 相依性。framework-dependent 依賴目標環境的 .NET,self-contained 把 .NET 執行階段包進來,Native AOT 可以進一步減少啟動時的相依性,但也有功能上的限制。single-file 與 Native AOT 基本上都是 OS 與架構專屬的,所以最低支援的 Windows 與 arch 必須在一開始就固定下來。
哪些應用程式比較難收成 1 個 EXE?
Shell 擴充、Windows 服務、驅動程式、以 WebView2 為基礎的 UI,以及 WinUI 3 的一部分。這些真正的題目不是檔案數量,而是要向 OS 註冊什麼、要處理哪些額外的執行階段。例如 Shell 擴充必須向 Explorer 註冊,服務需要向 SCM 註冊並設計權限與啟動帳戶,驅動程式要連 INF 與簽署都齊備才成立,所以放著就能跑的散發方式並不成立。WebView2 則必須先決定 WebView2 Runtime 的散發方式。
比起硬把東西塞進 1 個 EXE,有沒有更好的散發方式?
把 DLL 放在 EXE 旁邊的 app-local 散發是很有力的選項。就算形式是 app.exe 加上幾個相鄰的 DLL,只要能不用安裝程式、不用系統管理員權限就以 xcopy 散發,往往比硬壓成 1 個 EXE 更容易維護。重點是在一開始就分清楚:是想讓散發物只有 1 個,還是想省掉事先安裝執行階段,還是想不用安裝程式。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽