简介:面向Windows安装包开发者的「NSIS+Duilib 自定义安装程序」资料,围绕NSIS脚本安装逻辑与Duilib XML界面皮肤的结合,解决传统安装界面样式单一、交互与品牌化不足的问题,适合具备一定脚本和界面开发基础、希望制作高定制安装器的读者参考。压缩包共451个文件,约9.6MB,包含PNG/BMP/ICO图片与皮肤资源、NSH/NSI脚本、NLF多语言文件、XML布局、DLL组件及EXE工具等,覆盖界面素材、安装流程脚本、多语言与打包辅助模块。已有2947人学习下载。可据此拆解脚本安装流程、XML皮肤布局与多语言配置的组织方式,并复用到自定义Windows安装器项目。
1. NSIS 默认向导难以承载成熟产品:Duilib 补的是界面层
用 NSIS 做安装包,第一版基本都长一个样:灰底、宋体、下一步、我同意、安装。功能上没问题,但它传递的信号是「这是个内部小工具」。等到安装界面需要展示产品图、要在一屏里同时完成路径选择、组件勾选、许可协议滚动和安装进度,界面改动的需求会明显增加,而 MUI2 的页面模型每个页面都是独立的对话框模板,控件坐标写在资源脚本里,改一个按钮圆角都要重编资源,效率很低。
比较常见的做法是把两件事拆开:NSIS 只负责安装逻辑——解压文件、写注册表、建快捷方式、注册卸载项;界面交给 Duilib 这套 DirectUI 库,用一个 NSIS 插件把 Duilib 窗口拉起来,用户在自绘界面上做完选择,结果再回传给 NSIS 脚本。这篇讲的就是这套组合怎么落地,包括插件接口怎么对齐、资源怎么打包、字符集和 DPI 这些需要重点处理的坑。
2. 先让 NSIS 骨架跑通:从 InstallDir 到 Section 的最小结构
很多人在这一步就翻车:Duilib 界面写得漂漂亮亮,结果装完发现卸载项没注册、安装路径写死在脚本里、静默安装参数一进来就弹窗。问题不在界面,在于.nsi的执行顺序没理清。先把 NSIS 单独跑通,再接界面,后面会省掉一半返工。
2.1 NSIS 脚本的四个执行阶段与插件插入点
一个.nsi文件从上到下大致分四段,理解这四段的时序比记指令重要得多。
第一段是全局指令区,声明包名、输出文件名、默认安装目录、压缩方式、权限请求。这些指令在编译期生效,运行时只读。
第二段是 Page 区,决定向导页面的顺序。用 MUI2 时是!insertmacro MUI_PAGE_*一串宏;全自定义时是Page custom 函数名。
第三段是 Section 区,真正的安装动作在这里,SetOutPath、File、WriteRegStr、CreateShortCut都是 Section 里的活。
第四段是回调函数,.onInit在向导初始化前跑,un.onInit在卸载器初始化前跑,.onGUIEnd在界面销毁后跑。插件调用最常放在.onInit或者一个自定义 Page 里。
把 Duilib 插进去,本质上就是在这四段里找一个「用户能做选择、且 NSIS 还没开始拷文件」的位置。.onInit是最合适的:此时$INSTDIR已经有默认值,Section 还没执行,用户取消还来得及Quit。
2.2 一份能直接编译的.nsi骨架
下面这份脚本不依赖任何第三方宏,makensis直接能出包。先把它跑通,再往里塞插件调用。
; MyAppSetup.nsi —— 最小可运行骨架 Unicode true ; NSIS 3 走 Unicode,后面插件必须对齐 Name "某工具集" OutFile "MyAppSetup.exe" InstallDir "$PROGRAMFILES64\MyApp" ; 64 位程序默认装 Program Files InstallDirRegKey HKLM "Software\MyApp" "InstallDir" RequestExecutionLevel admin ; 要写 HKLM 和 Program Files,必须提权 SetCompressor /SOLID lzma ShowInstDetails nevershow ShowUninstDetails nevershow !include LogicLib.nsh !include x64.nsh Var InstallPath Var WantDesktop Function .onInit ; 静默安装 /S 时不弹界面,直接用默认值 ${If} ${Silent} StrCpy $InstallPath $INSTDIR StrCpy $WantDesktop "0" Goto init_done ${EndIf} ; 这里将来换成 Duilib 插件调用,先留占位 StrCpy $InstallPath $INSTDIR StrCpy $WantDesktop "1" init_done: FunctionEnd Section "Install" SetOutPath "$InstallPath" File /r "dist\*.*" ; dist 目录下是编译产物 WriteUninstaller "$InstallPath\uninst.exe" ; 卸载信息,缺了它控制面板里看不到条目 WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\MyApp" \ "DisplayName" "某工具集" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\MyApp" \ "UninstallString" '"$InstallPath\uninst.exe"' WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\MyApp" \ "DisplayIcon" "$InstallPath\app.exe" ; 用户勾了才建桌面快捷方式 ${If} $WantDesktop == "1" CreateShortCut "$DESKTOP\某工具集.lnk" "$InstallPath\app.exe" ${EndIf} CreateDirectory "$SMPROGRAMS\某工具集" CreateShortCut "$SMPROGRAMS\某工具集\某工具集.lnk" "$InstallPath\app.exe" SectionEnd Section "Uninstall" Delete "$DESKTOP\某工具集.lnk" RMDir /r "$SMPROGRAMS\某工具集" RMDir /r "$INSTDIR" DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\MyApp" DeleteRegKey HKLM "Software\MyApp" SectionEnd几个参数值得单独说。Unicode true决定了整个包的字符串模型,也决定了插件必须编 Unicode 版,这一条后面会反复出现。RequestExecutionLevel admin不是可选项,写HKLM或者往Program Files放文件,没有管理员权限会静默失败,失败点还很难定位。SetCompressor /SOLID lzma能明显压体积,但解压时吃内存,安装包超过 1GB 建议换/FINAL。
File /r "dist\*.*"的路径是相对.nsi文件所在目录的,相对路径写错时makensis只在编译期报一句 “file not found”,不会告诉你具体找的是哪个目录,这是新手最容易卡住的地方。
2.3 哪些页面该被 Duilib 接管
没必要一上来就全部替换。把 MUI2 的页面和 Duilib 的能力做个对照,按收益排序接管。
| 页面 | MUI2 能力 | 建议 | 理由 |
|---|---|---|---|
| 欢迎页 | 有,可换图 | Duilib 接管 | 品牌露出集中在这一屏 |
| 许可协议 | 有,富文本弱 | Duilib 接管 | 长文本滚动 + 勾选联动,自绘更可控 |
| 安装目录 | 有,样式固定 | Duilib 接管 | 常和组件选择放同屏 |
| 组件选择 | 弱 | Duilib 接管 | 树形/卡片勾选基本只能自绘 |
| 安装进度 | 有,可显示明细 | 先保留 NSIS | 进度回调要额外做,收益低 |
| 完成页 | 有 | 先保留 NSIS | 勾选项少,改造性价比低 |
我的建议是前四屏用 Duilib 一个窗口一次性做完,进度和完成页继续交给 NSIS 的MUI_PAGE_INSTFILES和MUI_PAGE_FINISH。这样插件只需要一个入口函数,回传三四个变量,接口面最小,出问题也好定位。
3. Duilib 插件的接口契约:签名、栈传参与字符集
NSIS 调插件不是普通的 DLL 导出调用,它有一套固定的函数签名和栈协议。签名对不上,表现是「插件调用无反应」或者整个安装包直接退出,连错误框都没有,调试成本很高,需要提前把规则弄清楚。
3.1 导出函数的标准写法
NSIS 3 的插件是一个导出特定函数的 DLL,函数签名固定为五个参数,返回值靠 NSIS 栈传递。
// duisetup.cpp —— NSIS 插件入口 #include <windows.h> #include "exdll.h" // NSIS 源码 Contrib 目录里带的头文件 #include "SetupWnd.h" // 调用约定用默认的 __cdecl,导出名不要加修饰 extern "C" __declspec(dllexport) void ShowSetupPage(HWND hwndParent, int string_size, TCHAR *variables, stack_t **stacktop, extra_parameters *extra) { EXDLL_INIT(); // 宏内部把 stacktop 解成局部变量 stack // hwndParent 是 NSIS 向导窗口句柄,直接当 Duilib 的父窗口用 CSetupWnd wnd; if (!wnd.CreateSetup(hwndParent)) { stack->push(_T("fail")); // 创建失败要把结果推回去 return; } bool ok = wnd.ShowModal(); // 阻塞到用户点「安装」或「取消」 if (!ok) { stack->push(_T("cancel")); return; } // 把用户选择写回 NSIS 变量,变量名要和脚本里 Var 声明的一致 wnd.GetInstallPath(variables, string_size); stack->push(_T("ok")); }EXDLL_INIT()这个宏是关键,它把stacktop解引用成本地变量stack,后面所有push/pop都走它。忘了加这一句,编译能过,运行时栈操作全部乱掉,现象是Pop $0拿到空串。
variables参数是一块连续内存,存着 NSIS 当前所有变量的值,格式是变量名\0值\0变量名\0值\0\0。往里面写值要小心长度,string_size是单条字符串的上限,超了会踩坏相邻变量。实际项目里我一般不在插件内直接写这块内存,而是让插件push结果,脚本侧Pop到变量,路径和勾选状态各弹一次,逻辑更清楚。
3.2 用栈把安装路径和勾选项回传给脚本
脚本侧接收结果的写法没什么花样,但顺序必须和插件push的顺序严格相反,这是栈,不是结构体。
Function .onInit ${If} ${Silent} StrCpy $InstallPath $INSTDIR Goto init_done ${EndIf} duisetup::ShowSetupPage Pop $0 ; 先弹出最后 push 的 "ok"/"cancel"/"fail" ${If} $0 != "ok" Quit ; 用户取消,直接结束安装器 ${EndIf} Pop $InstallPath ; 再弹安装路径 Pop $WantDesktop ; 最后弹桌面快捷方式标志 "0"/"1" init_done: FunctionEnd这里有个容易忽略的细节:插件里push了三个值,脚本里就要Pop三次,多弹或少弹都不会报错,但变量会错位,表现是「安装路径变成了 0」。排查时把$0、$InstallPath、$WantDesktop用MessageBox MB_OK打出来看一眼,比读代码快。
另外,Quit在.onInit里是合法调用,会正常退出安装器,不留下半装状态。这一点比在 Page 回调里取消要安全。
3.3 插件放到哪个目录,ANSI 和 Unicode 怎么并存
NSIS 3 编译时按Unicode true决定插件目录。头文件里TCHAR会展开成wchar_t,所以插件工程必须定义UNICODE和_UNICODE再编译,否则字符串传过去是乱码,而且不报错。
插件目录的约定是这样的:
| 编译目标 | 插件目录 | TCHAR 宽度 | 混用后果 |
|---|---|---|---|
| Unicode 安装包 | Plugins\x86-unicode | 2 字节 | 用 ANSI 插件,中文全乱码 |
| ANSI 安装包 | Plugins\x86-ansi | 1 字节 | 用 Unicode 插件,路径截断 |
| 64 位安装器 | Plugins\x86-unicode仍适用 | 2 字节 | NSIS 本体是 32 位,不要编 x64 DLL |
最后一行是很多人绕不过来的坎:NSIS 生成的安装器本身是 32 位进程,即使装的是 64 位程序,插件也必须编成 32 位 DLL。编成 x64 后加载失败,错误信息只有一句「无法加载插件」,没有更多线索。
工程里不想全局装插件,可以在脚本里指定目录,这样 CI 上不用改 NSIS 安装目录:
!addplugindir /x86-unicode ".\build\plugin"路径相对.nsi文件。这句要写在所有插件调用之前,放在文件头部最保险。
4. 把 Duilib 窗口接入安装流程:模态、通知与资源打包
接口对齐后,剩下的是 Duilib 这一侧的三件事:窗口怎么以模态方式挂在 NSIS 父窗口上、控件事件怎么收集、皮肤资源在安装器这种「解压即运行」的环境里怎么取。这三件事任何一件没处理干净,表现都是界面能开但交互异常。
4.1 从 WindowImplBase 到一个可复用的安装窗口
Duilib 的WindowImplBase已经把窗口注册、消息循环、XML 解析这套模板代码包好了,继承它比从CWindowWnd手写省事。安装窗口只需要重写三个方法:皮肤目录、皮肤文件名、通知回调。
// SetupWnd.h #pragma once #include "UIlib.h" using namespace DuiLib; class CSetupWnd : public WindowImplBase { public: CSetupWnd() : m_bAccepted(false) {} // 返回 false 表示用户取消,true 表示点了安装 bool ShowModal() { // 第二参数是父窗口,第三个是窗口标题,第四个是模态标志 Create(NULL, _T("某工具集 安装"), UI_WNDSTYLE_DIALOG, WS_EX_WINDOWEDGE); CenterWindow(); ShowModalLoop(); // 内部跑 GetMessage/DispatchMessage return m_bAccepted; } void GetInstallPath(TCHAR* buf, int size) const { _tcsncpy_s(buf, size, m_strPath.GetData(), _TRUNCATE); } protected: CDuiString GetSkinFolder() override { return _T("skin"); } CDuiString GetSkinFile() override { return _T("setup.xml"); } LPCTSTR GetWindowClassName() const override { return _T("SetupWnd"); } void Notify(TNotifyUI& msg) override { if (msg.sType != _T("click")) return; CDuiString name = msg.pSender->GetName(); if (name == _T("btn_install")) { // 从 Edit 控件读路径,空值兜底 CControlUI* pEdit = m_PaintManager.FindControl(_T("edit_path")); m_strPath = pEdit ? pEdit->GetText() : _T(""); if (m_strPath.IsEmpty()) { MessageBox(NULL, _T("请选择安装目录"), _T("提示"), MB_OK); return; } m_bAccepted = true; Close(); } else if (name == _T("btn_cancel")) { m_bAccepted = false; Close(); } } private: bool m_bAccepted; CDuiString m_strPath; };ShowModalLoop这个函数名在不同分支的实现里可能叫ShowModal,做法都一样:ShowWindow之后进一个自己的GetMessage循环,遇到WM_QUIT就退。关键在于循环期间要保证 NSIS 的父窗口被禁用,否则用户能点后面的向导窗口,出现两个可交互界面。
父窗口禁用的标准写法是在Create之后、进入循环之前EnableWindow(hwndParent, FALSE),循环退出后恢复TRUE。漏了这一对,模态就只是「看起来像模态」。
4.2 XML 布局与控件通知的对应关系
Duilib 的界面全在 XML 里,控件靠name属性在Notify里识别。安装界面这一屏,纵向排三个区块就够了:标题图、表单区、按钮区。
<?xml version="1.0" encoding="utf-8"?> <Window size="760,520" caption="0,0,0,40" roundcorner="8,8"> <VerticalLayout bkcolor="#FFFFFFFF"> <!-- 顶部品牌图,高度固定 --> <HorizontalLayout height="140" bkimage="file='banner.png'"/> <VerticalLayout inset="48,24,48,0"> <Label text="选择安装位置" font="1" textcolor="#FF222222" height="28"/> <HorizontalLayout height="36" margin="0,8,0,0"> <Edit name="edit_path" bordercolor="#FFD0D0D0" width="520" text="C:\Program Files\MyApp"/> <Button name="btn_browse" text="浏览" width="72" margin="12,0,0,0"/> </HorizontalLayout> <Label text="附加任务" font="1" textcolor="#FF222222" height="28" margin="0,24,0,0"/> <Option name="opt_desktop" text="创建桌面快捷方式" height="28" selected="true"/> </VerticalLayout> <!-- 底部按钮条 --> <HorizontalLayout height="64" inset="0,0,32,0" bkcolor="#FFF7F7F7"> <Control/> <Button name="btn_cancel" text="取消" width="96" height="32" margin="0,16,12,0"/> <Button name="btn_install" text="开始安装" width="120" height="32" margin="0,16,32,0" bkcolor="#FF2D7FF9" textcolor="#FFFFFFFF"/> </HorizontalLayout> </VerticalLayout> </Window>caption="0,0,0,40"决定顶部 40 像素可拖动,roundcorner是圆角,这两个属性不加,窗口就是 Win95 风格,前面做的视觉都白费。name属性和Notify里的字符串必须完全一致,包括大小写,写错了事件静默丢弃,没有任何提示。
Option控件的选中状态用IsSelected()读,Edit用GetText()读,这两个 API 在CControlUI和派生类上都有,取到指针后转成对应类型再调。
4.3 皮肤资源怎么在「解压即运行」的环境里拿到
安装器运行时是从临时目录执行的,工作目录不可靠,所以 Duilib 的SetResourcePath不能用相对路径。实际项目里我一般走两种方案之一。
第一种,NSIS 把皮肤目录解到$PLUGINSDIR,插件启动时用绝对路径指过去。
Section "Install" InitPluginsDir SetOutPath "$PLUGINSDIR\skin" File /r "skin\*.*" ; 皮肤资源随包带进来 SetOutPath "$INSTDIR" ; ... 后续安装动作 SectionEnd插件里拿到$PLUGINSDIR的路径,最省事的做法是脚本侧用System::Call或者直接StrCpy拼好路径,通过push传进插件,插件里CPaintManagerUI::SetResourcePath(path)后就开始找 XML 和图片。这个路径在安装结束后由 NSIS 自动清理,不用自己删。
第二种,把整个 skin 目录压成 zip,编译时塞进 DLL 的 RCDATA 资源里,插件启动后从内存解压到临时目录再指路径。这种方案安装包目录结构最干净,但多一层解压逻辑,出问题排查麻烦。第一次做这个方案,建议用第一种,跑通再优化。
无论哪种,GetSkinFile()返回的setup.xml必须能在SetResourcePath目录下找到,找不到时 Duilib 不报错、直接创建一个空白窗口,现象是「界面闪一下就没了」,这是 DuiLib 新手最常卡住的问题。
5. 避坑排查:字符集、模态、DPI 与静默安装的四类翻车
下面五条都是实际项目里踩过的,按「现象 → 原因 → 解决」写,遇到同款症状可以直接对照。
第一条:界面能开,所有中文变方块或乱码。
现象是 Duilib 窗口起来了,标签上的字全是方块,或者一半正常一半乱码。
原因是插件编成了 ANSI 版。Unicode true的安装包按 2 字节字符调用插件,插件按 1 字节解析,字符串直接错位。也可能插件本身是 Unicode,但 XML 文件存成了 GBK,Duilib 按 UTF-8 解析,读到非法字节就断。
解决办法:插件工程里加UNICODE;_UNICODE预处理宏,XML 文件统一另存为 UTF-8 无 BOM,所有字符串字面量加_T()。改完用dumpbin /exports确认导出函数名没被 C++ 修饰符改掉。
第二条:窗口一闪而过,安装器直接退出。
现象是点安装包后,Duilib 界面出现不到一秒就没了,NSIS 也没往下走。
原因是Create的第二个参数传了 NULL,没有父窗口,Duilib 在某些分支下拿不到消息循环的宿主,ShowModalLoop立即返回。或者GetSkinFile返回的 XML 没找到,WindowImplBase创建失败但没抛错。
解决办法:Create(hwndParent, ...)显式传 NSIS 的父窗口;在Create之后做一次IsWindow()校验,失败就把fail推回栈,让脚本侧给出明确提示,而不是让用户看到黑屏。
第三条:4K 屏下界面糊成一片,字边缘发虚。
现象是 1080P 显示器正常,4K 加 200% 缩放后所有控件放大但模糊。
原因是 manifest 里没声明 DPI 感知。NSIS 安装器默认是 DPI 不感知的进程,系统会把整个窗口按位图拉伸,Duilib 自己画得再精细也救不回来。
解决办法:在插件工程里加一个.manifest文件,声明dpiAware为true/PM,并且 NSIS 编译时用/DDUILIB_DPI_AWARE之类的开关把清单并进安装器。光在插件里声明不够,安装器主进程也得感知,两边都要处理宏。
第四条:带/S参数静默安装时,界 facing 仍然弹出来。
现象是自动化部署脚本传/S,结果卡在一个窗口上等人点,流水线超时。
原因是.onInit里的${If} ${Silent}判断写在了插件调用之后,或者判断条件写成了$R0 == "S"。${Silent}这个宏读的是 NSIS 内置的静默标志,位置放错就失效。
解决办法:把静默分支放在.onInit最前面,所有界面相关代码都在分支外;静默下直接给$InstallPath和$WantDesktop赋默认值,跳过插件调用。发布前用MyAppSetup.exe /S走一遍,再用%ERRORLEVEL%确认返回 0。
第五条:卸载时报「找不到 uninst.exe 依赖的模块」。
现象是安装正常,控制面板卸载时弹框说某个 DLL 缺失,卸载流程中断。
原因是 Duilib 插件 DLL 依赖的运行时库没随包带上,安装时插件从Plugins目录加载没问题,卸载器是独立进程,找不到依赖。
解决办法:插件编译选项里设静态链接 CRT(/MT),不要依赖目标机上的 VC 运行库;或者把必需 DLL 放到$INSTDIR并在卸载前显式确认存在。这条属于交付前必查项,别等用户反馈。
6. 进阶:把进度和卸载页也交给 Duilib,以及发布前的验证
做完前面四屏,界面体验基本齐了。要继续往深里做,收益最大的其实是进度回调和卸载界面,这块能明显减少「装到一半没反应」的投诉。
进度回调的思路是把 Duilib 窗口改成常驻对象:插件入口不关闭窗口,而是返回一个句柄给 NSIS 存在变量里,后续每个安装步骤之间调一次duisetup::UpdateProgress,传 0 到 100 的数字,插件内部找到自己的窗口,更新进度条和状态文本。这样用户在拷贝大文件时能看到实时百分比,而不是盯着一个静止的界面。
duisetup::ShowSetupPage Pop $0 Pop $InstallPath Pop $WantDesktop ${If} $0 != "ok" Quit ${EndIf} ; 窗口已创建,后续步骤回传进度 SetOutPath "$InstallPath" duisetup::UpdateProgress 10 File /r "dist\*.*" duisetup::UpdateProgress 70 ; ... 写注册表、建快捷方式 duisetup::UpdateProgress 100 duisetup::CloseSetupPage ; 主动关闭,释放窗口UpdateProgress的实现要点是拿到窗口句柄后用PostMessage投递一个自定义消息,窗口在HandleCustomMessage里更新控件,不要在插件函数所在的线程里直接操作 Duilib 控件,跨线程碰 UI 是崩溃和卡顿的源头。
卸载页同理,un.onInit里调一个ShowUninstallPage,问一句「是否同时删除配置」,回传布尔值,卸载 Section 里按这个值决定删不删%APPDATA%下的配置目录。
交付前我会过一遍这张表,每条都实际执行:
| 检查项 | 验证方式 | 通过标准 |
|---|---|---|
| 静默安装 | Setup.exe /S后看返回值 | 返回 0,无窗口 |
| 中文路径 | 装到D:\测试目录\应用 | 界面不糊,文件落位正确 |
| 高 DPI | 200% 缩放屏上跑一遍 | 字清晰,控件不错位 |
| 卸载残留 | 装完再卸,看安装目录与注册表 | 目录清空,卸载项消失 |
| 覆盖安装 | 同版本装两次 | 第二次不报文件占用 |
我自己在这套组合上最深刻的一课,是把「界面能跑」当成了「流程能跑」:Duilib 窗口验收通过,结果静默安装路径没人测,流水线一批量部署就全军覆没。后来养成习惯,界面做多好看都先拿/S走一遍,再看正常流程。做安装器这行,用户能容忍界面朴素,容忍不了装不上。希望帮到你。
本文还有配套的精品资源,点击获取