NSIS+Duilib 仿QQ安装包:自定义界面与安装逻辑实战
简介这是一份面向Windows桌面开发者的NSIS与Duilib综合实战工程目标是复刻腾讯QQ安装程序的界面风格与交互流程。资源以C与NSIS脚本为核心适合已具备一定Win32编程基础、希望掌握自定义安装包开发的程序员参考学习。压缩包共206个文件约69.53MB其中h与cpp头源文件占主体另有png界面素材、sln与vcxproj工程文件、obj与pdb编译产物以及nsi、nsh脚本和dll、lib等插件依赖目录划分为3party第三方库、samples示例、plugin插件与include头文件四大模块。已有1866人学习下载。通过该工程可完整了解Duilib界面渲染与控件管理机制、NSIS脚本编写与插件扩展方式以及两者整合构建仿QQ安装流程的实现思路对提升桌面应用安装程序开发能力具有实际参考价值。1. 从一次打包翻车说起NSISDuilib 仿 QQ 安装包到底能干什么前阵子帮一个做桌面工具的朋友处理安装包他原本用某脚本打包工具生成的向导界面是系统默认的灰白配色用户装到一半直接关掉后台统计安装完成率不到六成。后来他把安装界面换成了仿 QQ 那种深色渐变、圆角按钮、带进度动画的样式完成率肉眼可见地往上走。这件事让我重新翻出了这套 NSISDuilib 仿 QQ 安装包的项目——它解决的不是“能不能装”的问题而是“用户愿不愿意装完”的问题。NSIS 负责安装逻辑、文件释放、注册表写入和卸载流程Duilib 负责把那个原生向导窗口换成一套自绘的现代界面两者通过插件桥接。适合谁适合需要给 Windows 桌面软件做品牌化安装向导的开发者尤其是那些不想上 InstallShield 这类重型方案、又嫌默认 NSIS 界面太丑的团队。你拿到源码后改改 XML 皮肤、调调按钮回调就能出一套带自己产品气质的安装包。2. 技术选型拆解为什么是 NSIS 加 Duilib 而不是别的组合2.1 NSIS 的脚本模型与安装流程NSIS 的核心是一套脚本语言编译后生成一个自解压的 exe。它的执行模型是线性的从.onInit开始依次经过页面注册、页面创建、页面显示、安装执行、完成页最后.onGUIEnd收尾。每个页面Page可以绑定一个回调函数比如Page custom 自定义页面创建 自定义页面离开。安装逻辑通常写在Section里用SetOutPath指定释放目录File命令把文件从安装包内解压出来WriteRegStr写注册表CreateShortCut建快捷方式。这套模型的好处是确定性强每一步都在脚本里看得见出问题能定位到具体行号。坏处是原生控件太丑nsDialogs能做的定制有限想做出 QQ 那种界面基本不可能。2.2 Duilib 的渲染机制与窗口自绘Duilib 是一个轻量级 C 界面库核心思路是“窗口自绘 XML 描述布局”。它把整个窗口的客户区接管过来所有控件都是自绘的不依赖系统主题。一个典型的 Duilib 窗口由WindowImplBase派生类承载OnCreate里加载 XML 皮肤文件InitWindow里绑定控件指针。XML 里用VerticalLayout、HorizontalLayout做容器Button、Label、Progress做具体控件每个控件可以指定bkimage、textcolor、hotimage、pushedimage等属性。渲染时 Duilib 把图片资源解码成位图按布局计算坐标直接往窗口 DC 上画。这套机制让界面完全脱离系统控件想画成什么样就画成什么样。2.3 两者桥接的关键NSIS 插件与窗口句柄传递NSIS 本身不能直接调 C 界面库桥接靠的是 NSIS 插件Plugin。插件是一个导出特定函数的 DLLNSIS 脚本里用插件名::函数名调用。这套项目里Duilib 界面被编译成一个插件 DLL导出ShowInstallWizard之类的函数。NSIS 在Page custom的回调里调用这个函数插件内部创建 Duilib 窗口进入消息循环。窗口关闭时插件把用户的选择安装路径、是否创建快捷方式等写到一个共享内存或临时文件里NSIS 再读回来继续执行 Section。这里有个关键点Duilib 窗口必须作为 NSIS 主窗口的子窗口或者模态窗口否则消息循环会冲突。常见做法是传$HWNDPARENT给插件让 Duilib 窗口以WS_EX_TOOLWINDOW样式挂上去。; NSIS 脚本片段注册自定义页面并调用 Duilib 插件 Page custom ShowDuilibPage LeaveDuilibPage Function ShowDuilibPage ; 调用插件传入父窗口句柄和安装包路径 DuilibWizard::ShowWizard $HWNDPARENT $EXEDIR Pop $0 ; 插件返回值0 表示用户点了下一步1 表示取消 StrCmp $0 1 0 2 Abort ; 用户取消终止安装 FunctionEnd Function LeaveDuilibPage ; 从插件读取用户选择的安装路径 DuilibWizard::GetInstallPath Pop $INSTDIR FunctionEnd上面这段脚本里Page custom声明了一个自定义页面ShowDuilibPage是进入页面时的回调LeaveDuilibPage是离开时的回调。$HWNDPARENT是 NSIS 主窗口句柄传给插件后 Duilib 窗口才能正确挂载。Pop $0取出插件返回值Abort用于取消安装。GetInstallPath是插件导出的另一个函数把用户在 Duilib 界面里选的路径读回来赋给$INSTDIR。参数说明$EXEDIR是安装包自身所在目录传给插件是为了让界面能读取同目录下的皮肤资源。2.4 资源打包与皮肤热加载Duilib 的皮肤资源XML、PNG、字体默认是放在磁盘目录里的但安装包需要把这些资源嵌进去。常见做法是用 NSIS 的File /r把皮肤目录释放到临时目录插件从临时目录加载。更优雅的做法是把资源编译进 DLL 的资源段用FindResource、LoadResource读出来再交给 Duilib 的CRenderEngine解析。这套项目里用的是前者因为改皮肤不用重新编译 DLL直接换 XML 和图片就行。热加载的实现是在 XML 里用相对路径引用图片插件启动时把当前目录切到皮肤目录CPaintManagerUI::SetResourcePath指过去。3. 目录结构与核心模块从源码树看安装包的骨架3.1 源码目录一览拿到源码后根目录通常长这样NSIS-Duilib-Installer/ ├── nsis/ # NSIS 脚本与插件源码 │ ├── installer.nsi # 主安装脚本 │ ├── DuilibWizard/ # Duilib 插件工程 │ │ ├── DuilibWizard.cpp # 插件导出函数实现 │ │ ├── WizardFrame.cpp # 主窗口类 │ │ └── WizardFrame.h │ └── plugins/ # 编译好的插件 DLL 存放处 ├── skin/ # Duilib 皮肤资源 │ ├── wizard.xml # 主界面布局 │ ├── btn_next.png # 按钮图片 │ ├── bg_main.png # 背景图 │ └── progress.png # 进度条图片 ├── payload/ # 待安装的软件文件 │ ├── app.exe │ └── config/ └── build.bat # 一键编译脚本nsis/下是安装逻辑和插件源码skin/是界面资源payload/是实际要装到用户机器上的文件。build.bat一般先调 Visual Studio 的msbuild编译插件再用makensis编译脚本最后把插件 DLL 和皮肤资源一起塞进安装包。3.2 主窗口类的初始化链路Duilib 窗口的入口在WizardFrame.cpp核心是OnCreate和InitWindow两个函数。// WizardFrame.cppDuilib 窗口初始化 void CWizardFrame::OnCreate(LPCREATESTRUCT lpCreateStruct) { // 先调用基类完成窗口消息绑定 if (WindowImplBase::OnCreate(lpCreateStruct) -1) return -1; // 设置资源路径指向皮肤目录 CPaintManagerUI::SetResourcePath(CPaintManagerUI::GetInstancePath() _T(skin\\)); // 加载 XML 布局 CDialogBuilder builder; CControlUI* pRoot builder.Create(_T(wizard.xml), (UINT)0, NULL, m_pm); ASSERT(pRoot wizard.xml 加载失败); // 把布局挂到窗口上 m_pm.AttachDialog(pRoot); m_pm.AddNotifier(this); // 注册控件事件通知 return 0; } void CWizardFrame::InitWindow() { // 绑定控件指针方便后续操作 m_pBtnNext static_castCButtonUI*(m_pm.FindControl(_T(btn_next))); m_pBtnCancel static_castCButtonUI*(m_pm.FindControl(_T(btn_cancel))); m_pProgress static_castCProgressUI*(m_pm.FindControl(_T(progress_install))); m_pPathLabel static_castCLabelUI*(m_pm.FindControl(_T(label_path))); // 设置默认安装路径 if (m_pPathLabel) { m_pPathLabel-SetText(m_strDefaultPath); } }OnCreate里先调基类完成消息映射然后SetResourcePath把资源根目录指到skin\CDialogBuilder::Create解析 XML 并返回根控件AttachDialog把控件树挂到窗口的CPaintManagerUI上。AddNotifier(this)让窗口类能收到按钮点击等事件。InitWindow在窗口创建完成后被调用这里用FindControl按 XML 里的name属性找到控件指针存成成员变量。参数说明_T(wizard.xml)是布局文件名(UINT)0是文件类型标识NULL是父控件m_pm是绘制管理器。如果 XML 路径不对builder.Create返回 NULL后面的AttachDialog会崩所以ASSERT很有必要。3.3 按钮事件与安装进度回调按钮点击走的是Notify函数Duilib 把所有控件事件都汇总到这里。// WizardFrame.cpp处理按钮点击和进度更新 void CWizardFrame::Notify(TNotifyUI msg) { if (msg.sType _T(click)) { if (msg.pSender m_pBtnNext) { // 用户点了“下一步”读取当前路径并通知 NSIS CDuiString strPath m_pPathLabel-GetText(); WriteSharedData(_T(InstallPath), strPath); Close(); // 关闭窗口NSIS 继续执行 } else if (msg.pSender m_pBtnCancel) { WriteSharedData(_T(Cancelled), _T(1)); Close(); } } else if (msg.sType _T(valuechanged)) { // 进度条数值变化刷新界面 if (msg.pSender m_pProgress) { m_pProgress-SetValue(m_nProgress); } } }Notify里先判断msg.sTypeclick是点击事件valuechanged是值变化事件。msg.pSender是触发事件的控件指针和之前绑定的成员变量比较就能知道是哪个按钮。点“下一步”时把路径写到共享数据区可以是临时文件或内存映射然后Close()关窗。NSIS 那边在LeaveDuilibPage里读回来。进度更新通常由 NSIS 在安装 Section 里通过插件导出的SetProgress函数驱动插件内部再调m_pProgress-SetValue。这里要注意线程问题NSIS 安装线程和 Duilib 界面线程不是同一个跨线程更新 UI 必须用PostMessage或SendMessage投递到窗口线程直接调SetValue会出玄学崩溃。3.4 皮肤 XML 的关键节点wizard.xml决定了界面长什么样核心结构如下!-- skin/wizard.xml主界面布局 -- Window size600,400 caption0,0,0,32 mininfo600,400 VerticalLayout bkimagebg_main.png !-- 顶部标题栏 -- HorizontalLayout height32 bkimagetitlebar.png Label text安装向导 textcolor#FFFFFF font1 / Button namebtn_close bkimageclose_normal.png hotimageclose_hot.png / /HorizontalLayout !-- 中间内容区 -- VerticalLayout inset20,10,20,10 Label text选择安装位置 textcolor#CCCCCC / HorizontalLayout height30 Label namelabel_path textC:\Program Files\App textcolor#FFFFFF bkimageedit_bg.png / Button namebtn_browse text浏览... / /HorizontalLayout Progress nameprogress_install bkimageprogress_bg.png thumbimageprogress_thumb.png / /VerticalLayout !-- 底部按钮区 -- HorizontalLayout height40 inset0,0,20,10 Button namebtn_cancel text取消 / Button namebtn_next text下一步 / /HorizontalLayout /VerticalLayout /WindowWindow根节点定义窗口尺寸和标题栏拖拽区域caption0,0,0,32表示顶部 32 像素可以拖动窗口。VerticalLayout是垂直容器bkimage指定背景图。Label的name属性对应FindControl里的名字。Progress的bkimage是底图thumbimage是进度块。所有图片路径都是相对skin\目录的。改皮肤时换掉 PNG、调textcolor和inset就能出不同风格不用动 C 代码。4. 避坑与排查安装包开发中容易翻车的几个点4.1 插件 DLL 加载失败报“找不到指定模块”现象是 NSIS 编译通过但运行安装包时弹窗提示插件加载失败。原因通常是插件 DLL 没有放到 NSIS 能找到的路径。NSIS 查找插件的顺序是安装包同目录下的Plugins\文件夹、$PLUGINSDIR、系统 PATH。解决方法是把编译好的DuilibWizard.dll放到nsis/plugins/下并在脚本里用!addplugindir显式指定目录。另外注意 DLL 的位数要和 NSIS 一致32 位 NSIS 只能加载 32 位插件。4.2 Duilib 窗口一闪而过或消息循环卡死现象是安装包启动后界面闪一下就没了或者界面卡住不响应。原因是 Duilib 窗口的消息循环和 NSIS 主循环冲突。NSIS 本身有自己的消息泵插件里再起一个CPaintManagerUI::MessageLoop会抢消息。正确做法是让 Duilib 窗口以模态方式运行用DoModal而不是ShowWindow或者在插件里用SetTimer驱动一个轻量循环每次PeekMessage处理完就返回不阻塞 NSIS 主线程。4.3 皮肤图片显示为白块或错位现象是界面出来了但按钮和背景全是白块或者控件位置对不上。原因通常是资源路径不对或 XML 里图片尺寸和控件尺寸不匹配。先检查SetResourcePath指向的目录下有没有对应的 PNG再确认 XML 里bkimage的文件名大小写一致Windows 下不区分但打包后可能出问题。控件错位一般是inset和height算错了Duilib 的布局是流式的VerticalLayout里子控件的height之和超过父容器高度就会溢出。调试时可以在 XML 里临时给控件加bordersize1 bordercolor#FF0000看边界。4.4 安装路径含中文或空格时释放失败现象是用户选了带中文的路径安装到一半报错。原因是 NSIS 的File命令和 Duilib 的路径读取对 Unicode 处理不一致。NSIS 默认是 ANSI 版本遇到中文路径会乱码。解决方法是编译 NSIS 时用 Unicode 版本makensis /DUNICODE插件里所有字符串用CStringW或std::wstringXML 文件存成 UTF-8 带 BOM。另外路径里的空格要用引号包起来SetOutPath $INSTDIR本身能处理但传给插件的路径参数要加。4.5 卸载时残留文件和注册表现象是用户卸载后安装目录还在注册表里还有项。原因是卸载脚本没写全。NSIS 的卸载逻辑在UninstallSection 里需要手动Delete文件、RMDir目录、DeleteRegKey清注册表。常见遗漏是$INSTDIR下的子目录没递归删以及WriteRegStr写的项在卸载时忘了对应DeleteRegKey。建议在安装时把所有写过的注册表路径记到一个列表里卸载时遍历删除。另外RMDir /r $INSTDIR能递归删目录但用之前要确认$INSTDIR不是根目录否则血泪经验。5. 二次开发改造换皮肤、加页面、接自定义逻辑5.1 换一套皮肤要改哪些文件换皮肤是最常见的二次开发需求。核心改三个地方skin/下的 PNG 图片、wizard.xml里的布局和颜色、WizardFrame.cpp里的默认路径和文案。图片按原尺寸替换文件名保持一致就不用动 XML。如果要改布局比如把“浏览”按钮从路径右边移到下面调整HorizontalLayout和VerticalLayout的嵌套关系即可。颜色改textcolor和bkcolor字体在 XML 的font属性里引用字体定义通常在skin\font.xml里改face和size就行。改完不用重新编译 C直接重新打 NSIS 包因为皮肤是运行时加载的。5.2 增加一个自定义页面比如要在安装前加一个“许可协议”页。步骤是先在wizard.xml里加一个VerticalLayout namepage_license里面放RichEdit或Label显示协议文本加“同意”和“拒绝”按钮。然后在WizardFrame.cpp里加一个页面切换逻辑用m_pm.ShowControl或SetVisible控制哪个页面显示。按钮事件在Notify里加分支点“同意”就ShowControl(page_install)点“拒绝”就Close并写取消标记。NSIS 那边不用改因为整个向导还是一个页面只是内部多了几步。如果要把协议页做成 NSIS 的独立 Page那就要在插件里导出多个函数每个函数对应一个页面NSIS 脚本里用多个Page custom串联。5.3 接入自定义安装逻辑有时候安装不只是释放文件还要检测环境、写配置、调外部程序。这些逻辑可以放在 NSIS 的 Section 里也可以放在插件的 C 里。放 NSIS 里简单用ExecWait、ReadRegStr、IfFileExists就行。放 C 里灵活能调 Windows API 做复杂判断。常见做法是NSIS 负责文件释放和注册表插件负责界面交互和环境检测。插件导出一个CheckEnvironment函数返回一个状态码NSIS 根据状态码决定是否继续。比如检测到已安装旧版本插件弹个提示框问是否覆盖用户选“是”就返回 0NSIS 继续选“否”返回 1NSISAbort。5.4 验证改造效果的方法改完之后怎么确认没问题我一般走三步第一步在虚拟机里跑一遍完整安装和卸载看文件、注册表、快捷方式是否都正确第二步把安装包拷到不同 Windows 版本Win7、Win10、Win11上跑重点看界面渲染有没有差异Duilib 在 Win7 上对某些 PNG 透明通道支持不好可能要换bkimage的混合模式第三步用 Process Monitor 监控安装过程中的文件系统和注册表操作确认没有写到预期之外的路径。如果界面有动画还要在低配机器上看帧率Duilib 的SetFPS可以调默认 60 太高就降到 30。// 插件导出函数示例环境检测 extern C __declspec(dllexport) int CheckEnvironment(LPCTSTR lpInstallPath) { // 检查目标路径是否已存在旧版本 CString strOldExe; strOldExe.Format(_T(%s\\app.exe), lpInstallPath); if (PathFileExists(strOldExe)) { // 弹窗询问用户 int nRet MessageBox(NULL, _T(检测到已安装旧版本是否覆盖), _T(提示), MB_YESNO | MB_ICONQUESTION); return (nRet IDYES) ? 0 : 1; } // 检查磁盘空间 ULARGE_INTEGER freeBytes; if (GetDiskFreeSpaceEx(lpInstallPath, freeBytes, NULL, NULL)) { if (freeBytes.QuadPart 100 * 1024 * 1024) { // 小于 100MB MessageBox(NULL, _T(磁盘空间不足), _T(错误), MB_OK | MB_ICONERROR); return 2; } } return 0; // 环境正常 }这个函数先拼出旧版本 exe 的路径用PathFileExists判断是否存在存在就弹窗问用户。MessageBox的返回值IDYES对应覆盖返回 0 让 NSIS 继续IDNO返回 1 让 NSIS 终止。然后检查磁盘剩余空间小于 100MB 返回 2。NSIS 脚本里用DuilibWizard::CheckEnvironment $INSTDIR调用Pop $0取返回值StrCmp判断后决定Abort还是继续。参数说明lpInstallPath是用户选的安装路径freeBytes.QuadPart是剩余字节数100 * 1024 * 1024是阈值按实际软件大小调整。从那以后我每次改完安装包都强制在虚拟机里走一遍完整安装、启动、卸载、再安装的循环确认没有残留才敢发出去。这套 NSISDuilib 的组合不算新但胜在可控界面和逻辑分离得干净改起来不牵一发动全身。希望帮到你。本文还有配套的精品资源点击获取