VS2019下DuiLib从零配置WIN32空项目完整指南
DuiLib 这名字做 Windows 客户端的老兵肯定不陌生。当年从 MFC 切到 DirectUI 的朋友多少都摸过这套库或者至少听过它的大名。到现在VS2019 出来都这么多年了网上翻来覆去找资料很多还是老版本的方案配起来一堆错。我前段时间恰好用 VS2019 从头跑了一遍 DuiLib用的是最朴素也最可控的方式——WIN32 空项目不套 MFC不用向导从零配置到头跑通第一个自定义窗口。整个过程踩了若干坑也琢磨透了不少原理想着直接用一篇东西把完整步骤和避坑记录写清楚免得后来人再绕远路。先给还没上车的朋友交代一下DuiLib 是一个纯 C 的 DirectUI 界面库核心思路就是把整个窗口当成一个“画布”子控件不是靠 Win32 的窗口句柄而是靠逻辑对象绘制在同一个自绘窗口内部。所以资源占用低、绘图速度快、控件高度定制化布局用 XML 描述视觉与逻辑分离得比较彻底。这篇文章适合三种人一是接手了老项目被 DuiLib 界面折磨得想再抢救一下的开发者二是想研究 DirectUI 原理需要一个最小可运行工程做第一课的新手三是单纯想在 VS2019 环境里拥有一个干净可控的 DuiLib 空项目模板做二次开发底子的朋友。下面我直接按我的操作顺序从工程创建到跑通窗口一步步讲所有的坑都标注清楚。1. 为什么是 WIN32 空项目而不是 MFC 或向导模板很多人上来就纠结到底是用 MFC 应用还是用 Win32 项目向导还是干脆新建一个空项目再手动配我直接把结论摆出来——用空项目并且用 WIN32 空项目。原因得从 DuiLib 的属性说起。DuiLib 本身是纯 C/C 写的不依赖 MFC更不依赖 .NET。它依赖的是 Win32 API、GDI/GDI 那一套基础绘图机制。你如果用 MFC 作为宿主框架反而会引入一大堆用不到的封装层既要处理 MFC 的消息路由又要协调 DuiLib 自己的消息分发等于两套消息机制叠在一个进程里出了问题很难定位。MFC 里嵌入 DuiLib 的窗口其实也不是不行最常见的是用一个 MFC 的 CWnd 作为容器把 DuiLib 的窗口句柄挂进去。但这么做涉及到父窗口消息反射、焦点管理、子控件绘制区域裁剪等问题对初学者来说是灾难完全没有必要在第一课就加入这种复杂度。那为什么不选 VS2019 自带的“Windows 桌面应用程序”向导它确实能生成一个带 WinMain、消息循环和资源文件的完整骨架但这套骨架默认引入了很多我们不需要的东西比如默认的菜单资源、图标资源、加速键表。对于一个“空项目”目标来说这些都会成为干扰项。而且向导生成的工程默认开启了预编译头stdafx.h / pch.h后面接入 DuiLib 源码时头文件包含顺序和宏定义会产生一些冲突叠加起来很难受。倒不如直接创建一个空项目从干净的起点开始每一步加什么都是自己控制的出错了也知道去哪个环节查。还有一点很关键DuiLib 代码本身比较老某些版本对 Unicode、对高 DPI、对新版 Windows 的兼容性需要手动调整。空项目能让我们把这些问题逐个暴露并解决。MFC 向导或 Win32 向导生成的代码会隐藏一部分 Win32 初始化的细节反而不利于理解 DuiLib 的运行机制。所以我的建议就是在 VS2019 里文件→新建→项目选“空项目”然后在项目类型里手动指定 WIN32 目标或者直接选 Windows 桌面向导里的空项目选项。本质上我们要的是一个没有向导垃圾代码的 Win32 应用程序宿主。DuiLib 在文档里也明确说了建议用 Win32 Application不要用 MFC。你实际写代码时会发现DuiLib 的入口就是一个标准的 WinMain跟 MFC 的 CWinApp 完全没关系这决定了它的宿主必须是纯 Win32 胃口。2. 环境准备与第一个空工程的初始化2.1 拿到 DuiLib 源码并编译静态库DuiLib 的源码仓库在社区里流传了很多版本我建议找一份稳定的、注释齐全的版本而不是随手 GitHub 上拉的最新 commit。因为有些分支改动很大接口不稳定排错的时候会疯掉。比较稳妥的做法是找一个已经被广泛使用过的“经典版”编译出来是 32 位静态库。拿到源码后第一件事不是立刻新建工程而是先编译 DuiLib 自身生成静态库文件DuiLib.lib 或 DuiLib_d.lib。不要图省事直接把所有 .cpp 文件灌进你的应用工程里编译——虽然也能跑但会让工程文件异常臃肿而且每次编译都得拖着一堆内部文件不具备可维护性。打开 DuiLib 源码目录后里面一般有各个版本的解决方案文件。用 VS2019 打开时可能会提示升级工程。选“确定”等待升级完成。注意一个细节如果你拿到的 DuiLib 版本向导生成的工程默认是 Win32 x86 平台的升级成 VS2019 工程后Debug 和 Release 配置记得分别编译一次生成 lib 文件。我遇到过有人只编译了 Release 版结果 Debug 调试时链接错误就是这个原因。编译过程中可能遇到类型转换的问题比如从const CHAR*转LPCTSTR的警告这种是 DuiLib 的老代码在“字符集”设置上的遗留问题。你可以先不用管警告那是无关紧要的。但有一种错误会比较致命如果工程里有一堆“错误 C4996: strcpy: This function or variable may be unsafe”本质是因为没有定义_CRT_SECURE_NO_WARNINGS。解决的方式是在 DuiLib 工程属性里加预处理器定义_CRT_SECURE_NO_WARNINGS、WIN32、_WINDOWS、_DEBUGDebug 下加。这样编译出来的 lib 会安静很多。编译成功后记住 lib 文件的输出路径。Debug 目录下一个Release 目录下一个。通常名字是DuiLib_d.lib和DuiLib.lib也有版本叫DuiLibd.lib的按你实际编译出的文件名来引。2.2 创建 VS2019 空项目并配置目标平台打开 VS2019新建项目选择“空项目 C”给项目起个名字比如“DuiLibDemo”。语言选 C平台不用管后续自己配。创建完成后第一时间检查工程属性。右键项目 → 属性。第一步是配置管理器确认活动解决方案平台是 x86 还是 x64。DuiLib 老版本对 x64 的兼容性参差不齐很多接口内部用的是long来转HWND、用UINT来转指针在 x64 下可能由于指针截断引发异常。所以第一课建议老老实实用 Win32 / x86 平台。如果你以后跑 x64 版本需要仔细检查所有涉及句柄和指针强转的地方这是后话。接着设置“常规”属性页C 语言标准选“默认”或者“ISO C14”没必要追求 C17DuiLib 的年代还没这套太新反而可能引入编译差异。字符集设置成“使用 Unicode 字符集”。虽然 DuiLib 内部很多地方用的是 ANSI 字符串但宿主进程设置为 Unicode消息循环和注册窗口类时都用宽字符版本兼容性最好。等下你会在代码里发现DuiLib 内部封装了不少CDuiString它本质上不管你外部是不是 Unicode你只要保证 WinMain 那边的思路统一就行。MFC 的使用保持“不使用 MFC”。DuiLib 不依赖这套。2.3 配置头文件目录、库目录与依赖项现在到了最关键的一步配置工程路径这是新手最常出错的地方。右键项目 → 属性在顶部配置下拉框里一定要分别对 Debug 和 Release 各配置一次不要偷懒只改一个。因为 DuiLib 的 lib 文件在 Debug 和 Release 下名字不同如果你只改了 Debug切换到 Release 编译时会提示找不到DuiLib.lib。C/C → 常规 → 附加包含目录填入 DuiLib 源码的根目录路径以及源码里的DuiLib子目录。为什么填两个因为 DuiLib 源码内部头文件分了两层外层是统一暴露的接口头文件比如DuiLib.h内层是各控件类的具体头文件。你写代码的时候通常#include DuiLib.h这个文件在源码根目录下但 DuiLib.h 内部又会引用其他子目录的头文件所以两个路径都要加进去。链接器 → 常规 → 附加库目录填入之前编译好的 lib 输出目录。Debug 填 Debug 那个目录Release 填 Release 那个目录。链接器 → 输入 → 附加依赖项Debug 填DuiLib_d.libRelease 填DuiLib.lib。注意别以为只填这一个就完事了。DuiLib 编译后的静态库还依赖若干系统库比如comctl32.lib、gdi32.lib、user32.lib、kernel32.lib。在 VS2019 里user32.lib、kernel32.lib这些会通过默认依赖项自动链接但comctl32.lib有时不在默认列表里。为了保险我建议在附加依赖项里直接手写DuiLib_d.lib或 DuiLib.libcomctl32.libgdi32.libuser32.libkernel32.libcomdlg32.libmsimg32.libmsimg32.lib很多人会漏——它是AlphaBlend等 GDI 绘图函数所在的库DuiLib 的透明圆角窗口逻辑会调用到它。如果漏了链接器会报LNK2019 unresolved external symbol _AlphaBlend20之类的错。有个细节静态库如果同时链接了 Debug 版 C 运行时和 Release 版会报运行库不一致的警告。DuiLib 工程编译时如果用的“多线程调试 DLL” (/MDd)你的宿主工程也最好统一用“多线程调试 DLL” (/MDd) 而不是“多线程静态 (/MTd)”。运行时库不一致虽然很多时候也能跑起来但一旦出现内存跨模块分配释放就等着崩溃吧。建议DuiLib 怎么编的你宿主工程就怎么配。2.4 预处理定义与模块定义还需要在 C/C → 预处理器 → 预处理器定义里手动加上几项WIN32_WINDOWS_DEBUGDebug 配置下NDEBUGRelease 配置下_CRT_SECURE_NO_WARNINGS这些宏影响的是 Windows 头文件和 CRT 头文件的行为方式比如WIN32决定了一些 Windows 结构体的声明方式。DuiLib 内部很多地方用_WIN32_WINNT来做条件编译某些版本会在编译期判断 Windows 版本号来切换 API如果你什么都不定义默认值可能是 0x0500 或更低导致某些新函数没有声明。稳妥做法在预处理器定义里加上_WIN32_WINNT0x0601表示目标平台是 Windows 7 及以上。这样 DuiLib 在用到一些较新的 API 时就不会被编译器拒之门外。另外如果之后你想导出一个窗口类而不是只是内部使用DuiLib 的导出宏UILIB_API就已经通过#ifdef DUI_EXPORTS之类的条件处理好了作为使用方不需要额外处理。但有一个地方要注意如果你是直接把 DuiLib 的源文件拖进自己的工程编你需要定义DUI_EXPORTS或者撤销这个宏否则会出现符号不一致的链接问题。用我们前面说的“先编译 DuiLib 静态库、再引用”的方式就完全不存在这个问题。3. 从代码到窗口第一个干净的 DuiLib 程序配置完成之后开始写代码。这是核心环节。建议从最简单的主窗口开始不搞复杂布局就一个带背景色和一段静态文本的窗口。3.1 入口函数与消息循环打开你的源文件写一个标准的 WinMain 入口。这里的框架代码和普通 Win32 程序没有本质区别区别在于初始化顺序。#include windows.h #include DuiLib.h using namespace DuiLib; class CMainWnd : public CWindowWnd { public: CMainWnd() {} virtual ~CMainWnd() {} LPCTSTR GetWindowClassName() const override { return _T(DuiLibDemoMain); } void OnFinalMessage(HWND hWnd) override { CWindowWnd::OnFinalMessage(hWnd); ::PostQuitMessage(0); } void Notify(TNotifyUI msg) override { // 处理控件事件先留空 CWindowWnd::Notify(msg); } void OnCreate() override { // 在这里加载布局 XML CWidgetContainer* pLayout new CWidgetContainer(); if (pLayout) { pLayout-Create(this-GetHWND(), _T(default.xml)); } } };首先要注意的是WinMain 之前你需要调用::CoInitialize(NULL)吗DuiLib 内部某些皮肤加载、字体加载可能用到 COM 的某些接口但实际上 DuiLib 的经典版本对 COM 的依赖不大除非你用到了某些特性。保险起见我习惯在 WinMain 开头调用一次::OleInitialize(NULL)或至少::CoInitialize(NULL)。原因不是 DuiLib 必须用而是为了防止某些插件或后期扩展用到 COM 而崩溃。没有坏处。然后初始化公共控件样式这在老的 Win32 代码里几乎是约定俗称的动作int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPTSTR lpCmdLine, int nCmdShow) { INITCOMMONCONTROLSEX icc; icc.dwSize sizeof(icc); icc.dwICC ICC_WIN95_CLASSES | ICC_STANDARD_CLASSES; ::InitCommonControlsEx(icc); // 设置 DuiLib 的全局单例 CPaintManagerUI::SetInstance(hInstance); // 创建主窗口 CMainWnd* pWnd new CMainWnd(); if (pWnd NULL) return 1; pWnd-Create(NULL, _T(DuiLib 空项目示例), UI_WNDSTYLE_FRAME, WS_EX_STATICEDGE); if (pWnd-GetHWND() NULL) return 2; // 显示窗口 pWnd-ShowWindow(true); // 消息循环 CMessageLoop theLoop; theLoop.AddMessageFilter(pWnd-GetPaintManager()); theLoop.Run(); return 0; }这里重点解释三个容易被忽略的动作第一CPaintManagerUI::SetInstance(hInstance)。这行代码很多人不写结果就是 DuiLib 在加载资源时找不到实例句柄导致 XML 里的相对路径找不到。可以理解为 DuiLib 需要知道“我是谁”它从哪里加载皮肤和布局。这个调用本质上把模块句柄传给 DuiLib 内部。第二pWnd-Create里的样式参数。UI_WNDSTYLE_FRAME是一个宏DuiLib 定义了一个综合的窗口样式有标题栏、有系统菜单、可最小化最大化差不多就是标准 Frame 窗口该有的样子。不要自己随便组合WS_POPUP、WS_OVERLAPPEDWINDOW除非你真的知道自己在做什么。特别是做无边框窗口时用WS_POPUP组合那需要配合 DuiLib 的阴影绘制和点击穿透逻辑不是第一课该处理的。第三CMessageLoop。很多人会问DuiLib 不是自己在内部处理消息吗为什么还需要一个消息循环类这里要理解 DuiLib 的架构DuiLib 不是自己抢消息它是挂在 Win32 消息循环之下通过AddMessageFilter把自己关心的消息“过滤”出来。本质上窗口过程先收到消息然后 DuiLib 的消息过滤器把这些消息翻译为控件逻辑事件。这种设计非常精巧你既保留了 Win32 原生消息处理能力又获得了 DirectUI 的控件事件分发能力。3.2 DuiLib 窗口类的三层结构在写CMainWnd之前你得先理解 DuiLib 的窗口类体系我发现很多初学者把三个概念混在一起后面折腾半天都搞不清回调到底在哪层。CWindowWnd相当于 Win32 窗口类的薄封装它封装了注册窗口类、创建窗口、处理 WM_xxx 系统消息的部分。你的主窗口必须继承它重写GetWindowClassName()来指定窗口类名。CPaintManagerUI这是 DuiLib 的核心中的核心负责所有控件的布局、绘制、消息分发。一个主窗口对应一个 PaintManager它维护着控件树根节点是一个虚拟的“容器”。CContainerUI这是控件树里的一个具体容器对象同时承载布局逻辑。当你加载 XML 布局时实际上是在 PaintManager 的根节点上挂载了一个容器。这三层关系的通俗理解如果你把 DuiLib 程序类比成网页CWindowWnd是浏览器窗口CPaintManagerUI是 DOM 解析器加渲染引擎CContainerUI是 body 标签。三者相辅相成缺一个都不行。有些网上代码尤其是很老的版本直接把 PaintManager 当作主窗口成员变量然后在OnCreate里m_pm.Init(m_hWnd)。这是以前通行的写法。但现在我建议把 PaintManager 挂接在窗口类的内部通过 CWindowWnd 提供的方法来访问。原因不多说等你体会多窗口、皮肤切换时就会明白把 PaintManager 生命周期绑在窗口类上最稳妥。3.3 设计 XML 布局与资源管理主窗口创建流程的核心是OnCreate。里面要加载一个 XML 布局文件这个文件描述了窗口里面有哪些控件、怎么排布、看起来什么风格。在我的示例中我放了一个default.xml。文件内容很简单但足够展示 DuiLib 最核心的“布局”到“渲染”链路?xml version1.0 encodingutf-8? Window size400,300 caption0,0,0,36 VerticalLayout Label textHello DuiLib! font1 / Button namebtn_ok textClose width80 height30 aligncenter / /VerticalLayout /Window简单解释一下Window标签表示整个窗体的根节点size 指定初始窗体大小caption 是指可拖动标题栏的矩形区域这是 DuiLib 实现“自绘标题栏”的方式。如果 caption 的四个值是 0,0,0,0就意味着窗口没有可拖拽区域整个窗口都不能拖动那是很痛苦的。VerticalLayout垂直排列子控件。它是 DuiLib 最常用的布局容器。Label简单文本控件。Button按钮控件。接下来是最关键的工程文件组织。DuiLib 默认情况下的 XML 文件和图片资源的加载路径取决于你传给pLayout-Create或 PaintManager 的路径。我建议把 XML 文件放在项目目录下的resources文件夹中然后代码里用相对路径加载。但是VS2019 不会自动把你项目里的资源文件拷贝到运行目录这和当年 VC6 时代一样。你需要手动在项目里加一个“生成后事件”用命令行把资源文件夹复制到输出目录xcopy /Y /E $(ProjectDir)resources $(OutDir)resources或者简单点把 XML 放在源码目录Debug 编译后直接手工复制到 exe 同目录。这步漏了程序跑起来就是一片白屏然后你在那里怀疑人生。为什么是白屏因为 DuiLib 加载不到 XML创建根控件失败又没有报什么显而易见的错误窗口就空空的。我自己的经验是写一个资源路径的全局函数统一处理CDuiString GetResourcePath(LPCTSTR lpszFileName) { TCHAR szBuffer[MAX_PATH] { 0 }; ::GetModuleFileName(NULL, szBuffer, MAX_PATH); // 找到最后一个 \截断然后拼接资源目录 // 返回 e.g. C:\exe_dir\resources\default.xml }虽然简单但它是团队协作时避免路径问题的关键。DuiLib 内部对路径的处理有时很“耿直”你给相对路径它就相对于当前工作目录去读。在 VS 调试环境下当前工作目录一般是工程的.vcxproj目录而不是$(OutDir)。所以偏向使用绝对路径来保证一致性。3.4 编译开关与字符集匹配的隐藏坑配置和代码都齐全后点击编译可能出现几种经典的报错。其一错误 C2065: CWindowWnd : 未声明的标识符。原因通常是没包含DuiLib.h或者包含了但没加using namespace DuiLib。注意 DuiLib 的所有类都在DuiLib命名空间下老版本某些代码在命名空间外直接用类名是因为它们可能定义了宏。其二无法打开包括文件: DuiLib.h: No such file or directory。这个就是前面讲的附加包含目录没配对检查源码根目录路径是否正确以及你填的路径是否包含在C/C → 常规 → 附加包含目录中不要在项目属性里瞎配到“包含目录”那个已废弃的 VC 目录区域去VS2019 中可行的做法是用附加包含目录。其三错误 LNK2019: unresolved external symbol void __stdcall DuiLib::CPaintManagerUI::SetInstance(struct HINSTANCE__ *)。如果你编译的是 Debug 版应用但链接的是 Release 版 lib或者反过来也会出现这种错。检查 DuiLib 工程和宿主工程的配置是否一致Debug 对 DebugRelease 对 Releasex86 对 x86。其四一个容易被忽略的坑_WIN32_IE宏。有些 DuiLib 版本用到了较新的公共控件接口如果这个宏的值太小某些函数会被条件编译屏蔽。可以统一在宿主工程预处理器里加_WIN32_IE0x0900。还有更隐蔽的一种情况window.h和 DuiLib 的某些类名冲突。因为 DuiLib 内嵌到DuiLib命名空间里但它的某些宏比如CDUI_USE_STL会影响内部实现。如果你引用了 STL 的std::string而 DuiLib 自己实现了一套CDuiString两者之间转化时容易出幺蛾子。我建议在示例阶段强制使用 DuiLib 的CDuiString别急着混用 STL 字符串。后续熟悉了再做封装转换层。3.5 事件回调的最小闭环窗口能显示出来只是第一步真正的交互闭环要处理按钮事件。DuiLib 的事件分发机制是Notify回调函数你需要在主窗口类里重写Notify通过msg.sType判断事件类型通过msg.pSender-GetName()判断是哪个控件。以我们上面 XML 里的Close按钮为例void Notify(TNotifyUI msg) override { if (_tcscmp(msg.sType, DUI_MSGTYPE_CLICK) 0) { if (_tcscmp(msg.pSender-GetName(), _T(btn_ok)) 0) { Close(); } } CWindowWnd::Notify(msg); }DUI_MSGTYPE_CLICK是字符串click。为什么 DuiLib 不用数字枚举而是字符串因为这样易于扩展外部脚本或 XML 协议可以动态约定新事件。但也带来了轻微的性能损失老手通常会在初始化阶段把常用名称转成哈希值再比较。在简单示例里直接用_tcscmp就好没必要绕弯。还有一个更关键的事情控件树里的name属性是控件实例的唯一标识。在代码里如果要在窗口创建完成后去获取某个控件指针用CTextUI* pLabel static_castCTextUI*(m_pm.FindControl(_T(label_title)));但这个m_pm是从哪里拿如果你按照我所描述的窗口结构来写在窗口类里保存一个CPaintManagerUI m_pm成员那么直接在OnCreate里m_pm.Init(GetHWND())之后再m_pm.FindControl就可以了。注意FindControl要在 XML 加载成功之后再调用否则返回空指针然后你的静态转换崩掉。4. 调试运行时最容易踩的五个坑实录很多人工程配好了、代码编译通过结果一运行窗口出不来或者行为异常这类问题最耗时。我结合实战经验整理一份问题速查表帮助后来人快速定位。现象可能的原因解法窗口出现后立即退出OnFinalMessage里调用了PostQuitMessage(0)但窗口实际创建失败退出流程提前走了检查pWnd-Create返回值如果返回 NULL看是否 XML 路径不对或窗口类名冲突窗口白屏、没有控件XML 资源没有被复制到运行目录检查 exe 同级有没有resources文件夹确认生成后事件是否正确窗口无法拖动caption0,0,0,0或者 XML 格式解析错误确认 Window 标签 caption 的生效范围设置caption0,0,0,36表示顶部 36 像素可拖拽按钮点击没反应控件name与代码中GetName拼写不一致或者Notify中忘了调用基类在 XML 中检查name值区分大小写在Notify中添加基类调用编译链接时不断报未解决符号运行时库不一致把 DuiLib 工程和宿主工程的“运行库”统一为/MD或/MDd上面的前四类我都踩过。尤其是“按钮点击没反应”那次我排查了很久最后发现是 XML 里写了Name大写而代码里用的是name小写DuiLib 的属性名大小写敏感性在这个场景下真的很坑。这是老库通病所以契约如果定了就要全局一致。另外一个高发问题运行时错误 Debug Assertion Failed!出现在CMainWnd析构的地方。这多半是因为你有多个CPaintManagerUI实例或者在析构顺序上出错了。DuiLib 要求一个窗口只有一个 PaintManager当窗口销毁时PaintManager 必须先于控件树析构。如果你的主窗口类里定义的是CPaintManagerUI m_pm;这一切是自动的。但如果你在OnCreate里动态 new 了一个 PaintManager且没有在窗口销毁时 delete 干净就会出现对象析构顺序混乱。我见过一个同事把CPaintManagerUI封装成共享指针后在窗口关闭时程序直接崩溃本质上就是生命周期管理混乱。在那之后我坚持所有窗口类内部用值类型持有 PaintManager不用指针。还有贴吧时代老人们常用的一个操作窗口背景透明。DuiLib 通过设置透明度实现异形窗口但透明窗口时有一个经典故障就是窗口区域点击穿透或控件全变成“空洞”。这个涉及到WS_EX_LAYERED和 DuiLib 内部的UpdateLayeredWindow兼容性在 Win10/11 上表现不一致。对于空项目示例我强烈建议不要一上来就搞透明、圆角、阴影先把标准矩形窗口跑通再做这些噱头。不然你会同时面对“为什么圆角不圆”“为什么边缘锯齿”“为什么单击事件错位”三重打击。再补充一个内存优化的点。DuiLib 的控件树在创建窗口时用到了大量new但它有内部的对象池机制理论上析构窗口时会统一回收。可是如果你自己的代码里new了一个控件然后手动delete而 DuiLib 的后续回收流程还会再删除一次那就 double free。DuiLib 提供的所有控件都是通过new创建后交给容器管理代码不要自己回收只用CControlUI*指针对其做操作即可。5. 控件的消息分发与自定义扩展的起点第一课跑通之后大部分人不会止步于一个 HelloWorld 窗口。接下来必然要面对的是DuiLib 的控件机制是怎么支撑复杂界面的。DuiLib 的控件事件分发底层还是 Windows 消息。它的消息循环通过CMessageLoop在PreTranslateMessage和窗口过程的HandleMessage之间做拦截。核心链路是Win32 消息发到窗口过程。DuiLib 的WindowProc将消息转给CPaintManagerUI::MessageHandler。PaintManager 判断消息是否属于 DuiLib 控件区域比如鼠标点击是否落在某个控件的绘制区域内。如果是就先给控件分发事件Notify回调触发你写的处理逻辑。如果不是返回FALSE让 Win32 默认处理。所以“DirectUI”并不是真的“Direct”到绕过 Windows 消息而是“Direct”在绘制层面。控件没有真实句柄窗口过程收到的鼠标消息DuiLib 根据坐标反查出落在哪个控件上然后把事件翻译成控件级消息。理解了这条链路遇到“点击控件没反应”、“拖动控件时闪烁”、“键盘焦点切换异常”这些经典问题就有排查方向了。扩展一个自定义控件时你的工作无非是两件一是重写DoPaint/Paint实现绘制二是重写Event处理自定义输入。这两件事DuiLib 都把它们控制在CControlUI的子类中扩展性很强。当年很多皮肤库和控件库比如一些按钮美化、列表增强都是在这个基类上做二次开发的。我建议你把第一个 DuiLib 空项目跑通后下一步不是急着做复杂业务而是尝试给按钮换一张背景图片、给窗口增加自定义字体、写一个简单的 9 宫格背景。这几个操作覆盖了 DuiLib 最核心的技术点XML 属性解析、资源加载、自绘逻辑。做完这些你就算入门了。这里顺带讲一个常用但文档少提的配置字体。DuiLib 使用font属性对应一个 Font 对象全局字体表在 XML 里用Font.../Font声明。如果没有额外声明默认字体是System。在 Win10 上微软雅黑通常是默认款但 DuiLib 内部字体大小和高度的换算有偏差会出现实际字号偏小或偏大的现象。解决方式是自定义字体对象明确设置字体名、大小、加粗等。比如Font namemyfont defaulttrue size14 boldfalse / Window size400,300 caption0,0,0,36 fontmyfont但这套Font的写法在不同的 DuiLib 版本里有细微差异有的版本用shared1来标识共享资源有的版本没有。这也是为什么我一开始建议你固定一个版本的 DuiLib别频繁换分支。这个领域的“版本漂移”问题比标准库严重得多。6. 从空项目到正式工程组织结构与编码规范建议当你的空项目被验证可以跑通后我强烈建议你不要急着往上堆业务代码而是趁机把项目结构调整好。DuiLib 工程一旦写大了代码组织混乱是必然的。一个干净的起点是后期少流眼泪的保障。我建议的目录结构如下DuiLibDemo/ |-- src/ | |-- MainWindow.h/.cpp | |-- MainWindow.xml | |-- resource.h | |-- winmain.cpp |-- resources/ | |-- images/ | |-- skin/ |-- lib/ | |-- Debug/ | | -- DuiLib_d.lib | |-- Release/ | | -- DuiLib.lib |-- third_party/ | -- duilib/src/mainwindow.xml和resources分开是因为 XML 文件跟代码靠得太近容易在合并时产生大量无谓冲突而且不同窗口的布局会越来越多统一放到resources\layout下更合适。代码规范上我只提一点经验DuiLib 的CDuiString跟std::string/CString的转换要封装成一个小工具集避免在业务代码里到处穿插类型转换。原因很简单DuiLib 内部的字符串是它自己实现的在宽窄字符之间切换时如果直接强转内存布局会踩雷。最典型的就是msg.sType是CDuiString你还得转换成std::string再去做 map 查找。一个隐蔽的坑是CDuiString::GetData()返回的指针当你对它做std::string构造时如果字符串内部包含\0构造出来会截断。虽然界面操作很少碰到这种恶心数据但防御习惯要养成。合作开发时还要约定尽量少用全局变量来控制 DuiLib 界面状态。DuiLib 的设计模式本身贴近“界面对象树”全局变量一旦多了不同窗口之间共享界面状态很容易出现刷新顺序错乱。比如一个后台数据变化你需要通知列表刷新如果直接改全局变量然后Invalidate在多线程环境下会偶发崩溃。标准做法是定义一套简单的消息注册机制或者用 DuiLib 的IMessageFilter接口做统一消息转发。窗口之间的跳转也是空项目之后第一个要做的功能。DuiLib 不像 MFC 有CDialog这样的现成机制它实现多窗口的方式是创建多个CWindowWnd子类每个子类对应一个窗口 XML。窗口切换时你要注意OnFinalMessage的处理不能马上PostQuitMessage否则第一个窗口关闭后整个消息循环就退了第二个窗口还没弹出来。我当初在这里卡了几小时。正确的做法是只有最后一个窗口关闭时才应该结束消息循环。可以用一个窗口计数来管理或者设置一个“主窗口”概念只有它关闭时才通知退出。关于多窗口再分享一个隐性知识DuiLib 的每个窗口其实就是独立的 Win32 顶层窗口顶级句柄它们之间没有父子关系。如果你希望某个窗口是模态的DuiLib 没有直接提供模态机制你需要在创建第二个窗口时禁用第一个窗口的鼠标键盘输入或者用ShowWindow(false)隐藏它。这跟传统 Win32 模态对话框的EnableWindow逻辑不完全相同。想要 DuiLib 实现模态你可以自己封装一个循环在创建窗口后进入本窗口的局部消息循环直到该窗口关闭才返回。这种处理官方没有给现成的我们以前在项目里直接暴力启用两个CMessageLoop一个跑模态窗口一个跑主窗口配合SetWindowLong的禁用切换勉强能跑但实现复杂度确实高。新手建议第一版全用非模态顶层窗口不要追求模态效果。再一个经验DuiLib 处理高 DPI 时需要特别小心。VS2019 默认工程的 DPI 感知是“系统 DPI 感知”或“无感知”。如果你在高分屏比如 150% 缩放的显示器上运行 DuiLib 程序界面很可能出现模糊或控件错位。这是因为 DuiLib 内部绘制完全基于 GDI/GDI它以逻辑像素为准。解决方式有两种一是调用SetProcessDPIAware()让程序自己感知 DPI但这样你在非整数缩放比如 125%下字体依然会糊因为 GDI 在非整数缩放时的文本渲染本来就有毛边二是完全关闭缩放感知让 Windows 对程序做位图拉伸代价是整个界面变糊。这不是库本身的错所有 GDI 自绘库都这样。我建议空项目阶段先不管等界面复杂了再用 DuiLib 的SetDPI相关接口手动适配或者直接换成更现代化的框架但如果能换框架你也不会来看 DuiLib 了对吧。说回到这篇东西的起点。折腾 DuiLib 这种“老而弥坚”的库我认为最有价值的不是把界面做得多么花哨而是通过一个极简的空项目理解了 DirectUI 架构中“自绘窗口”、“控件树”、“布局容器”、“消息分发”几大概念。这套东西跟现在很多跨平台 UI 框架的底层思路是相通的。你用 DuiLib 跑通窗口的那一刻回头再看其他框架的渲染管线会有一种豁然开朗的既视感。如果接下来你想继续深入我建议按这个路线走研究CPaintManagerUI的表单消息处理前置和后置逻辑把PreMessage和MessageHandler的源码读透。尝试写一个带圆角、阴影、自定义标题栏按钮的窗口彻底搞清楚 DuiLib 的绘制流程。自己实现一个新的控件比如带进度动画的按钮这样你会对CControlUI的接口有更深的肌肉记忆。最后如果你要上生产环境务必做多分辨率适配测试和内存泄漏检查。DuiLib 本身的内存管理不算完美长时间运行可能会有轻微增长在空项目基础上尽早引入内存检测工具是防止线上事故的重要一步。最后再补一句个人体会VS2019 配 DuiLib最重要的不是记步骤而是养成“遇错查配置”的习惯。这个库年代久远编译器版本、链接器设置、字符集、运行库每一个环节都可能因为一个不匹配的选项而报出让人摸不着头脑的错误。但只要把核心原理掌握住知道“一个窗口对应一个 PaintManager一组控件挂在一棵树上资源用相对路径或绝对路径加载”大部分问题都能在半小时内定位。祝你们都能顺利跑出第一个 DuiLib 窗口。