资讯详情

资讯详情

NSIS+Duilib 打造高颜值 Windows 安装向导

简介面向Windows安装包开发者的「NSISDuilib 自定义安装程序」资料围绕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-unicode2 字节用 ANSI 插件中文全乱码ANSI 安装包Plugins\x86-ansi1 字节用 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 version1.0 encodingutf-8? Window size760,520 caption0,0,0,40 roundcorner8,8 VerticalLayout bkcolor#FFFFFFFF !-- 顶部品牌图高度固定 -- HorizontalLayout height140 bkimagefilebanner.png/ VerticalLayout inset48,24,48,0 Label text选择安装位置 font1 textcolor#FF222222 height28/ HorizontalLayout height36 margin0,8,0,0 Edit nameedit_path bordercolor#FFD0D0D0 width520 textC:\Program Files\MyApp/ Button namebtn_browse text浏览 width72 margin12,0,0,0/ /HorizontalLayout Label text附加任务 font1 textcolor#FF222222 height28 margin0,24,0,0/ Option nameopt_desktop text创建桌面快捷方式 height28 selectedtrue/ /VerticalLayout !-- 底部按钮条 -- HorizontalLayout height64 inset0,0,32,0 bkcolor#FFF7F7F7 Control/ Button namebtn_cancel text取消 width96 height32 margin0,16,12,0/ Button namebtn_install text开始安装 width120 height32 margin0,16,32,0 bkcolor#FF2D7FF9 textcolor#FFFFFFFF/ /HorizontalLayout /VerticalLayout /Windowcaption0,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 文件存成了 GBKDuilib 按 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:\测试目录\应用界面不糊文件落位正确高 DPI200% 缩放屏上跑一遍字清晰控件不错位卸载残留装完再卸看安装目录与注册表目录清空卸载项消失覆盖安装同版本装两次第二次不报文件占用我自己在这套组合上最深刻的一课是把「界面能跑」当成了「流程能跑」Duilib 窗口验收通过结果静默安装路径没人测流水线一批量部署就全军覆没。后来养成习惯界面做多好看都先拿/S走一遍再看正常流程。做安装器这行用户能容忍界面朴素容忍不了装不上。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →