简介:面向Windows桌面开发者的NSIS+Duilib自定义安装程序资源包,适合希望摆脱默认安装向导、快速搭建带品牌皮肤安装界面的中初级开发者,可用于软件发布、安装器换肤与多语言安装等场景。包内共451个文件,约9.6MB,包含113个PNG与32个BMP界面素材、35个ICO图标、24个XML布局、113个NSH脚本片段、58个NLF多语言文件,以及29个DLL、17个EXE和少量NSI、BAT、CHM文档,覆盖皮肤资源、脚本逻辑、语言包与构建辅助工具。已有2945人学习下载。借助这些素材可参考Duilib界面组织方式、NSIS安装流程与快捷方式、注册表等实现思路,并通过批处理与文档快速定位构建入口和换肤资源,减少从零搭建自定义安装界面的成本。
1. 为什么把 NSIS 和 Duilib 拼在一起:一份自定义安装界面的选型账
装过一些 Windows 客户端的人大概都有这个体验:安装包点开,灰底、宋体、一个“下一步”按钮,从 XP 年到 Win11 都没怎么变过。NSIS 给的默认界面就是这样,它把精力全放在了“文件怎么可靠地落到目标目录、注册表怎么写、卸载怎么清干净”,界面从来不是重点。Duilib 反过来,XML 描述布局、贴图自绘、无边框窗口,做客户端界面很顺手,但它不会解压、不会写注册表、不会处理静默安装。把两者拼在一起,就是用 NSIS 干安装逻辑,用 Duilib 干界面,中间靠一个插件 DLL 或者一条进程管道接起来。这份资源给的就是这套组合的完整工程骨架:插件导出约定、皮肤资源加载、.nsi 脚本装配,以及几个会让人卡一整天的坑。
2. 插件工程骨架:从导出函数约定到 Duilib 窗口的第一帧
NSIS 的自定义界面有两条路:一是用nsDialogs手写控件,二是写一个插件 DLL 接管整个窗口。前者做做“选择安装路径 + 勾选创建快捷方式”够用,一旦要圆角、要动效、要深色模式,就彻底不够看了。Duilib 走的是第二条路,所以第一件必须搞清楚的事是:NSIS 到底怎么调用一个插件里的函数,参数和返回值怎么传。
2.1 插件 DLL 的导出约定与 NSIS 调用栈
NSIS 调用插件函数时,约定是固定的五个参数,顺序不能变,而且函数名必须原样导出。很多教程只贴函数体不解释这几个参数,结果一换编译器就翻车。
// duiui.cpp —— 编译成 duiui.dll,放进 NSIS 的 Plugins 目录 #include <windows.h> #include "pluginapi.h" // NSIS 3.x 自带,注意用 Unicode 版本的头文件 #include "DuiFrame.h" static CDuiFrame* g_pFrame = nullptr; extern "C" __declspec(dllexport) void __stdcall ShowDuiPage( HWND hwndParent, int string_size, TCHAR* variables, stack_t** stacktop, extra_parameters* extra) { // 这个宏展开后把 string_size/variables/stacktop/hwndParent/exec_flags // 全部塞进 pluginapi 的全局变量,后面的 popstring 才有东西可读 EXDLL_INIT(); // 第一个参数:皮肤目录(建议传 $PLUGINSDIR\skin 这种绝对路径) TCHAR szSkinDir[MAX_PATH] = { 0 }; popstring(szSkinDir); // 第二个参数:窗口标题 TCHAR szTitle[128] = { 0 }; popstring(szTitle); // 静默模式下不弹窗,直接返回,交给脚本里的 IfSilent 分支 if (g_exec_flags && g_exec_flags->silent) { pushstring(_T("silent")); return; } if (g_pFrame == nullptr) { g_pFrame = new CDuiFrame(); } if (!g_pFrame->Init(GetModuleHandle(_T("duiui.dll")), szSkinDir, szTitle)) { pushstring(_T("init_failed")); return; } // 阻塞在这里跑界面,用户点“开始安装”后返回 g_pFrame->DoModal(); // 把用户的选择压回栈,脚本里用 Pop 取 pushstring(g_pFrame->IsCreateDesktopShortcut() ? _T("1") : _T("0")); }这段代码里有三个点值得单独说。EXDLL_INIT()依赖函数形参的名字,宏体里直接引用了string_size、variables、stacktop、hwndParent、extra,你把形参改名成nSize、pVars之类的,编译能过,运行时读到的全是野值。popstring是按调用顺序从栈顶往下取的,脚本里Push的顺序必须和插件里popstring的顺序严格对应,先Push皮肤目录再Push标题,插件里就先popstring皮肤目录。“g_exec_flags->silent” 是 NSIS 通过extra_parameters传进来的执行标志,它能让你在/S静默安装时直接跳过界面,比在脚本里额外传一个参数更省事。
还有一个新手必踩的细节:__stdcall在 32 位下会把函数名修饰成_ShowDuiPage@20,而 NSIS 是按纯名字ShowDuiPage去找的,找不到就报Invalid command: duiui::ShowDuiPage。解决办法是加一个.def文件,把导出名锁死:
; duiui.def LIBRARY duiui EXPORTS ShowDuiPage UpdateProgress CloseDuiPage编译命令大致是这样,注意/DUNICODE /D_UNICODE必须带上,否则字符串在 Unicode 安装器里全是乱码:
cl /LD /EHsc /DUNICODE /D_UNICODE /DWIN32_LEAN_AND_MEAN ^ duiui.cpp DuiFrame.cpp ^ /I"third_party\duilib\include" ^ /Fe:duiui.dll /link /DEF:duiui.def ^ /LIBPATH:"third_party\duilib\lib" duilib.lib编完别急着塞进安装包,先用dumpbin /EXPORTS duiui.dll看一眼导出表里是不是干干净净的ShowDuiPage。带@后缀或者名字被 C++ 修饰成一大串的,就是.def没生效或者extern "C"漏了。
2.2 Duilib 窗口初始化与 XML 皮肤加载
Duilib 的窗口类要同时继承CWindowWnd和INotifyUI,前者管窗口和消息,后者管控件事件。真正容易出问题的不是继承,而是实例句柄和资源路径这两件事——它们在 EXE 里和在 DLL 里的行为不一样。
// DuiFrame.cpp #include "StdAfx.h" #include "DuiFrame.h" bool CDuiFrame::Init(HINSTANCE hInst, LPCTSTR pszSkinDir, LPCTSTR pszTitle) { m_hInst = hInst; m_sTitle = pszTitle; // 关键:实例句柄要用 DLL 自己的,不能用 GetModuleHandle(NULL) CPaintManagerUI::SetInstance(hInst); // 皮肤目录必须是绝对路径,安装器运行时的当前目录是临时目录 CPaintManagerUI::SetResourcePath(pszSkinDir); if (!Create(NULL, pszTitle, UI_WNDSTYLE_FRAME & ~WS_MAXIMIZEBOX, WS_EX_WINDOWEDGE)) { return false; } CenterWindow(); return true; } void CDuiFrame::Notify(TNotifyUI& msg) { if (msg.sType == _T("click")) { if (msg.pSender->GetName() == _T("btn_install")) { m_bCreateShortcut = static_cast<CCheckBoxUI*>(m_pm.FindControl(_T("chk_desktop")))->IsSelected(); Close(); // 关闭窗口,DoModal 里的消息循环随之退出 } else if (msg.pSender->GetName() == _T("btn_close")) { m_bCancelled = true; Close(); } } } void CDuiFrame::DoModal() { ShowWindow(true); // 阻塞式消息循环,直到本线程收到 WM_QUIT CPaintManagerUI::MessageLoop(); CPaintManagerUI::Term(); }这里最反直觉的一点是CPaintManagerUI::SetInstance。在 EXE 里写GetModuleHandle(NULL)没问题,但插件是个 DLL,NULL拿到的是setup.exe的句柄,Duilib 会跑到安装器主模块里去解析资源段,结果就是窗口能弹出来但背景图、图标全是空白——这就是“白屏”现象最常见的一种成因。传GetModuleHandle(_T("duiui.dll"))才是对的,或者更稳妥的做法是在DllMain的DLL_PROCESS_ATTACH里把hInst存成全局变量。
DoModal里跑的是第二个GetMessage循环,它和 NSIS 自己的消息循环在同一个线程上。这意味着 NSIS 主窗口的消息也会被这个循环分发掉,所以不要在循环里对“不属于本窗口”的消息做任何拦截或丢弃,否则会出现安装器主窗口点不动、进度条不刷新的怪现象。另外,任何耗时操作都不能放在 UI 线程里——MessageLoop一卡,NSIS 主窗口跟着一起假死,用户看到的就是“安装程序未响应”。
皮肤 XML 本身没什么玄学,一个能跑起来的最小结构大概是这样,控件名要和Notify里的判断对上:
<?xml version="1.0" encoding="utf-8"?> <Window size="800,520" caption="0,0,0,32" roundcorner="8,8"> <VerticalLayout bkimage="file='bg.png' corner='8,8,8,8'"> <HorizontalLayout height="32"> <Text text="{title}" font="1" textcolor="#FF333333" /> <Control /> <Button name="btn_close" bkimage="file='close.png'" width="32" height="32" /> </HorizontalLayout> <Control /> <HorizontalLayout height="56" inset="0,0,24,20"> <CheckBox name="chk_desktop" text="创建桌面快捷方式" selected="true" /> <Control /> <Button name="btn_install" text="开始安装" bkimage="file='btn.png'" /> </HorizontalLayout> </VerticalLayout> </Window>{title}这类占位符是 Duilib 的SetText/GetText机制不支持的,实际工程里一般用CPaintManagerUI提供的AddText/RemoveText做字符串替换,或者干脆在Init之后FindControl定位控件再SetText。后者代码多一点但可控,我个人更倾向后者,尤其是要做多语言的时候,把字符串表从 XML 里剥出去会省很多事。
2.3 资源打包:把 skin 目录塞进 DLL
资源放哪,决定了这个安装包在别人机器上稳不稳。三种常见做法各有边界:
| 方式 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 外置目录 | 脚本File /r skin\*.*解到$PLUGINSDIR\skin | 改皮肤不用重编 DLL | 依赖InitPluginsDir,路径写错就白屏 |
| 资源段内嵌 | .rc里把 png/xml 编进 DLL,SetResourcePath换成 zip 资源 | 单文件,不怕丢 | 每次调皮肤都要重编 |
| ZIP 打包 | 皮肤压成skin.zip放资源段,Duilib 用UILIB_ZIP模式 | 兼顾体积和整洁 | 需要 Duilib 编译时开 ZIP 支持 |
用外置目录的话,脚本里这两行不能少,顺序也不能反:
InitPluginsDir ; 先创建 $PLUGINSDIR SetOutPath "$PLUGINSDIR\skin" File /r "..\skin\*.*" ; 再解压皮肤 SetOutPath "$INSTDIR"InitPluginsDir只负责把临时目录建出来,它不会帮你建子目录,SetOutPath才会。很多人漏了第一步,在某些机器上$PLUGINSDIR为空字符串,SetOutPath就变成了往当前目录写,皮肤文件散落到安装目录里,卸载还清不掉。另外$PLUGINSDIR是每次运行都不同的随机目录,插件里绝对不能用相对路径或者硬编码的固定路径去加载皮肤。
3. 两种架构落地:内嵌页面与双进程管道,谁更适合你的安装包
同样是 NSIS + Duilib,工程结构可以差很远。一种是插件 DLL 被 NSIS 直接LoadLibrary起来,界面跑在安装器进程里;另一种是 Duilib 单独编一个 EXE 当前端,NSIS 退居后端只负责静默安装。两者不是“新”和“旧”的关系,而是给不同规模的项目准备的。
3.1 内嵌模式:页面流转与消息循环怎么共存
内嵌模式的核心问题是:NSIS 有自己的向导页面框架,Duilib 有自己的一套窗口,两者谁说了算。常见做法是让 Duilib 全权接管,NSIS 的默认页面全部藏起来。
; 只挂一个自定义页,MUI 的其他页面照常插在后面 Page custom DuiPageCreate DuiPageLeave Function DuiPageCreate ; 隐藏 NSIS 自带的向导窗口,避免后面露出灰底 HideWindow duiui::ShowDuiPage "$PLUGINSDIR\skin" "某跨平台系统 安装向导" Pop $0 ${If} $0 == "silent" Abort ; 静默模式不建界面,直接跳过本页 ${ElseIf} $0 == "init_failed" MessageBox MB_ICONSTOP "界面资源加载失败,安装无法继续。" Quit ${EndIf} ; $0 是用户是否勾选桌面快捷方式的标记 StrCpy $R9 $0 FunctionEnd Function DuiPageLeave ; 用户点了关闭而不是安装,这里让脚本直接退出 ${If} $R9 == "" Quit ${EndIf} FunctionEnd这段脚本能跑通,靠的是两个隐含行为。一是HideWindow必须在插件弹窗前调用,反过来的话 NSIS 窗口会盖在 Duilib 窗口上面闪一下,视觉上很脏。二是Page custom的 create 回调里调用插件后,插件的DoModal会一直阻塞到用户点按钮,NSIS 的页面流程也就跟着停在这里——这正是我们想要的“接管”,但代价是这期间安装器主线程完全交给插件支配,任何异常退出都会让安装器卡死在一个看不见窗口的状态,只能靠任务管理器结束。
内嵌模式下进度条怎么更新?答案是插件再导出一个函数,脚本在每个 Section 里调它:
extern "C" __declspec(dllexport) void __stdcall UpdateProgress( HWND hwndParent, int string_size, TCHAR* variables, stack_t** stacktop, extra_parameters* extra) { EXDLL_INIT(); TCHAR szPercent[16] = { 0 }; TCHAR szText[256] = { 0 }; popstring(szPercent); popstring(szText); if (g_pFrame) { g_pFrame->SetProgress(_ttoi(szPercent), szText); } }SetProgress内部不要直接跨窗口SetWindowText,用PostMessage把自定义消息投递给 Duilib 主窗口,让 UI 线程自己处理。插件函数是在 NSIS 主线程上被调用的,而 Duilib 窗口也在同一个线程,看起来同线程直接改控件没问题,但只要你在SetProgress里触发了重绘,而此刻线程正卡在某个System::Call或文件拷贝上,重绘就会被推迟,进度条看上去“不动了”。
3.2 双进程模式:静默安装 + 进度回传
当安装逻辑变复杂(注册服务、装驱动、写计划任务、调外部安装器),把 UI 和安装塞在一个进程里会越来越难受:任何一个耗时步骤都会让界面假死,任何一次崩溃都会让整个安装失败。这时候更稳的结构是前后端分离。
前端是一个独立的 Duilib EXE,只做三件事:展示协议、收集选项、显示进度。后端是setup.exe /S,静默跑完全部 Section。两者之间的进度回传,最省事的是共享 INI 文件轮询,最干净的是命名管道,前者不需要额外插件,后者要多写一个 NSIS 插件或用nsExec挂外部工具。
先看 NSIS 侧的进度埋点:
!macro STEP_PROGRESS PERCENT TEXT WriteINIStr "$PLUGINSDIR\progress.ini" "state" "percent" "${PERCENT}" WriteINIStr "$PLUGINSDIR\progress.ini" "state" "text" "${TEXT}" !macroend Section "解压主程序" SEC_MAIN !insertmacro STEP_PROGRESS "10" "正在释放主程序文件" SetOutPath "$INSTDIR" File /r "..\build\app\*.*" !insertmacro STEP_PROGRESS "55" "主程序文件已就位" SectionEnd Section "写注册表" SEC_REG !insertmacro STEP_PROGRESS "70" "正在注册组件" WriteRegStr HKLM "Software\SomeApp" "InstallPath" "$INSTDIR" !insertmacro STEP_PROGRESS "85" "组件注册完成" SectionEnd Section "收尾" !insertmacro STEP_PROGRESS "100" "安装完成" WriteINIStr "$PLUGINSDIR\progress.ini" "state" "done" "1" SectionEnd前端这边用一个SetTimer每 200 毫秒读一次,读到done=1再做收尾动画:
void CDuiFrame::OnTimer(UINT_PTR id) { if (id != TIMER_POLL) return; TCHAR szPath[MAX_PATH] = { 0 }; // m_sIniPath 由启动参数传入,前端启动 setup.exe 时把同一路径告诉后端 GetPrivateProfileString(_T("state"), _T("text"), _T(""), szText, _countof(szText), m_sIniPath); int nPercent = GetPrivateProfileInt(_T("state"), _T("percent"), 0, m_sIniPath); SetProgress(nPercent, szText); if (GetPrivateProfileInt(_T("state"), _T("done"), 0, m_sIniPath) == 1) { KillTimer(m_hWnd, TIMER_POLL); OnInstallFinished(); } }GetPrivateProfileInt读不到键时返回默认值 0,这一点很有用,因为 INI 文件在安装刚开始时可能还不存在,读出来就是 0,不会崩。但要注意两件事:一是这个 API 有缓存,某些情况下会读到旧值,写完立刻读同一个文件建议换WritePrivateProfileString配合同步刷盘,或者干脆改用内存映射;二是$PLUGINSDIR是随机的,前端根本猜不到路径,所以必须由前端先建一个固定路径的临时目录(比如%TEMP%\SomeAppSetup\progress.ini),通过命令行把路径传给setup.exe,后端用${GetOptions}解析出来再用。
前端拉起后端的代码大致是:
BOOL RunSilentSetup(LPCTSTR pszSetup, LPCTSTR pszIniPath, LPCTSTR pszInstallDir) { CStringW cmd; // /S 静默,自定义参数用 /KEY=VALUE,路径带空格必须自己加引号 cmd.Format(_T("\"%s\" /S /PROGRESSINI=\"%s\" /D=%s"), pszSetup, pszIniPath, pszInstallDir); STARTUPINFOW si = { sizeof(si) }; PROCESS_INFORMATION pi = { 0 }; if (!CreateProcessW(NULL, cmd.GetBuffer(), NULL, NULL, FALSE, CREATE_NO_WINDOW, NULL, NULL, &si, &pi)) { return FALSE; } CloseHandle(pi.hThread); m_hSetupProcess = pi.hProcess; // 留着句柄,收尾时等它退出并取退出码 return TRUE; }/D=有个硬性规定:它必须是命令行最后一个参数,而且指向的路径不能用引号包起来。路径里有空格时这条规则会直接让安装目录解析错位,业界通行的绕法是让前端自己把安装目录写进 INI,后端用${GetOptions}读,不碰/D=。至于CREATE_NO_WINDOW,不加的话静默安装器在某些系统上会闪一个黑框出来,用户会以为中毒了。
3.3 选型判断:什么项目该走哪条路
把两种架构的边界摊开,判断其实不难:
| 维度 | 内嵌插件模式 | 双进程模式 |
|---|---|---|
| 界面与安装逻辑耦合 | 紧,同一进程同一线程 | 松,靠命令行和文件通信 |
| 进度条实时性 | 高,函数调用直传 | 中,取决于轮询间隔 |
| 单步耗时操作的容错 | 差,卡住就是假死 | 好,后端卡住前端仍能响应 |
| 打包体积 | 小,只有 DLL | 大,多一个 EXE |
| 调试难度 | 高,断点要挂在宿主进程上 | 低,前后端可分别调试 |
| 适配增量更新/在线安装 | 勉强 | 天然合适 |
我的经验是:安装流程里“纯文件搬运 + 注册表写入”占九成、总时长在两分钟以内的,直接上内嵌模式,代码少、体积小、进度条还准。一旦涉及服务注册、驱动安装、依赖组件分发、或者需要保留一个“安装完成后继续引导”的界面,双进程才是舒服的选择——你总不希望用户在某台老机器上看着一个白屏窗口转圈十分钟,然后只能强制结束。
4. 脚本侧装配:.nsi 页面顺序、静默分支与进度分段
插件编出来了只是零件,真正决定安装包能不能在真实环境里跑通的,是.nsi脚本。这一层写错的代价往往比 C++ 那边更隐蔽:编译不报错、双击能装、换个参数就出事。
4.1 页面注册与插件调用
一个能用的页面骨架如下,重点是页面顺序和!addplugindir的位置:
Unicode true !include "MUI2.nsh" !include "LogicLib.nsh" !include "FileFunc.nsh" !addplugindir /x86-unicode ".\plugins" ; 必须在 Page 之前声明 Name "某跨平台系统" OutFile "Setup.exe" InstallDir "$PROGRAMFILES64\SomeApp" RequestExecutionLevel admin SetCompressor /SOLID lzma !define MUI_ABORTWARNING !insertmacro MUI_PAGE_WELCOME Page custom DuiPageCreate DuiPageLeave !insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_PAGE_FINISH !insertmacro MUI_LANGUAGE "SimpChinese"!addplugindir的/x86-unicode参数很容易被忽略。NSIS 3 会按当前安装包的字符集和位数去子目录里找插件,默认路径是Plugins\x86-unicode,你写!addplugindir ".\plugins"它就在.\plugins\x86-unicode下找,找不到就报Invalid command。既然自己指定了目录,要么把 DLL 直接放在.\plugins下并加上/x86-unicode让它别再加子目录,要么老老实实建plugins\x86-unicode\duiui.dll。这个坑我见过不止一次,报错信息完全不提路径,只说不认识这个命令。
MUI_PAGE_WELCOME和自定义页的先后顺序也值得斟酌。全自绘的方案通常把 WELCOME 也砍掉,从自定义页直接进安装页;保留 WELCOME 的话,用户在 Duilib 界面点“下一步”后会看到 NSIS 原生的欢迎页,风格割裂得很明显。
4.2 静默分支:/S、/D= 与自定义参数
静默安装不是“加个/S就完事”,它要求脚本里每个交互点都有非交互的默认路径。最基本的写法是在所有可能弹窗的地方套IfSilent:
Function .onInit ${GetParameters} $R0 ${GetOptions} $R0 "/INSTALLMODE=" $R1 ${GetOptions} $R0 "/PROGRESSINI=" $R2 ${If} $R1 == "repair" StrCpy $INSTDIR "$INSTDIR" ; 修复模式沿用原目录 ${EndIf} ${If} $R2 != "" StrCpy $R8 $R2 ; 存下进度文件路径给 Section 用 ${EndIf} ; 非管理员环境下直接给出明确提示,别让用户装到一半才失败 UserInfo::GetAccountType Pop $0 ${If} $0 != "admin" ${IfSilent} SetErrorLevel 5 Quit ${Else} MessageBox MB_ICONSTOP "需要管理员权限才能安装。" Quit ${EndIf} ${EndIf} FunctionEnd${GetOptions}匹配的是完整键值对,/INSTALLMODE=repair才能匹配上,写/INSTALLMODE repair是匹配不到的。另外${GetParameters}拿到的是原始命令行,里面已经包含了/S和/D=,所以解析自定义参数时要小心别把/D=后面的路径误当成某个参数的值——这也是为什么很多团队干脆约定“路径类参数一律走 INI,不走命令行”。
静默模式下的错误处理必须显式写SetErrorLevel。默认情况下 NSIS 安装器成功退出码是 0,但Quit和Abort在静默下的行为不一样:Abort在.onInit里会让安装器直接退出且返回码可能不是你期望的值,前端拿GetExitCodeProcess一读,0 和非 0 分不清,用户明明失败了界面却显示成功。凡是可能失败的路径,都手动SetErrorLevel一个非零值,前端按码表翻译成提示文案。
4.3 Section 拆分与进度刻度
进度条准不准,八成取决于 Section 怎么切。NSIS 的SetDetailsPrint只在有输出时刷新界面,而文件拷贝速度快的时候输出会被合并,用户看到的就是“进度条从 0 直接跳到 100,然后卡住不动”。
我的做法是把 Section 按“可感知的阶段”切,而不是按代码模块切:
Section "运行环境检查" SEC_CHECK !insertmacro STEP_PROGRESS "5" "正在检查运行环境" ; 检查 VC 运行库、.NET 版本、磁盘空间 SectionEnd Section "释放程序文件" SEC_FILES !insertmacro STEP_PROGRESS "15" "正在释放主程序文件" SetOutPath "$INSTDIR" File /r "..\build\app\*.*" ; 这一步最耗时,权重给到 40% !insertmacro STEP_PROGRESS "55" "主程序文件释放完成" SectionEnd Section "注册组件" SEC_REG !insertmacro STEP_PROGRESS "70" "正在注册组件" ; regsvr32 /s 或写注册表 SectionEnd Section "创建快捷方式" SEC_LINK !insertmacro STEP_PROGRESS "85" "正在创建快捷方式" CreateShortCut "$DESKTOP\SomeApp.lnk" "$INSTDIR\SomeApp.exe" SectionEnd Section "清理临时文件" SEC_CLEAN !insertmacro STEP_PROGRESS "95" "正在清理临时文件" RMDir /r "$PLUGINSDIR\skin" SectionEnd刻度值是手工算的,不是按 Section 数量平均分。SEC_FILES通常占总时长的六成以上,给它 15% 到 55% 这 40 个点的区间是合理的;到了 95% 之后剩下的 5 个点留给收尾,用户心理上会觉得“快好了”,实际也确实快了。反过来,如果按 6 个 Section 平均分,每个 16.7%,那用户会在 16% 的位置停留很久,在 83% 的位置一闪而过,观感极差。
还有一个细节:SetDetailsPrint的开关。如果你在 Section 里用了DetailPrint,它会往详情列表里刷文字,同时触发表单重绘;如果没开SetDetailsPrint both,这些输出会被吞掉,进度条反而不刷新。全自绘方案里一般不依赖详情列表,直接把百分比通过插件推给 Duilib,文字自己控制,这样最干净。
5. 避坑记录:五个让安装包卡死或白屏的细节
下面这几条基本都是我在真实交付里撞过的,现象、原因、解决一一对应,遇到类似症状可以先对照着排查。
5.1 界面能弹出来,但背景图、图标全白
现象:Duilib 窗口正常显示,布局也在,就是所有图片资源加载失败,按钮是空白方块,背景纯白。
原因:CPaintManagerUI::SetInstance传的是GetModuleHandle(NULL),在插件 DLL 里这个调用拿到的是setup.exe的模块句柄,Duilib 跑到安装器主模块的资源段里去找图片,翻不到就静默降级成空白。同时SetResourcePath如果传的是相对路径skin,在安装器运行时当前目录是$PLUGINSDIR或者用户临时目录,照样找不到。
解决:句柄改成GetModuleHandle(_T("duiui.dll")),或者在DllMain里把HINSTANCE缓存成全局变量;资源路径一律传绝对路径,从脚本里Push "$PLUGINSDIR\skin"进去,插件里不做任何路径拼接猜测。改完用 Process Monitor 看一眼文件读取路径,确认落在临时目录下就对了。
5.2 插件函数报 Invalid command,但 DLL 明明在目录里
现象:编译makensis时报Invalid command: duiui::ShowDuiPage,检查plugins目录,duiui.dll确实存在。
原因:三种可能,按概率排序是导出名被修饰、插件目录层级不对、DLL 缺依赖。__stdcall在 x86 下会修饰成_ShowDuiPage@20,NSIS 按纯名字找,找不到;或者 DLL 放在了plugins\下但脚本写了/x86-unicode,实际去plugins\x86-unicode\找了;再或者 DLL 依赖了某个没跟着打包的运行时库,LoadLibrary直接失败,NSIS 报的却是“不认识这个命令”。
解决:先用dumpbin /EXPORTS duiui.dll确认导出名干干净净;再用dumpbin /DEPENDENTS看依赖,有MSVCP140.dll这类就改成静态链接(/MT);最后核对目录层级,实在不确定就把!addplugindir的路径打成绝对路径,让 makensis 的日志把真实查找位置打出来。
5.3 安装到一半界面点不动,任务管理器显示“未响应”
现象:进度条停在某个百分比,窗口拖不动、按钮点不了,几秒后 Windows 给窗口加上半透明遮罩。
原因:耗时操作跑在了 UI 线程上。最常见的两个来源是插件函数里同步调用了System::Call去做一件很慢的事,以及在Notify的按钮响应里直接做文件拷贝。同一个线程上跑着消息循环,线程一阻塞,消息就分发不出去,系统判定无响应。
解决:把所有耗时逻辑挪到 NSIS 的 Section 里,插件只负责画界面和回传状态;如果非要在 UI 侧做,起一个工作线程,通过PostMessage把进度回投给主窗口。另外检查一下是不是在MessageLoop里写了Sleep或者同步等待进程退出的代码,这些都会直接冻住界面。
5.4 进度条到 100% 之后还要等很久,用户以为卡死
现象:进度条走满,界面显示“安装完成”,但窗口一直不关,实际后台还在写注册表或者装服务。
原因:进度刻度分配不合理,最后一个 Section 承担了太多工作,或者 100% 是在最后一个 Section 的末尾才写进去的,而它前面的收尾动作(注册服务、写计划任务、签名校验)耗时远超预期。也有一种情况是done=1写得太早,前端提前进入收尾动画,后端其实还在跑。
解决:把收尾动作拆成独立 Section 并单独给刻度,100% 只在.onInstFiles的最后一刻或者done标记写入时置位;前端拿到done之后不要立刻关窗,再WaitForSingleObject等后端进程真正退出,同时留一个 3 到 5 秒的“正在完成最后配置”过渡态,比直接卡在那里体面得多。
5.5 中文路径或用户名下安装失败,报错信息是一堆乱码
现象:在中文用户名或者含中文的安装路径下,界面文字变成????,或者皮肤加载失败,日志里全是编码错乱的字符。
原因:Unicode true声明了安装器是 Unicode 版,但插件是按 ANSI 编的,popstring拿到的char*里塞的是 UTF-16 数据,长度都对不上;或者反过来,安装器是 ANSI 版而插件按 Unicode 编,字符串被截断。另一种情况是.nsi文件本身存成了 GBK,makensis按 UTF-8 解析,中文字符串从源头就坏了。
解决:统一编码链路——.nsi存 UTF-8 带 BOM,编译器加Unicode true,插件用/DUNICODE /D_UNICODE加TCHAR系列 API,插件文件放进Plugins\x86-unicode。改完拿一个中文用户名 + 中文安装路径的虚拟机跑一遍,这是唯一能确认的办法。
6. 上线前的验证:静默、签名、多语言三条线的走查
界面做得再漂亮,安装包最终是要在别人的机器上无人值守跑起来的。我自己有一套固定的走查清单,每次发版前跑一遍,能挡掉八成低级问题。
第一条线是静默安装。在一台干净的虚拟机上执行Setup.exe /S /INSTALLMODE=fresh,然后用echo %ERRORLEVEL%看退出码,再检查三件事:程序目录文件数量是否和预期一致、注册表项是否写全、开始菜单快捷方式是否存在。接着跑一次覆盖安装和一次卸载,确认卸载后注册表里没留孤儿键,$INSTDIR目录被清空。静默这条线最容易被跳过,因为它“看不见”,但恰恰是企业分发和自动更新唯一的入口。
第二条线是签名。signtool sign /fd sha256 /tr <时间戳服务> /td sha256 Setup.exe,签完之后用signtool verify /pa /v Setup.exe回验。要注意的是插件 DLL 和前端 EXE 也都要签,只签外层安装器的话,某些安全软件会在释放 DLL 的瞬间拦截,用户看到的是“安装到一半消失了”。签完最好再换一台没装过开发工具的机器双击一次,SmartScreen 的拦截提示在这个环节才暴露得出来。
第三条线是多语言。如果只做简体中文,至少也要在“非中文区域设置”的系统上跑一遍——把系统区域改成英文,看安装路径里的中文、界面上的中文、注册表里的中文是否都正常。Duilib 侧的文字如果写死在 XML 里,这时候就会暴露:所有文案挤在一起或者溢出控件。做法是把文案抽成 key-value 表,启动时根据GetUserDefaultUILanguage()选表,XML 里只留 key,运行期SetText。这个改造前期花半天,后面加语言就是加一个文本文件的事。
最后说一个我踩过的坑留下的习惯。有段时间我总在开发机上验证安装包,一切正常,换到测试同事的机器上就白屏。后来才想明白,开发机上装着 Duilib 的运行库和一堆调试工具,路径全在环境变量里;干净机器上什么都没有,SetResourcePath一个相对路径就直接废了。从那以后我每次改完皮肤或资源路径,都会强制在虚拟机快照里从头跑一遍完整安装流程,不信任任何“我机器上是好的”。这套 NSIS + Duilib 的工程骨架,把插件、皮肤、脚本三块拆得比较清楚,照着搭一遍再改文案和配色,比从零啃 NSIS 文档快得多,希望帮到你。
本文还有配套的精品资源,点击获取