NSIS 集成 Duilib:自定义安装界面、进度回传与静默安装实践
简介:这份资源面向需要在 Windows 平台制作专业级安装界面的开发者,也适合希望摆脱 NSIS 默认界面、用 Duilib 实现可换肤自定义安装程序的进阶学习者。它解决的核心问题,是把 NSIS 的安装逻辑(文件复制、注册表写入、快捷方式与开机启动项创建、多语言支持)与 Duilib 的 XML 布局、控件渲染能力结合起来,从而做出外观接近商业软件的安装向导。资源包共 451 个文件,约 9.6MB,包含 113 个 png 与 32 个 bmp 皮肤图片、113 个 nsh 脚本片段、58 个 nlf 多语言文件、35 个 ico 图标、29 个 dll 依赖库、24 个 xml 界面布局、2 个 nsi 主脚本以及若干 bat 构建脚本和 chm 帮助文档,覆盖界面素材、脚本模块与编译工具链。已有 2945 人学习,读者可从中获得一套可直接参考的换肤安装框架、界面与逻辑的集成示例、快捷方式与启动项的实现写法,以及构建脚本和资源目录组织思路,便于二次改造与排错。
1. 默认界面撑不起交付:NSIS 配 Duilib 到底在解决什么
用 NSIS 写安装包的人多半经历过同一个阶段:脚本一下午跑通,功能全对,客户双击 Setup.exe 看到那套向导页,回头问一句「这是正式版吗」。NSIS 自带的 MUI2 能换位图、改按钮文案,但要做圆角卡片、自绘进度条、带勾选列表的组件选择页,改脚本的成本很快就超过重写一个窗口。Duilib 补的正是这块:XML 描述布局、贴图驱动外观、纯 C++ 编译成 DLL,通过 NSIS 的插件机制嵌进安装流程。这套组合适合给商业软件做交付、要控制安装器观感和交互步骤、又不想把包体撑大的团队。代价是得同时摸清 NSIS 脚本和一点 Win32 消息循环。
2. 把两套技术栈拼起来:插件机制与工程骨架
2.1 先定分工:NSIS 管流程,Duilib 只管像素
动手前必须把职责切干净,不然后面全是互相打架。NSIS 侧负责的是它擅长的部分:判断是否已安装、检查磁盘空间、接收命令行参数、写卸载信息、注册到控制面板的程序列表、处理升级和静默安装分支。Duilib 侧只做一件事:把窗口画出来、把按钮点击转成指令、把安装进度显示成进度条。
之所以这么切,是因为 NSIS 的界面能力集中在页面(Page)模型上:每个 Page 是一次回调,Page custom让你接管绘制,但回调返回后页面就销毁了。而自定义安装器通常需要「一个窗口从头到尾活着,中间切换状态」,比如选项页点下一步直接变成进度页,而不是关掉一个窗口再开一个。这种常驻窗口用 NSIS 的 Page 很难做顺,用 Duilib 的WindowImplBase就是一个普通的模态窗口,消息循环在自己手里,状态切换只是控件的显隐。
所以调用关系是这样的:NSIS 脚本把必要的路径参数压到栈上,调用插件函数;插件内部创建窗口、启动工作线程做真正的文件复制,窗口活着的时候消息循环转着,主线程通过PostMessage把进度推给界面;窗口关闭后插件函数返回,NSIS 继续往下走收尾逻辑。
提示:不要试图让 NSIS 的 Section 和 Duilib 窗口并行跑。Section 是同步执行的,插件函数不返回,脚本就卡在那行,两边都想控制流程必然出时序问题。
2.2 插件目录、导出约定与工程结构
NSIS 找插件有一套固定规则:脚本里写nsDuilib::RunInstall,它就去插件目录找nsDuilib.dll,然后用GetProcAddress取名为RunInstall的导出函数。这里有两个容易翻车的点。
第一是目录。NSIS 按「架构 + 字符集」分插件子目录,常见的是Plugins\x86-unicode、Plugins\x86-ansi、Plugins\amd64-unicode。你的 NSIS 是 Unicode 版还是 ANSI 版,决定了它去哪个目录找;找错目录的表现是脚本编译期就报Invalid command: nsDuilib::RunInstall,而不是运行时报错,很多人第一反应会去查插件代码,其实根本没加载。
第二是导出名。x86 下__stdcall会把函数名修饰成_RunInstall@20,C++ 还会再加一层名字改编。GetProcAddress("RunInstall")拿不到,NSIS 就认为函数不存在。解决办法是加一个.def文件强制导出未修饰名,或者用extern "C"配合链接器开关,前者更稳。
工程目录我一般这么摆:
installer/ ├── nsis/ │ ├── installer.nsi ; 主脚本 │ └── uninstall.nsi ├── plugin/ │ ├── plugin_entry.cpp ; NSIS 导出函数 │ ├── install_wnd.cpp ; Duilib 窗口 │ ├── install_worker.cpp ; 工作线程 │ └── nsDuilib.def ; 导出定义 ├── skin/ │ ├── install_main.xml │ └── images/ ├── payload/ ; 真正要装的文件,由 NSIS 打包 └── build/ ; 输出目录payload和skin分开是关键。payload是用户机器上要落地的程序文件,skin是安装器自己的资源,安装完就该消失,两者混在一起会导致卸载时误删或误留。
.def文件内容不长,但少了它整个项目起不来:
LIBRARY nsDuilib EXPORTS RunInstall ShowOptionsPage第一行声明模块名,后面每行一个导出函数。加上这个文件后,x86 和 x64 都能用同一个函数名调用,省掉一堆修饰名排查。
2.3 第一次编译:三条命令跑通最小可运行窗口
先让窗口能弹出来,再去接安装逻辑。下面这套 CMake 配置能把 Duilib 源码直接编进插件 DLL,不依赖预编译库,改 Duilib 的代码也不用重新找 lib。
cmake_minimum_required(VERSION 3.16) project(nsDuilibPlugin CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # NSIS 安装目录下的 Include,里面有 exdll.h set(NSIS_SDK "D:/tools/nsis/Include") # Duilib 源码根目录 set(DUILIB_ROOT "D:/third_party/duilib") include_directories( ${NSIS_SDK} ${DUILIB_ROOT} ${DUILIB_ROOT}/Utils ${DUILIB_ROOT}/Control ) # Duilib 的源码文件,按实际目录结构裁剪 file(GLOB DUILIB_SRC ${DUILIB_ROOT}/*.cpp ${DUILIB_ROOT}/Utils/*.cpp ${DUILIB_ROOT}/Control/*.cpp ${DUILIB_ROOT}/Layout/*.cpp ) add_library(nsDuilib SHARED plugin/plugin_entry.cpp plugin/install_wnd.cpp plugin/install_worker.cpp ${DUILIB_SRC} plugin/nsDuilib.def ) target_compile_definitions(nsDuilib PRIVATE UNICODE _UNICODE) set_target_properties(nsDuilib PROPERTIES PREFIX "" OUTPUT_NAME "nsDuilib") # 依赖的 Windows 库 target_link_libraries(nsDuilib PRIVATE comctl32 gdi32 user32 ole32 oleaut32 shell32 shlwapi)PREFIX ""去掉 Linux 式的lib前缀,Windows 下虽然是空操作,但跨平台 CI 里能省一次事故。OUTPUT_NAME必须和脚本里::左边的名字完全一致,大小写也要对上,Windows 文件系统不敏感但 NSIS 的报错信息会让你误判。
编译完把nsDuilib.dll拷到Plugins\x86-unicode下,跑一次最小脚本:
Unicode true Name "某桌面工具" OutFile "..\build\Setup.exe" InstallDir "$PROGRAMFILES64\某桌面工具" RequestExecutionLevel admin !addplugindir /x86-unicode "..\build\plugins" !include "MUI2.nsh" Page custom PageMainCreate !insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_UNPAGE_CONFIRM Function PageMainCreate ; 弹出 Duilib 窗口,参数是皮肤目录名 nsDuilib::ShowOptionsPage "install_main" Pop $0 FunctionEnd Section DetailPrint "done" SectionEnd!addplugindir让 NSIS 去你指定的目录找插件,省掉每次手动拷到安装目录。Page custom的回调里调用插件,窗口关掉之后脚本才继续走后面的MUI_PAGE_INSTFILES。第一次跑建议先把窗口弹出来就关闭,确认皮肤能加载、按钮能响应,再去写复制文件的逻辑。跳过这一步直接上完整流程,出问题时你分不清是插件没加载、皮肤没找到还是工作线程崩了。
3. 让窗口真正跑起来:消息循环与数据回传
3.1 插件入口里创建窗口并接管消息循环
NSIS 插件的导出函数签名是固定的,参数列表一个都不能改:父窗口句柄、字符串缓冲区大小、变量表指针、栈指针、扩展参数指针。用EXDLL_INIT()把后三个存到全局变量里,之后popstring/pushstring才有意义。
// plugin_entry.cpp #include <windows.h> #include "exdll.h" // NSIS 自带,注意字符集要匹配 #include "install_wnd.h" #include "install_worker.h" extern "C" __declspec(dllexport) void __stdcall RunInstall(HWND hwndParent, int string_size, TCHAR* variables, stack_t** stacktop, extra_parameters* extra) { EXDLL_INIT(); // 初始化 g_stacktop / g_variables / g_stringsize TCHAR szGroup[MAX_PATH] = {0}; // 开始菜单组名 TCHAR szPayload[MAX_PATH] = {0}; // 临时解包目录 TCHAR szInstDir[MAX_PATH] = {0}; // 目标安装目录 // 出栈顺序与脚本里传参顺序相反:最后压的最先弹 popstring(szGroup); popstring(szPayload); popstring(szInstDir); CInstallWorker worker(szInstDir, szPayload, szGroup); CInstallWnd wnd(&worker); if (!wnd.Create(hwndParent, _T("安装 某桌面工具"), UI_WNDSTYLE_FRAME, WS_EX_WINDOWEDGE)) { pushstring(_T("create_failed")); return; } // 模态消息循环,窗口关闭后返回 CMessageLoop* pLoop = wnd.GetLoop(); pLoop->Run(); pushstring(worker.IsSuccess() ? _T("ok") : _T("install_failed")); }popstring的调用顺序是最常见的错。NSIS 里nsDuilib::RunInstall "$INSTDIR" "$PLUGINSDIR" "$SMPROGRAMS"是从左往右压栈,所以栈顶是最后一个参数。上面代码先弹szGroup对应$SMPROGRAMS,最后弹szInstDir对应$INSTDIR。顺序写反的典型表现是路径全乱,安装目录变成开始菜单组,CreateDirectory直接失败。
wnd.Create的第三个参数决定窗口带不带边框和最大化按钮。安装器一般用UI_WNDSTYLE_FRAME再加WS_EX_WINDOWEDGE,但真正需要控制的是在GetWindowClassName之后、Create之前把窗口样式改成无边框:在CInstallWnd里重写GetWindowClassName和GetSkinFile,然后在HandleMessage里拦WM_NCHITTEST返回HTCAPTION,实现拖动。这套做法比改 XML 靠谱,因为 Duilib 的布局文件和实际窗口样式是两套东西,只在 XML 里画个标题栏并不会让窗口真的能拖动。
3.2 进度回传:PostMessage 而不是直接改控件
工作线程绝对不能碰控件。Duilib 的绘制和控件树都在主线程,后台线程调SetValue属于跨线程操作 UI,轻则进度条不刷新,重则资源竞争直接崩。常规写法是后台线程算进度,用PostMessage投递到窗口,主线程在HandleCustomMessage里更新。
// install_wnd.h #pragma once #include "UIlib.h" using namespace DuiLib; #define UWM_INSTALL_PROGRESS (WM_USER + 100) #define UWM_INSTALL_FINISH (WM_USER + 101) #define UWM_INSTALL_STAGE (WM_USER + 102) class CInstallWnd : public WindowImplBase { public: explicit CInstallWnd(CInstallWorker* pWorker) : m_pWorker(pWorker), m_pProgress(NULL) {} LPCTSTR GetWindowClassName() const override { return _T("DuilibInstallWnd"); } CDuiString GetSkinFile() override { return _T("install_main.xml"); } CDuiString GetSkinFolder() override { return _T("skin"); } void InitWindow() override { // 控件指针必须在 InitWindow 之后取,构造时控件树还没建好 m_pProgress = static_cast<CProgressUI*>(m_pm.FindControl(_T("progress_install"))); m_pWorker->SetNotifyWnd(GetHWND()); m_pWorker->Start(); // 启动后台线程 } LRESULT HandleCustomMessage(UINT uMsg, WPARAM wParam, LPARAM lParam, BOOL& bHandled) override { switch (uMsg) { case UWM_INSTALL_PROGRESS: if (m_pProgress) m_pProgress->SetValue((int)wParam); bHandled = TRUE; break; case UWM_INSTALL_STAGE: { CTextUI* pText = static_cast<CTextUI*>(m_pm.FindControl(_T("text_stage"))); if (pText) pText->SetText((LPCTSTR)lParam); } bHandled = TRUE; break; case UWM_INSTALL_FINISH: Close(IDOK); // 关闭模态窗口,让 Run 返回 bHandled = TRUE; break; } return 0; } private: CInstallWorker* m_pWorker; CProgressUI* m_pProgress; };HandleCustomMessage是WindowImplBase预留的扩展点,WM_USER以上的自定义消息都会走到这里。注意两个细节:bHandled必须置TRUE,否则消息会继续往下传,可能被WindowImplBase的默认处理吞掉;lParam传字符串时不能用栈上局部变量的地址,PostMessage是异步入队,函数返回后内存就失效了,稳妥做法是new一块再在窗口侧delete,或者干脆只传枚举值,文案在窗口侧查表。
进度值本身也有讲究。文件复制阶段按字节数算,(已复制字节 / 总字节) * 100;注册表写入、快捷方式创建这些步骤耗时很短,不能平均分配百分比,否则进度条会在 95% 停住好几秒,用户以为卡死。我的做法是给每个阶段一个权重:复制文件占 80%,注册表 8%,快捷方式 7%,收尾 5%,每阶段内部再按实际数量插值。
3.3 线程边界与关窗时序:别让 NSIS 拿到空栈
工作线程的生命周期必须严格包在窗口生命周期里。窗口的Close只是投递关闭消息,Run()返回后才算真正结束,此时如果工作线程还在跑,它持有的窗口句柄已经无效,PostMessage返回失败但没人检查,进度就永远停在那。所以CInstallWnd的析构函数里要WaitForSingleObject等线程句柄,或者在工作线程里用事件对象做退出信号。
返回值同样容易出事。pushstring之前如果提前return,NSIS 侧Pop $0会弹到一个空值,脚本里的${If} $0 != "ok"判断就失效,安装失败也当成成功。所以插件的每个退出路径都要保证压了一个字符串回去,包括创建窗口失败、参数解析失败这类分支。
还有一种情况是用户在安装中途点关闭。这时不能直接Close,要么在OnClick里拦截关闭按钮弹出确认框,要么在HandleMessage里拦WM_CLOSE直接return不处理。放行的话,文件复制到一半被中断,目标目录留下半套文件,卸载程序又不认识这些残留,用户下次重装就撞上「文件已存在」的提示。
4. 安装动作落地:解包、写盘、注册与卸载
4.1 先把 payload 解到 $PLUGINSDIR
NSIS 的File指令在编译期把文件打进安装包,运行期由 NSIS 自己解压,插件拿不到这个过程。所以流程要反过来:让 NSIS 先把所有文件解到一个临时目录,插件再从临时目录复制到目标路径,这样进度才能按文件粒度上报。
!include "LogicLib.nsh" !include "FileFunc.nsh" Section "Install" ; 创建本次安装专用的临时目录,退出时 NSIS 自动清理 InitPluginsDir SetOutPath "$PLUGINSDIR\payload" File /r "..\payload\*.*" ; 皮肤资源也放临时目录,插件启动时从这读 SetOutPath "$PLUGINSDIR\skin" File /r "..\skin\*.*" SetOutPath "$INSTDIR" nsDuilib::RunInstall "$INSTDIR" "$PLUGINSDIR\payload" "$SMPROGRAMS\某桌面工具" Pop $0 ${If} $0 != "ok" SetErrorLevel 2 Abort "安装未完成" ${EndIf} SectionEndInitPluginsDir会创建$PLUGINSDIR,这个目录在安装器进程退出时由 NSIS 自动删除,不需要手动清理。File /r保持目录结构,SetOutPath决定解到哪。这里有个隐含前提:payload目录里的层级要和最终安装目录一致,插件只做整目录搬运,不做路径重映射,否则配置文件里的相对路径全会失效。
Abort的作用是中止当前 Section。注意它不会回滚已经复制的文件,所以插件内部的失败处理要先做清理再返回失败码,别指望 NSIS 帮你收拾。
4.2 复制文件与进度计算
工作线程的核心就是遍历临时目录、逐文件复制、按字节累计进度。用FindFirstFile/FindNextFile递归,比SHFileOperation可控,因为后者不给进度回调。
// install_worker.cpp 片段 void CInstallWorker::CopyTree(const std::wstring& src, const std::wstring& dst) { std::wstring pattern = src + L"\\*"; WIN32_FIND_DATAW fd; HANDLE hFind = FindFirstFileW(pattern.c_str(), &fd); if (hFind == INVALID_HANDLE_VALUE) return; do { if (wcscmp(fd.cFileName, L".") == 0 || wcscmp(fd.cFileName, L"..") == 0) continue; std::wstring s = src + L"\\" + fd.cFileName; std::wstring d = dst + L"\\" + fd.cFileName; if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { CreateDirectoryW(d.c_str(), NULL); CopyTree(s, d); // 递归子目录 } else { CopyFileW(s.c_str(), d.c_str(), FALSE); m_doneBytes += (fd.nFileSizeHigh * (MAXDWORD + 1ULL)) + fd.nFileSizeLow; int percent = (int)(m_doneBytes * 80 / m_totalBytes); // 复制阶段占 80% ::PostMessage(m_hNotifyWnd, UWM_INSTALL_PROGRESS, percent, 0); } } while (FindNextFileW(hFind, &fd)); FindClose(hFind); }m_totalBytes要在开始复制前先遍历一遍算出总和,否则进度算不准。文件大小是 64 位,nFileSizeHigh和nFileSizeLow拼的时候必须转成ULONGLONG,直接乘MAXDWORD在超大文件上会溢出。
复制过程中每个文件都PostMessage太频繁,小文件多的时候消息队列会爆,窗口响应变慢。常规做法是加阈值:进度值变化超过 1% 才投递一次,或者按时间节流,50 毫秒内只发一条。
还有一个容易忽略的点:CopyFileW的第三个参数是「是否允许覆盖」,传FALSE时目标已存在会失败,安装升级场景就会卡住。升级安装应该先记录旧版本文件列表,或者干脆传TRUE覆盖,再把新版本多出来的文件单独处理。我一般选覆盖,因为安装包的 payload 是完整的一套,不做增量。
4.3 注册表、快捷方式与卸载项
NSIS 侧收尾,写控制面板的程序列表和卸载入口。64 位程序必须显式指定注册表视图,否则 32 位安装器写进去的项在 64 位系统上会落到Wow6432Node,用户能在「应用和功能」里看到但卸载时找不到可执行文件。
Section "UninstallInfo" SetRegView 64 WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\某桌面工具" \ "DisplayName" "某桌面工具" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\某桌面工具" \ "DisplayVersion" "1.0.0" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\某桌面工具" \ "InstallLocation" "$INSTDIR" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\某桌面工具" \ "UninstallString" '"$INSTDIR\uninst.exe"' WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\某桌面工具" \ "NoModify" 1 WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\某桌面工具" \ "NoRepair" 1 WriteUninstaller "$INSTDIR\uninst.exe" ; 快捷方式用插件或 CreateShortCut,后者是 NSIS 内置指令 CreateDirectory "$SMPROGRAMS\某桌面工具" CreateShortCut "$SMPROGRAMS\某桌面工具\某桌面工具.lnk" "$INSTDIR\app.exe" CreateShortCut "$DESKTOP\某桌面工具.lnk" "$INSTDIR\app.exe" SectionEndSetRegView 64只影响后续的注册表操作,脚本里其他地方如果还有 32 位假设,记得一并检查。NoModify和NoRepair不写的话,控制面板会给你的程序显示「修改」和「修复」按钮,点了会调ModifyPath和RepairPath,没定义这两个值就报错弹框,用户体验很差。
DisplayIcon也值得加,不填的话列表里显示的是默认图标,跟你的程序完全不搭。
4.4 静默安装与命令行参数
企业部署场景下/S是硬需求。静默模式下不能弹出任何窗口,否则批量部署脚本会卡在等人点按钮。判断用IfSilent,插件入口处也要再判断一次,因为 NSIS 侧的IfSilent只影响脚本,插件被调用了照样会建窗口。
Function .onInit ${GetParameters} $R0 ${GetOptions} $R0 "/D=" $R1 ${IfNot} ${Errors} StrCpy $INSTDIR $R1 ${EndIf} ; 静默安装时不走自定义页面 IfSilent silent Return silent: SetSilent silent FunctionEnd/D=是 NSIS 约定的安装目录参数,必须放在命令行最后,且路径不能加引号,这是它的历史遗留规则。自定义参数比如/NORUN(装完不启动程序)可以用GetOptions解析。
插件内部也要处理:Start()之前检查一个从脚本传进来的silent标志,为真时直接创建工作线程、不创建窗口,进度消息投给一个空句柄也不报错。这里如果偷懒省略,静默安装在 CI 上跑的时候会直接挂住直到超时,排查半天才发现是弹了个看不见的窗口在等点击。
5. 避坑与排查:五个现场翻车记录
5.1 插件加载失败,编译期就报 Invalid command
现象:脚本编译直接失败,提示Invalid command: nsDuilib::RunInstall,但 DLL 明明拷进去了。
原因是 NSIS 版本和插件目录不匹配。Unicode 版 NSIS 只认Plugins\x86-unicode,ANSI 版只认Plugins\x86-ansi,放错目录等同于没放。另一种可能是!addplugindir的路径写法带了引号或末尾斜杠,NSIS 解析失败但不报错。
解决办法是先确认makensis /VERSION输出的是哪个版本,再对齐目录名;!addplugindir后面只写单引号或双引号包裹的绝对路径,不带尾部反斜杠。确认无误后清理一次build目录重新编译。
5.2 皮肤加载不到,窗口一片空白
现象:窗口能出来,但里面没有任何控件,或者干脆秒退。
原因是 Duilib 找资源的相对路径基于工作目录,而安装器运行时的工作目录是用户双击 exe 时所在的目录,不一定是安装包所在目录。你写GetSkinFolder()返回skin,它就去当前工作目录找skin\install_main.xml。
解决是在InitWindow之前显式设置资源路径。从插件 DLL 所在位置算起,但皮肤实际被打包在$PLUGINSDIR\skin,所以更可靠的做法是通过SetResourcePath传入临时目录的绝对路径,这个路径由 NSIS 侧用$PLUGINSDIR拼好,通过参数传给插件。
5.3 静默安装仍然弹出窗口
现象:命令行加/S跑,进程不退出,界面卡在那。
原因是IfSilent只跳过了 NSIS 的页面,Page custom注册的回调不会被调用,但Section里调用插件的语句照样执行,插件内部的Create也不判断环境。
解决是双保险:NSIS 侧在静默分支里跳过插件调用,直接走纯脚本的复制流程或调用另一个无界面导出函数;插件侧再读一次命令行参数,检测到/S就只起工作线程不建窗口。两边都做,是因为单边判断在升级场景下容易被绕过。
5.4 杀软拦截,安装包被直接删掉
现象:开发机运行正常,换一台机器下载后立刻被安全软件清除,或者运行时报「无法执行」。
原因是安装器自身没有数字签名,加上 NSIS 打包的 exe 是自解压结构,启发式引擎会把它和某些捆绑软件的特征混在一起。
解决是给Setup.exe和uninst.exe都加签名,签名后还要对$INSTDIR下的主程序也签一次。另外OutFile尽量用固定文件名、别用随机串,提交到软件分发渠道前先做一次多引擎扫描。
5.5 卸载残留文件,目录删不干净
现象:卸载跑完,安装目录还在,里面留着几个 dll 或日志文件。
原因是卸载时主程序还在运行,Delete指令删不掉被占用的文件,NSIS 对失败不报错,直接跳过。另一种是安装时写入的用户数据放在安装目录下,卸载脚本没覆盖到。
解决是卸载入口先检查进程是否在跑,用nsProcess插件或FindWindow判断,提示用户关闭后再继续;用户数据统一放$APPDATA,卸载时用RMDir /r清理但保留配置的选项要单独给用户。最后在un.onInit里用SetOutPath $TEMP避免卸载程序自身占用安装目录。
6. 收尾技巧:DPI、签名与一次装完的验证清单
高 DPI 是这类自定义安装器最容易翻车的地方。Duilib 的 XML 里如果全用固定像素写宽高,在 150% 缩放比的机器上窗口会显得又小又糊。我的处理是三层:manifest 里声明dpiAware为true,让进程自己处理缩放而不是被系统位图拉伸;XML 里容器宽度尽量用百分比,width="100%"、height="100%",只有图标和按钮给固定值;字体在InitWindow里按GetDpiForWindow算出的比例重新SetFont。第三层最容易被忘,字号不跟着缩放,界面上文字的视觉重量会明显不对。
void CInstallWnd::InitWindow() { UINT dpi = GetDpiForWindow(GetHWND()); // 需要 Windows 10 1607+ int scale = MulDiv(dpi, 100, 96); // 100 / 125 / 150 / 200 CTextUI* pTitle = static_cast<CTextUI*>(m_pm.FindControl(_T("text_title"))); if (pTitle) { // 基础字号 12px,按缩放比放大后重新设置 int fontSize = MulDiv(12, scale, 100); pTitle->SetFont(fontSize); } }签名这块,脚本化之后每次出包都跑一遍,别留手工步骤:
# 签名 Setup.exe,时间戳服务地址按实际环境替换 signtool sign /fd SHA256 /td SHA256 /tr "<时间戳服务地址>" \ /f "<证书文件路径>.pfx" /p "<证书密码>" \ build/Setup.exe # 验证签名是否有效 signtool verify /pa /v build/Setup.exe/fd和/td都指定 SHA256,老版本 signtool 默认 SHA1,Windows 新版本会拒签。/tr是 RFC3161 时间戳,比老的/t可靠,证书过期后签名仍然有效。签完必须verify,sign成功不代表系统认。
最后是出包前的验证清单,我固定跑这几项:
| 检查项 | 方法 | 不通过的典型表现 |
|---|---|---|
| 全新安装 | 干净虚拟机双击运行 | 缺 VC 运行库,窗口闪退 |
| 覆盖升级 | 装旧版后跑新版 | 旧文件残留,配置被重置 |
| 静默安装 | Setup.exe /S /D=D:\Test | 进程挂住,目录不对 |
| 卸载完整性 | 装完卸载后检查目录和注册表 | 残留 dll 和卸载项 |
| 高 DPI | 系统缩放调到 150% 再跑 | 文字模糊、控件错位 |
| 非管理员账户 | 标准用户双击 | 提权失败无提示 |
这张表我一般贴在构建脚本旁边,CI 上能自动化的就跑自动化,DPI 和标准用户这两项必须手动过一遍。
血泪教训是早期一次发版,功能全过,唯独漏了「覆盖升级」这一项,用户装完新版之后旧版的配置文件把新字段覆盖回去了,程序启动直接读配置崩溃。从那以后我每次出包都强制把这张表从头走一遍,一项不落。希望帮到你。
本文还有配套的精品资源,点击获取