简介:NSIS与Duilib结合的自定义安装程序开发素材包,主要面向需要美化Windows安装界面的中高级开发者,解决默认NSIS安装界面样式单一、难以贴合产品品牌视觉的问题。压缩包共451个文件,体积约9.6MB,类型覆盖113个PNG位图、113个NSH脚本片段、58个NLF多语言文件、35个ICO图标、32个BMP背景、24个XML布局文件、29个DLL动态库,以及EXE、BAT辅助工具和NSI主脚本;这些文件分别承担界面皮肤、安装逻辑、多语言、资源封装与编译打包等职责。包内还提供CHM帮助文档与多份示例工程,便于对照学习NSIS脚本命令、Duilib控件与XML布局的配合方式,以及快捷方式创建、注册表写入等常见安装功能的实现思路。内容还涉及安装目录选择、文件解压、界面事件绑定、错误处理与日志记录等环节,有助于减少实际项目中的排错成本。已有2942人学习下载,适合作为自定义安装程序从界面设计到功能集成的完整参考。
1. NSIS+DuiLib 自定义安装程序:用 NSIS 的壳,装 Duilib 的魂
第一次接触 NSIS+DuiLib 自定义安装程序,多半是产品经理丢来一句话:安装界面能不能做成我们自己的风格?默认 NSIS 向导页虽然能用,但在做品牌装机体验时确实撑不住,按钮、进度条、背景图都受系统主题限制。把 DuiLib 接进来以后,窗口由 DuiLib 的 XML 布局驱动,NSIS 继续负责文件释放、注册表、快捷方式和卸载器。这个组合适合手里有 C++ 维护能力,又想避免 Qt 那种十兆级运行时包袱的团队;如果你只是换文案和 Logo,直接改 NSIS 的 MUI 主题更划算。
2. 为什么是 NSIS+DuiLib:它把安装逻辑和界面分成两块,而不是一团乱麻
2.1 分工:NSIS 管“安装”,DuiLib 管“展示”
NSIS 最值钱的部分不是它的默认界面,而是脚本编译出来的安装骨架:文件提取、目录选择、注册表写入、卸载器生成、版本判断,这些能力经过十几年验证,插件生态也完整。DuiLib 则是一个 DirectUI 界面库,窗口上的控件不依赖系统控件,而是由 DuiLib 自己绘制。它的 XML 布局文件能描述窗口大小、背景、按钮位置和字体颜色,改样式时不用重新编译 C++ DLL,只要替换资源和 XML 就行。
所以我的分工策略很固定:NSIS 的 Section 只写安装逻辑,不在里面弹 MessageBox;DuiLib DLL 只做展示和收集用户选择,不直接碰文件复制。两者通过一个很薄的接口通信——用户点了“安装”或“取消”,DLL 往 NSIS 栈里压一个结果值,NSIS 根据这个值决定继续 Section 还是 Abort。这种拆分让安装器变成可维护的工程,而不是把 C++ 和 NSIS 混在一起调试到怀疑人生。
2.2 页面插件与整窗接管:两种接法,两种代价
实际落地时,我看过两种主流接法。第一种是页面插件法:把 DuiLib 窗口嵌到 NSIS 的 Page 流程里,用Page custom指定一个自定义页面函数,函数里调用 DuiLib 插件显示窗口。用户操作结束,函数返回,NSIS 继续执行后面的 Section。这个方案和 NSIS 的安装生命周期天然兼容,卸载器也能照常生成,改动最小。
第二种是整窗接管法:在.onGUIInit阶段直接把 NSIS 默认窗口隐藏,创建一个无边框的 DuiLib 顶层窗口盖在上面。商业安装器经常这么干,界面自由度最大,但你要自己处理消息循环、窗体销毁和 NSIS 页面跳转,否则很容易出现“点安装之后程序假死”的故障。新手我建议从页面插件法起步,不要一上来就追求整窗接管。
2.3 NSIS 插件入口和栈传参的最小约定
如果你选择页面插件法,需要把 DuiLib 窗口封装成一个 NSIS 插件 DLL。NSIS 插件不是普通的 Win32 DLL,它有一套入口约定:导出函数会收到父窗口句柄、参数指针和堆栈指针;脚本里调用插件后,插件可以把结果 push 到 NSIS 栈,脚本再用Pop取出来。这个约定是 NSIS 所有插件的基础,DuiLib 插件也逃不开。
我一般会写一个名字很直白的入口,比如Show。脚本侧看起来是这样的:
Function ShowDuiPage DuiUI::Show $HWNDPARENT Pop $0 ${If} $0 == "cancel" Abort ${EndIf} FunctionEnd这段代码里的DuiUI::Show不是 NSIS 官方插件,是我自己编译的 DuiLib UI 插件名。$HWNDPARENT是 NSIS 传给当前页面的父窗口句柄,插件需要用这个句柄来决定窗口挂载关系。Pop $0取出插件压入栈的结果,如果用户点取消,Abort会让安装流程停下来。
如果不想按 NSIS 插件规范写,也可以把 DuiLib DLL 编成普通 DLL,用System::Call显式调用。比如:
System::Call "DuiUI.dll::ShowInstallWindow(i $HWNDPARENT, i 0) i .r0"这里i表示整数参数,.r0表示把返回值写进$0。普通 DLL 方式的好处是复用已有 DuiLib 代码更方便,坏处是你要自己处理 DLL 释放路径和 stdcall 约定,容易在路径上踩坑。我的建议是:能走 NSIS 插件就走插件,栈传参比手工调System::Call更稳。
2.4 为什么不建议把安装逻辑全塞进 DuiLib DLL
有时候团队会觉得,反正界面是 DuiLib 的,干脆文件复制、注册表写入也全用 C++ 做,NSIS 只当压缩壳。这种思路能让进度条做到字节级精确,但代价是要复活一套安装器该有的所有边缘逻辑:覆盖已安装版本时备份文件、卸载时回滚注册表、处理被杀毒软件占用的文件、支持静默安装参数。这些能力 NSIS 已经趟过无数坑,没必要重新发明一遍。
所以我坚持让 NSIS 保留主流程。如果进度条精度不够,用阶段进度来补偿;如果 UI 需要实时反馈,可以让 Section 每完成一个大步骤就调用一次插件上的SetProgress方法。这样既保住了 DuiLib 的观感,又保住了 NSIS 的稳定性。
3. 最小可跑工程:NSIS 脚本、DuiLib DLL 和 XML 三件套
3.1 目录结构与编译输出
一个能实际跑起来的最小工程,至少要有四块东西:NSIS 脚本、DuiLib 插件 DLL、Duilib 资源目录、被安装的应用文件。我习惯于建这样一个目录:
| 路径 | 作用 |
|---|---|
script/install.nsi | NSIS 安装脚本入口 |
plugin/DuiUI/ | C++ 插件源码,编译产出DuiUI.dll |
res/skin/layout.xml | DuiLib 窗口布局 |
res/skin/img/ | 按钮、背景、Logo 等图片资源 |
payload/ | 最终要装进用户机器的文件 |
编译 DuiLib 插件时,把产物放到 NSIS 的Plugins/x86-unicode目录下。这样 NSIS 编译器会自动把它打进安装包,运行时自动解压到临时目录并加载。不用自己在脚本里File插件 DLL,这是 NSIS 插件机制自带的能力。
3.2 NSIS 脚本骨架:把 DuiLib 页面挂进安装流程
下面是一个可复现的脚本骨架,只保留了最核心的流程:
Unicode true Name "DemoApp" OutFile "DemoSetup.exe" InstallDir "$LOCALAPPDATA\DemoApp" RequestExecutionLevel user Page custom ShowDuiPage Section "Main" SetOutPath "$INSTDIR" File "payload\app.exe" File "payload\README.txt" WriteUninstaller "$INSTDIR\Uninstall.exe" SectionEnd Section "Uninstall" Delete "$INSTDIR\app.exe" Delete "$INSTDIR\README.txt" Delete "$INSTDIR\Uninstall.exe" RMDir "$INSTDIR" SectionEnd Function ShowDuiPage DuiUI::Show $HWNDPARENT Pop $0 ${If} $0 == "cancel" Abort ${EndIf} FunctionEndUnicode true是必须的,尤其当 DuiLib 的 XML 里包含中文时,ANSI 模式会出现乱码。Page custom ShowDuiPage把自定义页面插到安装流程最前面;如果用户点了 DuiLib 窗口上的“取消”,插件返回"cancel",Abort会让安装器直接退出。Section "Uninstall"是卸载器的逻辑,WriteUninstaller会生成卸载程序。
注意这里我用了user权限而不是admin,因为示例安装到用户目录,不需要提权。如果你的安装目录在Program Files或需要写系统服务,改成admin,但 UAC 弹窗会出现在 DuiLib 界面之前,这一层交互要提前跟产品经理说清楚。
3.3 插件 DLL:DuiLib 消息循环怎么和 NSIS 握手
下面是一段 C++ 伪代码,展示 DuiLib 插件入口的基本形状:
#include "duilib/UIlib.h" #include "nsis/pluginapi.h" class CMainWnd : public CWindowWnd, public INotifyUI { public: LPCTSTR GetWindowClassName() const override { return _T("DuiDemoWnd"); } void Notify(TNotifyUI& msg) override { if (msg.sType == DUI_MSGTYPE_CLICK) { if (msg.pSender->GetName() == _T("btn_install")) { ::PostQuitMessage(0); } } } }; PLUGIN_EXPORT void Show(HWND hwndParent, int, char*, stack_t** stacktop, extra_parameters*) { CMainWnd* wnd = new CMainWnd(); wnd->Create(hwndParent, _T("layout.xml"), WS_POPUP, 0, 0); wnd->CenterWindow(); ::ShowWindow(wnd->GetHWND(), SW_SHOW); CPaintManagerUI::MessageLoop(); pushstring(stacktop, _T("ok")); delete wnd; }这里的重点有两个。第一,Create的父窗口句柄直接用了 NSIS 传进来的hwndParent,这样 DuiLib 窗口能作为页面嵌入 NSIS 容器。第二,CPaintManagerUI::MessageLoop会进入 DuiLib 自己的消息循环,直到窗口收到退出消息。用户点“安装”按钮时,PostQuitMessage(0)让消息循环退出,然后pushstring把结果压给 NSIS。
这套流程最忌讳的是在按钮回调里写耗时的安装操作。因为你还在消息循环里,界面会被卡住,看起来像死掉。所以按钮回调只做两件事:记录用户意图、退出消息循环。
3.4 XML 布局:DuiLib 的窗口样式从这里开始
DuiLib 控件的摆放不靠代码写,而是靠 XML 布局文件。一个最小布局长这样:
<Window size="500,360" caption="0,0,0,0" shadow="false"> <VerticalLayout bkcolor="#FFFFFF" inset="20,20,20,20"> <Label name="title" text="安装 DemoApp" align="center" height="48" font="1"/> <Label name="desc" text="点击安装开始部署" height="32"/> <HorizontalLayout height="36" pos="0,-56" childmargin="10"> <Button name="btn_install" text="安装" width="100"/> <Button name="btn_cancel" text="取消" width="100"/> </HorizontalLayout> </VerticalLayout> </Window>Window标签决定窗口大小和是否显示系统标题栏。VerticalLayout和HorizontalLayout是 DuiLib 的容器,子控件会按容器方向排列。name是控件在代码里的唯一标识,上面 C++ 代码里FindControl就是靠这个名字来绑定按钮事件的。pos="0,-56"表示相对父容器右下角定位,适合把按钮固定在窗口底部。
XML 文件路径要传给 DuiLib 的窗口创建函数,路径基准通常是插件 DLL 所在目录。强调一个坑:不要在 XML 里写绝对路径,否则换机器就崩。资源图片用相对路径,并在 C++ 里通过CPaintManagerUI::SetResourcePath设置当前资源目录。
4. 参数与资源文件:把进度、取消、静默安装都接到 DuiLib 窗口上
4.1 阶段进度:别追求字节级精确,先把流程打通
NSIS 的File命令是压缩包解压流程,它不提供逐字节回调。硬要在 DuiLib 进度条上显示“当前解压到第 3 个文件”会非常别扭。我一般用阶段进度:
Section "Main" SetOutPath "$INSTDIR" DuiUI::SetProgress 10 File "payload\app.exe" DuiUI::SetProgress 50 File "payload\README.txt" DuiUI::SetProgress 80 WriteUninstaller "$INSTDIR\Uninstall.exe" DuiUI::SetProgress 100 SectionEndDuiUI::SetProgress是插件暴露的另一个方法,参数直接填 0 到 100 的整数。DLL 侧收到后把这个值转成进度条控件的百分比。这样每一步只跳一次进度,用户看到的是“在动”,而不是精确到字节的流畅动画。
如果产品经理坚持要平滑进度,唯一合理路径是把文件复制放到 DuiLib DLL 的工作线程里,用CopyFileEx的回调函数更新 UI。但这意味着你要自己写文件存在性判断、目录创建、写失败回滚,NSIS 的优势就废了。我的经验是先用阶段进度交付,大多数用户根本不关心安装进度条精确不精确,只关心它没卡住。
4.2 卸载器也要复用同一套 DuiLib UI
卸载器默认不会自动带上 UI 资源,需要你在安装时把插件和 XML 放到固定位置,或在卸载 Section 里重新释放。一个常见做法是这样:
Section "Uninstall" SetOutPath "$PLUGINSDIR" File "plugin\DuiUI.dll" File "res\skin\layout.xml" un.DuiUI::SetResourcePath "$PLUGINSDIR" un.DuiUI::Show "uninstall" Pop $0 Delete "$INSTDIR\app.exe" Delete "$INSTDIR\README.txt" Delete "$INSTDIR\Uninstall.exe" RMDir "$INSTDIR" SectionEndun.前缀是 NSIS 用来区分安装和卸载调用插件函数的约定。卸载 Section 里会先用File把资源释放到$PLUGINSDIR,再加载 DuiLib 窗口。如果你不在安装时额外保存这些文件,卸载器跑起来会找不到 layout.xml,窗口直接黑屏或者闪退。
有人会觉得这样做导致卸载程序体积变大,但其实资源文件很小,完全值得。还有一种做法是把 UI 相关文件装进$INSTDIR,但卸载器在删除目录时会有文件占用问题,$PLUGINSDIR是更安全的临时落点。
4.3 静默安装和/S:让 UI 在需要时消失
企业批量部署通常要用静默安装,NSIS 本身已经支持/S参数。问题在于,Page custom函数在静默模式下也会被调用,如果不处理,用户看到的会是闪一下窗口然后退出。所以在ShowDuiPage函数开头要加一道判断:
Function ShowDuiPage IfSilent 0 +2 Abort DuiUI::Show $HWNDPARENT Pop $0 ${If} $0 == "cancel" Abort ${EndIf} FunctionEnd如果安装器收到/S,NSIS 会进入静默状态,IfSilent成立,直接Abort跳过自定义页面。这样交互安装走 DuiLib 窗口,静默安装走纯 NSIS 逻辑,两边不冲突。
如果静默安装还要指定安装目录,可以在命令行里加/D=参数,NSIS 会把它解析到$INSTDIR。但要注意/D=必须放在命令行最后,这是 NSIS 的硬性规定,写参数解析代码时别被这个坑绊倒。
5. 避坑 / 常见问题:DuiLib 窗口卡死、DPI 模糊和中文乱码的排查记录
5.1 点击安装后整个程序无响应
现象:DuiLib 窗口正常显示,点“安装”按钮后窗口卡住,等十几分钟也没反应。如果按住按钮还能看到界面上的文字,但安装流程没启动,十有八九是消息循环堵住了。
原因:最常见的是在 DuiLib 按钮回调里直接调用了耗时操作,或者尝试从 UI 线程启动 NSIS Section,而 NSIS 还在等待Show函数返回。插件函数没返回,NSIS 就不会继续跑安装,但 UI 线程又被消息循环占住,于是两边互等。
解决:严格遵循“UI 只收集意图”的原则。按钮回调里只记录结果并退出消息循环,安装逻辑完全交给 NSIS Section。如果你需要安装过程中持续更新界面,也不要直接在插件里开线程调 NSIS 内部 API,而是用SetProgress这类消息驱动方式,让 NSIS 在 Section 执行间隙通知插件。
5.2 DPI 缩放导致界面模糊和控件错位
现象:同一台电脑上,默认 100% 缩放时界面正常,把系统分辨率改成 150% 后,DuiLib 窗口看着像被拉伸过,按钮之间缝隙忽大忽小,文字还发虚。
原因:安装器没有声明 DPI 感知,Windows 对它做了虚拟化缩放。DuiLib 内部按 96 DPI 计算布局坐标,一旦系统缩放比例改变,控件坐标就对不上了。
解决:在插件入口最早的地方调用SetProcessDpiAwarenessContext或声明 PerMonitorV2 的 manifest,让系统不要拉伸。然后根据窗口的 DPI 值对字体和布局做缩放。至少保证安装器在 100%、125%、150% 三档常见缩放下不错位。这条经验很血泪,因为大多数开发机是 100% 缩放,交付给用户就翻车。
5.3 XML 中文乱码
现象:layout.xml 里写的按钮文字“安装”在窗口上显示成“瀹夎”,路径填写框输入中文目录后,落盘路径也变成乱码。
原因:NSIS 脚本或 C++ 工程编译成了 ANSI 版本,DuiLib 控件内部却用 Unicode 字符串,两边编码不一致。还有一个隐蔽原因:XML 文件本身没有声明 UTF-8,也没有以 UTF-8 保存。
解决:NSIS 侧写Unicode true;C++ 工程统一使用/D_UNICODE /DUNICODE;XML 文件保存为 UTF-8 with BOM。检查这三个地方后,中文问题基本可以消除。如果是从老项目迁移,还要看一下 DuiLib 控件SetText传进去的是_T还是L,建议全工程只用宽字符。
5.4 窗口阴影和圆角在部分系统上出现黑块
现象:安装界面在 Win10 上看着还行,在一台 Win7 上窗口周围出现黑色边缘,圆角区域变成黑底。
原因:阴影和圆角依赖分层窗口或透明区域裁剪,有些老系统/远程桌面环境不支持,或者 DuiLib 的默认绘制路径走了 GDI 而非硬件加速。
解决:在 XML 布局里把shadow强制设为false,roundcorner根据目标系统决定是否开启。如果产品上必须保留圆角,可以用一张带透明通道的 PNG 背景来模拟,而不是依赖窗口层。我一般会专门在虚拟机里用最旧的目标系统过一遍 UI,黑块这种东西截图到测试单上很难解释。
5.5 用户取消安装后注册表还是被写了
现象:点“取消”按钮,DuiLib 窗口正常关闭,安装流程也停了,但某些注册表项已经写入到系统里,残留了垃圾数据。
原因:ShowDuiPage函数返回后,NSIS 并没有真正取消后续操作;或者在取消前已经执行了部分 Section 逻辑。自定义页面插件的Abort只能在页面阶段阻止流程,如果用户在文件复制完成之后才点取消,Section 里的写入已经完成了。
解决:在ShowDuiPage里判断Pop $0后如果是取消,立即Abort,同时把 DuiLib 窗口的关闭按钮也映射成取消状态,不要在窗口关闭时默认当“确定”。对于更彻底的回滚,需要给 Section 加事务,记录已写入的路径和注册表项,取消时反向清理。普通场合作到“页面取消不继续执行”就够了,别承诺用户取消后系统完全干净。
6. 进阶:在安装器里加一套命令行上下文,让同一份包同时服务交互和静默分发
如果安装器只会“下一步下一步”,它就只能撑起最简单的场景。一旦进入区分版本、区分安装模式,甚至要支持 CI/CD 自动打包,就要给 DuiLib UI 和 NSIS 之间加一层“安装上下文”。
我的做法是在 NSIS 脚本开头解析几个自定义参数,比如/MODE=dev或/MODE=prod。解析结果存进 NSIS 变量,然后在调用 DuiLib 插件之前通过插件接口传进去。代码示例如下:
${GetParameters} $R0 ${If} $R0 == "/MODE=dev" StrCpy $InstallMode "dev" ${Else} StrCpy $InstallMode "prod" ${EndIf} DuiUI::SetInstallMode $InstallMode DuiUI::Show $HWNDPARENT插件侧收到模式字符串后,可以向 DuiLib 窗口设置不同的背景图片或按钮文案。这样一个安装包既能给内部测试用,也能给正式用户用,界面元素通过参数切换,不用维护两套安装工程。
最后的验证工作,我会按表格逐项过一遍:
| 检查项 | 通过标准 |
|---|---|
| 全新安装 | 首次安装无残留,目录权限正确 |
| 覆盖安装 | 旧版本被替换,不会同时存在两份配置 |
| 取消安装 | 不继续写注册表,不释放后续文件 |
| 静默安装 | /S参数下退出码为 0,无 UI 闪现 |
| 高分辨率 | 150% 缩放下控件不重叠、文字不虚 |
| 卸载 | 能弹出 DuiLib 卸载界面,删净主文件和目录 |
当年我图省事,直接做成整窗接管,结果 NSIS 和 DuiLib 的消息循环互相打架,一度以为这个方案根本跑不通。后来退回Page custom,把插件当成一个“会画界面的函数”,所有安装行为交回 NSIS,问题才真正解决。现在我做这类安装器都先立这个规矩:UI 只表达用户意图,不越权干活;进度可以粗糙,流程必须稳定。希望帮到你。
本文还有配套的精品资源,点击获取