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、_DEBUG(Debug 下加)。这样编译出来的 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 C++14”,没必要追求 C++17,DuiLib 的年代还没这套,太新反而可能引入编译差异。
- 字符集:设置成“使用 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.lib,Release 填DuiLib.lib。注意,别以为只填这一个就完事了。DuiLib 编译后的静态库还依赖若干系统库,比如comctl32.lib、gdi32.lib、user32.lib、kernel32.lib。在 VS2019 里,user32.lib、kernel32.lib这些会通过默认依赖项自动链接,但comctl32.lib有时不在默认列表里。为了保险,我建议在附加依赖项里直接手写:- DuiLib_d.lib(或 DuiLib.lib)
- comctl32.lib
- gdi32.lib
- user32.lib
- kernel32.lib
- comdlg32.lib
- msimg32.lib
msimg32.lib很多人会漏——它是AlphaBlend等 GDI 绘图函数所在的库,DuiLib 的透明圆角窗口逻辑会调用到它。如果漏了,链接器会报LNK2019 unresolved external symbol _AlphaBlend@20之类的错。
有个细节:静态库如果同时链接了 Debug 版 C 运行时和 Release 版,会报运行库不一致的警告。DuiLib 工程编译时如果用的“多线程调试 DLL” (/MDd),你的宿主工程也最好统一用“多线程调试 DLL” (/MDd) 而不是“多线程静态 (/MTd)”。运行时库不一致虽然很多时候也能跑起来,但一旦出现内存跨模块分配释放就等着崩溃吧。建议:DuiLib 怎么编的,你宿主工程就怎么配。
2.4 预处理定义与模块定义
还需要在 C/C++ → 预处理器 → 预处理器定义里,手动加上几项:
WIN32_WINDOWS_DEBUG(Debug 配置下)NDEBUG(Release 配置下)_CRT_SECURE_NO_WARNINGS
这些宏影响的是 Windows 头文件和 CRT 头文件的行为方式,比如WIN32决定了一些 Windows 结构体的声明方式。DuiLib 内部很多地方用_WIN32_WINNT来做条件编译,某些版本会在编译期判断 Windows 版本号来切换 API,如果你什么都不定义,默认值可能是 0x0500 或更低,导致某些新函数没有声明。稳妥做法:在预处理器定义里加上_WIN32_WINNT=0x0601,表示目标平台是 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 version="1.0" encoding="utf-8"?> <Window size="400,300" caption="0,0,0,36"> <VerticalLayout> <Label text="Hello DuiLib!" font="1" /> <Button name="btn_ok" text="Close" width="80" height="30" align="center" /> </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 对 Debug,Release 对 Release,x86 对 x86。
其四,一个容易被忽略的坑:_WIN32_IE宏。有些 DuiLib 版本用到了较新的公共控件接口,如果这个宏的值太小,某些函数会被条件编译屏蔽。可以统一在宿主工程预处理器里加_WIN32_IE=0x0900。
还有更隐蔽的一种情况,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_cast<CTextUI*>(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文件夹,确认生成后事件是否正确 |
| 窗口无法拖动 | caption="0,0,0,0"或者 XML 格式解析错误 | 确认 Window 标签 caption 的生效范围,设置caption="0,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 name="myfont" default="true" size="14" bold="false" /> <Window size="400,300" caption="0,0,0,36" font="myfont">但这套Font的写法在不同的 DuiLib 版本里有细微差异,有的版本用shared="1"来标识共享资源,有的版本没有。这也是为什么我一开始建议你固定一个版本的 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 窗口。