简介这份资源面向需要在 Windows 平台制作专业级安装界面的开发者也适合希望摆脱 NSIS 默认界面、用 Duilib 实现可换肤自定义安装程序的进阶学习者。它解决的核心问题是把 NSIS 的安装逻辑文件复制、注册表写入、快捷方式与开机启动项创建、多语言支持与 Duilib 的 XML 布局、控件渲染能力结合起来从而做出外观接近商业软件的安装向导。资源包共 451 个文件约 9.6MB包含 113 个 png 与 32 个 bmp 皮肤图片、113 个 nsh 脚本片段、58 个 nlf 多语言文件、35 个 ico 图标、29 个 dll 依赖库、24 个 xml 界面布局、2 个 nsi 主脚本以及若干 bat 构建脚本和 chm 帮助文档覆盖界面素材、脚本模块与编译工具链。已有 2945 人学习读者可从中获得一套可直接参考的换肤安装框架、界面与逻辑的集成示例、快捷方式与启动项的实现写法以及构建脚本和资源目录组织思路便于二次改造与排错。1. 默认界面撑不起交付NSIS 配 Duilib 到底在解决什么用 NSIS 写安装包的人多半经历过同一个阶段脚本一下午跑通功能全对客户双击 Setup.exe 看到那套向导页回头问一句「这是正式版吗」。NSIS 自带的 MUI2 能换位图、改按钮文案但要做圆角卡片、自绘进度条、带勾选列表的组件选择页改脚本的成本很快就超过重写一个窗口。Duilib 补的正是这块XML 描述布局、贴图驱动外观、纯 C 编译成 DLL通过 NSIS 的插件机制嵌进安装流程。这套组合适合给商业软件做交付、要控制安装器观感和交互步骤、又不想把包体撑大的团队。代价是得同时摸清 NSIS 脚本和一点 Win32 消息循环。2. 把两套技术栈拼起来插件机制与工程骨架2.1 先定分工NSIS 管流程Duilib 只管像素动手前必须把职责切干净不然后面全是互相打架。NSIS 侧负责的是它擅长的部分判断是否已安装、检查磁盘空间、接收命令行参数、写卸载信息、注册到控制面板的程序列表、处理升级和静默安装分支。Duilib 侧只做一件事把窗口画出来、把按钮点击转成指令、把安装进度显示成进度条。之所以这么切是因为 NSIS 的界面能力集中在页面Page模型上每个 Page 是一次回调Page custom让你接管绘制但回调返回后页面就销毁了。而自定义安装器通常需要「一个窗口从头到尾活着中间切换状态」比如选项页点下一步直接变成进度页而不是关掉一个窗口再开一个。这种常驻窗口用 NSIS 的 Page 很难做顺用 Duilib 的WindowImplBase就是一个普通的模态窗口消息循环在自己手里状态切换只是控件的显隐。所以调用关系是这样的NSIS 脚本把必要的路径参数压到栈上调用插件函数插件内部创建窗口、启动工作线程做真正的文件复制窗口活着的时候消息循环转着主线程通过PostMessage把进度推给界面窗口关闭后插件函数返回NSIS 继续往下走收尾逻辑。提示不要试图让 NSIS 的 Section 和 Duilib 窗口并行跑。Section 是同步执行的插件函数不返回脚本就卡在那行两边都想控制流程必然出时序问题。2.2 插件目录、导出约定与工程结构NSIS 找插件有一套固定规则脚本里写nsDuilib::RunInstall它就去插件目录找nsDuilib.dll然后用GetProcAddress取名为RunInstall的导出函数。这里有两个容易翻车的点。第一是目录。NSIS 按「架构 字符集」分插件子目录常见的是Plugins\x86-unicode、Plugins\x86-ansi、Plugins\amd64-unicode。你的 NSIS 是 Unicode 版还是 ANSI 版决定了它去哪个目录找找错目录的表现是脚本编译期就报Invalid command: nsDuilib::RunInstall而不是运行时报错很多人第一反应会去查插件代码其实根本没加载。第二是导出名。x86 下__stdcall会把函数名修饰成_RunInstall20C 还会再加一层名字改编。GetProcAddress(RunInstall)拿不到NSIS 就认为函数不存在。解决办法是加一个.def文件强制导出未修饰名或者用extern C配合链接器开关前者更稳。工程目录我一般这么摆installer/ ├── nsis/ │ ├── installer.nsi ; 主脚本 │ └── uninstall.nsi ├── plugin/ │ ├── plugin_entry.cpp ; NSIS 导出函数 │ ├── install_wnd.cpp ; Duilib 窗口 │ ├── install_worker.cpp ; 工作线程 │ └── nsDuilib.def ; 导出定义 ├── skin/ │ ├── install_main.xml │ └── images/ ├── payload/ ; 真正要装的文件由 NSIS 打包 └── build/ ; 输出目录payload和skin分开是关键。payload是用户机器上要落地的程序文件skin是安装器自己的资源安装完就该消失两者混在一起会导致卸载时误删或误留。.def文件内容不长但少了它整个项目起不来LIBRARY nsDuilib EXPORTS RunInstall ShowOptionsPage第一行声明模块名后面每行一个导出函数。加上这个文件后x86 和 x64 都能用同一个函数名调用省掉一堆修饰名排查。2.3 第一次编译三条命令跑通最小可运行窗口先让窗口能弹出来再去接安装逻辑。下面这套 CMake 配置能把 Duilib 源码直接编进插件 DLL不依赖预编译库改 Duilib 的代码也不用重新找 lib。cmake_minimum_required(VERSION 3.16) project(nsDuilibPlugin CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # NSIS 安装目录下的 Include里面有 exdll.h set(NSIS_SDK D:/tools/nsis/Include) # Duilib 源码根目录 set(DUILIB_ROOT D:/third_party/duilib) include_directories( ${NSIS_SDK} ${DUILIB_ROOT} ${DUILIB_ROOT}/Utils ${DUILIB_ROOT}/Control ) # Duilib 的源码文件按实际目录结构裁剪 file(GLOB DUILIB_SRC ${DUILIB_ROOT}/*.cpp ${DUILIB_ROOT}/Utils/*.cpp ${DUILIB_ROOT}/Control/*.cpp ${DUILIB_ROOT}/Layout/*.cpp ) add_library(nsDuilib SHARED plugin/plugin_entry.cpp plugin/install_wnd.cpp plugin/install_worker.cpp ${DUILIB_SRC} plugin/nsDuilib.def ) target_compile_definitions(nsDuilib PRIVATE UNICODE _UNICODE) set_target_properties(nsDuilib PROPERTIES PREFIX OUTPUT_NAME nsDuilib) # 依赖的 Windows 库 target_link_libraries(nsDuilib PRIVATE comctl32 gdi32 user32 ole32 oleaut32 shell32 shlwapi)PREFIX 去掉 Linux 式的lib前缀Windows 下虽然是空操作但跨平台 CI 里能省一次事故。OUTPUT_NAME必须和脚本里::左边的名字完全一致大小写也要对上Windows 文件系统不敏感但 NSIS 的报错信息会让你误判。编译完把nsDuilib.dll拷到Plugins\x86-unicode下跑一次最小脚本Unicode true Name 某桌面工具 OutFile ..\build\Setup.exe InstallDir $PROGRAMFILES64\某桌面工具 RequestExecutionLevel admin !addplugindir /x86-unicode ..\build\plugins !include MUI2.nsh Page custom PageMainCreate !insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_UNPAGE_CONFIRM Function PageMainCreate ; 弹出 Duilib 窗口参数是皮肤目录名 nsDuilib::ShowOptionsPage install_main Pop $0 FunctionEnd Section DetailPrint done SectionEnd!addplugindir让 NSIS 去你指定的目录找插件省掉每次手动拷到安装目录。Page custom的回调里调用插件窗口关掉之后脚本才继续走后面的MUI_PAGE_INSTFILES。第一次跑建议先把窗口弹出来就关闭确认皮肤能加载、按钮能响应再去写复制文件的逻辑。跳过这一步直接上完整流程出问题时你分不清是插件没加载、皮肤没找到还是工作线程崩了。3. 让窗口真正跑起来消息循环与数据回传3.1 插件入口里创建窗口并接管消息循环NSIS 插件的导出函数签名是固定的参数列表一个都不能改父窗口句柄、字符串缓冲区大小、变量表指针、栈指针、扩展参数指针。用EXDLL_INIT()把后三个存到全局变量里之后popstring/pushstring才有意义。// plugin_entry.cpp #include windows.h #include exdll.h // NSIS 自带注意字符集要匹配 #include install_wnd.h #include install_worker.h extern C __declspec(dllexport) void __stdcall RunInstall(HWND hwndParent, int string_size, TCHAR* variables, stack_t** stacktop, extra_parameters* extra) { EXDLL_INIT(); // 初始化 g_stacktop / g_variables / g_stringsize TCHAR szGroup[MAX_PATH] {0}; // 开始菜单组名 TCHAR szPayload[MAX_PATH] {0}; // 临时解包目录 TCHAR szInstDir[MAX_PATH] {0}; // 目标安装目录 // 出栈顺序与脚本里传参顺序相反最后压的最先弹 popstring(szGroup); popstring(szPayload); popstring(szInstDir); CInstallWorker worker(szInstDir, szPayload, szGroup); CInstallWnd wnd(worker); if (!wnd.Create(hwndParent, _T(安装 某桌面工具), UI_WNDSTYLE_FRAME, WS_EX_WINDOWEDGE)) { pushstring(_T(create_failed)); return; } // 模态消息循环窗口关闭后返回 CMessageLoop* pLoop wnd.GetLoop(); pLoop-Run(); pushstring(worker.IsSuccess() ? _T(ok) : _T(install_failed)); }popstring的调用顺序是最常见的错。NSIS 里nsDuilib::RunInstall $INSTDIR $PLUGINSDIR $SMPROGRAMS是从左往右压栈所以栈顶是最后一个参数。上面代码先弹szGroup对应$SMPROGRAMS最后弹szInstDir对应$INSTDIR。顺序写反的典型表现是路径全乱安装目录变成开始菜单组CreateDirectory直接失败。wnd.Create的第三个参数决定窗口带不带边框和最大化按钮。安装器一般用UI_WNDSTYLE_FRAME再加WS_EX_WINDOWEDGE但真正需要控制的是在GetWindowClassName之后、Create之前把窗口样式改成无边框在CInstallWnd里重写GetWindowClassName和GetSkinFile然后在HandleMessage里拦WM_NCHITTEST返回HTCAPTION实现拖动。这套做法比改 XML 靠谱因为 Duilib 的布局文件和实际窗口样式是两套东西只在 XML 里画个标题栏并不会让窗口真的能拖动。3.2 进度回传PostMessage 而不是直接改控件工作线程绝对不能碰控件。Duilib 的绘制和控件树都在主线程后台线程调SetValue属于跨线程操作 UI轻则进度条不刷新重则资源竞争直接崩。常规写法是后台线程算进度用PostMessage投递到窗口主线程在HandleCustomMessage里更新。// install_wnd.h #pragma once #include UIlib.h using namespace DuiLib; #define UWM_INSTALL_PROGRESS (WM_USER 100) #define UWM_INSTALL_FINISH (WM_USER 101) #define UWM_INSTALL_STAGE (WM_USER 102) class CInstallWnd : public WindowImplBase { public: explicit CInstallWnd(CInstallWorker* pWorker) : m_pWorker(pWorker), m_pProgress(NULL) {} LPCTSTR GetWindowClassName() const override { return _T(DuilibInstallWnd); } CDuiString GetSkinFile() override { return _T(install_main.xml); } CDuiString GetSkinFolder() override { return _T(skin); } void InitWindow() override { // 控件指针必须在 InitWindow 之后取构造时控件树还没建好 m_pProgress static_castCProgressUI*(m_pm.FindControl(_T(progress_install))); m_pWorker-SetNotifyWnd(GetHWND()); m_pWorker-Start(); // 启动后台线程 } LRESULT HandleCustomMessage(UINT uMsg, WPARAM wParam, LPARAM lParam, BOOL bHandled) override { switch (uMsg) { case UWM_INSTALL_PROGRESS: if (m_pProgress) m_pProgress-SetValue((int)wParam); bHandled TRUE; break; case UWM_INSTALL_STAGE: { CTextUI* pText static_castCTextUI*(m_pm.FindControl(_T(text_stage))); if (pText) pText-SetText((LPCTSTR)lParam); } bHandled TRUE; break; case UWM_INSTALL_FINISH: Close(IDOK); // 关闭模态窗口让 Run 返回 bHandled TRUE; break; } return 0; } private: CInstallWorker* m_pWorker; CProgressUI* m_pProgress; };HandleCustomMessage是WindowImplBase预留的扩展点WM_USER以上的自定义消息都会走到这里。注意两个细节bHandled必须置TRUE否则消息会继续往下传可能被WindowImplBase的默认处理吞掉lParam传字符串时不能用栈上局部变量的地址PostMessage是异步入队函数返回后内存就失效了稳妥做法是new一块再在窗口侧delete或者干脆只传枚举值文案在窗口侧查表。进度值本身也有讲究。文件复制阶段按字节数算(已复制字节 / 总字节) * 100注册表写入、快捷方式创建这些步骤耗时很短不能平均分配百分比否则进度条会在 95% 停住好几秒用户以为卡死。我的做法是给每个阶段一个权重复制文件占 80%注册表 8%快捷方式 7%收尾 5%每阶段内部再按实际数量插值。3.3 线程边界与关窗时序别让 NSIS 拿到空栈工作线程的生命周期必须严格包在窗口生命周期里。窗口的Close只是投递关闭消息Run()返回后才算真正结束此时如果工作线程还在跑它持有的窗口句柄已经无效PostMessage返回失败但没人检查进度就永远停在那。所以CInstallWnd的析构函数里要WaitForSingleObject等线程句柄或者在工作线程里用事件对象做退出信号。返回值同样容易出事。pushstring之前如果提前returnNSIS 侧Pop $0会弹到一个空值脚本里的${If} $0 ! ok判断就失效安装失败也当成成功。所以插件的每个退出路径都要保证压了一个字符串回去包括创建窗口失败、参数解析失败这类分支。还有一种情况是用户在安装中途点关闭。这时不能直接Close要么在OnClick里拦截关闭按钮弹出确认框要么在HandleMessage里拦WM_CLOSE直接return不处理。放行的话文件复制到一半被中断目标目录留下半套文件卸载程序又不认识这些残留用户下次重装就撞上「文件已存在」的提示。4. 安装动作落地解包、写盘、注册与卸载4.1 先把 payload 解到 $PLUGINSDIRNSIS 的File指令在编译期把文件打进安装包运行期由 NSIS 自己解压插件拿不到这个过程。所以流程要反过来让 NSIS 先把所有文件解到一个临时目录插件再从临时目录复制到目标路径这样进度才能按文件粒度上报。!include LogicLib.nsh !include FileFunc.nsh Section Install ; 创建本次安装专用的临时目录退出时 NSIS 自动清理 InitPluginsDir SetOutPath $PLUGINSDIR\payload File /r ..\payload\*.* ; 皮肤资源也放临时目录插件启动时从这读 SetOutPath $PLUGINSDIR\skin File /r ..\skin\*.* SetOutPath $INSTDIR nsDuilib::RunInstall $INSTDIR $PLUGINSDIR\payload $SMPROGRAMS\某桌面工具 Pop $0 ${If} $0 ! ok SetErrorLevel 2 Abort 安装未完成 ${EndIf} SectionEndInitPluginsDir会创建$PLUGINSDIR这个目录在安装器进程退出时由 NSIS 自动删除不需要手动清理。File /r保持目录结构SetOutPath决定解到哪。这里有个隐含前提payload目录里的层级要和最终安装目录一致插件只做整目录搬运不做路径重映射否则配置文件里的相对路径全会失效。Abort的作用是中止当前 Section。注意它不会回滚已经复制的文件所以插件内部的失败处理要先做清理再返回失败码别指望 NSIS 帮你收拾。4.2 复制文件与进度计算工作线程的核心就是遍历临时目录、逐文件复制、按字节累计进度。用FindFirstFile/FindNextFile递归比SHFileOperation可控因为后者不给进度回调。// install_worker.cpp 片段 void CInstallWorker::CopyTree(const std::wstring src, const std::wstring dst) { std::wstring pattern src L\\*; WIN32_FIND_DATAW fd; HANDLE hFind FindFirstFileW(pattern.c_str(), fd); if (hFind INVALID_HANDLE_VALUE) return; do { if (wcscmp(fd.cFileName, L.) 0 || wcscmp(fd.cFileName, L..) 0) continue; std::wstring s src L\\ fd.cFileName; std::wstring d dst L\\ fd.cFileName; if (fd.dwFileAttributes FILE_ATTRIBUTE_DIRECTORY) { CreateDirectoryW(d.c_str(), NULL); CopyTree(s, d); // 递归子目录 } else { CopyFileW(s.c_str(), d.c_str(), FALSE); m_doneBytes (fd.nFileSizeHigh * (MAXDWORD 1ULL)) fd.nFileSizeLow; int percent (int)(m_doneBytes * 80 / m_totalBytes); // 复制阶段占 80% ::PostMessage(m_hNotifyWnd, UWM_INSTALL_PROGRESS, percent, 0); } } while (FindNextFileW(hFind, fd)); FindClose(hFind); }m_totalBytes要在开始复制前先遍历一遍算出总和否则进度算不准。文件大小是 64 位nFileSizeHigh和nFileSizeLow拼的时候必须转成ULONGLONG直接乘MAXDWORD在超大文件上会溢出。复制过程中每个文件都PostMessage太频繁小文件多的时候消息队列会爆窗口响应变慢。常规做法是加阈值进度值变化超过 1% 才投递一次或者按时间节流50 毫秒内只发一条。还有一个容易忽略的点CopyFileW的第三个参数是「是否允许覆盖」传FALSE时目标已存在会失败安装升级场景就会卡住。升级安装应该先记录旧版本文件列表或者干脆传TRUE覆盖再把新版本多出来的文件单独处理。我一般选覆盖因为安装包的 payload 是完整的一套不做增量。4.3 注册表、快捷方式与卸载项NSIS 侧收尾写控制面板的程序列表和卸载入口。64 位程序必须显式指定注册表视图否则 32 位安装器写进去的项在 64 位系统上会落到Wow6432Node用户能在「应用和功能」里看到但卸载时找不到可执行文件。Section UninstallInfo SetRegView 64 WriteRegStr HKLM Software\Microsoft\Windows\CurrentVersion\Uninstall\某桌面工具 \ DisplayName 某桌面工具 WriteRegStr HKLM Software\Microsoft\Windows\CurrentVersion\Uninstall\某桌面工具 \ DisplayVersion 1.0.0 WriteRegStr HKLM Software\Microsoft\Windows\CurrentVersion\Uninstall\某桌面工具 \ InstallLocation $INSTDIR WriteRegStr HKLM Software\Microsoft\Windows\CurrentVersion\Uninstall\某桌面工具 \ UninstallString $INSTDIR\uninst.exe WriteRegDWORD HKLM Software\Microsoft\Windows\CurrentVersion\Uninstall\某桌面工具 \ NoModify 1 WriteRegDWORD HKLM Software\Microsoft\Windows\CurrentVersion\Uninstall\某桌面工具 \ NoRepair 1 WriteUninstaller $INSTDIR\uninst.exe ; 快捷方式用插件或 CreateShortCut后者是 NSIS 内置指令 CreateDirectory $SMPROGRAMS\某桌面工具 CreateShortCut $SMPROGRAMS\某桌面工具\某桌面工具.lnk $INSTDIR\app.exe CreateShortCut $DESKTOP\某桌面工具.lnk $INSTDIR\app.exe SectionEndSetRegView 64只影响后续的注册表操作脚本里其他地方如果还有 32 位假设记得一并检查。NoModify和NoRepair不写的话控制面板会给你的程序显示「修改」和「修复」按钮点了会调ModifyPath和RepairPath没定义这两个值就报错弹框用户体验很差。DisplayIcon也值得加不填的话列表里显示的是默认图标跟你的程序完全不搭。4.4 静默安装与命令行参数企业部署场景下/S是硬需求。静默模式下不能弹出任何窗口否则批量部署脚本会卡在等人点按钮。判断用IfSilent插件入口处也要再判断一次因为 NSIS 侧的IfSilent只影响脚本插件被调用了照样会建窗口。Function .onInit ${GetParameters} $R0 ${GetOptions} $R0 /D $R1 ${IfNot} ${Errors} StrCpy $INSTDIR $R1 ${EndIf} ; 静默安装时不走自定义页面 IfSilent silent Return silent: SetSilent silent FunctionEnd/D是 NSIS 约定的安装目录参数必须放在命令行最后且路径不能加引号这是它的历史遗留规则。自定义参数比如/NORUN装完不启动程序可以用GetOptions解析。插件内部也要处理Start()之前检查一个从脚本传进来的silent标志为真时直接创建工作线程、不创建窗口进度消息投给一个空句柄也不报错。这里如果偷懒省略静默安装在 CI 上跑的时候会直接挂住直到超时排查半天才发现是弹了个看不见的窗口在等点击。5. 避坑与排查五个现场翻车记录5.1 插件加载失败编译期就报 Invalid command现象脚本编译直接失败提示Invalid command: nsDuilib::RunInstall但 DLL 明明拷进去了。原因是 NSIS 版本和插件目录不匹配。Unicode 版 NSIS 只认Plugins\x86-unicodeANSI 版只认Plugins\x86-ansi放错目录等同于没放。另一种可能是!addplugindir的路径写法带了引号或末尾斜杠NSIS 解析失败但不报错。解决办法是先确认makensis /VERSION输出的是哪个版本再对齐目录名!addplugindir后面只写单引号或双引号包裹的绝对路径不带尾部反斜杠。确认无误后清理一次build目录重新编译。5.2 皮肤加载不到窗口一片空白现象窗口能出来但里面没有任何控件或者干脆秒退。原因是 Duilib 找资源的相对路径基于工作目录而安装器运行时的工作目录是用户双击 exe 时所在的目录不一定是安装包所在目录。你写GetSkinFolder()返回skin它就去当前工作目录找skin\install_main.xml。解决是在InitWindow之前显式设置资源路径。从插件 DLL 所在位置算起但皮肤实际被打包在$PLUGINSDIR\skin所以更可靠的做法是通过SetResourcePath传入临时目录的绝对路径这个路径由 NSIS 侧用$PLUGINSDIR拼好通过参数传给插件。5.3 静默安装仍然弹出窗口现象命令行加/S跑进程不退出界面卡在那。原因是IfSilent只跳过了 NSIS 的页面Page custom注册的回调不会被调用但Section里调用插件的语句照样执行插件内部的Create也不判断环境。解决是双保险NSIS 侧在静默分支里跳过插件调用直接走纯脚本的复制流程或调用另一个无界面导出函数插件侧再读一次命令行参数检测到/S就只起工作线程不建窗口。两边都做是因为单边判断在升级场景下容易被绕过。5.4 杀软拦截安装包被直接删掉现象开发机运行正常换一台机器下载后立刻被安全软件清除或者运行时报「无法执行」。原因是安装器自身没有数字签名加上 NSIS 打包的 exe 是自解压结构启发式引擎会把它和某些捆绑软件的特征混在一起。解决是给Setup.exe和uninst.exe都加签名签名后还要对$INSTDIR下的主程序也签一次。另外OutFile尽量用固定文件名、别用随机串提交到软件分发渠道前先做一次多引擎扫描。5.5 卸载残留文件目录删不干净现象卸载跑完安装目录还在里面留着几个 dll 或日志文件。原因是卸载时主程序还在运行Delete指令删不掉被占用的文件NSIS 对失败不报错直接跳过。另一种是安装时写入的用户数据放在安装目录下卸载脚本没覆盖到。解决是卸载入口先检查进程是否在跑用nsProcess插件或FindWindow判断提示用户关闭后再继续用户数据统一放$APPDATA卸载时用RMDir /r清理但保留配置的选项要单独给用户。最后在un.onInit里用SetOutPath $TEMP避免卸载程序自身占用安装目录。6. 收尾技巧DPI、签名与一次装完的验证清单高 DPI 是这类自定义安装器最容易翻车的地方。Duilib 的 XML 里如果全用固定像素写宽高在 150% 缩放比的机器上窗口会显得又小又糊。我的处理是三层manifest 里声明dpiAware为true让进程自己处理缩放而不是被系统位图拉伸XML 里容器宽度尽量用百分比width100%、height100%只有图标和按钮给固定值字体在InitWindow里按GetDpiForWindow算出的比例重新SetFont。第三层最容易被忘字号不跟着缩放界面上文字的视觉重量会明显不对。void CInstallWnd::InitWindow() { UINT dpi GetDpiForWindow(GetHWND()); // 需要 Windows 10 1607 int scale MulDiv(dpi, 100, 96); // 100 / 125 / 150 / 200 CTextUI* pTitle static_castCTextUI*(m_pm.FindControl(_T(text_title))); if (pTitle) { // 基础字号 12px按缩放比放大后重新设置 int fontSize MulDiv(12, scale, 100); pTitle-SetFont(fontSize); } }签名这块脚本化之后每次出包都跑一遍别留手工步骤# 签名 Setup.exe时间戳服务地址按实际环境替换 signtool sign /fd SHA256 /td SHA256 /tr 时间戳服务地址 \ /f 证书文件路径.pfx /p 证书密码 \ build/Setup.exe # 验证签名是否有效 signtool verify /pa /v build/Setup.exe/fd和/td都指定 SHA256老版本 signtool 默认 SHA1Windows 新版本会拒签。/tr是 RFC3161 时间戳比老的/t可靠证书过期后签名仍然有效。签完必须verifysign成功不代表系统认。最后是出包前的验证清单我固定跑这几项检查项方法不通过的典型表现全新安装干净虚拟机双击运行缺 VC 运行库窗口闪退覆盖升级装旧版后跑新版旧文件残留配置被重置静默安装Setup.exe /S /DD:\Test进程挂住目录不对卸载完整性装完卸载后检查目录和注册表残留 dll 和卸载项高 DPI系统缩放调到 150% 再跑文字模糊、控件错位非管理员账户标准用户双击提权失败无提示这张表我一般贴在构建脚本旁边CI 上能自动化的就跑自动化DPI 和标准用户这两项必须手动过一遍。血泪教训是早期一次发版功能全过唯独漏了「覆盖升级」这一项用户装完新版之后旧版的配置文件把新字段覆盖回去了程序启动直接读配置崩溃。从那以后我每次出包都强制把这张表从头走一遍一项不落。希望帮到你。本文还有配套的精品资源点击获取