ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

NSIS与Duilib自定义安装界面:x86/Unicode插件编译与避坑

NSIS与Duilib自定义安装界面:x86/Unicode插件编译与避坑

简介:「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,后面折腾了一晚上才定位到位数冲突,问题根源就在这里。

组件版本目标平台字符集说明
NSIS3.09x86Unicode3.0 之后默认 Unicode 版,不再提供 ANSI 主程序
Duilib主干版本x86Unicode只编静态库 Release,不编 DLL
VS2019——平台工具集 v142
Windows SDK10 系列——跟随 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 SectionEnd

Unicode 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 FunctionEnd

Page 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.libmachine 为 14C
插件被识别运行测试包看是否报 Invalid command正常弹窗
取消流程点欢迎页“取消”安装进程立即退出
卸载残留安装后卸载,看 Program Files 和注册表无残留目录与键值
静默安装installer.exe /S无窗口,退出码为 0

静默安装那一行有个细节:/S必须大写,NSIS 只认大写,小写会被当成普通参数忽略,然后你会看到一个没人点的窗口挂在那里。测退出码可以用echo %ERRORLEVEL%,正常安装返回 0,用户取消返回 1 或 2,构建脚本里靠它判断成败。

最后补一个习惯:每次改完.nsi,我都会先把out\setup目录清空再编,因为 makensis 不会自动删旧产物,编译失败时旧 exe 还在原地,很容易拿旧包去测试,然后怀疑人生。那次装完之后,我就把“清空输出目录、检查 DuiLib.lib 位数、跑一遍静默安装”这三步写进了发布脚本,虽然麻烦,但确实省下了不少事后排查的时间。希望这套流程能帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进