简介:「NSIS+Duilib 自定义安装程序」是一套面向 Windows 桌面软件开发者与安装包制作人员的实战素材,聚焦用 NSIS 脚本驱动安装流程、以 Duilib 的 XML 布局与控件搭建美化安装界面的完整组合方案,可解决传统安装界面简陋、交互与皮肤难以定制的问题,适合已具备基础脚本能力、希望进阶到自定义皮肤与多语言安装的中级开发者参考。包体共 451 个文件、约 9.6MB,以 113 个 png 皮肤图片、113 个 nsh 脚本头文件、58 个 nlf 多语言文件、35 个 ico 图标与 32 个 bmp 位图构成界面素材主体,另有 29 个 dll、24 个 xml 布局、17 个 exe、2 个 nsi 主脚本及若干 bat 构建批处理,覆盖皮肤资源、语言包与编译打包链路。已有 2945 人学习下载。读者可从中获取可直接复用的皮肤资源与 XML 布局结构、NSIS 脚本的组织方式(安装目录选择、文件释放、注册表写入、快捷方式与开机启动等),以及多语言文件与构建批处理的配置思路,便于对照搭建并调试自己的安装包界面。
1. 给桌面工具套一层自定义安装界面,NSIS 与 Duilib 是最省事的组合,也是最容易卡住的组合
给一个桌面工具做安装包,脚本层用 NSIS 基本没悬念:体积小、中文文档多、卸载信息、注册表、快捷方式全都能覆盖。问题是 NSIS 自带的 MUI2 页面在用户眼里属于 2010 年的长相,于是把 Duilib 拉进来做那层带皮肤的安装向导。NSIS 负责释放文件、写注册表、生成卸载项,Duilib 负责画界面,两者中间靠一个 32 位插件 DLL 连接。
这套组合装不好会很麻烦:makensis 编不过、插件 DLL 加载不到而报Invalid command、界面闪一下就白屏、x86 和 x64 库混着链进去直接 LNK1112。我前后重装过三次,真正卡住时间的是位数和字符集两个开关,代码本身没怎么费劲。下面按我最后一次成功安装的顺序写:先定环境,再装 NSIS,再编 Duilib 和插件,最后是装坏之后的补救记录和验证方法。
2. 装前先确定环境:NSIS 3.09 与 Duilib 的位数、字符集组合
这一章不安装任何东西,只把后面会反复用到的版本、位数和目录定死。安装程序这类项目最怕中途改位数,改一次等于前面所有库全部重编。
2.1 三层分工:脚本层、界面层、编译层各自负责什么
NSIS 这一层由.nsi脚本和一个编译产物makensis.exe组成。它做的事很固定:解释脚本、把File指令引用的文件压缩进安装包、生成一个 exe 外壳。它不负责界面绘制,只暴露页面钩子;自带页面只有许可协议、安装目录、进度条、完成页这几种固定形态。
Duilib 这层是纯 C++ 的窗口库,负责画那套带背景图、自定义按钮、进度动画的安装向导。它不感知“安装”这件事,唯一要做的就是把用户点击的“下一步”转换成返回值,交回 NSIS 脚本。两者之间不能直接调用,得经过 NSIS 的插件机制:一个导出固定签名函数的 DLL。这个 DLL 是整篇文章的核心缓冲层,Duilib 编译产物和 NSIS 脚本都围绕它对接。
编译层就是 VS 的 msbuild。市面上很多教程直接把 Duilib 编成 DLL 再链接,我不推荐:安装包本体只需要一个插件 DLL,Duilib 用静态库链进去更干净,既省一个发布文件,也避免安装包里出现两个 Dll 互相找不到。代价是静态库的字符集和位数必须和插件工程完全一致,这也是后面两条踩坑记录的来源。
2.2 版本对照表:位数是重要决定因素
NSIS 只有 32 位版本,makensis.exe和它生成的安装包都是 32 位进程。这意味着加载进来的插件 DLL 必须是 32 位,它依赖的 Duilib 静态库也必须是 32 位,一路往下传递,没有例外。第一次安装时我用 x64 编了 Duilib,后面折腾了一晚上才定位到位数冲突,问题根源就在这里。
| 组件 | 版本 | 目标平台 | 字符集 | 说明 |
|---|---|---|---|---|
| NSIS | 3.09 | x86 | Unicode | 3.0 之后默认 Unicode 版,不再提供 ANSI 主程序 |
| Duilib | 主干版本 | x86 | Unicode | 只编静态库 Release,不编 DLL |
| VS | 2019 | — | — | 平台工具集 v142 |
| Windows SDK | 10 系列 | — | — | 跟随 VS 安装即可 |
| msbuild | 随 VS | — | — | 命令行编译,避免手动点解决方案 |
表里的 Duilib 我刻意没写具体版本号,因为不同分支的目录结构和头文件名差异不小,写死了反而容易让读者照着找不到文件。判断标准只有一条:能编出DuiLib.lib这个静态库、头文件里能找到CWindowWnd和CPaintManagerUI就够了。
字符集这一列比版本号重要。NSIS 3 的pluginapi.h在 Unicode 模式下,popstring和pushstring收发的都是宽字符,插件工程的字符集如果还停在“使用多字节字符集”,字符串能编过,运行时中文路径就会变成乱码,属于最难查的一类问题。
提示:位数和字符集这两项,建议在动手前就写进项目 README,后面任何一个新同事接手都按这两条走,不要凭印象改。
2.3 目录规划:先把产物路径固定下来
安装类项目最容易乱的是中间产物:Duilib 编出来的 lib 放在源码目录、插件 DLL 又扔在 Debug 目录、.nsi里写的是相对路径,换一台机器立刻失效。我一般会在盘符根目录建一个工作区,路径不带空格、不带中文,因为 makensis 处理含空格路径时偶尔会在File指令上出问题,虽然可以加引号绕过,但没必要给自己找麻烦。
# 工作区固定结构,后面所有路径都从这里推 mkdir -p D:/dev/nsis-dui/src # Duilib 源码与插件工程 mkdir -p D:/dev/nsis-dui/build # 中间产物,可整目录删除 mkdir -p D:/dev/nsis-dui/out/lib # Duilib 静态库最终落地处 mkdir -p D:/dev/nsis-dui/out/plugins # 插件 DLL 产物 mkdir -p D:/dev/nsis-dui/out/setup # 安装包输出这几行的作用是把“源码”“中间产物”“发布产物”三类东西隔开。build目录随时可以清空重来,out目录里的东西则是安装脚本真正引用的,.nsi里写路径时只写out这一层。这样做的直接好处是:某天 Duilib 编译出错,删build重编,不会影响已经能跑通的安装脚本。
接下来给工作区加一个系统变量DUISDK指向D:\dev\nsis-dui\out,后面.nsi里用!define DUISDK或者环境变量读取都方便。变量值不要带结尾反斜杠,.nsi里自己拼接\,这是 NSIS 路径处理的老规矩。
3. 安装 NSIS 3.09:从 makensis 命令行到 x86-unicode 插件目录
NSIS 本身的安装是整套流程里最省心的一步,基本是下一步到底。真正需要动手的是安装完之后的三件事:环境变量、插件目录规则、外加一次命令行编译验证。
3.1 安装包选择与组件勾选
下载 NSIS 3.09 的安装程序,一路默认即可,安装路径用C:\Program Files (x86)\NSIS。这个路径里有空格,但 makensis 自己能处理,不需要改。安装过程中会问要不要装几个可选组件,我的选择是:脚本示例装(Examples目录里有很多现成的页面写法可以抄)、语言文件装(多语言安装界面要用)、.nsh头文件必装,源码包不用装。
装完之后看一眼目录,有四个位置后面会天天用到:
makensis.exe:编译器本体,命令行入口。Include\:MUI2.nsh、LogicLib.nsh、FileFunc.nsh这些头文件都在这里。Plugins\x86-unicode\:插件 DLL 的默认搜索目录,只有这个目录对 Unicode 版生效。Stubs\:安装包外壳的模板文件,一般不动。
为什么单独强调x86-unicode?因为 NSIS 3 的安装包是 Unicode 版,编译时它只在Plugins\x86-unicode\下找插件。老教程里写的Plugins\根目录是 NSIS 2 时代的规则,照着放进去编译器不会报错,但生成安装包运行时会提示Invalid command,这种“编译通过、运行才报错”的失败最难查。
3.2 环境变量配置与命令行首次编译
装完之后不要急着写业务脚本,先用一个三行脚本把命令行链路跑通。第一步是把 NSIS 目录加进 PATH,让任意命令行窗口都能直接调makensis。推荐改用户级环境变量,不要用setx /M改系统级 PATH,因为系统 PATH 有长度上限,被截断之后会有别的工具莫名失效,这类事故我遇过不止一次。
# 用户级追加路径,只影响当前用户,安全 setx PATH "%PATH%;C:\Program Files (x86)\NSIS" # 重新打开一个命令行窗口再验证,setx 不会刷新已打开的窗口 makensis /VERSION第一行把 NSIS 目录追加到用户 PATH 后面,不用%PATH%覆盖系统级内容。第二行的makensis /VERSION是最轻量的验证方式,正常会打印版本号v3.09。如果提示“不是内部或外部命令”,说明窗口没重开或者 PATH 写坏。
确认能调起来之后,写一个最小脚本验证编译链路:
; hello.nsi —— 最小可编译脚本,只验证工具链 Unicode true Name "编译链路测试" OutFile "D:\dev\nsis-dui\out\setup\hello.exe" InstallDir "$TEMP\hello-test" RequestExecutionLevel user Page instfiles Section "Main" SetOutPath $INSTDIR FileOpen $0 "$INSTDIR\mark.txt" w FileWrite $0 "ok" FileClose $0 SectionEndUnicode true是第一行就必须写的指令,它决定整个脚本的字符串按宽字符处理,也决定后面插件必须按宽字符约定通信。RequestExecutionLevel user让安装包不弹 UAC,测试阶段少一道点击。Page instfiles用自带进度页,这段不涉及界面,只为确认 makensis 能产出 exe。
编译命令很简单:
cd /d D:\dev\nsis-dui makensis /V4 /WX hello.nsi/V4是详细输出级别,能看到它引用了哪些头文件、压缩率多少;/WX表示把警告当错误处理,这个开关建议一直开着。安装脚本里最常见的警告是变量未使用和File指令引用的路径不存在,前者无害,后者往往是漏拷贝文件,一旦放过,装出来的包就是缺文件的半成品。
3.3 插件搜索路径与第一次插件调用
链路通了以后,把插件目录规则一次性配好。有两个做法:一是把插件 DLL 拷进C:\Program Files (x86)\NSIS\Plugins\x86-unicode\,二是用!addplugindir在脚本里显式指定目录。前者的缺点是污染全局 NSIS 安装,换台机器就得再拷一遍;后者把依赖写进脚本,谁拉代码谁都能编。
; 在脚本顶部声明插件所在目录,路径末尾不要加反斜杠 !addplugindir /x86-unicode "D:\dev\nsis-dui\out\plugins" ; 调用插件时写 插件名::函数名,插件名即 dll 文件名去掉扩展名 Section "CallPlugin" duiui::ShowPage Pop $0 DetailPrint "插件返回:$0" SectionEnd!addplugindir后面的/x86-unicode是必须带的修饰,告诉编译器按 Unicode 插件去查找。duiui::ShowPage里,duiui对应duiui.dll,ShowPage对应 DLL 里导出的函数名,两者大小写不敏感但建议和代码保持一致。Pop $0取的是插件用pushstring压回栈的返回值,这个栈是后进先出的,插件里压了几个,脚本里就要按相反顺序Pop几次,否则后面的页面会读到脏数据。
插件 DLL 还不会写的话,这一节先跳过,把!addplugindir那行注释掉,等第 4 章编出 DLL 再回来接。
4. 编译 Duilib 并封装 NSIS 插件:Release x86 Unicode 三个开关
这一章是整套安装里最容易出问题的部分。Duilib 编译本身不难,难的是让它的产物和 NSIS 插件工程、NSIS 主程序三方在位数和字符集上完全对齐。
4.1 Duilib 源码编译:三个必须固定的开关
源码拿到src目录下之后,不要急着用 VS 打开解决方案点生成,命令行编译更容易复现,也更容易写进构建脚本。Duilib 的主干版本一般带一个.sln,里面通常有 Debug、Release 两个配置,平台默认是 Win32(也就是 x86),这一点刚好符合我们的要求。
# 编译 Duilib 静态库:Release + x86 + Unicode msbuild DuiLib.sln ^ /t:Rebuild ^ /p:Configuration=Release ^ /p:Platform=Win32 ^ /p:CharacterSet=Unicode ^ /p:PlatformToolset=v142 ^ /m逐项说明:/t:Rebuild强制全量重编,避免旧 obj 干扰;/p:Platform=Win32是必须的,写x86有些工程不认;/p:CharacterSet=Unicode会覆盖工程文件里写死的字符集,省得手动去改项目属性;/p:PlatformToolset=v142对应 VS2019,如果本机是 VS2022 就换成v143,工具集和运行时库版本不匹配会报一堆LNK2038。
编译完成后找到DuiLib.lib,连同DuiLib头文件目录一起拷到out\lib。判断编对了没有,不用看日志,用一个命令查库文件的机器类型:
dumpbin /headers DuiLib.lib | findstr machine正常输出里应该是8664之外的14C,也就是 x86。如果看到8664,说明平台选错了,这个库链进 NSIS 插件必然报LNK1112。
4.2 写一个最小 NSIS 插件:函数签名和栈约定
NSIS 插件的本质是一个导出特定签名函数的 DLL,函数名就是脚本里调用的名字。NSIS 3 提供了一个pluginapi.h,里面定义了栈操作宏和EXDLL_INIT。这个头文件在 NSIS 安装目录的Include下找不到,需要从 NSIS 源码包的Contrib\PluginAPI里拷出来,很多人卡在这一步。
// duiui.cpp —— 最小插件,只负责弹出一个 Duilib 窗口并返回结果 #include <windows.h> #include <tchar.h> #include "pluginapi.h" // 从 NSIS 源码包 Contrib\PluginAPI 拷来 #include "UIlib.h" using namespace DuiLib; // 模态跑 Duilib 消息循环,窗口关掉才返回 static int RunDuiWindow(HWND hParent) { CWindowWnd* pFrame = new CInstallFrame(); // 父窗口传 hParent,安装包主窗口会变成它的 owner pFrame->Create(hParent, _T("安装向导"), UI_WNDSTYLE_DIALOG, WS_EX_WINDOWEDGE); pFrame->CenterWindow(); pFrame->ShowWindow(true); CPaintManagerUI::MessageLoop(); // 阻塞在这里,直到窗口销毁 delete pFrame; return 1; } // 导出函数:名字必须和 .nsi 里的 duiui::ShowPage 对应 extern "C" void __declspec(dllexport) ShowPage( HWND hwndParent, int string_size, TCHAR* variables, stack_t** stacktop) { EXDLL_INIT(); // 必须第一行,它依赖上面三个形参名,不能改 TCHAR szCaption[128] = {0}; popstring(szCaption); // 取出脚本 push 进来的标题 int nRet = RunDuiWindow(hwndParent); TCHAR szRet[16] = {0}; _stprintf_s(szRet, _T("%d"), nRet); pushstring(szRet); // 脚本用 Pop $0 接收这个值 }这段代码有三个必须遵守的约定。第一,导出函数名ShowPage就是脚本里::后面的名字,改成ShowPageW之类脚本立刻找不到。第二,四个形参的名字不能改,EXDLL_INIT()这个宏内部直接用string_size、variables、stacktop这三个标识符,改成nSize之类会编不过。第三,popstring和pushstring的顺序必须和.nsi严格对应,脚本里 push 两个,这边就要 pop 两个。
工程配置上还有三个开关要固定:字符集设为 Unicode、配置选 Release、平台选 Win32。链接器附加依赖项里加上DuiLib.lib,附加库目录指向out\lib,C/C++ 附加包含目录指向 Duilib 的头文件目录。产物输出路径直接设成out\plugins\duiui.dll,编完就是成品,不需要手动挪。
4.3 在 .nsi 脚本里接管页面流
插件编好之后,把它接到安装脚本里。NSIS 的自定义页面有两种写法:用Page custom挂一个函数,函数里调插件;或者干脆让插件自己弹一个模态窗口,脚本只等结果。第二种更简单,也更符合 Duilib 的窗口模型。
Unicode true !addplugindir /x86-unicode "D:\dev\nsis-dui\out\plugins" Name "某桌面工具" OutFile "D:\dev\nsis-dui\out\setup\installer.exe" InstallDir "$PROGRAMFILES\SomeTool" RequestExecutionLevel admin ; 欢迎页用 Duilib 接管,返回 0 表示用户取消 Page custom PageWelcome PageWelcomeLeave Page directory Page instfiles Function PageWelcome Push "欢迎安装" duiui::ShowPage Pop $0 StrCmp $0 "0" 0 +2 Abort ; 用户点了取消,终止安装 FunctionEnd Function PageWelcomeLeave FunctionEndPage custom需要成对的两个回调:进入页面时调用的PageWelcome,离开页面时调用的PageWelcomeLeave,后者留空即可。Abort是 NSIS 里终止安装流程的标准做法,放在页面回调里会直接退出安装。Push "欢迎安装"对应插件里的popstring,参数数量必须一致,少 push 一个的话,插件会从栈上取到上一个页面的残留值,表现是标题莫名其妙变成安装目录之类。
编译时加/WX,确认没有警告之后再上真机测试:
makensis /V4 /WX /DDUISDK="D:\dev\nsis-dui\out" installer.nsi/D用来在脚本里定义符号,比如脚本里写!define DUISDK时可以用命令行覆盖,方便 CI 上换路径而不改脚本。
5. 避坑与排查:五条装坏之后的补救记录
下面这五条都是我实际遇到过的记录,每条按“看到什么现象、为什么、怎么处理”写,能对号入座基本就不用重装。
5.1 编译期:两个链接错误
现象一:LNK1112: 模块计算机类型“x64”与目标计算机类型“x86”冲突。原因是链接进去的 Duilib 静态库是 64 位,而插件工程是 32 位。这种错误有时不做提示,只在链接阶段突然报出来,让人怀疑是不是工程配置被谁改了。解决方法是回第 4.1 节重编DuiLib.lib,/p:Platform明确写Win32,编完用dumpbin /headers DuiLib.lib | findstr machine确认是14C而不是8664。同类错误还有LNK2038: 检测到“RuntimeLibrary”的不匹配项,那是 Release 库和 Debug 工程混用,统一到 Release 即可。
现象二:LNK2001: 无法解析的外部符号 _DuiLib::CPaintManagerUI::MessageLoop。原因通常是库没加上,或者加上了但库文件是空的,比如编译时CharacterSet和源码里写死的不一致导致整个工程被跳过。检查链接器附加依赖项里有没有DuiLib.lib,以及附加库目录是否指到了out\lib。还有一种少见情况:库加对了,但工程开了“全程序优化”,静态库的符号被裁掉了,把/GL关掉就好。
5.2 运行期:加载不到、白屏、闪退
现象三:运行安装包时提示Invalid command: duiui::ShowPage。编译阶段完全正常,运行才报。原因只有一个:插件 DLL 没放在Plugins\x86-unicode\下,且脚本里没写!addplugindir /x86-unicode。NSIS 3 只在x86-unicode目录里找 Unicode 插件,放到Plugins\根目录或者Plugins\x86-ansi\都找不到。解决办法是二选一:把 DLL 拷进Plugins\x86-unicode\,或者在脚本第一行加!addplugindir /x86-unicode "你的目录"。我倾向后者,因为依赖写在脚本里,换机器不用重新配。
现象四:安装界面闪一下就不见了,或者弹出空白窗口。原因是 Duilib 窗口对象在消息循环开始前就被析构了。常见写法是在插件函数里new一个窗口、Create、ShowWindow,然后直接return,函数返回时栈上的指针被销毁,窗口跟着没了。正确做法是像 4.2 节那样,在ShowWindow之后调用CPaintManagerUI::MessageLoop(),把线程阻塞住,等窗口自己关闭再往下走。另一种可能是父窗口没传,Create的 first 参数传 NULL 时窗口没有 owner,安装包主窗口会抢焦点,看起来就像白屏。
现象五:静默安装/S模式下安装包卡死或者崩溃。原因是插件无条件弹窗,而静默安装环境下没有用户交互,窗口一直等不到点击。解决方法是插件入口先判断命令行参数,NSIS 提供GetParameters,或者更简单,在脚本层用IfSilent跳过自定义页面:
Function .onInit IfSilent +2 0 ; 静默模式直接跳过欢迎页 FunctionEnd配合把Page custom用IfSilent包一层,静默模式下就不进插件。这类问题在自动化部署脚本里出现概率很高,本机双击测一百次都正常,一进批量安装就翻车。
5.3 发布期:体积和拦截
现象六:安装包从 8MB 涨到 30MB,或者被杀毒软件拦下。原因是 Duilib 静态链接时带进了调试信息和用不到的界面控件。解决办法:插件工程选 Release、关闭“生成调试信息”、C/C++ 里把“优化”设为“使大小最小化”、链接器开启“消除未引用的代码”。做完这几项,一般能压回去一半。另外不要给安装包外壳加 UPX 之类的压缩壳,安装包本身已经有 LZMA 压缩,再加壳不仅压不了多少,还容易触发启发式拦截。
6. 装完怎么验证:三层检查跑一遍再交付
环境装完、脚本编过,不等于这套东西能用。我一般按三层验证,每层都能单独定位问题,出问题时不用在一整条链上瞎猜。
第一层验工具链,只跑makensis /VERSION和一次最小脚本编译,确认 PATH 和头文件没问题。第二层验插件,写一个只有Page custom的测试脚本,不释放任何文件、不写注册表,只调duiui::ShowPage,看窗口能不能正常弹出、点“取消”能不能让安装终止、点“下一步”能不能走到进度页。第三层才验完整安装包,在一个干净的系统环境里跑一遍全流程,再跑一遍/S静默安装。
| 检查项 | 命令或动作 | 通过标准 |
|---|---|---|
| 编译器可用 | makensis /VERSION | 输出 v3.09 |
| 脚本零警告 | makensis /V4 /WX installer.nsi | 无 warning 输出 |
| 库位数正确 | dumpbin /headers DuiLib.lib | machine 为 14C |
| 插件被识别 | 运行测试包看是否报 Invalid command | 正常弹窗 |
| 取消流程 | 点欢迎页“取消” | 安装进程立即退出 |
| 卸载残留 | 安装后卸载,看 Program Files 和注册表 | 无残留目录与键值 |
| 静默安装 | installer.exe /S | 无窗口,退出码为 0 |
静默安装那一行有个细节:/S必须大写,NSIS 只认大写,小写会被当成普通参数忽略,然后你会看到一个没人点的窗口挂在那里。测退出码可以用echo %ERRORLEVEL%,正常安装返回 0,用户取消返回 1 或 2,构建脚本里靠它判断成败。
最后补一个习惯:每次改完.nsi,我都会先把out\setup目录清空再编,因为 makensis 不会自动删旧产物,编译失败时旧 exe 还在原地,很容易拿旧包去测试,然后怀疑人生。那次装完之后,我就把“清空输出目录、检查 DuiLib.lib 位数、跑一遍静默安装”这三步写进了发布脚本,虽然麻烦,但确实省下了不少事后排查的时间。希望这套流程能帮到你。
本文还有配套的精品资源,点击获取