翻到笔记里躺着的一条需求记录日期停在2021年8月19日内容是“在InstallShield主程序安装包之外附属安装和卸载一个MSI组件”。笔记本上就这一句话但做过Windows安装包开发的人看到这句话应该马上能想到一堆具体问题MSI怎么塞进主安装包、什么时候以什么权限启动安装、卸载时怎么把独立产品清干净、装到一半失败了怎么排查。这篇文章就把这类需求完整拆一遍从方案选型、安装动作配置、卸载逻辑、日志排错到个人踩过的坑一条条写清楚。适合正在维护InstallShield老工程、或者需要把多个独立MSI组合成一套交付包的开发者参考。1. 需求还原与方案选型先想清楚要解决什么问题1.1 这个需求到底在说什么先还原一下场景。假设手里已经有一个主产品的安装包工程可能是InstallScript类型也可能是Basic MSI类型。主产品本体是一套软件但运行依赖另一个独立的MSI组件——比如某个运行时、驱动包、服务模块、公共库甚至可能是另一个团队维护的插件。这个MSI不能简单被“融入”主安装包因为它有自己的版本号、发布渠道和升级节奏必须以独立产品的形式出现在“程序和功能”里也必须在主产品卸载时一并卸载。用户视角就一句话双击主安装包装完主程序附属组件一起到位卸载主程序附属组件也一起消失。但这句话落到InstallShield工程里意味着要在安装和卸载两个时序里各插一段自定义逻辑还要处理好权限、条件、路径和残留问题。很多人在这一步容易犯迷糊不等价于把一个MSI文件拖进安装包就完事。拖进去只是让文件“随包分发”Windows Installer不会因为你把一个MSI复制到了安装目录就把它注册成一个产品。真正让它安装起来、卸载干净靠的是执行序列里的自定义动作Custom Action调用msiexec命令。1.2 三条主流路径怎么选处理这种“主程序之外附属一个MSI”的需求业内常见有三条路各有利弊先放个对比表方案安装集成方式卸载联动工程改造成本适用场景Merge Module.msm把MSI组件转成合并模块嵌入主MSI的Feature天然随主产品卸载无残留需要获取组件源码工程重新构建MSM每次组件升级都要出包组件是纯静态资源、无需独立升级Suite/Advanced UI工程编排多个MSI统一安装进度界面Suite可独立卸载每个MSI需要迁移整个安装工程到Suite项目类型全新的多产品组合安装场景主工程加自定义动作调用msiexec在安装序列中静默执行msiexec /i在卸载序列中执行 msiexec /x只改现有工程组件方完全不用动维护老InstallShield工程的最佳选择如果手里是个运行了很多年的老工程或者团队里还有一堆基于InstallScript脚本写死的业务逻辑迁移Suite项目的成本基本等于重做安装包。Merge Module方案又要求组件方配合改工程跨团队协调经常卡壳。所以我在实际处理时选的是第三条路主工程保持不动在安装和卸载序列里分别插入执行msiexec的自定义动作。选择这条路的核心原因是“改动最小、可控性最强”。主安装包还是原来的主安装包附属MSI还是原来的独立MSI两边都不需要动源码。InstallShield只负责在正确的时机、用正确的身份、带上正确的参数去调用msiexec而已。听起来简单实际配置起来有不少细节下面两章按安装、卸载分开讲。2. 安装阶段把附属MSI装进主安装包2.1 准备阶段MSI文件放哪、怎么进包开始配置之前先把附属MSI文件弄进主安装包。这一步看似基础但文件放的位置会影响后面卸载逻辑的可靠性。InstallShield工程里一般有两个地方可以放这类“额外文件”。InstallScript工程习惯用“Support Files”视图Basic MSI工程可以在“Files and Folders”里把它加到一个组件目录下。我个人的建议是专门在安装目录下开一个子目录比如[INSTALLDIR]\Components\xxx.msi让MSI文件随着主安装流程释放到用户机器上。为什么不放临时目录临时目录看起来干净装完可以自动清理但问题是卸载时很可能已经拿不到了。Windows安装目录在主程序卸载时大概率还在而临时文件夹可能被系统盘清理、用户手动清理或者其他安装过程给清掉。如果卸载动作想依赖本地文件做二次确认文件却没了那场面会很尴尬。还有两个小细节值得提前注意。第一MSI文件名尽量全英文、不带空格。msiexec命令行解析路径时空格必须靠双引号处理一旦引号嵌套出问题安装动作直接失败。第二确认这个MSI是32位还是64位组件。这两个位数在64位系统上的行为完全不同后面排查部分会专门讲。2.2 用自定义动作触发静默安装文件进包之后开始配置安装动作。以Basic MSI工程为例在InstallShield里打开“Custom Actions and Sequences”在InstallExecuteSequence中找到位置右键新增一个执行程序的自定义动作核心配置如下动作类型Launch Program启动外部程序命令行msiexec /i [INSTALLDIR]\Components\xxx.msi /qn /norestart /l*v [TempFolder]\component_install.log执行位置InstallFiles之后、InstallFinalize之前事件定位选“After InstallFiles”条件NOT Installed执行上下文Deferred Execution in System Context延迟到系统上下文执行每个参数都不是随便写的。/qn表示完全静默安装不弹MSI自带界面如果主安装包是带向导界面的而附属MSI又弹出一个安装进度框用户会看到两个安装界面叠在一起显得非常业余也容易让用户误以为安装程序出了问题。/norestart是防止附属MSI在安装过程中触发系统重启导致主安装流程直接中断。/l*v是生成详细日志这个参数在出问题排查时几乎是救命稻草后面第4章会反复提到。条件NOT Installed保证了只在首次全新安装时触发。主安装包做修复操作或者添加组件时不应该再执行一遍附属MSI的安装动作。执行上下文选择“系统上下文”也很关键普通用户双击安装包时Windows Installer的权限往往不足以在Program Files目录写文件、在注册表HKLM写键值把自定义动作提升到系统上下文可以避开大量权限报错。InstallScript工程的做法思路完全一样只不过位置在InstallScript序列里点的位置叫“Call Custom Action”或者直接在脚本码区写一段LaunchApplication或调用ShellExec的代码。区别在于老InstallScript工程更灵活同一个动作甚至可以做成同步等待或异步执行——遇到需要确认安装结果的情况可以选同步让主安装流程等待msiexec返回的退出码再继续。2.3 安装时机与进度反馈上的经验配置好动作后还有一个很少有人会在第一时间考虑的问题安装时机对用户体验的影响。贴上“After InstallFiles”位置是有讲究的。放在这个点意味着主安装包的文件已经先落盘安装目录结构已经存在。如果放在InstallFiles之前比如放在InstallInitialize后面附属MSI依赖的一些目录或文件可能还没释放轻则安装失败重则留下半个装完的组件残骸在系统里。我在实际工程中测试过一次把动作放在InstallFiles之前那次测试机重启后直接报告组件损坏属于典型的不按执行顺序办事的教训。另外附属MSI如果比较大静默安装在用户看来就是“一直没反应”。主安装包的进度条可能会停在某个阶段长时间不动。建议在自定义动作前后加一个进度提示安装动作开始前把进度条文案改成“正在安装附加组件...”动作返回后恢复原有进度。InstallShield里可以通过设置进度条消息文本或插入一个小的等待窗口来实现不同版本界面叫法不一样但逻辑都是异步任务加界面反馈。还有一个容易被忽略的点安装动作的时序选择会直接影响后续卸载逻辑的组织。比如安装时把附属MSI放到[INSTALLDIR]\Components卸载时就可以按这个路径去确认文件是否还在。路径策略要和卸载策略一起设计不要装的时候随手放卸的时候又临时找。3. 卸载阶段主程序走了附属组件也得跟着走3.1 为什么默认不会自动卸载现在说卸载。很多人配置完安装动作就以为大功告成了实际上坑全在卸载这半边。前提认知是用msiexec安装成功的MSI会以独立产品的身份注册在Windows Installer的“已安装产品”列表里拥有自己的ProductCode。主安装程序卸载时Windows Installer只会清理主MSI产品数据里记录的文件、注册表项和组件不会主动去扫描机器上还存在哪些其他产品。所以主程序卸载完后附属MSI会变成孤儿产品留在“程序和功能”列表里用户下次重新安装主程序时还会被这个残留版本干扰。我遇到过的最典型的残留症状是卸载主程序后重装新版安装过程一切正常但新版附属组件安装动作返回错误打开日志一看系统提示“该产品的另一个版本已安装”。这就是孤儿产品没清干净导致的冲突。3.2 卸载自定义动作的配置所以必须给卸载动作单独配置一段自定义逻辑。同样是以Basic MSI工程为例动作位置RemoveFiles之后、InstallFinalize之前条件REMOVEALL AND NOT UPGRADINGPRODUCTCODE命令行msiexec /x {GUID-XXXX-XXXX-XXXX-XXXXXXXXXXXX} /qn /norestart /l*v [WindowsFolder]\component_uninstall.log执行上下文Deferred Execution in System Context这里的ProductCode不是瞎填的。要从附属MSI安装包本身的属性表里读取也就是该组件作为独立产品时注册用的那个GUID。这个GUID在“程序和功能”里、注册表HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall条目下都能对应上。安装时写进命令行用的就是它。条件里的REMOVEALL表示完整卸载整个产品而不是修改某个功能AND NOT UPGRADINGPRODUCTCODE也很重要它的作用是防止主安装包做升级操作时误把附属组件卸载掉。升级场景下旧版本的主产品被移除时也会触发卸载序列如果不加这个条件就会出现“升级主程序结果附属组件被卸了”的严重事故。卸载用/x参数配合ProductCode不要用文件路径。原因很简单附属MSI在机器上可能因为后续单独升级、手动安装过新版本而发生变化用ProductCode卸载能保证卸载的是“当前注册的产品”而不是“某个路径下的文件对应的那个版本”。3.3 卸载时序的取舍关于卸载时序不同团队有不同习惯。我看到网上有方案安排在InstallInitialize之前卸载附属MSI理由是越早清掉组件主程序卸载时越不会受它的文件影响。但实际上这个时序有个隐患如果附属组件有服务或进程正在运行主程序文件还没清理就被卸载可能因为文件被占用导致卸载失败。我实际更推荐放在RemoveFiles之后。先让主程序把自己的文件清理掉如果主程序进程正在运行也让它先释放句柄然后再触发附属MSI的卸载此时系统的文件占用已经最小化卸载成功率明显提高。卸载日志的存放位置也要留意。不要写到[INSTALLDIR]下因为主程序文件已经被清掉了目录可能都不存在了写到[WindowsFolder]或[TempFolder]更稳妥。我在一次测试中把卸载日志路径写死到了安装目录下结果卸载完成后日志文件根本找不到排查直接没了线索。另外卸载动作调用msiexec返回3010需要重启时主程序卸载也不能直接假装成功最好给用户一个“请重启系统完成卸载”的提示。很多安装包对3010的处理都很随意但附属组件的文件如果被占用不重启的情况下卸载其实并不彻底重新安装时可能还会有残留冲突。4. 常见问题与日志排查实录4.1 msiexec返回码速查表自定义动作本质是启动msiexec进程排查时第一眼先看进程的退出码。msiexec的返回码很有规律列个速查表方便对照返回码含义排查方向0安装/卸载成功无3010操作成功但需要重启系统检查是否有文件占用、服务待重启1603安装严重错误看详细日志定位权限、路径、依赖都可能1619安装包无法打开检查MSI文件路径、文件是否存在1620安装包无法解析检查MSI文件是否损坏1625系统策略禁止安装检查组策略、UAC限制1635升级版本不匹配检查已安装版本和升级包版本1706找不到源文件检查安装源路径、MSI缓存是否完整1605产品未安装检查ProductCode是否正确这张表能在排查时帮我们把问题快速分成两类一类是安装包本身的问题一类是环境或路径的问题。比如1619、1706往往伴随着文件路径错误1603则需要结合日志里更具体的错误信息往下追。4.2 一次1603的完整排查过程拿一个典型的1603举例。某次测试主程序在开发机上安装正常在另一台测试机上安装时主程序装完了最后却整体回滚。打开附属组件的安装日志末尾写着“MainEngineThread is returning 1603”。经验不够的人看到1603可能会先去怀疑权限。但仔细看日志发现错误发生在系统上下文执行阶段权限基本没有问题。继续往日志前面翻看到一行路径相关的记录附属MSI尝试访问64位程序目录下的路径但日志里记录的目录被重定向到了SysWOW64下。问题就出在位数匹配上。这台机器上跑的附属MSI是32位组件而主安装包工程是64位工程自定义动作默认启动的msiexec继承了主进程的位数环境。结果就是动作执行环境、MSI目标平台、实际落盘路径三者不一致组件安装到一半找不到目标路径于是回滚。解决办法是在自定义动作的属性里显式标记“64-bit”执行或者干脆把附属MSI换成和主工程匹配的位数。这个案例说明位数问题表面上看是配置问题本质上是对Windows Installer重定向机制的认知问题。32位进程在64位系统上写文件、写注册表时系统会悄悄把路径和注册表键重定向到SysWOW64目录和Wow6432Node节点日志里如果不熟悉这套行为根本看不出异常。4.3 三个高频坑与避让第一个高频坑是ProductCode抄错。GUID连字符的位置、大小写、数字字母写错一个卸载时直接1605。尤其是从安装日志里复制ProductCode时很容易因为日志格式截断而抄漏一段。最稳的做法是打开附属MSI的Property表直接读ProductCode或者让组件方提供正式参数清单。第二个高频坑是卸载依赖本地文件路径。用msiexec /x 路径\xxx.msi这种写法在文件还在时确实能卸但一旦主程序文件先被清理路径失效卸载动作直接失败。前面章节已经强调过卸载统一用ProductCode这是整个方案里最重要的原则之一。第三个高频坑是条件漏掉升级判断。很多人照着网上的模板填了REMOVEALL没加AND NOT UPGRADINGPRODUCTCODE结果主安装包升级时卸载序列触发了附属组件的卸载动作用户升级完主程序发现附属组件不见了服务起不来。这种问题在测试环境不容易发现因为升级测试往往被排在很靠后的阶段但一旦发生就是生产事故级别的影响。还有一个小坑值得单独提安装动作放在InstallFiles之前导致的找不到文件错误。前面提到过InstallShield界面上自定义动作的时序位置非常好拖拖错位置也不会报明显错误只在安装时静默失败。所以配置完一定要看一整条执行序列的完整顺序不要只看单个动作。5. 实操总结与踩坑记录5.1 一套最简可行配置清单下面把整套方案浓缩成一张可以直接照着做的清单以Basic MSI工程为例配置项安装动作卸载动作文件位置[INSTALLDIR]\Components\xxx.msi不依赖文件路径使用ProductCode动作类型Launch ProgramLaunch Program命令行msiexec /i [INSTALLDIR]\Components\xxx.msi /qn /norestart /l*v [TempFolder]\component_install.logmsiexec /x {GUID-...} /qn /norestart /l*v [WindowsFolder]\component_uninstall.log执行位置After InstallFilesAfter RemoveFiles条件NOT InstalledREMOVEALL AND NOT UPGRADINGPRODUCTCODE执行上下文Deferred Execution in System ContextDeferred Execution in System Context按照这张表配置理论上就能跑通“主程序安装 - 附属MSI自动安装 - 主程序卸载 - 附属MSI自动卸载”的完整闭环。InstallScript工程的区别只是把自定义动作放到脚本序列里命令行参数完全一致。实际动手前建议先把三件事写在纸上附属MSI的ProductCode、安装日志要放的路径、卸载日志要放的路径。这三个信息在排错时会频繁用到提前列清楚能省很多时间。5.2 我的几点体会这种“主程序外再带一个MSI”的需求表面看是InstallShield界面上的两次自定义动作配置深层涉及的其实是Windows Installer的产品模型、执行序列时序、权限上下文、32/64位重定向机制这四层知识。只会在界面里点按钮不理解背后的执行逻辑出了问题就会很被动。我个人强烈建议配置完成后不要只在开发机上验证一次“能装上”就结束。至少要在干净虚拟机里完整跑一遍“安装-卸载-重装”三连测重点观察三件事卸载完成后“程序和功能”里还有没有残留产品记录重装时附属动作的日志有没有出现1605、1706这类异常再卸载时日志路径是否正确。这套测试流程能提前暴露大部分残留问题成本比生产环境出问题低一个数量级。最后再分享一个细节日志文件命名时把动作名带进去。比如安装日志叫component_install.log卸载日志叫component_uninstall.log不要统一叫msi.log。出问题时用户收集回来的是一堆文件名相同的日志光靠时间戳区分会非常痛苦。把动作名写进日志名三四份日志摆在一起一眼就能看出哪份是安装动作产生的、哪份是卸载动作产生的排查效率完全不一样。