☰
NSIS+Duilib自定义Windows安装向导:插件边界与进度回传
2026/11/4 7:47:44 网站建设 项目流程

简介:面向 Windows 桌面开发者的 NSIS 与 Duilib 自定义安装程序示例包,适合希望摆脱默认安装向导、打造品牌化界面的中高级开发者。它把 NSIS 的安装逻辑与 Duilib 的 XML 界面渲染结合起来,可用于学习安装目录选择、文件释放、注册表写入、桌面与快速启动项创建、多语言切换及日志排错等常见环节。压缩包共 451 个文件,约 9.6MB,包含较多 PNG、BMP、ICO 皮肤图片,NSH 脚本片段,NLF 多语言文件,以及 XML 布局、DLL 组件、NSI 安装脚本和批处理工具,便于按皮肤资源、界面布局与安装脚本分层查阅。已有 2947 人学习下载。读者可从中获得可参考的界面皮肤素材、脚本组织方式和 NSIS 调用 Duilib 的集成思路,并借助示例梳理安装流程、错误处理与测试要点,为构建美观且功能完整的自定义安装程序提供实践蓝本。

1. 为什么要把 NSIS 和 Duilib 拼在一起:一套安装外壳要处理两类工作

做 Windows 桌面分发的团队,几乎都会在某一天遇到同一个问题:NSIS 生成的安装向导长得太“标准”,用户一眼就能看出这是脚本拼出来的界面。而自绘一套安装界面又有代价:文件写入、注册表、卸载信息这些脏活没人愿意重写。NSIS + Duilib 的组合恰好把两类工作分开——NSIS 管安装事务,Duilib 管像素。这份资源的思路就是这样一条接口边界:把安装逻辑留在脚本里,把窗口、动效和进度显示交给一个 32 位插件 DLL,两边靠窗口句柄和参数栈通信。适合已经能跑通基础 NSIS 脚本、但界面卡在默认样式上的开发者。

2. 插件边界先划清楚:脚本骨架、导出约定与参数栈

先确定哪部分逻辑放在哪一层,比急着写界面重要得多。NSIS 脚本负责页面顺序、文件落地、注册表、卸载登记;插件只负责“画”和“报进度”,不碰安装结果。这条边界一旦确定,后面所有调试都会变得有迹可循——界面出问题查插件,安装结果出问题查脚本,不用两头猜。

2.1 工程目录与工具链约定

我的习惯是把一个安装包工程拆成三块:脚本目录、插件源码目录、待打包产物目录。这样每次改完插件只需要重新编译 DLL,脚本不用动,File /r指向的目录也能保持稳定。

路径内容说明
installer\setup.nsiNSIS 主脚本页面、Section、卸载逻辑
plugin\skin_ui\Duilib 插件源码编译出skin_ui.dll
plugin\build\x86\插件输出目录!addplugindir /x86指向这里
dist\待安装的应用程序文件File /r的源目录
dist\skin\Duilib 皮肤 XML 与图片运行时解压到$PLUGINSDIR

工具链上只有一个硬约束:插件必须是 32 位。NSIS 本体是 32 位进程,插件 DLL 位数不匹配时不会给你任何友好提示,直接表现为“函数找不到”或者安装程序静默退出。VS 里新建 Win32 DLL 工程,配置平台选Win32,运行库选/MT——这条后面在避坑章还会展开,因为它是最容易在干净机器上翻车的一处。

Duilib 本身用静态库方式引入,避免安装包运行时还要拖一个 DLL 依赖。编译时把 Duilib 源码编成静态 lib,插件工程链接它,导出函数只有寥寥几个,最终 DLL 体积通常能压到 1MB 出头。

2.2 脚本骨架:页面、Section 与卸载登记

页面结构上我不建议直接用 MUI2 那一套欢迎页、许可页、目录页的宏,因为那些页面会被 Duilib 整个盖住,留着只是浪费。更省事的方式是只保留一个自定义页承载整个向导,后面接固定的instfiles页执行 Section。

Unicode true Name "某工具安装程序" OutFile "Setup.exe" InstallDir "$PROGRAMFILES\MyTool" InstallDirRegKey HKLM "Software\MyTool" "InstallPath" RequestExecutionLevel admin SetCompressor /SOLID lzma !include "LogicLib.nsh" !include "nsDialogs.nsh" ; 告诉编译器插件 DLL 在哪里,/x86 表示 32 位插件 !addplugindir /x86 ".\plugin\build\x86" ; 向导页:Duilib 窗口全部挂在这个空页面上 Page custom PageWizardInit PageWizardLeave ; 进度页:Section 在这里执行,Duilib 窗口继续盖在上面 Page instfiles UninstPage uninstConfirm UninstPage instfiles

PageWizardInit的职责只有三件事:创建 nsDialogs 空页、把皮肤目录压给插件、把 Duilib 窗口挂到主窗口上。它不处理任何业务。

Function PageWizardInit nsDialogs::Create 1018 Pop $0 ${If} $0 == error Abort ${EndIf} ; 把皮肤解压到临时目录,卸载时随 $PLUGINSDIR 一起清理 SetOutPath "$PLUGINSDIR\skin" File /r "..\dist\skin\*.*" ; 只传一个参数:皮肤目录。主窗口句柄由 NSIS 直接传给插件 skin_ui::CreateUI "$PLUGINSDIR\skin" Pop $0 ; 隐藏 NSIS 自带的下一步/取消按钮,由 Duilib 自己画 GetDlgItem $1 $HWNDPARENT 1 ShowWindow $1 ${SW_HIDE} GetDlgItem $1 $HWNDPARENT 2 ShowWindow $1 ${SW_HIDE} nsDialogs::Show FunctionEnd

这里有一个顺序细节值得强调:nsDialogs::Create必须在SetOutPath之前吗?不需要,但SetOutPath会改变当前输出目录,如果后面还有别的File指令要落到别处,记得再改回去。我在第一次写的时候就是因为这个顺序,把主程序文件也解压进了$PLUGINSDIR,测试时安装目录空空如也,查了半天。

PageWizardLeave是用户点“开始安装”后触发的回调,里面做一次路径合法性检查,然后直接返回,NSIS 会推进到instfiles页并执行 Section。

Function PageWizardLeave ; 安装目录为空时不允许前进 ${If} $INSTDIR == "" MessageBox MB_ICONEXCLAMATION "请先选择安装目录" Abort ${EndIf} ; 告诉插件切换进度模式,避免闪一下旧界面 skin_ui::SwitchToProgress FunctionEnd

Section 里每个耗时步骤前调一次SetProgress,这是后面进度条的来源。

Section "MainSection" SEC_MAIN ; 页面切换后 Duilib 窗口会被新页面盖住,先提到最前 skin_ui::BringToFront skin_ui::SetProgress 5 "正在准备安装目录" CreateDirectory "$INSTDIR" skin_ui::SetProgress 15 "正在释放程序文件" SetOutPath "$INSTDIR" File /r "..\dist\app\*.*" skin_ui::SetProgress 70 "正在写入注册表" WriteRegStr HKLM "Software\MyTool" "InstallPath" "$INSTDIR" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\MyTool" \ "DisplayName" "某工具" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\MyTool" \ "UninstallString" '"$INSTDIR\uninst.exe"' WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\MyTool" \ "EstimatedSize" 20480 skin_ui::SetProgress 90 "正在创建快捷方式" CreateShortCut "$DESKTOP\某工具.lnk" "$INSTDIR\MyTool.exe" skin_ui::SetProgress 100 "安装完成" SectionEnd

WriteRegDWORD EstimatedSize这个值单位是 KB,写小了“应用和功能”里显示的体积会明显偏小,写大了又显得不诚实。实用做法是编译后用FileSize或者对dist目录做一次统计,把结果作为命令行参数传给makensis,脚本里用!define APP_SIZE接住,比手填靠谱。

2.3 插件 DLL 的导出约定与参数栈

NSIS 调插件的方式和普通 DLL 调用差别很大:参数不是走函数形参,而是从 NSIS 的运行时栈上一个个弹出来。pluginapi.h提供的popstring和pushstring就是这套协议的入口。

#include <windows.h> #include "pluginapi.h" // NSIS 自带的头文件,定义 stack_t 与 EXDLL_INIT #pragma comment(lib, "user32.lib") // 参数顺序提醒:NSIS 从左到右压栈,因此 pop 出来的第一个是"最后一个参数" extern "C" __declspec(dllexport) void CreateUI(HWND hwndParent, int string_size, TCHAR* variables, stack_t** stacktop) { EXDLL_INIT(); // 绑定 string_size / variables / stacktop TCHAR szSkinDir[1024] = { 0 }; popstring(szSkinDir); // 脚本里只有一个参数,弹一次即可 // hwndParent 是 NSIS 主窗口,不需要用参数传 BOOL ok = SkinBridge::Instance().Create(hwndParent, szSkinDir); pushstring(ok ? _T("1") : _T("0")); // 返回值同样走栈 } // 进度上报:参数是 百分比 和 提示文本,所以先 pop 文本,再 pop 数字 extern "C" __declspec(dllexport) void SetProgress(HWND, int, TCHAR*, stack_t** stacktop) { EXDLL_INIT(); TCHAR szText[1024] = { 0 }; TCHAR szPercent[32] = { 0 }; popstring(szText); popstring(szPercent); int nPercent = _ttoi(szPercent); SkinBridge::Instance().UpdateProgress(nPercent, szText); }

参数逆序这一点,第一次接触的人几乎都会踩。脚本里写skin_ui::SetProgress 30 "正在解压",插件里第一次popstring拿到的是“正在解压”,第二次才拿到30。如果反过来写,_ttoi("正在解压")返回 0,进度条永远不动,你还以为是窗口没刷新。

导出名也有讲究。C++ 默认会对函数名做修饰,extern "C"能关掉修饰,但为了兼容不同版本的 NSIS 对W后缀的查找,最稳妥的做法是加一个.def文件显式声明导出。

LIBRARY skin_ui EXPORTS CreateUI SetProgress SwitchToProgress BringToFront

不写.def也能工作——NSIS 3 在找不到CreateUIW时会回退去找CreateUI——但依赖这种回退逻辑意味着换个人接手就得重新查一遍文档。显式导出成本很低,收益是半年后你自己还看得懂。

3. 把 Duilib 窗口塞进 NSIS 主窗口:父句柄、尺寸与层级抢占

窗口嵌不进去的原因,九成出在父句柄选错了。NSIS 里有三个句柄容易混:$HWNDPARENT是安装程序主窗口,nsDialogs::Create返回的是内层页面窗口,$PLUGINSDIR只是路径不是句柄。选错哪个,症状都差不多:窗口要么不显示,要么一闪就没了。

3.1 父句柄到底该拿哪一个

如果把 Duilib 窗口挂到nsDialogs::Create返回的页面窗口上,页面一切换,父窗口被销毁,子窗口跟着陪葬。进度页需要窗口一直存在,所以这种挂法只在“单页面全程不切换”的场景可行。

挂到$HWNDPARENT上则稳定得多:主窗口在整个安装流程里只创建一次,页面切换只是替换它的子 dialog。代价是层级问题——新页面作为后创建的子窗口,天然盖在 Duilib 窗口上面。解决办法是在每次页面切换后手动把自己提到最前,SetWindowPos的HWND_TOP就够了。

class SkinBridge { public: BOOL Create(HWND hParent, LPCTSTR pszSkinDir) { m_hParent = hParent; m_skinFile = CString(pszSkinDir) + _T("\\main.xml"); RECT rc = { 0 }; ::GetClientRect(hParent, &rc); // 留出主窗口边框,实际可用区比客户区略小 m_frame.Create(hParent, _T("MyInstallerFrame"), WS_CHILD | WS_VISIBLE | WS_CLIPCHILDREN, 0, 0, 0, rc.right, rc.bottom); return m_frame.GetHWND() != NULL; } void BringToFront() { HWND h = m_frame.GetHWND(); if (h) { ::SetWindowPos(h, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); } } private: HWND m_hParent = NULL; CString m_skinFile; CInstallFrame m_frame; };

WS_CHILD这个样式不能少。少了它,Duilib 会创建一个独立顶层窗口,浮动在安装程序外面,用户拖动一下主窗口就露馅了。WS_CLIPCHILDREN则是防止父窗口重绘时把子窗口刷花,NSIS 的主窗口在某些主题下会主动重绘,加上这个样式能省掉一类闪烁问题。

3.2 子窗口创建与皮肤加载路径

Duilib 的WindowImplBase默认会把GetSkinFolder()和GetSkinFile()拼到程序所在目录后面。这里有个隐蔽的坑:NSIS 运行时会把自身释放到%TEMP%\nsxXXXX.tmp目录,GetModuleFileName拿到的是那个临时路径,不是安装包所在目录,更不是$PLUGINSDIR。所以最省心的做法是让GetSkinFile()直接返回绝对路径,GetSkinFolder()返回空串。

class CInstallFrame : public WindowImplBase { public: // 皮肤目录由插件在 Create 时设置,跳过默认的"程序目录 + 相对路径"拼接 virtual CDuiString GetSkinFolder() { return _T(""); } virtual CDuiString GetSkinFile() { return m_skinFile; } virtual LPCTSTR GetWindowClassName() const { return _T("MyInstallerFrame"); } virtual void Notify(TNotifyUI& msg) { if (msg.sType == _T("click")) { LPCTSTR pszName = msg.pSender->GetName(); if (_tcscmp(pszName, _T("btn_install")) == 0) { PostCommand(IDOK); // 等价于点"下一步 / 安装" } else if (_tcscmp(pszName, _T("btn_cancel")) == 0) { PostCommand(IDCANCEL); } } WindowImplBase::Notify(msg); } private: void PostCommand(int nId) { // 按钮被隐藏了,但 WM_COMMAND 依然有效,BN_CLICKED 才会触发 NSIS 的页面推进 HWND hBtn = ::GetDlgItem(m_hParent, nId); ::PostMessage(m_hParent, WM_COMMAND, MAKEWPARAM(nId, BN_CLICKED), (LPARAM)hBtn); } HWND m_hParent = NULL; CDuiString m_skinFile; };

Notify里用控件名判断是最稳的,因为改布局时名字通常不会动。用控件 ID 判断也行,但FindControl拿到的指针在 XML 重载后可能失效,需要每次重新取,反而麻烦。

XML 这边,根节点留一个bkimage做背景,中间放一个TabLayout承载多步向导,四个按钮做成Button并设好name。这一步没什么玄学,需要注意的只有两点:图片资源用 32 位 PNG,深色背景上不会出现白边;XML 文件保存为 UTF-8 无 BOM,带 BOM 时某些版本的解析器会把第一个标签吃进注释里。

3.3 按钮与页面推进的消息回路

NSIS 的页面推进完全依赖对话框消息。它不关心按钮上写的是“下一步”还是“安装”,只认IDOK和IDCANCEL两个控件 ID。所以 Duilib 里画多少个按钮都行,最后统一走WM_COMMAND。

Duilib 控件名发出的消息NSIS 侧效果
btn_installWM_COMMAND(IDOK, BN_CLICKED)触发PageWizardLeave,进入instfiles
btn_cancelWM_COMMAND(IDCANCEL, BN_CLICKED)触发取消确认,退出安装
btn_close同上,但只在进度完成后显示关闭安装程序

取消的语义要和用户预期一致。如果用户点“取消”,NSIS 会弹默认的确认框(这个框是系统消息框,样式改不了),确认之后会跳到.onUserAbort。在那里要清理已经写了一半的文件,否则用户重装时会遇到“文件已存在”的冲突。

Function .onUserAbort ; 用户取消后清理半成品,避免下次安装目录不干净 ${If} ${FileExists} "$INSTDIR\MyTool.exe" Delete "$INSTDIR\MyTool.exe" ${EndIf} RMDir /r "$INSTDIR\skin_tmp" FunctionEnd

这里不需要删整个$INSTDIR,因为用户可能选了一个已有目录,里面有别的软件。只删自己确认写进去的东西,是安装包的基本礼貌。

4. 进度、皮肤与静默安装:Section 执行期间的状态回传

进度条能不能动,本质上取决于一件事:Section 执行时 NSIS 的消息循环还在不在跑。好消息是它在跑,所以 Duilib 的窗口能正常刷新;坏消息是instfiles页也会创建自己的进度条和日志,直接和你的窗口抢地盘。

4.1 单向进度协议与刷新时机

最省事的协议是单向的:脚本调SetProgress,插件收到后更新 UI,不回传任何东西。好处是不会有重入问题,坏处是脚本必须自己算好百分比。我的习惯是在 Section 开头按步骤数分配权重,每个步骤结束时推进到对应的累计值,而不是“每写一个文件加 1%”——文件数量在编译期确定不了,那样写进度会跳得很难看。

void SkinBridge <p> <a href="https://download.csdn.net/download/luoyangchechen/10858967" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询