Inno Setup 覆盖安装前执行卸载、获取原安装路径实战
用 Inno Setup 做安装包时,最让我头疼的一个改动需求就是“覆盖安装时,先把旧版本卸掉再装新的”。听起来很简单,但真做起来全是细节:怎么在安装启动阶段拿到旧版本的位置、怎么保证卸载程序能干净跑完、如果用户中途取消卸载要不要继续装、装完之后注册表残留怎么办。这篇就把我在实际项目中落地的完整方案和踩过的坑一次说清。
这篇文章适合正在维护 Windows 桌面软件安装包、对 Inno Setup 语法有一定基础(至少知道 [Setup]、[Files] 段怎么用)的开发者。如果你是第一次听说 Inno Setup,建议先跑通一个最简单的安装包再回来看,不然部分概念会有点跳。核心解决三个问题:知道旧版本装在哪里、什么时候触发卸载、卸载失败如何处理。
1. 为什么覆盖安装前必须先卸旧版
1.1 覆盖安装的隐藏雷区
接手这个需求之前,我其实也是“偷懒派”——直接让 Inno Setup 覆盖写文件,觉得只要版本号升上去、文件覆盖掉就行。但现实很快教做人,覆盖安装至少有这三个坑:
第一,旧版注册的 COM 组件、服务、驱动不会自动清理。比如旧版注册了一个全局 Hook DLL,新版本如果不再需要这个组件,覆盖安装后这个 DLL 还在系统里被加载着,用户迟早会碰上诡异冲突。
第二,文件版本不同但文件名相同。如果你新旧版本里某个公共 DLL 的版本号相同,直接覆盖可能不会替换;如果新版本把文件拆分了(比如一个 exe 拆成 exe + dll),旧文件就会残留,装完出现“幽灵文件”。
第三,用户配置和注册表项的状态不可控。旧版卸载时会执行一些清理逻辑(比如删除旧版专用配置、重置 Shell 状态),但覆盖安装完全绕过了这些。等用户手动卸载新版时,那些旧版遗留的清理逻辑早已失效。
所以,产品侧提出“覆盖安装前先执行卸载”这件事,不是小题大做。尤其是老项目迭代多年、历史包袱大,干净卸载再装新版本,比指望覆盖逻辑永远正确要稳妥得多。
1.2 卸载再安装的核心判断逻辑
“先卸载再安装”听起来是暴力方案,但它背后的判断逻辑其实很讲究:什么情况下允许自动卸载?什么情况下不能?
我的经验是至少要看这三层:
- 旧版必须是在本机上确实存在的。如果用户是全新安装,根本没装过旧版,你上来就执行卸载,只会弹一个“未找到卸载程序”的报错,体验极差。
- 卸载不能是静默无声的。用户的机器上可能有正在运行的旧版程序,直接静默卸载会把用户的工作进程杀掉,至少得弹窗问一句“检测到旧版本,是否先卸载再安装”。
- 用户取消卸载时,安装流程必须终止。不能出现“用户点了取消卸载,结果新版本照样装”的尴尬局面,否则两个版本文件混杂,比覆盖安装还危险。
这里还要提一个容易被忽略的点:如果新版安装包是 MSI 包,而旧版是 Inno Setup 包的,卸载方式完全不同。MSI 包不能靠执行 unins000.exe 来卸载,得走 msiexec /x。所以实现自动卸载前,先要确认旧版确实是 Inno Setup 打的包。怎么确认?后面讲注册表时一起说。
2. 安装状态检测:AppId 与注册表里的卸载线索
2.1 AppId 是查找旧安装的唯一线索
Inno Setup 在安装包脚本里有一个很关键的字段,叫AppId。它的默认值是 script 文件的 GUID,每次新建脚本都会自动生成一个。AppId决定了卸载信息在注册表里挂在哪个键下面。
Inno Setup 安装完成后,会在:
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{AppId}_is1或者如果是“仅当前用户”安装模式(privilegesRequired=lowest),则在:
HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{AppId}_is1写入一整组卸载相关信息,包括DisplayName、UninstallString、QuietUninstallString、InstallLocation、DisplayVersion等。这就是我们判断“旧版是否装过”和“装在哪里”的核心依据。
提示:
AppId一旦在线上发布过,就不要随便改。改了 GUID 之后,老版本的卸载信息就找不到了,新版本会变成“看似全新安装但旧文件还在”的孤儿状态,用户手动卸载也没法一次卸干净。我在项目里吃过这个亏,后来强制团队把 AppId 写死在脚本里。
2.2 读取注册表拿到原安装路径
知道了注册表路径,接下来就是在 Inno Setup 的 Pascal 脚本代码里读取它。核心代码很简短:
function GetOldInstallPath(): string; var sPath: string; sAppId: string; begin Result := ''; sAppId := '{#emit SetupSetting("AppId")}'; if RegQueryStringValue( HKLM, 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\' + sAppId + '_is1', 'InstallLocation', sPath) then begin Result := sPath; end else if RegQueryStringValue( HKCU, 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\' + sAppId + '_is1', 'InstallLocation', sPath) then begin Result := sPath; end; end;这段代码的逻辑很直白:先查 HKLM,再查 HKCU,谁有值就用谁。为什么要两个都查?因为 Inno Setup 的privilegesRequired设置不同,卸载信息存放位置也不同。你无法百分百保证所有用户都是管理员权限安装,所以两个根键都要看。
InstallLocation就是安装时DefaultDirName实际解析出来的完整路径。如果你在脚本里用了{autopf}、{userpf}这类预定义常量,注册表里存的也是展开后的真实路径。
注意:
InstallLocation这个值只有在安装时确实写入了才会存在。理论上DefaultDirName决定了安装路径,Inno Setup 也会在卸载信息里写一份,但老版本(5.x 早期)有可能不写。如果读不到InstallLocation,可以退一步读UninstallString,从卸载命令的参数/DIR=里解析路径,后面会讲这个兜底方案。
2.3 32 位与 64 位注册表视图
这一步非常容易踩坑。如果安装包是 32 位的 Inno Setup 程序(默认就是 32 位),它在 64 位 Windows 上读写注册表时,会遇到注册表重定向。
简单说,32 位进程默认只能看到:
HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\...而 64 位安装程序写的信息在:
HKEY_LOCAL_MACHINE\SOFTWARE\...如果你是 32 位安装包,去读 HKLM 的SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\...,很可能读不到 64 位路径下的卸载信息,因为系统给你偷偷重定向到了 WOW6432Node 节点。
Inno Setup 的RegQueryStringValue有第 4 个参数Use64bitKey。设置为True时,32 位进程也能读写 64 位视图下的注册表。所以代码要改成:
if RegQueryStringValue( HKLM, 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\' + sAppId + '_is1', 'InstallLocation', sPath, True) then begin Result := sPath; end;这个True就是“我明确要访问 64 位注册表视图”。顺便提醒,如果安装包脚本里设置了ArchitecturesInstallIn64BitMode,Inno Setup 本身会以 64 位模式运行,这时候默认就可以直接读 64 位视图。但为了稳妥,我建议无论哪种模式都把Use64bitKey显式传值,避免团队其他人改配置时把行为搞乱。
3. 完整实现:覆盖安装时先卸载旧版本
3.1 在 InitializeSetup 中完成路径读取
Inno Setup 脚本的生命周期里,第一个适合干“检测旧版本”这件事的地方是InitializeSetup()函数。它在安装包解压出所有文件之前触发,此时 Windows 还没有任何文件占用冲突,进程可以放心做检测和判断。
我的写法是这样的:
function InitializeSetup(): Boolean; var sOldPath: string; sOldUninstall: string; iResult: Integer; begin Result := True; // 1. 找到旧版本卸载程序路径 sOldUninstall := GetOldUninstallString(); if sOldUninstall = '' then begin // 没有旧版本,直接进全新安装流程 Log('No previous installation found.'); Exit; end; // 2. 读取旧版本安装目录,供后续逻辑使用 sOldPath := GetOldInstallPath(); if sOldPath <> '' then begin Log('Previous installation path: ' + sOldPath); end; // 3. 询问用户是否卸载,同时兼容无人值守场景 if WizardSilent then begin // 静默安装模式下:直接卸载,不弹窗 Exec(RemoveQuotes(sOldUninstall), '/VERYSILENT /NORESTART', '', SW_HIDE, ewWaitUntilTerminated, iResult); if iResult <> 0 then begin Result := False; end; end else begin iResult := MsgBox('检测到已安装旧版本,是否先卸载?' + #13#10 + '卸载完成后将继续安装新版本。', mbConfirmation, MB_YESNO); if iResult = IDYES then begin Exec(RemoveQuotes(sOldUninstall), '/VERYSILENT /NORESTART', '', SW_HIDE, ewWaitUntilTerminated, iResult); if iResult <> 0 then begin MsgBox('卸载旧版本失败,安装已中止。', mbError, MB_OK); Result := False; end; end else begin // 用户拒绝卸载就直接退出安装 Result := False; end; end; end;这段代码把整个核心逻辑都串起来了:
- 先找卸载程序,找不到就直接走全新安装。
- 找得到先弹窗确认,用户同意就静默执行卸载命令,等待卸载结束。
- 卸载失败就终止安装,不让系统处于半旧半新状态。
- 静默安装模式下不弹窗,直接卸载。
这里最关键的决策点是ewWaitUntilTerminated。它让安装程序挂起等待卸载程序执行完毕,拿到返回值再决定下一步。如果不等待、直接用ewNoWait,安装进程和卸载进程会同时工作,卸载程序正在删文件、安装程序正在写文件,这个冲突画面我不敢细想。
3.2 卸载时机的选择:Install 前 vs InitializeSetup
有同学可能会问,为什么不用CurStepChanged(ssInstall)或者[Run]段触发卸载?我的回答是:只要能把卸载安排在“文件复制开始之前”,都可行,但 InitializeSetup 是最早、最安全的位置。
为什么最早最好?因为安装包解压到临时目录里,文件都还没往目标盘写。此时执行卸载,目标盘上的文件是完整的老版本,卸载程序删起东西来不会有“文件被占用”的顾虑(当然你自己程序正在运行还是要单说)。
如果把卸载放在[Run]段,那么新版本文件可能已经覆盖了老文件,此时卸载程序再跑,大概率会提示“找不到文件”或“文件已损坏”,用户看到这种卸载报错会非常困惑。
CurStepChanged(ssInstall)也曾经是我第一版的做法,但它踩了一个问题:文件复制前的检查阶段,卸载还没执行时,安装向导已经让你选了目录和组件。万一你读到的旧路径和用户新选择的路径不一样,用户体验非常分裂——明明要“先卸再装”,但向导界面看着跟普通覆盖安装没区别。所以最终还是回到InitializeSetup,在向导显示之前就处理完旧版本。
3.3 卸载命令的参数与返回值处理
Inno Setup 自带的卸载程序unins000.exe支持多个参数,实际开发中我会关注这几个:
| 参数 | 作用 | 建议 |
|---|---|---|
/SILENT | 静默卸载,但保留进度条窗口 | 交互模式下可以不用,直接走程序弹窗 |
/VERYSILENT | 完全静默卸载,不显示任何窗口 | 自动卸载时用它最省心 |
/NORESTART | 禁止卸载程序触发重启 | 强烈建议,安装流程里插一次重启体验很差 |
/SUPPRESSMSGBOXES | 禁止卸载过程弹出任何消息框 | 自动卸载场景下必须加,防止卸载过程中弹一个用户没看到的框卡住 |
/DIR="..." | 指定目标目录 | 如果UninstallString里缺了路径或用错位置,可以手动指定 |
当旧版本的卸载程序路径里有空格时,我们一般用RemoveQuotes处理引号。sOldUninstall读出来通常是这样的:
"C:\Program Files (x86)\MyApp\unins000.exe"带不带引号,取决于注册表里的存储格式。用RemoveQuotes去掉引号,再让Exec自己按正常路径格式执行比较保险。
返回值怎么判定?Inno Setup 卸载程序正常退出返回 0;如果因为安装状态异常或者参数错误返回非 0,我们就中止后续安装。这里建议:卸载失败时,不要在静默模式下弹任何消息框,静默模式直接返回 False 让安装退出即可,否则用户会看到“半静默”的诡异体验。
3.4 卸载完成后的残留文件处理
卸载程序正常执行完毕后,很多时候会残留一个或几个空目录。原因往往是旧版本卸载时,有文件被占用没删干净,或者用户自己往安装目录丢过东西。
这时候安装新版本就有一个隐患:如果你选的新安装路径和旧路径一样,残留文件可能在安装向导的“目录已存在”检查中触发警告;更坑的是,如果残留文件里恰好有同名 DLL,新装的文件可能被覆盖或者版本错乱。
我的处理方式是:卸载结束后,再确认一次旧路径是否还存在,如果存在就尝试彻底删除:
function RemoveOldPathIfExists(sOldPath: string): Boolean; begin Result := True; if DirExists(sOldPath) then begin if not RemoveDir(sOldPath) then begin // 目录不为空或者被占用,至少清到能删则删 Log('Warning: old path still exists and cannot be removed: ' + sOldPath); end; end; end;注意:这里只做“尽力而为”的清理,别强制递归删除。万一用户把个人文件放进了安装目录,你一刀切删光就会引发投诉。真实项目里,我会在卸载完成后检查一下卸载程序是否已经把文件清干净,如果还有少数残留,记录下来,不阻塞安装流程。
4. 获取原安装路径的进阶方案
4.1 从 UninstallString 中解析目录
前文提到了InstallLocation的读取,但老版本可能没有写这个值,或者注册表项被别人改了。这时候兜底方案是解析UninstallString里的/DIR=参数:
function ParseDirFromUninstallString(sUninstall: string): string; var iPos: Integer; sTemp: string; begin Result := ''; iPos := Pos('/DIR=', sUninstall); if iPos > 0 then begin sTemp := Copy(sUninstall, iPos + 5, Length(sUninstall) - iPos - 4); // 去掉首尾引号 if (Length(sTemp) >= 2) and (sTemp[1] = '"') and (sTemp[Length(sTemp)] = '"') then begin sTemp := Copy(sTemp, 2, Length(sTemp) - 2); end; Result := sTemp; end; end;这个解析逻辑不算复杂,但有一个坑:路径里如果包含",处理起来要小心。所幸 Inno Setup 生成的卸载命令里,目录参数一定会用引号包起来,所以按引号处理就够用。
4.2 多实例安装的判断
有些软件允许用户同时装多个实例(比如不同版本共存),Inno Setup 卸载信息里会以{AppId}_is1、{AppId}_is2这样的递增编号出现。如果你要处理的是“检测到任意旧版本就卸载”,那么读取卸载信息时需要遍历_is1、_is2……而不是只查一个键。
实现上可以写一个循环:
function FindAllOldInstalls(): Integer; var iIndex: Integer; sSubKey: string; sUninstall: string; begin Result := 0; iIndex := 1; while True do begin sSubKey := 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\' + '{#emit SetupSetting("AppId")}_is' + IntToStr(iIndex); if RegQueryStringValue(HKLM, sSubKey, 'UninstallString', sUninstall, True) then begin Log('Found install #' + IntToStr(iIndex) + ': ' + sUninstall); Result := Result + 1; end else Break; iIndex := iIndex + 1; end; end;不过大多数产品只会有一个安装实例,这个遍历是防御性写法。如果你确定自家产品只允许单实例,那直接查_is1就够了,别画蛇添足。
4.3 用户级与机器级安装的注册表差异
Inno Setup 的安装权限模式会影响写注册表的位置。privilegesRequired=admin时写 HKLM,privilegesRequired=lowest或poweruser时可能写 HKCU。真实环境中经常遇到“之前是管理员装的,这次是非管理员跑安装包”的情况。
这种情况的典型表现是:读 HKLM 读不到,读 HKCU 也读不到。不是没装过,而是记账位置不匹配。面对这种混乱状态,我会再做一层判断:同时查两个根键下的DisplayName,跟当前 AppId 是否匹配,命中任何一个都认为是“旧版本已存在”。
5. 常见问题与排查技巧实录
5.1 问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 总是找不到旧版本 | AppId 对不上,或者注册表视图选错 | 检查 AppId 是否变更;32 位包读 64 位注册表要补True |
| 卸载程序执行返回非 0 | 卸载程序路径带引号导致解析失败 | 用RemoveQuotes处理路径后再Exec |
| 卸载过程中弹窗卡住 | 卸载参数漏了/SUPPRESSMSGBOXES | 自动卸载场景务必加上 |
| 卸载完成后旧目录还在 | 有文件被占用或用户放置了额外文件 | 不要强制删,记录日志即可 |
| 用户取消卸载后继续安装 | 逻辑里没写Result := False | 用户取消必须中止安装 |
| 安装包在 UAC 提权前后行为不一致 | 32 位包和 64 位注册表重定向问题 | 用Is64BitInstallMode分支处理 |
| 新版装完旧版还能找到 | 安装顺序或卸载时机不对,新版覆盖了旧文件 | 确保卸载在InitializeSetup中执行完毕 |
5.2 卸载后安装路径选择
还有一个实际项目里很容易被埋坑的细节:卸载执行完毕后,Inno Setup 的DefaultDirName如何确定?
如果你的安装包脚本里DefaultDirName是固定的,那没问题。但如果你允许用户在向导页上自选安装路径,那么覆盖安装场景下,用户新选的路径可能和旧路径不同。卸载旧版本时,旧路径上的文件被清空,但新版本装到了新路径,等于一台机器上出现了两个目录,文件名还都叫 MyApp.exe。
这个不是 bug,但很容易被用户告状。我的建议是:在InitializeSetup拿到旧路径后,如果用户没有手动改路径(即WizardForm.DirEdit内容还是默认值),就悄悄把旧路径塞进去作为默认值;如果用户改了,那就尊重用户选择,不做强制。
实现片段:
procedure CurPageChanged(CurPageID: Integer); begin if CurPageID = wpSelectDir then begin // 仅当用户还没手动修改时,用旧路径覆盖默认路径 if sOldInstallPath <> '' then begin WizardForm.DirEdit.Text := sOldInstallPath; end; end; end;这样既能保证覆盖安装时路径一致性,又不限制用户主动更换目录。
5.3 卸载程序被杀掉后再次检测
实际生产环境有个老哥遇到一个问题:卸载程序在自动执行时被安全软件拦截,用户看到卸载进程一直被查杀,最后系统里的旧版本“看不见了”,卸载信息被清了一部分但文件还在。
这种情况下,安装包重新跑一次,GetOldUninstallString可能已经读不到卸载命令了,但InstallLocation或者文件还残留。我当时的处理是在检测完注册表之后,再补一道文件检测:
if FileExists(sOldPath + '\unins000.exe') then begin // 注册表信息被清理但卸载程序仍在,继续走卸载流程 sOldUninstall := sOldPath + '\unins000.exe'; end;这种做法有点暴力,但对于“卸载到一半被打断”的场景很管用。
6. 实际项目中的一些经验心得
这套“先卸后装”的流程我已经在三个项目里落地过,最典型的是一套内网工具软件,用户基数不大但升级频率很高,没有这套机制前售后群天天有人反馈“升级完打不开”、“两个版本混在一起”。改成先卸后装之后,这类问题基本绝迹。
分享几个实操经验:
第一个经验是关于卸载日志。Exec执行卸载程序时,Uninstall 程序默认会写日志,但生成位置不固定。我建议在卸载命令里加上/LOG="...路径..."参数,把日志写到公共目录(比如{tmp}或系统临时目录),这样一旦后面安装或运行出问题,可以拉着卸载日志一起排查。
第二个经验是关于静默卸载的版本兼容性。旧版可能是非常老的 Inno Setup 打的包(比如 5.3.3),它的 unins000.exe 不一定支持/VERYSILENT以外的参数组合。如果发现旧的卸载程序对/SUPPRESSMSGBOXES识别不好,就会返回错误。为了让兼容性最大化,我的自动卸载命令通常后面挂一个/NORESTART就好,其他参数根据实际测试情况再加。
第三个经验是安装包自身版本与旧版本 AppId 的一致性。很多团队在项目初期随便复制了一个 Inno Setup 脚本,改了下名称和版本号就发布了,根本没管 AppId。等后面做成“检测旧版再卸载”时,发现 AppId 早换过好几次了,老用户机器上的卸载信息全不在当前 AppId 下面。这时候你写多少代码都找不到旧版。所以如果你现在还能改 AppId,务必在第一个对外发布版本就定死它,后续永不修改。
第四个经验是卸载后和目标目录的冲突判断。有些项目卸载完成后,旧目录因为文件占用没法删干净,新版本又恰好要往这个目录写文件,结果安装过程中 Windows 报“文件夹访问被拒绝”。这个问题的根因在于卸载结束后到文件复制前的时间窗太短,某些系统进程(杀软、索引服务)还没释放句柄。我的处理方式是在卸载后加一个短暂等待,比如Sleep(200),给系统一点缓冲时间,实测有效降低了这种“偶发装不上”的问题。
7. 留给你的动手建议
如果你现在正好要改手上的 Inno Setup 安装包,我建议按这个顺序动手:
一是在当前脚本里把AppId固定下来,不要让它跟着工程文件 GUID 乱跳。二是先写一个只读旧路径和卸载字符串的函数,用日志把读到的值打出来,确认你能在目标环境里拿到正确信息。三是再写卸载调用,先跑一遍完整安装流程,确认卸载、安装、注册表清理全链路没有报错。最后再考虑静默模式、多实例这些进阶场景。
另外有个细节建议:代码里所有注册表读取,都明确传True或False给Use64bitKey参数,不要用默认值。我之前见过同事的代码漏传这个参数,在 32 位和 64 位系统上表现不一致,排查了整整一个下午。
最后再分享一个技巧:在InitializeSetup里做的所有判断,最好都打日志。Inno Setup 的Log()函数会把信息写到安装日志里(配合命令行/LOG参数可以导到指定文件),这个日志在用户反馈“安装失败”时,是排查第一现场的最强武器。
我实际用下来,这套方案在普通内网软件、商业桌面产品上都表现稳定。只要你把 AppId 守死、注册表路径读对、卸载时机放准,基本不会有意外。如果你们项目里还有更独特的场景——比如旧版是 MSI 安装包、或者要基于某个注册表 Key 做条件卸载,那就在这个框架上加分支逻辑,核心思路是一样的。