作为一个 .NET 开发者,你可能遇到过这样的尴尬:程序写好了,但要发给别人用的时候,要么丢一个几百 MB 的文件夹让人家自己想办法运行,要么写一份长长的部署文档教人装环境。有没有一种办法,能像商业软件一样,给用户一个双击就能安装、带桌面快捷方式、支持卸载的 .exe 安装包?
答案是 Inno Setup。这个免费的安装程序制作工具已经存在了二十多年,久经考验,而且对 .NET 程序的支持非常友好。今天我就用一个真实的 .NET 8 项目来手把手教你从零开始制作安装包。
为什么选 Inno Setup
市面上做安装包的工具不少,InstallShield 动辄几千美元的授权费,WiX 的学习曲线陡得让人怀疑人生,NSIS 的脚本语法像汇编语言。相比之下 Inno Setup 有几个突出的优势:完全免费且可商用,脚本语法接近 Pascal 非常好懂,内置 IDE 可以即时编译和预览,生成的安装包体积小压缩率高,支持 64 位、注册表操作、服务安装等高级功能。
对于我们 .NET 开发者来说,最重要的一点是:它可以在安装时检测并自动安装 .NET Runtime 和其他依赖,让用户真正做到"一键安装"。
准备工作
在开始之前,你需要准备三样东西。
第一,安装 Inno Setup 6。去官网 https://jrsoftware.org/isdl.php 下载安装,全程默认选项即可。安装完成后你会得到一个 IDE(Inno Setup Compiler)和一个命令行编译器 ISCC.exe。

第二,确保你的 .NET 项目能够正常发布。我们后面会用到 dotnet publish 命令。
第三,准备好程序的图标文件(.ico 格式),这会让你的安装包看起来更专业。没有也没关系,可以先跳过。
第一步:发布 .NET 程序
在制作安装包之前,首先需要把你的 .NET 程序发布为可独立运行的文件。打开终端,进入你的项目根目录:
dotnet publish src/MyApp -c Release -r win-x64 --self-contained true
这条命令做了三件事:-c Release 表示以发布模式编译(优化过的代码),-r win-x64 指定目标平台为 64 位 Windows,--self-contained true 把 .NET 运行时一起打包进去,这样目标机器上不需要单独安装 .NET。
发布完成后,产物默认在 bin/Release/net8.0-windows/publish/ 目录下。这个目录里的所有文件就是要打包的内容。
如果你想自定义输出路径,可以加 -o 参数:
dotnet publish src/MyApp -c Release -r win-x64 --self-contained true -o ./dist
第二步:编写 Inno Setup 脚本
在项目中创建一个 setup 目录,新建一个 setup.iss 文件。Inno Setup 脚本由多个 Section(节)组成,每个节用方括号标记,负责安装包的一个方面。我们来逐节编写。

定义常量
脚本开头用 #define 定义一些常量,方便后续维护:
#define MyAppName "MyApp"
#define MyAppVersion "1.0.0"
#define MyAppPublisher "YourCompany"
#define MyAppExeName "MyApp.exe"
这样做的好处是,以后版本升级只需要改一行 #define,不用全文搜索替换。
[Setup] 节:安装包的基本信息
[Setup]
AppId={{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName}
OutputDir=Output
OutputBaseFilename=MyApp_Setup_{#MyAppVersion}
Compression=lzma2/ultra64
SolidCompression=yes
WizardStyle=modern
PrivilegesRequired=admin
SetupIconFile=Resources\icon.ico
几个关键参数解释一下。AppId 是这个程序的唯一标识符,用 GUID 生成器生成一个,它决定了安装程序如何识别"这个软件已经装过了"。DefaultDirName 是默认安装路径,{autopf} 会自动选择 Program Files 还是 Program Files (x86)。PrivilegesRequired=admin 表示需要管理员权限安装,如果你的程序不需要写 Program Files 或注册表 HKLM,可以改成 lowest。Compression=lzma2/ultra64 是最高压缩比,对于包含 .NET 运行时的大文件效果很好。
[Files] 节:要打包的文件
[Files]
Source: "..\MyApp\bin\Release\net8.0-windows\publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
这一行把 publish 目录下的所有文件递归复制到安装目录({app})。ignoreversion 表示覆盖旧文件时不弹版本冲突提示,recursesubdirs 递归子目录,createallsubdirs 保留目录结构。
[Icons] 节:快捷方式
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{group}\卸载 {#MyAppName}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
{group} 是开始菜单的程序组文件夹,{autodesktop} 是桌面。第三个图标关联了 Tasks: desktopicon,意味着只有用户勾选了"创建桌面快捷方式"选项才会创建。
[Tasks] 节:可选安装选项
[Tasks]
Name: "desktopicon"; Description: "创建桌面快捷方式(&D)"; GroupDescription: "附加选项:"
Name: "autostart"; Description: "开机自动启动(&A)"; GroupDescription: "附加选项:"; Flags: checkedonce
这些选项会出现在安装向导的"选择附加任务"页面。checkedonce 表示只在首次安装时默认勾选,升级安装时会记住用户上次的选择。
[Registry] 节:注册表操作
如果需要开机自启,可以写入注册表:
[Registry]
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#MyAppName}"; ValueData: """{app}\{#MyAppExeName}"""; Tasks: autostart; Flags: uninsdeletevalue
Tasks: autostart 关联了前面的可选任务,uninsdeletevalue 确保卸载时删除这个注册表项。注意 ValueData 中路径两端的引号要用双引号转义(两个双引号 = 一个字面双引号),这是为了防止安装路径包含空格时出问题。
[Run] 节:安装完成后执行的操作
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "启动 {#MyAppName}"; Flags: nowait postinstall skipifsilent
这会在安装完成页面显示一个"启动程序"的复选框,用户勾选后安装结束时会自动运行程序。nowait 表示安装程序不会等它运行完才退出,postinstall 表示只在安装后执行,skipifsilent 表示静默安装时跳过。
[UninstallRun] 节:卸载时的清理
[UninstallRun]
Filename: "taskkill"; Parameters: "/F /IM {#MyAppExeName}"; Flags: runhidden; RunOnceId: "KillProcess"
卸载前先终止正在运行的进程,否则删除文件时会报"文件被占用"。
第三步:处理依赖——自动安装 .NET Runtime
这是打包 .NET 程序最关键的一环。如果目标机器没有安装 .NET Desktop Runtime,程序会直接报错。我们可以在安装时自动检测并安装。
首先添加一个 [Code] 节来写 Pascal 脚本:
[Code]
function IsDotNetInstalled(): Boolean;
var
Installed: String;
begin
Result := RegQueryStringValue(HKLM,
'SOFTWARE\dotnet\Setup\InstalledVersions\x64\Microsoft.NETCore.App',
'8.0.0', Installed);
end;
然后在 [Run] 节最前面加上 .NET Runtime 的安装步骤:
[Run]
Filename: "https://dotnet.microsoft.com/download/dotnet/8.0"; \
StatusMsg: "正在检查 .NET 运行时..."; \
Check: not IsDotNetInstalled; \
Flags: shellexec runhidden
Filename: "{app}\{#MyAppExeName}"; Description: "启动程序"; \
Flags: nowait postinstall skipifsilent
Check: not IsDotNetInstalled 意味着只有在检测到 .NET 没装的时候才会打开下载页面。如果你想要完全静默安装,可以把 .NET Runtime 的离线安装包一起打包进去,但这会显著增大安装包体积。
第四步:编译安装包
一切就绪后,编译非常简单。
方式一:在 IDE 中编译。 双击打开 setup.iss 文件,按 Ctrl+F9(或点击菜单 Build → Compile)。编译进度条跑完后,安装包就生成在 Output 目录下了。
方式二:命令行编译。 适合集成到 CI/CD 流程中:
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "path\to\setup.iss"
编译成功后你会看到类似这样的输出:
Successful. (4.231 s)
Output: C:\...\setup\Output\MyApp_Setup_1.0.0.exe
第五步:测试安装包
生成的安装包一定要在干净的虚拟机或沙箱环境中测试。重点检查这几项:全新安装是否顺利完成、安装后程序能否正常启动、桌面快捷方式和开始菜单项是否正确、卸载是否干净(文件和注册表项全部清除)、升级安装(先装旧版再装新版)是否正常覆盖。
进阶:检测 WebView2 等额外依赖
如果你的程序像我的项目一样依赖 WebView2 Runtime,可以用同样的模式添加检测逻辑。WebView2 的安装状态可以通过注册表查询:
[Code]
function IsWebView2Installed(): Boolean;
var
Installed: Cardinal;
begin
Result := RegQueryDWordValue(HKLM,
'SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}',
'EBWebView', Installed);
end;
然后在 [Run] 节添加在线安装步骤:
[Run]
Filename: "https://go.microsoft.com/fwlink/p/?LinkId=2124703"; \
Parameters: "/silent /install"; \
StatusMsg: "正在安装 WebView2 Runtime..."; \
Check: not IsWebView2Installed; \
Flags: shellexec runhidden waituntilterminated
常见踩坑记录
路径中的反斜杠。 Inno Setup 脚本中路径分隔符用反斜杠 \,不是正斜杠。Source 路径中 ..\MyApp\bin\... 是相对于 .iss 文件所在目录的。
中文语言包。 默认的安装向导界面是英文的。要改成中文,在 [Languages] 节添加:
[Languages]
Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl"
Inno Setup 6 自带了简体中文语言文件,无需额外下载。
大文件压缩慢。 包含 .NET 运行时的 publish 目录通常有 150-200MB。用 lzma2/ultra64 压缩后大约 60-80MB。如果嫌编译太慢,可以退而求其次用 lzma2/normal,速度快很多但体积稍大。
调试脚本。 Inno Setup IDE 支持断点调试。在 [Code] 节的 Pascal 脚本中可以用 MsgBox 弹窗来调试变量值,编译时会弹出消息框。
GUID 不要复用。 每个不同的程序必须用不同的 AppId GUID。如果两个程序用了同一个 GUID,安装程序会认为它们是同一个软件,导致升级/卸载行为异常。
完整脚本参考
把上面的片段组合起来,这是一个可以直接使用的完整模板:
#define MyAppName "MyApp"
#define MyAppVersion "1.0.0"
#define MyAppPublisher "YourCompany"
#define MyAppExeName "MyApp.exe"
[Setup]
AppId={{你的GUID}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName}
OutputDir=Output
OutputBaseFilename={#MyAppName}_Setup_{#MyAppVersion}
Compression=lzma2/ultra64
SolidCompression=yes
WizardStyle=modern
PrivilegesRequired=admin
[Languages]
Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl"
[Files]
Source: "..\MyApp\bin\Release\net8.0-windows\publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{group}\卸载 {#MyAppName}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Tasks]
Name: "desktopicon"; Description: "创建桌面快捷方式(&D)"; GroupDescription: "附加选项:"
Name: "autostart"; Description: "开机自动启动(&A)"; GroupDescription: "附加选项:"
[Registry]
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#MyAppName}"; ValueData: """{app}\{#MyAppExeName}"""; Tasks: autostart; Flags: uninsdeletevalue
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "启动 {#MyAppName}"; Flags: nowait postinstall skipifsilent
[UninstallRun]
Filename: "taskkill"; Parameters: "/F /IM {#MyAppExeName}"; Flags: runhidden; RunOnceId: "KillProcess"
总结
Inno Setup 的学习成本很低,从零到做出第一个安装包可能只需要半小时。对于 .NET 开发者来说,它解决了"怎么把程序优雅地交给用户"这个问题:一个专业的安装界面、自动处理依赖、创建快捷方式、干净的卸载体验。这些细节虽然不影响程序功能本身,但会极大影响用户对你软件的第一印象。
如果你正在为自己的 .NET 项目怎么分发而发愁,不妨试试这个方案。从 dotnet publish 到 Inno Setup 编译,整个流程可以很方便地集成到 CI/CD 中,实现提交代码后自动生成安装包。