简介这是一份面向Windows桌面开发者的资源包目标是让开发者将NSIS安装逻辑与Duilib界面框架结合制作出界面美观、交互友好的自定义安装程序。适合对NSIS脚本不熟悉又想摆脱默认安装向导样式的中高级开发人员。压缩包共451个文件约9.6MB其中包含大量png皮肤素材、nsh脚本片段、nlf语言文件、xml布局定义及dll运行时组件也收录了nsi主脚本、bat构建工具、chm帮助文档和示例exe结构便于按模块复用。包内资源覆盖了自定义皮肤、安装界面布局、安装步骤控制、注册表/快捷方式操作以及日志输出等常用场景开发者可在此基础上快速搭建出符合产品风格的安装流程。压缩包已有2943人学习下载口碑较好。对于希望提升安装程序视觉质量与用户体验的开发者而言这是一份实用且可直接演化的参考工程。1. 把 NSIS 引擎交给 Duilib 界面这场改造到底在改什么很多做客户端交付的同学都有过同一种尴尬程序本体做得再精致一到安装环节就露怯。默认的 NSIS 向导页、生硬的字体、老旧的按钮用户第一眼就觉得“这软件怕不是十年前的东西”。大家常说换张 Banner 图就好真做过的都知道那只是把绿皮火车刷了个漆车厢结构一点没动。真正拿得出手的定制安装程序是把整个 UI 层从 NSIS 的默认控件里摘出来换上一套现代自绘界面同时把文件释放、注册表写入、快捷方式创建这些安装逻辑继续交给 NSIS 引擎去跑。这就是 NSISDuilib 组合的核心价值NSIS 负责“装得稳”Duilib 负责“长得体面”。这个方向适合两类人一类是产品团队想把安装包外观做成和主程序一脉相承的视觉风格另一类是开发团队需要在安装过程中塞入复杂交互比如协议勾选、组件选择、环境检测、账号录入。这两类需求用 NSIS 原生的 InstallOptions 都能做但做到精致程度的上限很低。把 Duilib 引入之后界面这一侧几乎不受限制剩下的事情就是解决两个框架的通信和生命周期问题。2. 先把两种技术框清楚NSIS 的边界和 Duilib 的 UI 能力2.1 NSIS 脚本引擎到底能改到什么程度NSIS 最强的部分从来不是界面而是那套成熟的脚本机制。它能把文件压缩进安装包、在安装时解压到指定目录、写注册表、建快捷方式、注册卸载器还能根据运行时条件分支执行。这些能力都围绕脚本指令展开脚本里声明的 Section、Function、宏构成了整个安装过程的执行骨架。但是 NSIS 自带的界面层非常有限。标准的 MUI2Modern UI 2页面本质上是一组系统控件拼出来的向导页预选安装目录的目录选择框、组件多选框、安装进度条能用但不好看。想自己加一个自定义页面官方提供的方法是写 INI 文件配合 InstallOptions 插件页面上放什么控件、控件在哪个坐标全部写在 INI 的字段里。这个方案的维护成本随着界面复杂度直线上升做三五个页面还能忍做到品牌化视觉的时候光是用 INI 描述坐标和状态就够让人崩溃的。好在 NSIS 留了一个更底层的扩展口插件 DLL。NSIS 脚本可以调用 DLL 里导出的函数并把脚本变量传入 DLL 环境。反过来DLL 里的代码也能引导 NSIS 执行特定脚本函数。这个机制就是 Duilib 能进来的门缝。常见的做法是让一个自定义页面挂钩到一个插件函数插件函数内部创建出 Duilib 窗口让这个窗口覆盖或替换掉 NSIS 默认页面区域。2.2 Duilib 作为安装界面层能做哪些事Duilib 是一套 Windows 平台上的 DirectUI 界面库所谓 DirectUI 是指所有控件由框架自己绘制而不是依赖系统公共控件。这就带来一个很关键的好处控件长相完全由代码和资源文件决定不受系统版本和主题影响。按钮、编辑框、进度条、单选、复选、列表全部可以用 XML 描述布局用代码控制行为视觉风格可以做得很统一。拿安装程序这个场景来说Duilib 现成的控件基本全部够用。安装路径输入用 CEditUI组件勾选用 CCheckBoxUI安装进度用 CProgressUI欢迎页的图文混排可以用 CLabelUI 配合 CContainerUI 自由排版。再加上它支持半透明背景、圆角矩形、自绘状态图做出的效果比传统向导页高出一个量级。与其去学 MUI2 那套模板不如直接用 Duilib 把整个页面重画一遍。还有一点容易被忽略Duilib 对 DPI 缩放有自己的一套资源缩放逻辑。安装包分发给不同分辨率的机器时界面能按缩放因子自适应而 NSIS 原生页面在高 DPI 下经常出现字体发虚、控件错位。这一点在笔记本电脑横行的当下格外有存在感。2.3 两条组合路线的选型判断把两者接起来业内实际操作中有两条路线。一条是以 NSIS 为主体NSIS 启动后加载一个插件 DLLDLL 内部创建 Duilib 窗口窗口和脚本之间通过插件接口通信。另一条是以 Duilib 为主体先启动一个小型主程序界面交互完成后再去调用 NSIS 的静默安装包通过配置文件或者进程参数把用户选择的内容传过去。这两条路各有适用场景。前者适合安装流程复杂、需要步骤之间紧密衔接的项目所有逻辑保持在 NSIS 的掌控内界面的存在只是换了一层皮。后者的好处是界面和安装引擎完全解耦升级安装逻辑时不用重新编译界面程序反过来也一样但对安装流程的控制力弱一些多了一步进程间通信。对比维度NSIS 为主体插件承载 DuilibDuilib 为主体外挂 NSIS 引擎安装流程控制强脚本直接控制每一步弱UI 只能发起和等待结果开发语言重心C 插件 NSIS 脚本C 界面程序 NSIS 静默脚本改动成本首次接入成本高后续迭代快首次接入简单流程改动要同步两套适合场景多步骤向导、交互密集单页品牌化安装、配置极简我一般建议只要安装过程超过两个页面就选第一条路线。第二条路线做出来的东西本质上是“启动器 后台解压器”遇到安装前检测磁盘空间、依赖组件这类需要中途反馈给用户的场景处理起来非常别扭。下面整篇的代码和参数都以第一条路线为准。3. 用 NSIS 插件承载 Duilib 窗口最小可运行工程3.1 工程结构一个 DLL 插件一段 NSIS 脚本动手之前先把工程骨架搭起来。插件部分是一个 C 动态库项目编译产物是一个 DLLNSIS 脚本里通过!system或者插件命令直接调用它。Duilib 的源码有现成的库工程添加到解决方案里一起编译即可。目录结构大致如下InstallerUI/ ├── duilib/ // Duilib 源码与资源 ├── plugin/ │ ├── duilib_plugin.cpp // NSIS 插件导出实现 │ ├── install_window.h // 安装主窗口类声明 │ ├── install_window.cpp // 安装主窗口实现 │ └── resource/ │ ├── skin.xml // Duilib 界面布局描述 │ └── theme/ // 图片、字体等资源 ├── nsis/ │ └── installer.nsi // NSIS 脚本 └── output/ └── Installer.exeNSIS 插件有一个固定的导出函数约定DLL 至少需要导出一个函数NSIS 脚本里以duilib_plugin::function_name的格式调用。插件函数的签名是固定的接收一个HWNDNSIS 传过来的父窗口句柄、一个整数参数、一个变量栈指针。这个签名在 NSIS 插件开发文档里有明确约定照着实现就不会出错。// duilib_plugin.cpp #include windows.h #include nsis/pluginhelper.h extern C void __declspec(dllexport) ShowInstallUI(HWND hwndParent, int string_size, char_t* variables, stack_t** stacktop) { // 插件函数入口hwndParent 是 NSIS 页面的宿主窗口 // variables 是 NSIS 变量区的指针插件可通过它读写脚本变量 EXDLL_INIT(); // 这里把控制权转交给 Duilib 窗口类 CInstallWindow win; win.Create(NULL, NULL, UI_WNDSTYLE_FRAME, 0, 0, 0, 0, hwndParent); win.CenterWindow(); win.ShowWindow(); // 进入 Duilib 消息循环窗口关闭后返回 win.Run(); }这段代码里要留意两个点。hwndParent必须传给 Duilib 窗口的 Create这样能保证安装界面始终模态在 NSIS 宿主之上不会出现任务栏里两个窗口互相抢焦点的问题。win.Run()会阻塞当前线程直到窗口关闭这是刻意为之目的是让插件函数不要提前返回否则 NSIS 会立刻进入下一步脚本界面会被直接销毁。3.2 窗口类与消息循环的写法Duilib 窗口类的写法有一套固定的骨架。继承CWindowWnd和INotifyUI在OnCreate里初始化CPaintManagerUI加载 XML 布局资源然后注册控件通知事件。安装窗口的关闭逻辑要自己处理不能像默认的 MessageBox 一样点了 X 就直接消失得先问过 NSIS 那边能不能取消。// install_window.h class CInstallWindow : public CWindowWnd, public INotifyUI { public: CInstallWindow() {} virtual LPCTSTR GetWindowClassName() const { return _T(InstallWindow); } virtual void OnFinalMessage(HWND hWnd) { delete this; } virtual void Notify(TNotifyUI msg); protected: virtual LRESULT HandleMessage(UINT uMsg, WPARAM wParam, LPARAM lParam); virtual LRESULT OnCreate(UINT uMsg, WPARAM wParam, LPARAM lParam, BOOL bHandled); private: CPaintManagerUI m_pm; // Duilib 核心管理所有控件和绘制 CProgressUI* m_pProgress; CEditUI* m_pEditPath; };OnCreate里的逻辑很直接调用m_pm.Init把窗口和绘制管理器绑定然后m_pm.AttachDialog加载 XMLm_pm.AddNotifier让窗口接收控件事件。注意 XML 里的控件名字要和代码里FindControl的名字完全一致否则编译能过、运行时就是找不到控件空指针崩溃。// install_window.cpp 关键片段 LRESULT CInstallWindow::OnCreate(UINT uMsg, WPARAM wParam, LPARAM lParam, BOOL bHandled) { m_pm.Init(m_hWnd); // 加载界面布局这里用的是相对路径发布时确保 skin.xml 和 theme 目录与 DLL 同级 if (!m_pm.AttachDialog(_T(resource/skin.xml))) { ::MessageBox(m_hWnd, _T(加载界面资源失败), _T(错误), MB_OK); return -1; } m_pm.AddNotifier(this); // 绑定控件指针后续读取用户输入和更新进度都通过这些指针 m_pEditPath static_castCEditUI*(m_pm.FindControl(_T(edit_install_path))); m_pProgress static_castCProgressUI*(m_pm.FindControl(_T(progress_install))); // 初始进度为 0路径默认为空由 NSIS 脚本在调用插件前把默认路径传进来 if (m_pProgress) m_pProgress-SetValue(0); return 0; }资源加载失败是刚上手最常见的报错点。Duilib 的AttachDialog需要能访问到 XML 文件而插件 DLL 的工作目录往往不是安装包释放后的临时目录。我一般会在调用AttachDialog之前用SetCurrentDirectory把工作目录切到 DLL 所在路径再从相对位置加载资源。另一种做法是把 XML 打包进 DLL用m_pm.LoadResource走内存加载少一层路径问题但每次改界面都得重编 DLL。3.3 Duilib 控件与 NSIS 脚本的通信方式界面和脚本之间要交换四类信息安装路径、组件勾选状态、按钮点击事件、进度反馈。前两类是界面到脚本方向第三类是双向的第四类是脚本到界面方向。按钮点击事件的处理在Notify里。用户点击“安装”按钮后Duilib 会触发UI_EVENT_CLICK通知代码读取控件值然后调用 NSIS 插件 API 里的ExecuteCodeSegment跳转到脚本里预埋的执行函数。ExecuteCodeSegment是一个回调函数指针NSIS 在执行插件时把它挂在了一个全局结构里插件代码里要先保存这个结构然后才能调用它。// 保存 NSIS 回调环境在插件入口处调用 EXDLL_INIT 后 static NSISExports g_nsis; // install_window.cpp Notify 片段 void CInstallWindow::Notify(TNotifyUI msg) { if (msg.sType DUI_MSGTYPE_CLICK) { if (msg.pSender m_pBtnInstall) { // 把界面路径传给 NSIS 变量 InstallPath g_nsis.set_var(InstallPath, m_pEditPath-GetText()); // 跳转到 NSIS 脚本里的 install_start 函数 g_nsis.exec_code_segment(install_start); } else if (msg.pSender m_pBtnCancel) { // 先询问 NSIS 是否可以取消返回 1 表示同意取消 if (g_nsis.register_plugin_callback(check_cancel) 1) { Close(); } } } }exec_code_segment的字符串参数对应 NSIS 脚本里的一个Function标签。这段调用是同步的也就是说 Duilib 的消息循环会被 NSIS 的安装指令阻塞界面上的进度条必须用别的机制来更新否则安装过程中界面会像死掉一样。进度更新的处理方式在下一章展开这里先把通信链路建立起来。4. 参数配置从界面取值到安装动作的完整映射4.1 NSIS 脚本里如何接收 Duilib 传来的值NSIS 脚本这一侧先要声明一个自定义页面把页面回调指向插件函数。Page指令支持pre、show、leave三个阶段的回调函数名。我们通常在show阶段调用插件让 Duilib 窗口出现在当前页面。; installer.nsi !include MUI2.nsh !include x64.nsh ; 声明自定义安装页 Page custom ShowCustomPage ; 预留给插件调用的函数这是整个安装动作的起点 Function install_start ; 停用安装页面的下一步按钮防止用户重复点安装 GetDlgItem $0 $HWNDPARENT 1 EnableWindow $0 0 ; 从变量区读取 Duilib 写入的安装路径 StrCpy $INSTDIR $InstallPath ; 执行具体的安装逻辑 Call do_install_files ; 通知 Duilib 界面安装完成 MessageBox MB_OK 安装完成 FunctionEnd Function ShowCustomPage ; 调用插件 DLL 里的 ShowInstallUI 函数 ; 这个调用会阻塞直到 Duilib 窗口关闭才返回 duilib_plugin::ShowInstallUI FunctionEnd$InstallPath是一个自定义变量Duilib 插件写入这个变量的方式有两种。一种是通过set_var接口直接映射到 NSIS 变量区前提是插件代码里知道变量的索引这要求在 NSIS 脚本里声明变量时用Var /GLOBAL InstallPath。另一种方式是插件把路径通过栈推回去NSIS 用Pop指令取出来。两种方式效果一样变量方式更直观栈方式更符合插件 API 习惯我偏好变量方式因为跨多个函数传递时不用反复压栈出栈。安装目录的改动是这把架构里最常见的联动场景。用户界面上的“浏览”按钮负责弹目录选择框选中的路径写回 Duilib 的编辑框点击安装后这个路径要同时更新 NSIS 的$INSTDIR否则安装程序会把文件按脚本默认路径放出去界面白选了。4.2 进度条如何从安装进程反推回界面这块是几乎所有第一次做 NSISDuilib 组合的人都要翻车的地方。NSIS 的安装进度是按文件粒度推进的SetDetailsPrint会把进度文字输出到默认详情页但 Duilib 窗口是独立绘制的根本看不到 NSIS 内部的状态。想让进度条动起来得建立一条数据通路。最实用的方案是共享文件。NSIS 脚本在安装文件的过程中把当前进度写进一个临时文件Duilib 窗口里放一个定时器每 500 毫秒读取这个文件解析出百分比更新进度条。为什么不用共享内存共享内存需要跨进程句柄同步在插件模式下Duilib 窗口和 NSIS 其实运行在同一个进程里理论上可以直接调函数但 NSIS 脚本语言里没有办法在文件复制的间隙去执行一段任意代码所以必须通过一个中间介质来传。; 安装文件时更新进度 Function do_install_files StrCpy $0 0 DetailPrint 开始释放文件... ; 这里用 GetTempFileName 生成一个进度文件路径 GetTempFileName $3 StrCpy $4 $3 ; 假设要释放 10 个文件每释放一个就更新一次进度文件 StrCpy $1 1 loop: SetOutPath $INSTDIR\dir$1 File source\file$1.bin ; 计算进度 ($1 * 10) 拼成字符串 IntOp $2 $1 * 10 StrCpy $5 $2 ; 写入进度文件 FileOpen $6 $4 w FileWrite $6 $5 FileClose $6 IntOp $1 $1 1 IntCmp $1 11 loop done done: ; 写入 100 表示完成 FileOpen $6 $4 w FileWrite $6 100 FileClose $6 FunctionEndDuilib 窗口里的定时器用SetTimer创建在HandleMessage里处理WM_TIMER读取进度文件并更新进度条。需要注意的是这个文件路径要提前约定好。可以在插件入口时用 NSIS 的$PLUGINSDIR生成一个路径通过变量传进 Duilib也可以在 Duilib 里直接用GetTempPath生成安装结束后清理掉。4.3 三个安装动作前必须调好的参数一是压缩方式。NSIS 默认的压缩算法是 ZLib单线程、速度快、体积大。如果不考虑安装包体积用默认没问题如果产品要对外分发、需要压缩比建议选 LZMA但 LZMA 在安装时解压耗时明显变长。考虑到界面是现代风格的用户感知安装速度是体验的一部分我一般推荐 ZLib 配合 Solid 关闭用体积换流畅度。二是权限请求。安装程序经常需要写Program Files或者注册表HKLM必须申请管理员权限。在脚本顶部写RequestExecutionLevel admin这样 UAC 弹窗出现在安装包启动时。如果只写user运行时写Program Files会静默失败用户看到的结果就是“安装完了但程序不见了”排查起来非常玄学。三是架构判断。64 位系统上安装路径默认是C:\Program Files (x86)还是 64 位目录取决于脚本有没有做架构判断。最稳妥的是在脚本开头用${If} ${RunningX64}检测一次设置不同的默认安装目录同时把 32 位注册表视图和 64 位视图分开写避免程序装完注册表写入位置不对卸载时留下残留。5. 避坑界面卡死、进度不动、配置丢失三条最常翻车的路5.1 现象窗口创建成功但点击按钮毫无反应这个故障通常表现为Duilib 窗口正常显示界面能画出来但点击任何按钮都像打在死区上窗口甚至无法拖动。原因是 NSIS 脚本调用插件函数时当前线程进入了 NSIS 的阻塞状态而 Duilib 的消息循环可能因为父子窗口关系没有正确拿到键盘和鼠标消息。更常见的原因是插件函数里Create之后立刻被别的事件打断消息泵还没有跑起来。解决方法是确认创建窗口时用的父窗口句柄是 NSIS 当前页面句柄而不是一个空句柄并在创建完成后立即进入CWindowWnd::Run的消息循环。如果窗口仍然不响应检查 Duilib 的m_pm.Init是否被调用没有初始化绘制上下文控件事件根本不会分发。5.2 现象安装速度很快但进度条一直停在 0%安装文件量大时尤其明显安装包要跑十几秒Duilib 进度条却完全不动安装完成后直接跳到 100%。原因是 NSIS 的File指令一次复制一个文件在这个复制动作执行完毕之前脚本不会让出控制权进度文件也没有更新界面那边干等。解决思路是拆小文件粒度别把一个 500MB 的大文件一次性复制可以用 NSIS 的File /r配合递归复制让脚本按目录迭代写入进度。实在拆不开的大文件可以改在后台线程里跑安装逻辑用线程更新进度文件Duilib 定时器照常读取。5.3 现象安装结束后设置的配置项丢失用户明明在界面上勾选了“添加桌面快捷方式”装完后桌面空空如也。原因是 NSIS 脚本的变量作用域没有对上。插件写入变量的时机是在Notify事件里但此时可能还没有进入安装 Section或者变量被Section内部的局部计算覆盖了。另一个常见原因是快捷键创建逻辑读取的是另一个变量名两处拼写不一致。解决方法是统一变量命名建议在脚本最顶部用Var /GLOBAL声明全部跨页面变量插件写入名和脚本读取名保持完全一致写完立刻用MessageBox打印调试先确认变量传进来了再排查后续逻辑。5.4 现象高 DPI 屏幕上控件错位、文字被裁4K 屏、150% 缩放下Duilib 窗口的布局从正常比例变成错位重叠。原因是 Duilib 的资源缩放机制要求在创建窗口前先设置缩放因子而 NSIS 插件初始化时读取不到系统的 DPI 信息或者读取到的值和实际值不符。解决方法是插件函数入口处调用SetProcessDpiAwarenessContext获取真实 DPI把缩放因子传给 Duilib 的全局参数确保控件坐标和字体大小按比例缩放。注意要放在创建窗口之前否则窗口尺寸已经按 96 DPI 建立中途再改就晚了。5.5 现象用户点取消后进程没有完全退出关闭 Duilib 窗口后任务管理器里安装进程还在界面消失但后台一直占着 CPU。原因是 NSIS 的安装流程还在执行中插件函数返回后NSIS 并不知道 UI 已经关闭继续把脚本剩下的指令跑完。如果脚本里卸载逻辑有阻塞操作进程就会一直挂住。解决方式是在 Duilib 关闭事件中先同步调用 NSIS 脚本里的abort_install函数让脚本主动跳转到退出流程再关闭窗口。两个动作顺序不能反先关窗口再通知脚本脚本里的页面上下文已经没了。6. 进阶安装界面与安装引擎的完全解耦打法如果你的安装流程足够简单只有一页品牌展示加一个安装按钮可以试试更省事的架构Duilib 主程序 后台 NSIS 静默包。主程序启动时读一个配置文件用户点击安装后把选择写入同一个配置文件然后调用 NSIS 静默安装包静默包里的脚本读取配置并按配置执行。这样界面和引擎彻底分离后续改界面不用重新打包安装逻辑维护成本下降明显。// 主程序里调用静默安装包 void OnInstallClicked() { // 写配置文件NSIS 脚本会读取这里 WritePrivateProfileString(_T(Install), _T(Path), m_editPath-GetText(), _T(.\\install_cfg.ini)); WritePrivateProfileString(_T(Install), _T(Shortcut), m_bShortcut ? _T(1) : _T(0), _T(.\\install_cfg.ini)); // 启动 NSIS 静默安装包 // /S 是静默参数/config 指向配置文件 SHELLEXECUTEINFO sei {0}; sei.cbSize sizeof(SHELLEXECUTEINFO); sei.fMask SEE_MASK_NOCLOSEPROCESS; sei.lpFile _T(installer_core.exe); sei.lpParameters _T(/S /configinstall_cfg.ini); sei.nShow SW_HIDE; ShellExecuteEx(sei); // 等待安装进程结束再根据返回值决定界面提示 WaitForSingleObject(sei.hProcess, INFINITE); }静默包里的脚本也简单用ReadINIStr把配置读回变量再走常规的 Section 逻辑。这个方案的代价是安装过程中的交互瞬间不是实时的进度只能靠主程序轮询安装进程是否结束做不了细粒度反馈。适合安装时间在十秒内的场景拉长了会显得像卡死。说到底选哪条路线取决于你要多少控制力。我做过一个模拟项目X刚开始图省事选了完全解耦方案后来加了一个环境检测步骤要求检测失败时界面给出具体原因并阻止安装结果只能用文件通信绕了一大圈才实现。最后回退到插件方案反而半小时就改完了。控制力和开发成本永远是权衡不是单选题。这也是我后来养成的习惯先画安装流程状态图再决定架构。如果你的流程状态超过五个就别犹豫直接走插件路线。希望帮到你。本文还有配套的精品资源点击获取