☰
NSIS+Duilib自定义安装程序:三层架构与安装向导状态机
2026/10/27 19:15:21 网站建设 项目流程

简介:面向 Windows 桌面软件开发者的一套 NSIS 与 Duilib 集成安装程序示例,适合希望摆脱默认安装向导外观、需要自定义皮肤与交互流程的中高级开发者参考。它将 NSIS 的安装脚本能力与 Duilib 的 XML 界面布局结合,可用于学习安装目录选择、文件释放、注册表写入、快捷方式与开机启动项创建、快速启动栏处理、错误日志和多语言适配等实现思路。压缩包共 451 个文件,约 9.6MB,包含 nsi/nsh 脚本、xml 布局、png/ico/bmp 皮肤与图标资源、nlf 多语言文件、dll 依赖、exe 示例及 bat 构建脚本,界面资源与安装逻辑相对完整,便于按模块查阅和复用。已有 2945 人学习,读者可从中获得可运行的安装程序组织范例,理解 Duilib 界面如何嵌入 NSIS 流程,并参考皮肤资源、脚本片段和构建批处理,搭建自己的自定义安装向导,降低界面与安装逻辑衔接的摸索成本。

1. NSIS+Duilib 自定义安装程序:为什么安装向导值得单独做一层界面

你双击一个桌面工具,最先看到的往往不是主界面,而是一个灰扑扑的安装向导——很多产品的体验分就丢在这里。NSIS+Duilib 自定义安装程序解决的正是这个尴尬:NSIS 负责文件释放、注册表、快捷方式、卸载信息和静默安装,Duilib 负责把向导做成有品牌感的 DirectUI 窗口,按钮、进度条、协议页都能自己画。它适合 Windows 桌面工具、内部系统和需要批量部署的客户端,尤其适合既要图形安装又要命令行静默安装的团队。反直觉的是,难点不在把界面画好看,而在 UI 与安装线程的握手、UAC 提权时机和卸载清理顺序,下面拆开看。

2. 技术栈与工程骨架:NSIS 脚本、Duilib 窗口和桥接层怎么分层

安装器看起来只是一个窗口,实际是两条执行流:NSIS 脚本按 Section 顺序做文件落盘、注册表、服务、快捷方式;Duilib 窗口在消息循环里等用户点击。把两者硬塞进同一个函数,后面升级、静默安装、卸载都会难受。常见的分层方式是 NSIS 主脚本、Duilib UI 工程、桥接层三块,桥接层负责把用户选择带回 NSIS,再把安装结果告诉 UI。

2.1 三层结构:把安装流程和界面渲染拆开

NSIS 的长处是流程稳定、脚本直白、对 Windows 安装场景的覆盖足够全,短板是原生界面风格偏旧,复杂交互写起来费劲。Duilib 的长处是控件树和皮肤机制灵活,能做出接近主程序风格的窗口,短板是它本质上是 C++ 界面库,需要消息循环、资源加载和编译链,不能直接写在 NSIS 脚本里。把两者放在一个工程里,最怕的是“UI 里顺手写安装逻辑”,最后安装流程散落在按钮回调里,静默安装和卸载根本复用不了。

我一般把职责划成三层。第一层是 NSIS 主脚本,负责安装目录、文件清单、注册表、服务、卸载器和静默参数解析,这一层不关心窗口长什么样。第二层是 Duilib UI 工程,负责欢迎页、许可协议、安装路径、组件选择、进度展示和完成页,这一层不直接写文件,只收集参数和展示状态。第三层是桥接层,常见有两种做法:独立 UI exe 由 NSIS 启动并等待,UI 把结果写到临时文件或通过退出码返回;或者把 UI 编译成 NSIS 插件 DLL,在安装器进程内创建窗口,直接读写 NSIS 变量。

两种做法各有取舍。独立 exe 的好处是隔离性好,UI 崩了不会拖垮安装器,调试时可以直接双击运行,编译链也和 NSIS 解耦;代价是进程间传参要设计清楚,临时文件要清理,提权时机要单独处理。插件 DLL 的好处是同进程,读写变量方便,进度回调直接;代价是调试要 attach 到安装器进程,UI 一崩整个安装器就黑屏,而且插件导出接口要按 NSIS 插件规范写,稍不注意就加载失败。常见做法是主安装流程用独立 UI exe,只有必须实时刷新自定义进度条时才补一个很小的插件。

这套骨架选择独立 UI exe 路线:NSIS 先InitPluginsDir,把 UI 和资源释放到$PLUGINSDIR\ui,用ExecWait启动并等待退出码;UI 收集完安装路径、组件、语言后写result.ini,退出码 0 表示继续,1 表示用户取消,2 表示 UI 内部错误;NSIS 读到结果后校验目录,再进入真正的文件安装 Section。安装阶段的进度优先用 NSIS 自带进度页,确实要自定义进度条时再用进程间消息补,避免 UI 和安装线程互相卡死。

提示:不要把 UI 释放到安装目标目录再启动。安装目录可能还没有写权限,UI 启动失败会让用户以为安装包坏了。$PLUGINSDIR由 NSIS 管理,安装结束会自动清理,适合放这种一次性 UI。

2.2 工程目录:一个可维护的安装器仓库长什么样

安装器工程最怕“所有东西都在一个 nsi 文件里”。文件清单一长,脚本没法 review;UI 资源一多,皮肤和语言混在一起;构建脚本再散落在桌面,换一台机器就编不出来。下面这个目录结构不复杂,但把脚本、UI、配置、构建脚本和输出分开了,适合作为起步模板。

installer/ build/ # 中间产物,不入库 nsis/ main.nsi # 安装器入口脚本 include/ filelist.nsh # 由配置生成的文件清单 staging/ ui/ # 构建时复制进来的 UI 可执行文件 ui/ DuilibApp/ App.cpp # wWinMain 和命令行解析 MainWnd.cpp # 主窗口与页面切换 MainWnd.h res/ skin/ # Duilib 皮肤 XML 和图片 lang/ # 多语言字符串 third_party/ duilib/ # Duilib 源码或预编译库 scripts/ build_ui.bat # 编译 UI build_installer.bat # 生成文件清单并编译安装包 sign.bat # 签名入口,按需接入 config/ product.ini # 产品名、版本、安装目录等唯一来源 output/ # 最终安装包,不入库

nsis/main.nsi只写流程和页面顺序,具体的文件列表由scripts里的生成脚本从config/product.ini和构建产物目录生成,落到include/filelist.nsh。这样做的好处是产品名、版本号、默认安装目录只维护一处,UI 和 NSIS 都读同一份配置,换产品线时不用全局搜索替换。ui/DuilibApp/res下的皮肤和语言按目录分,打包时整个目录复制到staging/ui,UI 启动后按 exe 所在目录找资源,不依赖当前工作目录。

构建中间目录build/和最终输出output/不要提交到版本库,nsis/staging也是构建时生成。安装器仓库里真正需要 review 的是main.nsi、UI 业务代码、配置和构建脚本。文件清单生成脚本可以很简单:读配置里的文件列表,展开通配符,输出File指令和SetOutPath指令。这样新增一个运行时文件只需要改配置,不用手改 nsi。

注意:路径里尽量不要出现中文和空格。NSIS 的 Unicode 版能处理,但构建脚本、签名工具、某些旧插件混在一起时,中文路径很容易变成玄学问题。工程根目录用短英文路径,能省不少排查时间。

2.3 构建链:一条命令从 UI 到安装包

构建脚本的目标不是“能跑一次”,而是换机器也能按同样顺序跑。下面这个批处理先编译 UI,再把 UI 和资源复制到 NSIS 暂存目录,然后根据配置生成文件清单,最后调用makensis出包。每一步失败都直接退出,避免把旧产物打进去。

@echo off setlocal set ROOT=%~dp0.. set BUILD=%ROOT%\build set OUT=%ROOT%\output if not exist "%OUT%" mkdir "%OUT%" rem 1. 编译 Duilib UI,输出 ui.exe 和资源到 build\ui call "%ROOT%\scripts\build_ui.bat" || exit /b 1 rem 2. 把 UI 产物复制到 NSIS 暂存目录 if exist "%ROOT%\nsis\staging\ui" rmdir /s /q "%ROOT%\nsis\staging\ui" xcopy /E /I /Y "%BUILD%\ui\*" "%ROOT%\nsis\staging\ui\" >nul || exit /b 1 rem 3. 从配置生成文件清单 include python "%ROOT%\scripts\gen_filelist.py" --config "%ROOT%\config\product.ini" --out "%ROOT%\nsis\include\filelist.nsh" || exit /b 1 rem 4. 编译安装包,/V3 输出详细日志,/D 传入输出目录宏 makensis /V3 /DOUTDIR="%OUT%" "%ROOT%\nsis\main.nsi" || exit /b 1 echo done: %OUT%

build_ui.bat里按实际编译器写,核心是把 UI 工程编成ui.exe,把res目录原样复制到输出目录,保证 UI 启动后能按相对路径找到皮肤。xcopy /E /I /Y会递归复制空目录、自动创建目标目录、覆盖旧文件,前面先删一次staging\ui是为了避免上次构建残留的旧资源被打进包。python gen_filelist.py只是常见做法,用其他脚本语言也行,关键是文件清单由配置生成,不手写。makensis /V3的日志级别建议在 CI 里保留,出问题时能看到脚本执行到哪一步;/DOUTDIR是自定义宏,main.nsi里用!ifndef OUTDIR给一个默认值,避免直接双击脚本编译时找不到输出目录。

主脚本里最关键的几行是安装器名称、输出路径、默认安装目录、提权声明和文件释放。下面这段只保留骨架,文件列表从生成的 include 里来。

Unicode true !include "MUI2.nsh" !include "include\filelist.nsh" !ifndef OUTDIR !define OUTDIR "." !endif Name "${PRODUCT_NAME}" OutFile "${OUTDIR}\${PRODUCT_NAME}_Setup.exe" InstallDir "$PROGRAMFILES64\${PRODUCT_NAME}" RequestExecutionLevel admin Section "Main" SEC_MAIN SetOutPath "$INSTDIR" !include "include\files_body.nsh" WriteUninstaller "$INSTDIR\uninstall.exe" SectionEnd

Unicode true建议打开,中文路径和界面字符串会省心很多。RequestExecutionLevel admin表示整个安装器以管理员权限运行,写Program Files和HKLM最直接,但 UI 也会在高权限进程里,拖拽文件、调用资源管理器时体验会差一些。更稳的做法是 UI 以普通权限收集参数,安装阶段再提权,这会在第 5 章展开。InstallDir用$PROGRAMFILES64只是默认值,实际安装路径从 UI 结果覆盖。files_body.nsh由生成脚本产出,里面是File和SetOutPath指令,主脚本不手写几百行文件清单。

3. 核心模块拆解:Duilib 页面状态机与 NSIS 安装流程如何握手

安装器的核心不是某个控件画得多漂亮,而是“用户在 UI 里做的选择,能不能稳定、可校验地变成安装参数”。这一章把 Duilib 主窗口、NSIS 启动 UI、结果文件回传、进度通知和静默安装拆开看。代码以常见 Duilib 窗口基类和 NSIS 脚本为例,不同 Duilib 分支的类名可能略有差异,思路是一样的。

3.1 Duilib 主窗口与页面状态机:别把“下一步”写成 if-else 迷宫

安装向导天然是状态机:欢迎页、许可协议页、路径选择页、组件选择页、安装进度页、完成页。每个页面的“下一步”行为不同,但按钮是同一个。常见翻车写法是在按钮回调里堆if (page == 1) ... else if (page == 2) ...,页面一多就没人敢改。更稳的是把页面 ID 和切换逻辑收在窗口类里,按钮只负责触发Next(),由状态机决定下一页和按钮文案。

// MainWnd.h:把页面状态和控件指针收在窗口类里 enum class PageId { Welcome, License, Path, Components, Progress, Finish }; class CMainWnd : public WindowImplBase { public: virtual LPCTSTR GetWindowClassName() const { return _T("InstallerMainWnd"); } virtual void InitWindow() override; virtual void OnClick(TNotifyUI& msg) override; std::wstring InstallDir() const { return m_installDir; } std::wstring Components() const { return m_components; } private: void SwitchPage(PageId id); void Next(); bool ValidateCurrentPage(); PageId m_page = PageId::Welcome; CButtonUI* m_next = nullptr; CButtonUI* m_cancel = nullptr; COptionUI* m_accept = nullptr; CTextUI* m_path = nullptr; std::wstring m_installDir; std::wstring m_components; };

头文件里只暴露窗口类名、初始化和点击入口,页面切换和校验放在私有函数。GetWindowClassName返回窗口类名,Duilib 用它注册窗口;InitWindow在窗口创建后调用,适合FindControl绑定控件;OnClick统一处理按钮点击,避免每个按钮单独绑回调。InstallDir和Components是给外部读取的结果,UI 不直接写注册表,也不直接复制文件。

void CMainWnd::InitWindow() { m_next = static_cast<CButtonUI*>(m_pm.FindControl(_T("btn_next"))); m_cancel = static_cast<CButtonUI*>(m_pm.FindControl(_T("btn_cancel"))); m_accept = static_cast<COptionUI*>(m_pm.FindControl(_T("opt_accept"))); m_path = static_cast<CTextUI*>(m_pm.FindControl(_T("txt_path"))); // 控件名来自皮肤 XML,找不到时不要直接解引用 if (!m_next || !m_cancel || !m_accept || !m_path) { ::MessageBox(nullptr, _T("界面资源不完整"), _T("错误"), MB_ICONERROR); return; } SwitchPage(PageId::Welcome); } void CMainWnd::SwitchPage(PageId id) { m_page = id; const wchar_t* pages[] = { _T("page_welcome"), _T("page_license"), _T("page_path"), _T("page_components"), _T("page_progress"), _T("page_finish") }; for (auto name : pages) { auto* ctrl = m_pm.FindControl(name); if (ctrl) ctrl->SetVisible(false); } // 实际项目按 id 显示对应页面,这里只示意按钮状态 m_next->SetText(id == PageId::Progress ? _T("安装中") : _T("下一步")); m_next->SetEnabled(id != PageId::Progress); }

FindControl按皮肤 XML 里的控件名查找,找不到返回空指针。这段代码在初始化时统一判空,避免某个皮肤漏了控件导致启动就崩。SwitchPage先把所有页面隐藏,再显示目标页,按钮文案和可用状态跟着页面走。实际项目里可以把页面名和 ID 做成表,避免数组顺序和枚举顺序不一致。SetVisible(false)只是隐藏,不销毁控件,页面切换开销很小,适合安装器这种页面不多的场景。

Next()里先调ValidateCurrentPage(),比如许可协议页检查复选框是否勾选,路径页检查目录是否为空、是否有写权限,组件页至少选一个组件。校验失败只弹提示,不切页。校验通过后按状态机进入下一页,最后到进度页时禁用“下一步”和“取消”,把参数交给外部。这样 UI 的职责很干净:收集、校验、展示,不碰安装动作。

3.2 NSIS 启动 UI 并读取结果:退出码和临时文件要约定死

NSIS 侧要做的第一件事是把 UI 释放到临时目录并启动。下面这个宏把 UI 资源复制到$PLUGINSDIR\ui,用ExecWait等待退出,然后读取result.ini里的安装目录。退出码非 0 直接Abort,不进入文件安装。

!include "LogicLib.nsh" !macro RUN_INSTALL_UI RESULT_FILE InitPluginsDir SetOutPath "$PLUGINSDIR\ui" File /r "staging\ui\*.*" StrCpy $0 "$PLUGINSDIR\result.ini" ExecWait '"$PLUGINSDIR\ui\ui.exe" /result "$0" /lang ${LANG_ID}' $1 ${If} $1 != 0 ; 1 用户取消,2 UI 错误,其他非 0 也按失败处理 Abort ${EndIf} ReadINIStr $2 "$0" "install" "dir" ${If} $2 == "" MessageBox MB_ICONSTOP "安装目录为空,安装终止。" Abort ${EndIf} StrCpy $INSTDIR $2 !macroend

InitPluginsDir初始化$PLUGINSDIR,这是 NSIS 提供的临时目录,安装结束自动清理。SetOutPath把后续File指令的释放目标切到$PLUGINSDIR\ui,File /r递归释放 UI 和资源。ExecWait启动 UI 并等待,第二个参数$1接收退出码;UI 里约定 0 继续、1 取消、2 错误。ReadINIStr从结果文件读[install] dir,读到空值就弹错误并Abort。Abort会终止当前安装流程,此时还没有写目标目录,回滚成本低。

注意:ExecWait的退出码在不同 Windows 版本和提权方式下可能表现不一致,尤其是 UI 自己又提权的情况。UI 不要二次提权,提权统一由 NSIS 的RequestExecutionLevel控制,否则 NSIS 等到的可能是提权进程的退出码,而不是 UI 的真实结果。

结果文件的格式越简单越好。INI 的好处是 NSIS 有ReadINIStr直接读,C++ 侧用WritePrivateProfileStringW就能写。坑在编码:NSIS Unicode 版和 C++ 写 INI 的编码如果没对齐,中文安装路径会乱码。常见做法是安装路径统一用短英文,或者把结果文件改成 UTF-16 LE 的 key=value 文本,NSIS 侧用FileReadUTF16LE读。不要一边写 UTF-8 一边用ReadINIStr读,出了问题很难一眼看出来。

3.3 C++ 侧写结果文件和退出码:UI 只负责收集,不负责安装

UI 的入口函数负责解析/result和/lang,创建主窗口,跑消息循环,用户完成后把结果写文件并返回退出码。下面这段是骨架,ParseArg和RunInstallerUi需要按项目补全。

int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, LPWSTR cmdLine, int) { std::wstring resultPath = ParseArg(cmdLine, L"/result"); if (resultPath.empty()) { return 2; // 没有结果文件路径,视为调用方参数错误 } CMainWnd wnd; if (!wnd.Create(nullptr, _T("安装向导"), UI_WNDSTYLE_FRAME, 0L, 0, 0, 900, 560)) { return 2; } wnd.CenterWindow(); wnd.ShowWindow(true); CMessageLoop loop; loop.AddMessageFilter(&wnd); int code = loop.Run(); if (code != 0) { return code; // 用户取消或窗口异常 } // 只写收集结果,真正的文件复制由 NSIS 完成 WritePrivateProfileStringW(L"install", L"dir", wnd.InstallDir().c_str(), resultPath.c_str()); WritePrivateProfileStringW(L"install", L"components", wnd.Components().c_str(), resultPath.c_str()); return 0; }

wWinMain是 Windows 图形程序入口,cmdLine不包含 exe 路径,解析/result时要注意引号。Create的窗口类名、样式、位置按实际皮肤调整,CenterWindow让安装器居中。CMessageLoop是 Duilib 的消息循环,AddMessageFilter把窗口加进去,Run返回后表示窗口关闭。WritePrivateProfileStringW写 INI,节名install,键名dir和components,值来自窗口收集结果。返回值 0 表示用户完成了向导,非 0 表示取消或异常。

这里有一个容易忽略的点:UI 启动后的当前工作目录不一定是 exe 所在目录,NSIS 用ExecWait启动时 cwd 可能是安装器目录或临时目录。皮肤、语言文件、图标都要用GetModuleFileNameW拿到 exe 路径再拼相对路径,不能直接写res\skin\main.xml。另外,UI 不要在这个阶段写目标安装目录,哪怕只是创建一个空文件夹。写权限、UAC、磁盘空间都应该由 NSIS 在安装 Section 里处理,UI 只做参数收集。

3.4 进度回传:自定义进度条别和安装线程抢消息循环

如果 UI 收集完参数就退出,安装进度用 NSIS 自带进度页,这是最稳的方案。NSIS 的Section里用DetailPrint输出当前文件,进度页会自己刷新,不需要跨进程通信。很多项目非要一个和主程序风格一致的自定义进度条,这时要么把 UI 做成 NSIS 插件在同进程里更新

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询