简介:面向Windows桌面软件安装包开发者的NSIS+Duilib自定义安装程序工程,适合希望把传统安装流程升级为可视化界面的开发者与界面/脚本学习者。它解决NSIS原生界面简陋、Duilib应用接入安装逻辑繁琐的问题,围绕安装目录选择、文件释放、注册表写入、快捷方式与开机启动、多语言及错误日志等环节给出可参考的实现素材。压缩包共451个文件、约9.6MB,以png、bmp、ico等界面皮肤资源,nsh、nlf、nsi等NSIS脚本与语言文件,以及xml布局、dll库、exe程序和bat构建脚本为主,便于按皮肤、脚本、依赖库分层查阅。已有2945人学习。读者可据此理解Duilib界面与NSIS安装逻辑的对接方式,参考XML布局和脚本组织搭建自定义安装向导,并借鉴快捷方式创建、注册表写入、多语言适配与日志排错思路,减少从零整合UI与安装流程的试错成本。
1. 装 NSIS + Duilib 前先想清楚:为什么很多人卡在第一个编译错误
上周帮一位开发者处理一个安装包项目,他那边的情况很典型:NSIS 脚本编译通过,Duilib 界面也在 Demo 里跑得挺好,结果一合并,安装向导刚启动就白屏,日志里连个像样的报错都没有。这套组合在 Windows 桌面安装程序里太常见了:NSIS 负责安装逻辑、文件释放、注册表写入,Duilib 负责把那个灰扑扑的原生向导换成能自定义的界面。听起来分工清晰,真装起来坑全在衔接层,版本对不上、字符集对不上、资源路径对不上,任何一个都能让你对着黑匣子调一整天。这篇记录的是我自己走过一遍的完整流程:装什么版本、目录怎么摆、脚本怎么接、跑不起来先看哪里,适合正在做桌面安装包、需要自定义向导界面的开发者照着复现。
2. 环境准备与版本选型:NSIS 3.x 搭配 Duilib 的正确组合
2.1 NSIS 版本怎么选,为什么我固定在 3.08 以上
NSIS 2.x 和 3.x 之间的差异不是小版本迭代,是 Unicode 支持的分水岭。2.x 时代默认 ANSI 编译,中文路径、中文安装目录、日文系统下的显示问题一堆;3.x 从 3.0 开始原生支持 Unicode,脚本里Unicode true一行就能把整个安装器切到宽字符模式。做 Duilib 界面更要注意这点,Duilib 内部用宽字符渲染,NSIS 侧如果还是 ANSI,传进去的字符串会在第一个非 ASCII 字符处截断,界面上的中文直接变问号或者空白。
我一般固定用 NSIS 3.08 或 3.09,这两个版本对插件调用和!include路径处理比较稳,再往上的小版本也能用,但没有必要追新。安装包直接从官方发布页下载nsis-3.08-setup.exe,安装时有个选项叫「Install NSIS compiler and libraries」,默认勾选,保持默认即可。安装目录不要带空格和中文,C:\NSIS或D:\Tools\NSIS都行,后面 MakeNSISW 编译时会读这个路径下的Include和Stubs目录,路径里有空格容易在自定义编译参数时出问题。
安装完成后验证一下最高效:
makensis /VERSION输出类似v3.08就说明编译器已经在 PATH 里。如果没有输出,说明安装时没勾选「Add NSIS to the system PATH」,重新跑一遍安装程序勾上,或者手动把C:\NSIS加进系统环境变量。这一步别跳过,后面用命令行批量编译脚本时会反复用到。
2.2 Duilib 拿哪一份:原版、分支版和静态库的取舍
Duilib 本身没有一个官方的「标准发行版」,市面上的源码分支很多,差异主要在三块:是否带 DPI 适配、是否修复了 XML 资源加载的内存泄漏、是否支持静态库编译。做安装程序场景,我建议直接选一个能编译成静态库(.lib)的分支版本,原因很实在——安装器是单文件分发,动态链接 DLL 意味着你还要处理 DLL 和安装包的相对位置,用户双击 exe 的时候 DLL 找不到,界面直接起不来,而你连错误提示都没机会弹。
源码拉下来之后,目录结构通常是这样:
Duilib/ ├── DuiLib/ # 核心库源码,编译产物是 DuiLib.lib ├── DuiLibDemo/ # 官方示例,用来验证库能不能跑 ├── bin/ # 示例运行时需要的资源 └── DuiLib.sln # VS 解决方案关键动作是用 Visual Studio 打开DuiLib.sln,把配置切到Release+Win32,编译 DuiLib 项目,拿到DuiLib.lib。这里有个容易忽略的点:如果你的 NSIS 安装器是 32 位进程(NSIS 编译出来的安装包默认就是 32 位),那么 Duilib 必须也编成 Win32,不能编 x64。混编的话链接阶段会报一堆LNK2019未解析外部符号,新手很容易在这里怀疑是源码问题,其实是位数不匹配。
我把这套环境要求整理成一张表,装之前对着核一遍能省掉大半返工:
| 组件 | 推荐版本 | 位数 | 产物 | 备注 |
|---|---|---|---|---|
| NSIS | 3.08 / 3.09 | 32 位编译器 | makensis.exe | 必须开 Unicode |
| Duilib | 支持静态库的分支 | Win32 | DuiLib.lib | 与安装器位数一致 |
| Visual Studio | 2017 及以上 | — | — | 需装 C++ 桌面开发负载 |
| 字符集 | Unicode | — | — | 全程不要混 ANSI |
2.3 工作目录规划:源码、资源、脚本三分离
开工前先把目录定下来,后面改起来不折腾:
InstallerProject/ ├── src/ # Duilib 界面工程源码 ├── lib/ # DuiLib.lib 及依赖库 ├── res/ # 界面 XML、图片、字体 ├── nsis/ # .nsi 脚本与 NSIS 插件 └── output/ # 编译产物,安装包落地处分离的好处是 NSIS 脚本里引用资源可以用相对路径,编译时用/XOutFile指定输出,不会把中间文件撒得到处都是。res目录里的 XML 和图片最终要打进安装包,所以体积要控制,图片优先用 PNG,背景图别超过 200KB,否则安装包启动时解压资源会有明显延迟。
3. 编译 Duilib 静态库并接入 NSIS:从 .lib 到界面进程
3.1 编译 DuiLib.lib 的两个必须改的工程设置
打开解决方案后别急着点编译,先改两个地方。第一处是项目属性里的「C/C++ → 代码生成 → 运行库」,改成「多线程 (/MT)」。默认是/MD,动态链接 CRT,这样编出来的 lib 在链接进 NSIS 插件时会带上对msvcrXX.dll的依赖,而目标机器上不一定有这个运行库,安装器一启动就报缺少 DLL。改成/MT把 CRT 静态链进去,单文件分发的目标才成立。
第二处是「C/C++ → 语言 → 将 WChar 作为内置类型」,设为「是 (/Zc:wchar_t)」。Duilib 源码里大量使用wchar_t,这个选项关掉会导致CString之类的类型和 Windows SDK 头文件里的定义冲突,编译报重定义错误。
改完后编译 DuiLib 项目,输出窗口最后一行应该是类似:
DuiLib.vcxproj -> ...\lib\DuiLib.lib把DuiLib.lib拷到工作目录的lib/下。如果编译报fatal error C1083: 无法打开包括文件: "windows.h",那是 VS 安装时没勾 Windows SDK,回到安装器补上「Windows 10 SDK」即可,跟 Duilib 本身没关系。
3.2 NSIS 插件方式:把 Duilib 界面嵌进安装流程的三种做法
让 NSIS 和 Duilib 协同工作,常见有三条路,我按推荐度排:
第一种是 NSIS 插件(Plugin)方式:把 Duilib 界面封装成一个 DLL 插件,放进 NSIS 安装目录的Plugins文件夹,脚本里用nsDialogs之外的插件命令调用。这种方式对 NSIS 流程侵入最小,页面切换、安装进度回调都能通过插件导出函数传给 NSIS。
第二种是把 Duilib 界面做成独立 exe,在 NSIS 脚本的某个页面用ExecWait拉起来,界面关闭后返回。实现简单,但两个进程之间的状态同步要靠临时文件或者命令行参数,安装进度条刷新会有延迟。
第三种是直接用 Duilib 重写整个安装器外壳,NSIS 只做打包不用它的界面。控制力最强,但工作量也最大,NSIS 的页面栈、静默安装参数都得自己实现。
做中小型项目的自定义安装向导,我一般走第一种。插件工程的关键配置:
// DuilibPlugin.cpp 导出函数示例 extern "C" __declspec(dllexport) void __stdcall ShowInstallUI(HWND hParent, const wchar_t* lpCmdLine) { // hParent 由 NSIS 传入的父窗口句柄,用于模态挂接 // lpCmdLine 传界面资源目录,安装器启动时由脚本拼好相对路径 CPaintManagerUI::SetInstance(GetModuleHandle(NULL)); CPaintManagerUI::SetResourcePath(lpCmdLine); // 资源路径必须是绝对路径或相对安装器的可解析路径 CInstallWnd* pWnd = new CInstallWnd(); pWnd->Create(hParent, L"安装向导", UI_WNDSTYLE_FRAME, 0L); pWnd->CenterWindow(); pWnd->ShowModal(); // 模态阻塞,直到界面退出 }这段代码里三个参数最关键。hParent是 NSIS 传进来的父窗口句柄,挂上去之后界面会跟随安装器主窗口的层级,不会跑到任务栏单独占一个项。lpCmdLine是资源目录,SetResourcePath要求传入的路径能被CPaintManagerUI解析,用相对路径时它会以当前工作目录为基准,而 NSIS 执行插件时的工作目录不一定是安装器所在目录——这是白屏的头号原因,后面排查章节展开讲。ShowModal是阻塞调用,NSIS 脚本会停在这行等界面关闭,所以界面里的「取消」「完成」按钮要负责把窗口销毁,否则安装流程会一直挂着。
插件 DLL 编完后,文件名建议带后缀区分位数,比如DuilibUI.dll,丢进C:\NSIS\Plugins\x86-unicode\目录,这是 NSIS 3.x 存放 Unicode 插件的位置,放错到x86-ansi下调用会失败。
3.3 .nsi 脚本里挂载界面的最小可用片段
脚本侧要做的事情就三件:声明插件、准备资源路径、在合适的位置调起来。
Unicode true Name "某桌面工具安装程序" OutFile "..\output\setup.exe" InstallDir "$PROGRAMFILES\SomeTool" ; 插件调用前把界面资源释放到临时目录,避免资源路径失效 Section "Install" SetOutPath "$TEMP\InstallerRes" File /r "..\res\*.*" ; 拼出资源目录的绝对路径传给插件 StrCpy $0 "$TEMP\InstallerRes" DuilibUI::ShowInstallUI $0 SectionEndSetOutPath加File /r的组合是把res目录整个释放到临时目录,这样传给插件的路径在运行期一定存在。StrCpy $0把路径存进 NSIS 的变量寄存器$0,插件命令按顺序接收参数。注意DuilibUI::ShowInstallUI里的DuilibUI必须和 DLL 文件名一致,冒号必须是双冒号,写成单冒号编译期直接报未知命令。
安装包体积方面,File /r会把目录下所有文件打进去,res里的调试用 XML 和不用的图片记得清理,不然安装包白白大几 MB。
4. 配置项逐个拆:资源路径、DPI 与进度回调
4.1 资源路径三处必须一致,否则白屏
Duilib 启动时按顺序找资源:SetResourcePath设置的目录、exe 所在目录、当前工作目录。安装器场景最容易在这三者之间打架。我一般固定成一种约定:资源目录永远由 NSIS 释放到临时目录,插件收到路径后调SetResourcePath,XML 里的图片引用全部写相对路径(比如file="logo.png"而不是file="..\res\logo.png")。这样只要根目录对,子文件全对。
XML 加载失败时 Duilib 默认不报错,界面就是一片空白。验证资源路径是否生效,可以在插件里加一行临时日志:
// 加载 XML 前先确认文件存在,方便定位路径问题 std::wstring xmlPath = std::wstring(lpCmdLine) + L"\\install.xml"; if (GetFileAttributesW(xmlPath.c_str()) == INVALID_FILE_ATTRIBUTES) { OutputDebugStringW((L"XML not found: " + xmlPath).c_str()); // 输出到 DebugView 观察 } CPaintManagerUI::SetCurrentPath(xmlPath.c_str());GetFileAttributesW返回INVALID_FILE_ATTRIBUTES就说明路径拼错了,OutputDebugStringW的内容用 DebugView 工具能看到,比弹 MessageBox 干净。这段临时日志定位完问题就可以删,别留在正式版本里。
4.2 DPI 适配:高分辨率屏幕下界面错位的处理
安装器在 4K 屏上跑,如果宿主进程不声明 DPI 感知,Windows 会把整个窗口位图拉伸,Duilib 画的控件全是糊的。处理方式是在ShowInstallUI里创建窗口前声明感知级别:
// 在窗口创建之前声明,时机错了不生效 SetProcessDPIAware(); // 简易声明,适合绝大多数安装器场景SetProcessDPIAware是系统级声明,调用一次整个进程生效,代价是不支持每显示器不同缩放比例。安装器一般是单窗口场景,够用。如果界面里有精细的图标和文字排版,还需要在 XML 里把字号按 DPI 换算一遍,Duilib 的CPaintManagerUI提供GetDPIScale之类的接口,把原始设计稿的 px 值乘以缩放系数再设进控件,不然 150% 缩放下按钮文字会溢出。
这里有个血泪经验:DPI 声明必须在CPaintManagerUI初始化之前调用,放在窗口Create之后调是完全无效的,界面先按 96 DPI 布局完了,你再声明也来不及。
4.3 安装进度回调:让界面知道 NSIS 释放到哪一步了
自定义界面最容易做得「假」——进度条一直在动,但其实跟实际安装进度无关。正确做法是让 NSIS 在释放文件的每个阶段调插件导出函数更新界面。
; 每释放一批文件后通知界面刷新进度 DuilibUI::SetProgress 30 File "bin\main.exe" DuilibUI::SetProgress 60 File "bin\config.ini" DuilibUI::SetProgress 100插件侧SetProgress内部就是找到已经创建的CInstallWnd实例,调它的进度条控件SetValue。节点数量不用太细,10% 一档足够了,调用太频繁反而拖慢文件释放。百分比的分配按文件体积加权比按文件个数更准,大文件单独占一档。
如果安装流程里有解压、写注册表、创建快捷方式这些耗时操作,也插在SetProgress之间,让用户看到的进度和真实耗时对应上。
5. 常见问题排查:白屏、链接失败与中文乱码
5.1 安装器启动白屏,无报错
现象:安装包双击后窗口出来了,但界面全白,没有任何控件,关闭按钮也没有。
原因:九成是SetResourcePath收到的路径在运行期不存在。NSIS 调插件时的工作目录是安装包解压的临时目录,不是脚本里File /r的目标目录,用相对路径传给插件就解析到了错误位置。
解决:传给插件的路径必须是绝对路径,或者用GetModuleFileName拿到插件自身的绝对位置再拼相对目录。前面 3.3 的脚本示例用$TEMP\InstallerRes就是这个思路,临时目录在安装期间一定存在。
5.2 链接期报 LNK2019 未解析外部符号
现象:插件工程编译过,链接时一堆LNK2019: 无法解析的外部符号,符号名看起来都是 Duilib 内部类。
原因:一个是位数不匹配,lib 是 x64 的,工程是 Win32;另一个是运行库不一致,lib 用/MD编的,工程用/MT,CRT 符号对不上。
解决:确认DuiLib.lib和插件工程同为 Win32、同为/MT。改完运行库设置要清理解决方案重编,增量编译有时候不会重新链接所有目标文件,残留旧 obj 会继续报同样的错。
5.3 界面中文变成问号或方块
现象:界面能出来,但所有中文标签显示为???或者方块。
原因:XML 文件保存编码不是 UTF-8,或者 NSIS 脚本没开Unicode true,字符串在传给插件的过程中被截断。
解决:XML 统一存成 UTF-8 无 BOM(Duilib 对 BOM 的处理因分支而异,无 BOM 最稳),NSIS 脚本第一行加Unicode true,插件工程字符集设为 Unicode。三处都对齐之后,中文不会再出问题。
5.4 安装完成后界面进程残留
现象:安装完成后主窗口关了,任务管理器里还有个进程挂着。
原因:ShowModal返回后窗口对象没有销毁,Duilib 的消息循环退出了但CInstallWnd的 new 出来的实例没 delete。
解决:在ShowInstallUI末尾补delete pWnd,并在窗口的关闭处理里调PostQuitMessage。这是个内存层面的小疏漏,但用户看到残留进程会觉得安装器没装干净。
6. 装完怎么验证:三步确认这套安装器真的能交付
环境搭好、脚本编过,不代表能交付。我每次都按下面三步走一遍,顺序不能反,任何一步不过就别往下走。
第一步是干净机器验证。找一台没装过 Visual Studio、没装过任何运行库的 Windows 环境(虚拟机快照最方便),双击setup.exe。观察三件事:向导窗口能否正常显示完整界面,安装路径选择页的中文是否正常,安装完成后目标目录里的文件数量是否和源目录一致。这一步卡住的话,问题基本都在运行库依赖或者资源路径上,回到第 3、4 章对参数。
第二步是安装流程验证。装完之后必须实际启动目标程序,确认它依赖的 DLL、配置文件、注册表项都到位。我一般会在安装脚本的末尾写一个校验节点:
Section "Verify" IfFileExists "$INSTDIR\main.exe" 0 +3 DetailPrint "主程序存在" Goto +2 MessageBox MB_ICONSTOP "主程序释放失败,请检查打包路径" SectionEndIfFileExists的跳转数字是相对行数,0 +3表示文件不存在时往后跳 3 行到 MessageBox。这种校验节点在测试期很有用,交付版可以保留,异常时给用户一个明确提示。
第三步是卸载回环。装完立刻卸载,看三件事:临时资源目录$TEMP\InstallerRes是否被清理、安装目录是否删除干净、注册表里的卸载项是否移除。Duilib 安装器最容易漏掉的是临时目录清理,脚本里加一行RMDir /r "$TEMP\InstallerRes"放在卸载段末尾,这笔债不还,用户机器上会慢慢堆一堆缓存。
| 验证项 | 通过标准 | 失败先看 |
|---|---|---|
| 界面显示 | 无白屏、中文正常、控件齐全 | 资源路径、XML 编码 |
| 功能文件 | 主程序可启动、依赖完整 | File 路径、位数匹配 |
| 卸载清理 | 临时目录与安装目录清空 | 卸载段 RMDir |
从那以后我每次改完res目录或者换 Duilib 分支,都会强制走一遍这三步,尤其是第一步的干净机器验证——曾经有一次为了省事只在开发机上重编,结果交付后用户那边因为缺一个 VC 运行库的间接依赖,安装器窗口压根没起来,返工成本比验证高得多。希望这套流程能帮到你,少走一段我踩过的路。
本文还有配套的精品资源,点击获取