我做了这么多年开发软件分发这个环节被无数人忽略了。最常见的情况是exe做出来了往压缩包里一塞丢给用户解压。结果用户双击程序系统弹个风险提示对方反手就问你这个绿色版是不是有毒。后来我把工具链重新梳理了一遍发现Inno Setup这种安装包制作工具几乎每个做桌面程序的开发者都应该掌握。它做的事情就是把手里的exe主程序、依赖库、配置文件、说明文档打包成一个带安装向导、桌面快捷方式、卸载入口、注册表写操作的正式安装包程序。适合的人群很广用PyInstaller打包Python程序的人、用exe4j做Java桌面工具的人、以及任何需要把绿色版变成正规安装版的开发者。这篇文章不打算写成文档翻译而是按照我自己实际做一个安装包的流程来讲从选型到脚本从踩坑到发布每一步都说清楚为什么这么做。1. 先搞清楚一件事为什么分发软件要绕开zip用安装包1.1 绿色版文件夹分发的问题在哪很多开发者觉得zip解压就是安装这想法在技术圈里没问题但放到真实用户那边全是问题。用户不知道把解压出来的文件夹放哪有人放桌面有人放下载目录还有人临时解压到C盘根目录。过两个月程序出问题用户自己都找不到程序在哪。更麻烦的是绿色版没有卸载入口用户不想用了只能手动删文件夹删不干净还残留一堆配置文件。杀毒软件对绿色版的态度也是个大问题。一个从来没被签名、没进过白名单的exe从zip里解压出来直接运行很多安全软件的启发式引擎会格外警惕。同样的程序做成安装包安装过程中写注册表、创建快捷方式行为路径反而是常规操作被拦的概率反而低。我做过多年的软件分发总结下来安装包解决的是这么几件事固定安装目录、提供开始菜单和桌面快捷方式、写入卸载信息和注册表项、安装前做环境检测、支持静默安装做批量部署。这些独立功能zip永远给不了。1.2 同类型安装包工具怎么选当时我在几个安装包工具之间纠结过简单说说我的对比结论工具授权模式脚本风格上手难度典型场景Inno Setup开源免费Pascal-like脚本低中小型桌面软件、个人工具分发NSIS开源免费类汇编脚本中需要大量插件扩展的场景InstallShield商业授权IDE向导加脚本高企业级大型软件安装Advanced Installer商业授权/免费版GUI配置为主低需要MSI格式、侧重GUI配置的团队我最后选了Inno Setup原因很直接第一开源免费商用也没授权成本第二脚本是类似Pascal的语法比NSIS那套接近汇编的写法友好太多逻辑复杂的安装逻辑我写着不费劲第三它的文档和社区例子非常多遇到问题搜索一下基本都有答案第四支持Pascal脚本扩展意味着我可以写检测函数、自定义安装页面这是很多轻量级工具做不到的。Inno Setup对个人开发者的价值还有个隐藏点它的脚本本质是文本文件可以纳入Git管理。安装包逻辑跟代码一起做版本控制这在团队协作里非常方便。2. 五分钟跑通第一个安装包先用向导建脚本再读懂它生成了什么2.1 向导模式快速建工程Inno Setup装好后第一次打开会直接弹出向导。选择使用向导创建新脚本文件跟着一步步填就行应用程序信息填软件名、版本号、发布者。发布者名称会在安装界面上显示如果后期要买代码签名证书这里最好是真实的公司或个人姓名。应用程序文件夹安装目录的默认值通常改成{autopf}\你的软件名{autopf}会自动映射到Program Files目录兼容32位和64位系统。应用程序文件这一步是关键选主程序exe再添加其它依赖文件。如果你有整个文件夹要带进去可以把文件夹作为整体添加向导会自动递归处理子目录。应用程序快捷方式勾选开始菜单快捷方式和桌面快捷方式桌面快捷方式后面通常要改成可选任务而不是默认勾选。文档关联如果你不需要双击文件打开程序直接跳过。安装模式默认是管理员安装模式意思是安装到Program Files这类受保护目录时会触发UAC提权。如果你希望普通用户也能安装可以选择当前用户安装模式默认装到AppData目录不用提权后面我会讲两种模式的各自适用场景。语言设置勾上你要的语言包简体中文是官方自带支持的安装时用户能自己选。向导最后会生成一个.iss脚本文件然后点编译等几秒钟Output目录下就会出现一个Setup.exe第一个安装包就做出来了。这个过程确实只要五分钟但我要提醒一句向导生成的东西只是骨架真正的安装包体验差距全在后面的脚本细节上。2.2 向导生成的脚本里藏了几个关键信息向导生成的脚本值得从头到尾读一遍几百行代码里有几个信息特别关键脚本顶部是说明注释之后就是各段配置。下面是我一个实际项目的脚本开头段比向导生成的更精简[Setup] AppId{{8A1E7B3D-6F2C-4E8B-9D4A-C3F7A1B2E5D0} AppNameMyTool AppVersion1.0.0 AppPublisherMyStudio DefaultDirName{autopf}\MyTool OutputDirOutput OutputBaseFilenameMyToolSetup Compressionlzma2 SolidCompressionyes ArchitecturesInstallIn64BitModex64compatible PrivilegesRequiredadmin SetupIconFileres\install.ico注意AppId这行向导会自动生成一个GUID这个值就是整个安装包在系统里的身份标识。升级安装、检测已安装版本、写卸载信息全都依赖这个ID。如果你复制了别人的脚本又没改AppId你的软件和他人的软件会被系统当成同一个软件卸载时可能把对方的程序也卸掉。这个坑我放在后面专门讲。3. 脚本语言逐段拆解从[Setup]到[Run]的完整逻辑3.1 [Setup]段决定安装包出生配置[Setup]段是整个脚本的大脑几乎所有全局行为都由这里控制。除了上面那段里出现的参数还有几个我经常用的参数作用补充说明DefaultDirName默认安装目录{autopf}是Program Files{userpf}是用户Program FilesPrivilegesRequired权限级别admin需要UAC提权lowest不需要提权装AppDataArchitecturesInstallIn64BitMode64位行为x64compatible表示安装进程以64位身份运行避免注册表重定向CloseApplications安装前关闭正在运行的目标程序写yes后还会配合RestartApplications自动重启OutputBaseFilename生成的安装包文件名例如MyToolSetup.exeUninstallDisplayIcon控制面板中卸载项的图标一般指向{app}\主程序.exe这里最值得展开的是权限。我建议绝大多数的桌面工具都使用PrivilegesRequiredadmin。原因很简单今天很多软件要写自己的配置到系统目录要装驱动要写公共注册表项普通用户权限根本干不了。但代价是UAC弹窗。如果你确定你的软件不需要写Program Files以外的东西所有数据都存到用户AppData那用PrivilegesRequiredlowest配合安装到{userappdata}可以让用户双击直接安装、完全不触发UAC安装体验确实更顺滑。3.2 [Files]段文件拷贝的规则与Flags[Files]段是最容易出错的部分也是安装包能否正常工作的基础。它的基本语法是Source: 源路径; DestDir: 目标目录; Flags: 标志位源路径可以写具体文件也可以写通配符。以下是我常用的三种写法; 拷贝整个dist目录包含子目录 Source: dist\*; DestDir: {app}; Flags: ignoreversion recursesubdirs createallsubdirs ; 单个exe主程序 Source: dist\MyTool.exe; DestDir: {app}; Flags: ignoreversion ; 配置文件只在不存在时拷贝卸载时也不删除 Source: config\app.ini; DestDir: {app}; Flags: onlyifdoesntexist uninsneveruninstallFlags的每个值都有明确含义选错就会出问题我列个速查表Flag含义典型场景ignoreversion强制覆盖目标上已有的同名文件几乎所有程序文件都应该加recursesubdirs递归处理源目录子目录整个文件夹分发时必加createallsubdirs空的子目录也创建需要预留日志目录时onlyifdoesntexist目标存在同名文件时不覆盖用户配置文件、数据库文件deleteafterinstall安装完成后删除源文件临时解压的VC运行库安装包uninsneveruninstall卸载时保留该文件用户数据、激活信息我给新手一个建议程序文件exe、dll、资源文件全部加ignoreversion否则升级安装时如果目标系统里已有一个同名文件被标记为受保护Inno Setup可能不会覆盖它结果是用户以为装上了新版实际跑的还是旧程序。这也是后面我要讲的一个经典坑。3.3 [Icons]与[Tasks]快捷方式与附加任务[Icons]段负责创建快捷方式。最常见的两个位置是开始菜单和桌面[Tasks] Name: desktopicon; Description: 创建桌面快捷方式; GroupDescription: 附加任务: [Icons] Name: {group}\MyTool; Filename: {app}\MyTool.exe; WorkingDir: {app} Name: {autodesktop}\MyTool; Filename: {app}\MyTool.exe; WorkingDir: {app}; Tasks: desktopicon这里有个细节[Tasks]段定义的desktopicon任务默认是勾选状态用户安装时可以在附加任务页面去掉勾选就不会生成桌面快捷方式。这样设计比较尊重用户不会往人桌面上乱丢图标。WorkingDir一定要设置成{app}。很多程序在运行时会读取相对路径下的文件如果工作目录不对程序可能找不到配置文件。不设WorkingDir的话快捷方式默认的工作目录是目标exe所在目录通常也就是{app}但显式写出来更保险。3.4 [Registry]与[Run]写注册表、装运行库、启动程序安装包写注册表是很常见的行为比如记录安装路径、版本号、协议关联。我的写法通常是[Registry] Root: HKCU; Subkey: Software\MyStudio\MyTool; ValueType: string; ValueName: InstallPath; ValueData: {app}; Flags: uninsdeletekey Root: HKCU; Subkey: Software\MyStudio\MyTool; ValueType: dword; ValueName: Version; ValueData: 100; Flags: uninsdeletekeyFlags: uninsdeletekey是必须加的它保证用户在卸载时注册表项会被清理干净。如果没加卸载后系统里会留下一堆垃圾项极不专业。[Run]段控制安装完成后要执行哪些程序。实际项目里最常见的两种用法启动主程序和安装运行库。; 安装完成后启动主程序 Filename: {app}\MyTool.exe; Description: 运行 MyTool; Flags: nowait postinstall skipifsilent ; 静默安装VC运行库 Filename: {tmp}\vcredist_x64.exe; Parameters: /install /quiet /norestart; Check: VCRedistNeededskipifsilent这个标志很有意思正常双击安装时安装完询问要不要启动但如果用静默安装比如企业批量部署就跳过这一步避免机器上莫名其妙弹个程序出来。这个细节很贴心值得养成习惯。4. 真实项目才用得上的进阶能力环境检测、静默安装、自定义页面4.1 环境依赖检测别让用户装完打不开如果你分发的是Python打包后的exe或者Java打包的exe用户机器上很可能缺运行环境。很多开发者把这些运行库直接塞进安装包里装完再说其实更好的是先检测再提示。Inno Setup的Pascal脚本可以写检测函数并且能和[Files]、[Run]段联动。比如检测VC运行库是否安装脚本片段大致如下[Code] function VCRedistNeeded(): Boolean; var Version: String; begin Result : True; if RegQueryStringValue(HKEY_LOCAL_MACHINE, SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64, Version, Version) then begin Result : CompareStr(Version, v14.20.27508) 0; end; end;逻辑不复杂读注册表里的版本号和期望的最低版本比较低于期望就返回True安装时用Check: VCRedistNeeded去判断要不要执行那个Run条目。这种检测思路同样适用于.NET Framework、Java运行时、DirectX等。核心价值在于安装过程就能发现问题而不是等用户装完双击报错。4.2 静默安装参数与命令行编译做企业内部分发或者接入CI/CD流水线时静默安装是刚需。用户双击安装包的交互式流程在自动化场景里没人愿意点下一步。Inno Setup编译出来的安装包天然支持静默参数MyToolSetup.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-/VERYSILENT不显示安装界面/SUPPRESSMSGBOXES抑制所有提示框/NORESTART不重启系统/SP-跳过安装完成后运行程序的询问页卸载也是一样的{app}\unins000.exe /VERYSILENT命令行编译则是把整个安装包构建过程纳入CI/CD的关键。Inno Setup自带ISCC.exe编译器我一般在CI脚本里这么用C:\Program Files (x86)\Inno Setup 6\ISCC.exe build.iss /DMyVersion1.0.0 /Odist\installer /FMyTool-setup这里的/D可以定义脚本里的预处理器变量/O指定输出目录/F指定输出文件名。版本号通过CI传入同一个脚本就可以构建出多个版本的安装包不用每次手动改脚本。4.3 Pascal脚本扩展与预处理器的小套路Inno Setup真正的威力来自于[Code]段它支持完整的Pascal脚本。除了检测函数还能干这些事用InitializeSetup事件函数做安装前置检查比如不允许在Windows 7上安装用CurStepChanged钩子在安装的不同阶段执行自定义动作比如安装完成后创建空配置目录用CreateInputOptionPage做自定义选项页面让用户勾选是否创建数据目录、是否注册文件关联预处理器是另一个容易被忽视的功能。它类似C语言的宏处理脚本里重复出现的路径、版本号、产品名都可以抽成常量#define AppName MyTool #define AppVersion 1.0.0 #define AppDir C:\Build\dist [Setup] AppName{#AppName} AppVersion{#AppVersion}注意区分两个语法{#变量}是编译期由预处理器替换的{app}、{group}这些是安装运行时的常量。搞混了脚本会直接报错编译时多看报错信息就能明白。5. 编译、验证与分发把安装包真正交到用户手里之前要做的事5.1 编译与命令行产物控制写完脚本IDE里按编译按钮产物就出来了。但我要特别强调一下编译输出路径的管理。很多人直接用默认的OutputDirOutput编译结果散落在项目目录里。我习惯用一个固定构建目录并且把所有临时脚本文件、中间产物全部纳入.gitignore只提交.iss脚本和资源文件。这样做的理由很简单安装包本身是二进制产物不该进Git仓库。但脚本必须进仓库这样任何人拉下代码都能复现同一个安装包。5.2 代码签名与SmartScreen/UAC体验这是很多个人开发者最不愿意面对但必须面对的问题未签名的安装包用户双击时会遇到Windows SmartScreen的蓝色警告写着Windows已保护你的电脑很多用户看到这个就直接放弃了。解决思路分两层商业代码签名证书花钱买机构颁发拿到后配置到Inno Setup里。编译时它会自动给安装包签名SmartScreen警告会变成未知发布者甚至完全消除。自签名证书免费自己用工具生成能防止文件被篡改但用户机器上依然会提示无法验证发布者适合企业内部用企业证书推送到终端。Inno Setup里配置签名很简单在[Setup]段指定SignTool[Setup] SignToolstandard signtool /a /t http://timestamp.digicert.com $f关键是$f这个占位符Inno Setup会用编译好的安装包路径替换它。签名要放在编译之后自动执行这个过程不用手动干预。5.3 杀软误报的来龙去脉写安装包的人十有八九遇到过误报。Inno Setup做出来的安装包本质上是一个自解压程序它会释放文件、写注册表、创建快捷方式这些行为在沙箱里看起来就是标准的安装后门程序行为模式。如果安装包的代码里再有点字符串匹配特征被启发式引擎标记是常有的事。我踩过坑之后的处理经验是别用加壳、混淆、直接改特征之类的手段去对抗杀软这种做法只会让误报更严重。正确做法是申请代码签名证书并签名签名后的文件在白名单体系里信任度会大幅提升。除此之外保持安装包文件结构简单、不用加密压缩、定期提交误报申诉都能有效降低误报概率。5.4 发布前的自测清单我每次发版本之前会走一遍检查清单不多但每项都有实际意义在全新虚拟机里装一遍确认安装向导无报错在32位和64位系统各装一遍确认目录和注册表路径正确关闭UAC的情况下测试一次确认提权逻辑正常在旧版本基础上覆盖安装确认升级后文件确实被更新完整安装再完整卸载检查系统里是否还有残留安装包在杀软全开的环境下安装一次确认无拦截这轮测试做完我才敢把安装包发出去。6. 我在Inno Setup上踩过的坑从脚本报错到卸载残留的完整排查链路6.1 编译脚本时的Source路径问题一次报错的完整定位链路我在一次新项目里配好脚本编译时Inno Setup直接报错Source file not found。这个错绝大部分情况下是路径相对位置出了问题。Inno Setup的源路径是相对脚本文件所在目录解析的不是相对当前工作目录。我那次把.iss脚本放在C:\build\目录下但Source写的是Source: dist\*实际上dist文件夹在C:\build\release\dist\下面所以找不到。排查的思路是这样先看报错行定位到哪个Source然后用脚本文件的完整路径加上Source路径自己在文件管理器里走一遍确认文件确实存在后再检查是不是目录名写错。脚本编辑器里有个快速同目录定位按钮可以直接跳到脚本所在目录非常方便。经验教训是把脚本和源文件放在同一个固定目录结构下不要用绝对路径因为换一台机器构建时绝对路径一定会出问题。6.2 升级安装时新文件盖不上ignoreversion的两面性这是我印象最深的一个线上事故。用户机器上装了1.0版本我发了一个1.1版本安装包用户安装了注册表显示版本确实更新了但程序跑起来还是老版本的界面。查到最后问题出在[Files]段文件拷贝时没有加ignoreversion标志。Windows里如果一个文件被标记为系统文件或者有其他保护属性Inno Setup默认会跳过覆盖导致旧文件残留。加上ignoreversion问题就消失了。但这个标志也有反面作用如果安装包里包含用户配置文件加了ignoreversion就会在升级时把用户的个性化配置覆盖掉。所以正确做法是程序文件加ignoreversion用户配置文件用onlyifdoesntexist两套策略分开。这个教训也回答了为什么我在前面不厌其烦地强调区分文件类型。6.3 64位系统下的注册表重定向与目录错位还有一个坑出现在我做一个需要写注册表的32位程序时。程序是32位的exe安装包也没声明64位支持。运行在64位系统上时程序写HKCU\Software时被系统重定向到了HKCU\Software\WOW6432Node所以32位程序读到了值64位工具却看不到。解决办法是升级Inno Setup 6.1以上版本并在脚本里加上ArchitecturesInstallIn64BitModex64compatible。这一行的作用是让安装进程本身以64位身份运行目录选择直接是真实的{autopf}注册表也不会走重定向。如果你的安装包里既有32位程序又要处理这些系统细节这个参数几乎是必加的。6.4 卸载不干净与AppId撞车的连带问题卸载不干净的核心原因是安装包只负责卸载自己[Files]段管理过的文件。如果你的程序运行后在AppData、注册表其他位置写了数据那些不在安装包的知识范围内卸载器自然不清理。处理方式是分层管理配置文件如果要保留文件级加uninsneveruninstallAppData下的数据在[Code]段的CurUninstallStepChanged事件里写清理逻辑注册表项统一在写的时候加uninsdeletekey。这样卸载时该留的留该清的清。AppId撞车是另一个隐蔽问题。AppId就是安装包的身份证如果从网上复制了一段脚本没改AppId两个不同的软件在系统看来就是同一个软件。用户装了A再装B系统会提示已存在该软件的安装是否修复卸载时两个程序都会被删掉。这个坑非常致命但解决办法一句话每个项目生成独立的GUID永不复用。最后分享一个我自己用下来的小技巧Inno Setup的脚本写完之后我习惯把编译命令和签名命令放到一个再简单不过的批处理文件里双击就能产出最终签名好的安装包。这看起来不起眼但每次版本发布时节省的时间和心力是实打实的。工具链的最后一环往往不在技术多深而在流程顺畅。希望这个安装包的工程化思路也能让你分发软件的路走得更稳。