简介:DirectUI for VC6.0 是一套开源的图形界面库,主要服务于使用 VC6.0 及后续版本的桌面软件开发者,解决传统窗口控件界面风格单一、绘制效率不高的问题,面向有一定 C++ 基础的中高级开发者,帮助快速构建美观且交互友好的用户界面。压缩包共包含453个文件,大小约8.35MB;内容涵盖大量源代码、头文件、库文件、位图与PNG图片、XML配置以及编译好的DLL等,目录划分明确,便于按需查阅。库内组件化设计、事件驱动、GDI+绘图、布局管理、皮肤切换和性能优化等特性一应俱全,开发者可以直接链接到旧版工程中快速搭建界面,也可以通过阅读源码理解设计思路并自行扩展控件。目前已有345人浏览学习,适合有一定 C++ 基础、希望提升桌面程序界面品质的开发者参考与使用。
1. 拿到这个 DirectUI for VC6.0(开源).rar,先别急着删文件
我猜你点进这篇笔记,十有八九是手头有个老工程:要么是某工控上位机,要么是某图像处理 Demo 的配套界面,项目还锁在 VC6.0 里,窗户纸还是那套 MFC 对话框加一排原生控件。客户突然提了个换肤需求,你改自绘改到怀疑人生,才想起来搜 DirectUI for VC6.0(开源) 这种东西。别急着否定,这个包解决的核心问题就一句话:用单个宿主窗口承载全部控件,把几十个 HWND 换成一棵 C++ 对象树,绘制、命中、消息全由自己说了算。代价是要自己补一套布局和分发逻辑,换来的是换肤自由、控件样式自由,以及把界面卡顿按在地上摩擦的空间。它适合两类人:一类是想在老 VC6.0 工程里快速做出现代化界面的维护者,另一类是故意想用最老的编译器学 DirectUI 原理的进阶学习者。这篇笔记就按“原理 → 编译落地 → 布局扩展 → 踩坑 → 性能优化”的顺序,把它讲成一份可复现的作业。
2. 认识 DirectUI 的两条主线:控件对象树与单窗口消息分发
2.1 传统控件树 vs DirectUI 对象树:为什么界面会卡顿
传统 Win32/MFC 界面里,每个按钮、编辑框、静态文本都是一个独立的顶层窗口,都有自己的 HWND 和窗口过程。窗口一多,系统要维护的消息队列、Z序、焦点链就跟着膨胀;更致命的是样式定制路径极其别扭——你想让某个按钮背景换成渐变色,得处理 WM_CTLCOLORBTN、自绘或者 Owner Draw,风格不统一就得逐个控件做子类化。换肤需求一来,几十个控件挨个改,改到后面自己都记不住哪些改过。
DirectUI 的思路是彻底反过来:整个界面只有一个真正意义上的窗口,其余“控件”是普通 C++ 对象,存在一棵树里。根节点是宿主窗口,子节点是容器、按钮、标签、编辑框。系统消息只发给宿主窗口,宿主根据鼠标坐标做命中测试,再把消息转交给命中的控件对象。控件对象之间通过父子关系、兄弟顺序管理 Z序,绘制时从根节点开始深度遍历。
这种做法带来的直接好处有两个。第一,控件数量不直接影响系统窗口资源占用,窗口过程调用次数大幅下降;第二,样式不再绑定某个系统控件规范,你可以给按钮定义普通、悬停、按下、禁用四套颜色,绘制函数里自己选一套。对一个老 VC6.0 工程来说,不用升级编译器,不用换 UI 框架,就能把界面外观和交互模式重写一遍,这是它最大的现实价值。
2.2 宿主窗口承担全部消息:一个可运行的消息分发骨架
DirectUI 宿主窗口的窗口过程通常写得极其统一。核心逻辑就三件事:保存根对象指针、把客户区坐标换算成逻辑点、调用根节点命中测试后分发。我习惯把指针存在 GWL_USERDATA,窗口创建时用 SetWindowLong 塞进去,之后每次消息进来先取出来。
LRESULT CALLBACK DuiWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { CDuiRoot* root = (CDuiRoot*)GetWindowLong(hWnd, GWL_USERDATA); if (root == NULL) return DefWindowProc(hWnd, msg, wParam, lParam); switch (msg) { case WM_PAINT: { PAINTSTRUCT ps; BeginPaint(hWnd, &ps); // 双缓冲:内存DC画完后一次性拷回屏幕,避免闪烁 HDC memDC = CreateCompatibleDC(ps.hdc); RECT rc; GetClientRect(hWnd, &rc); HBITMAP bmp = CreateCompatibleBitmap(ps.hdc, rc.right - rc.left, rc.bottom - rc.top); HBITMAP oldBmp = (HBITMAP)SelectObject(memDC, bmp); root->Draw(memDC); BitBlt(ps.hdc, 0, 0, rc.right, rc.bottom, memDC, 0, 0, SRCCOPY); SelectObject(memDC, oldBmp); DeleteObject(bmp); DeleteDC(memDC); EndPaint(hWnd, &ps); return 0; } case WM_LBUTTONDOWN: { POINT pt; pt.x = (short)LOWORD(lParam); pt.y = (short)HIWORD(lParam); CDuiControl* ctl = root->HitTest(pt); if (ctl) ctl->OnLButtonDown(pt); return 0; } case WM_SIZE: { RECT rc; GetClientRect(hWnd, &rc); root->SetPos(&rc); // 根容器铺满客户区 InvalidateRect(hWnd, NULL, FALSE); return 0; } default: return DefWindowProc(hWnd, msg, wParam, lParam); } }这里几个参数值得说清楚。HitTest 用的坐标必须是客户区坐标,WM_LBUTTONDOWN 的 lParam 正好是客户区坐标,但如果你在 WM_MOUSEMOVE 里用 GetCursorPos 拿坐标,就得多做一次 ScreenToClient,否则命中永远偏一截。InvalidateRect 的第三个参数 FALSE 是指不清除背景,DirectUI 自己会把整个客户区画满,没必要让系统先刷一层背景色。双缓冲里 CreateCompatibleBitmap 的尺寸必须匹配客户区,否则 BitBlt 会丢弃一部分内容。
2.3 控件树的构建与绘制调度:从根到叶的一次遍历
控件对象树本身倒不复杂,关键是几个约定。每个控件持有自己的矩形区域 m_rc、可见标志、启用状态、子控件列表;容器类多了一个子列表,非容器类则没有。命中测试时从最上层兄弟开始倒序查找,因为视觉上后绘制的控件在上面,鼠标也优先点中它。
CDuiControl* CDuiContainer::HitTest(POINT pt) { // 子控件倒序遍历,保证最上层控件优先响应 for (int i = (int)m_children.size() - 1; i >= 0; i--) { CDuiControl* pChild = m_children[i]; if (!pChild->IsVisible()) continue; if (PtInRect(&pChild->m_rc, pt)) { if (pChild->IsContainer()) { CDuiControl* pInner = ((CDuiContainer*)pChild)->HitTest(pt); if (pInner) return pInner; } return pChild; } } return NULL; }绘制顺序和命中顺序相反:从根开始,先画父容器背景,再依次画每个子控件,子控件递归自己的子节点。这样后加入列表的子控件就画在兄弟上面,Z序天然等同于添加次序。绘制时必须做两件事:一是保存并设置裁剪区,避免子控件画到兄弟区域外面;二是控件内部要画满自己的矩形背景,否则透明区域会漏出下层内容。
void CDuiRoot::Draw(HDC hDC) { // 第一步:整根清底 HBRUSH bg = CreateSolidBrush(m_bgColor); FillRect(hDC, &m_rc, bg); DeleteObject(bg); DrawChild(hDC, this); } void CDuiRoot::DrawChild(HDC hDC, CDuiControl* pParent) { for (int i = 0; i < (int)pParent->GetChildCount(); i++) { CDuiControl* pChild = pParent->GetChildAt(i); if (!pChild->IsVisible()) continue; int saved = SaveDC(hDC); IntersectClipRect(hDC, pChild->m_rc.left, pChild->m_rc.top, pChild->m_rc.right, pChild->m_rc.bottom); pChild->Draw(hDC); if (pChild->IsContainer()) DrawChild(hDC, pChild); RestoreDC(hDC, saved); } }SaveDC / RestoreDC 必须成对,不然裁剪区会越积越小,最底层控件画不全。VC6.0 的 GDI 不支持硬件加速,这种全量遍历在高分辨率窗口下确实有压力,所以后面我会专门讲脏矩形和图层缓存。先把这套逻辑跑通,再谈优化。
3. 把开源包接进 VC6.0 工程:解压、集成到弹出一个淡蓝窗口
3.1 先看包内文件与项目组织:哪些文件必须进工程
解压这个 rar 后,典型的目录结构是 include、src、demo 三块,外加一个工作区文件和一个说明文档。具体文件名会因包而异,但核心内容基本跑不掉这几类:控件基类、容器类、布局类、窗口宿主类,以及一套最简单的控件集(按钮、标签、编辑框)。demo 工程一般是用来验证库能跑的,不一定要直接参考,它往往把按钮和进度条的用法摆得很完整,建议先从 demo 里抄初始化顺序。
集成方式我推荐“源码直接进工程”,而不是先编成静态库。原因有三个:一是 VC6.0 的调试器对符号路径比较挑剔,源码直接编进去断点好踩;二是老库多少会依赖一些编译选项,比如 _MBCS,直接编库容易在链接阶段对不上;三是你大概率要改库的绘制代码,进工程后改一处立刻生效。
需要手动加入工程的源文件大致包括:
| 文件分类 | 建议 | 说明 |
|---|---|---|
| 控件基类 | 必须加 | 定义自绘虚函数、命中测试、状态 |
| 容器/布局类 | 必须加 | 管理子控件排列与递归绘制 |
| 窗口宿主类 | 必须加 | 封装窗口注册、消息分发 |
| XML 解析器(若有) | 视情况 | 只用代码布局可以先不编 |
| demo 里的对话框代码 | 不加 | 仅作参考 |
加入方式是在 Project 菜单里选择 Add to Project,把 src 目录下的 .cpp 全选进去。如果编译时报一堆重复定义,多半是某些公共源文件被同时加进了两个项目。
3.2 最小 MFC 宿主:对话框只留一个空白客户区
我习惯的做法是:Dialog 模板里不放任何原生控件,只留一个空客户区。对话框本身保留标题栏和边框,DirectUI 根容器直接铺满客户区。这样做的好处是窗口拖动、最小化、任务栏行为全部由系统接管,不用自己实现非客户区逻辑。在 OnInitDialog 里创建根容器,设置背景色,把客户区矩形传进去。
BOOL CDuiDlg::OnInitDialog() { CDialog::OnInitDialog(); m_pRoot = new CDuiRoot; // 根容器,内部持有子控件列表 CRect rc; GetClientRect(&rc); m_pRoot->SetPos(&rc); // 铺满整个客户区 m_pRoot->SetBkColor(RGB(240, 248, 255)); // 淡蓝背景 // 加一个测试按钮 m_pBtn = new CDuiButton; m_pBtn->SetText("点我测试"); m_pBtn->SetRect(20, 20, 140, 60); m_pRoot->AddChild(m_pBtn); return TRUE; }SetPos 传入的是根容器将要覆盖的矩形,它内部会把这个矩形再分配给子控件做布局参考。SetRect 的四个参数分别是左、上、右、下,注意这是控件本身的边界,不是宽高。按钮加到根容器后,绘制和命中测试都会自动走根节点的遍历逻辑。此时如果你直接运行,窗口还是传统画法,鼠标也点不动按钮,因为对话框的默认消息循环还没把消息转给 DirectUI。
3.3 把鼠标消息喂给 DirectUI:PreTranslateMessage 的关键改动
MFC 对话框有一个天然的分发入口 PreTranslateMessage,所有消息在进入窗口过程之前都会经过它。利用这一点,可以把鼠标消息截下来转交给控件树。这是我落地的核心一步,也是最容易写错的一步。
BOOL CDuiDlg::PreTranslateMessage(MSG* pMsg) { if (pMsg->hwnd == m_hWnd) { UINT nMsg = pMsg->message; if (nMsg >= WM_MOUSEFIRST && nMsg <= WM_MOUSELAST) { POINT pt = pMsg->pt; ScreenToClient(&pt); CDuiControl* pCtl = m_pRoot->HitTest(pt); if (pCtl && !pCtl->IsTransparent()) { pCtl->OnMouseMessage(nMsg, pt, pMsg->wParam); return TRUE; // 消息已被 DirectUI 消费 } } } return CDialog::PreTranslateMessage(pMsg); }这里出现了一个常见误区:pMsg->pt 是屏幕坐标,必须转成客户区坐标再给 HitTest,否则点击偏差正好是窗口在屏幕上的偏移量。另一个坑是透明区域判断,如果按钮是圆角或者图标周围有大量透明像素,不判断 IsTransparent 会导致这些区域的点击被吞掉,对话框的右键菜单、系统行为全部失效。
消息区间 WM_MOUSEFIRST 到 WM_MOUSELAST 覆盖了移动、按下、抬起、双击,但不包含 WM_MOUSEWHEEL。滚轮消息要走 WM_MOUSEWHEEL 单独转发,而且坐标来源是屏幕坐标,同样要先转换。如果省略这一步,列表滚动条永远滚不动。
3.4 编译选项与首次运行注意事项
VC6.0 下编译 DirectUI 老包,最常见的编译失败来自头文件兼容和预处理器定义。建议在 Project Settings 里做三件事:C/C++ 标签页的 Preprocessor 定义里加上 _MBCS,不要用 _UNICODE,因为老库的字符串接口大多按 char* 实现;C++ Language 里把 Enable RTTI 打开;优化选项 Release 用 Maximize Speed,Debug 用默认即可。
首次编译通过后,先别急着加业务逻辑。跑一次 demo 或者最小对话框,确认三件事:窗口能拖动、按钮有悬停和按下反馈、拉伸窗口时按钮位置跟随。如果按钮位置不动,去查根容器的 WM_SIZE 处理,看 SetPos 是否重新触发布局。如果悬停状态没反应,检查是否有 WM_MOUSEMOVE 的重复触发,TrackMouseEvent 需要自己调用,否则系统不会持续汇报鼠标离开事件。
4. 布局、换肤与自定义控件:做一个纵向按钮列表
4.1 纵向布局算法:Arrange 时先算 Y 再递归算子级
有了根容器和基本按钮,下一步就是把控件组织成真正的界面。最常用的容器是纵向布局,它负责把子控件按顺序从上到下排列,每个子控件的宽度撑满容器客户区,高度由子控件自己声明,纵向间距由布局参数控制。
void CDuiVerticalLayout::Arrange(CDuiControl* pParent) { RECT rc = pParent->GetClientRect(); int y = rc.top + m_nPaddingTop; for (int i = 0; i < (int)m_children.size(); i++) { CDuiControl* pChild = m_children[i]; if (!pChild->IsVisible()) continue; int nHeight = pChild->GetFixedHeight(); if (nHeight == 0) { pChild->Measure(); // 按文本内容估算高度 nHeight = pChild->GetDesiredHeight(); } pChild->SetRect(rc.left + m_nPaddingLeft, y, rc.right - m_nPaddingRight, y + nHeight); y += nHeight + m_nSpacing; } }这段逻辑里三个参数决定了最终观感:m_nPaddingTop 控制整体距容器顶部的空白,m_nPaddingLeft / Right 控制子控件的左右边距,m_nSpacing 控制相邻控件的间距。SetRect 的右值用 rc.right 减去右边距,意味着子控件宽度自动撑满。如果你想要固定宽度的按钮,就得在 Measure 里修改 desired width,或者给布局增加对齐模式,这部分不同包实现方式不太一样,但入口都是 Arrange。
容器变化时一定要触发重排,否则窗口拉伸后按钮还在原地。常见做法是在容器的 SetPos 里调用 Arrange,再传递给子容器做递归。我习惯把 Arrange 和 Draw 分开:Draw 每帧都可能执行,Arrange 只在位置变化、子控件增删、显隐变化时执行,省下的 CPU 在列表滚动时会很明显。
4.2 按钮状态与换肤:按下时颜色怎么变
按钮的交互状态是 DirectUI 拿手好戏。普通态、悬停态、按下态、禁用态,每种状态对应一组前景色和背景色;绘制函数里先查状态,再取对应颜色,全部在内存里完成,不需要系统帮你重建窗口。
void CDuiButton::Draw(HDC hDC) { COLORREF clrBg, clrText; switch (m_nState) { case BTN_NORMAL: clrBg = m_clrNormalBg; clrText = m_clrNormalText; break; case BTN_HOVER: clrBg = m_clrHoverBg; clrText = m_clrHoverText; break; case BTN_PRESSED: clrBg = m_clrPressedBg; clrText = m_clrPressedText; break; default: clrBg = m_clrDisabledBg; clrText = m_clrDisabledText; break; } HBRUSH brush = CreateSolidBrush(clrBg); FillRect(hDC, &m_rc, brush); DeleteObject(brush); SetBkMode(hDC, TRANSPARENT); SetTextColor(hDC, clrText); DrawText(hDC, m_strText.c_str(), -1, &m_rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); }状态更新发生在鼠标消息里:WM_MOUSEMOVE 时先判定点是否仍落在本控件内,落到外部就切回 normal,按下去那一刻切 pressed,释放时恢复 normal。连续快速移动会产生大量 WM_MOUSEMOVE,状态切换不需要每次都重绘一整个窗口,调 InvalidateRect 时把矩形收缩到按钮区域即可。
换肤的本质就是对这组颜色的统一替换。最简单的做法是给控件增加 SetSkinColor 接口,把 normal / hover / pressed / disabled 四个颜色值一次传入;更工程化的做法是做一个全局皮肤管理器,控件绘制时从皮肤管理器取色,这样切换主题只需要改一份配置,不用遍历整棵控件树。
4.3 添加自定义控件而不碰库源码
老库自带的控件数量有限,遇到开关、滑块、图表这类组件就要自己扩展。扩展方式很固定:继承控件基类,实现 Draw、HitTest、OnMouseMessage 三个核心方法,必要时加一个新接口给外部设置数据。
class CDuiSwitch : public CDuiControl { public: virtual void Draw(HDC hDC); virtual BOOL HitTest(POINT pt) { return PtInRect(&m_rc, pt); } virtual void OnMouseMessage(UINT nMsg, POINT pt, WPARAM wParam) { if (nMsg == WM_LBUTTONDOWN) { m_bOn = !m_bOn; // 点击切换开关状态 InvalidateRect(GetHostHWND(), &m_rc, FALSE); } } void SetOn(BOOL bOn) { m_bOn = bOn; } BOOL m_bOn; };自定义控件不要直接调用系统 InvalidateRect,应该通过宿主窗口句柄去失效,或者给控件基类封装一个 Invalidate 方法统一处理。这样能保证双缓冲绘制流程完整走一遍。绘制函数里注意别在每次 Draw 里创建字体和画刷,创建 GDI 对象有成本,频繁创建还会造成句柄泄露,建议在控件构造时把固定笔刷缓存下来。
4.4 别把 DirectUI 当万金油:何时退回到真实窗口
DirectUI 不是全场景通吃。以下四种情况我一般会保留原生窗口:输入法合成窗口、系统右键菜单、可访问性辅助、嵌入浏览器或 ActiveX 控件。输入法需要窗口消息配合候选框定位,DirectUI 控件没有真实 HWND,IME 不知道该把候选框放在哪里;系统菜单和右键菜单用 TrackPopupMenu 需要宿主窗口坐标,处理起来格外绕。
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 文本输入框 | 保留真实 Edit 或者用 DirectUI 控件+手写 IME 适配 | IME 候选框需要真实窗口 |
| 右键菜单 | TrackPopupMenu + 宿主窗口做 TPM_RETURNCMD | 菜单交互本身依赖 HWND |
| ActiveX / 浏览器内核 | 嵌入真实子窗口 | DirectUI 无法渲染外部窗口内容 |
| 屏幕阅读器 | 退回传统控件或在 DirectUI 上实现 MSAA | 无障碍接口依赖 HWND |
换肤再漂亮,输入法乱跑这类的体验问题很容易让一个项目全部白干。我的建议是提前把边界画清楚:DirectUI 负责视觉和常规交互,系统能力仍然借用宿主窗口,真实窗口作为特殊区域嵌入到布局树的叶子节点。
5. DirectUI for VC6.0 常见问题与排查:五个翻车现场
5.1 点击没有反应:消息在 PreTranslateMessage 被吞
现象:按钮画出来了,鼠标移上去也有悬停反馈,但按下毫无反应,连焦点框都不出现。
原因:我排查过的大部分情况都在 PreTranslateMessage 上。要么只转了 WM_LBUTTONDOWN,漏了 WM_LBUTTONUP,控件只在按下瞬间记录状态,抬起时没人告诉它该触发 Click;要么返回 TRUE 的条件判断写反了,透明区域连系统默认行为也拦截掉了,对话框本身收不到任何点击。
解决:按第 3.3 节的写法,把 WM_MOUSEFIRST 到 WM_MOUSELAST 整段区间都转发,并且用 IsTransparent 做统一出口。调试时可以在 HitTest 后加一个 OutputDebugString 打印控件 ID,确认命中目标是否预期。如果某个区域点了以后既没有控件响应,也没有系统行为,就去查是不是把透明区域判定成了可点击。
5.2 界面闪烁、黑屏或一块一块地花
现象:窗口拖动时整个客户区像曝光过度一样闪,或者拉动窗口边缘后出现大块黑斑,要等下一次重绘才恢复。
原因:几乎都是绘制配对问题。第一种是 WM_ERASEBKGND 没处理,系统先按对话框背景色擦一遍,DirectUI 紧接着再画一遍,双重绘制在低频刷新率下就闪。第二种是 InvalidateRect 第三个参数传了 TRUE,背景清除和自绘交叠。第三种是 WM_PAINT 里用了 GetDC 而不是 BeginPaint,导致重绘标志没被清除,系统认为窗口永远需要刷新,CPU 直接被拉满,界面卡到像花屏。
解决:在消息映射里明确处理 WM_ERASEBKGND 并返回 TRUE,禁止系统擦背景;所有 InvalidateRect 统一用 FALSE;绘制统一走 BeginPaint/EndPaint。双缓冲不是可选项,是必须项。内存 DC 用完立即 DeleteDC,位图用完立即 DeleteObject,不然 GDI 对象也会成为下一个坑。
5.3 GDI 对象只增不减:画刷、位图、字体的清理不当
现象:窗口运行十几分钟,任务管理器里 GDI 对象数量一路从两百涨到上千,最终绘图花掉、点击失效,整个界面像被人泼了墨。
原因:绘制函数里每次创建画刷、字体、位图,但没有及时释放。最常见的是 CreateSolidBrush 之后忘记 DeleteObject,或者 SelectObject 把新字体选进 DC 后没有把旧对象选回来,导致 DC 的默认对象被覆盖,之后 DeleteObject 删除的又是错误对象,真正的泄露对象反而留在 DC 里无法释放。
解决:养成三个习惯。局部用的 GDI 对象,函数结束前必须 DeleteObject;长期复用的对象作为控件成员变量,在析构函数里统一释放;SelectObject 的返回值保存成旧句柄,画完立刻选回去,再删除本地创建的对象。
void CDuiButton::Draw(HDC hDC) { HBRUSH brush = CreateSolidBrush(m_clrNormalBg); HBRUSH oldBrush = (HBRUSH)SelectObject(hDC, brush); // 绘制逻辑 SelectObject(hDC, oldBrush); DeleteObject(brush); // 必须删除,不是选回旧对象就完事 }凡是创建型 GDI 函数,GetStockObject 返回的对象不需要删除,其他一概需要。排查时可以在 OnPaint 末尾加一个 GetGuiResources 调用跟踪当前 GDI 对象数,连续刷新十帧,数字不回落就说明有泄露。
5.4 点击偶尔崩溃、窗口拖动时直接退出
现象:程序大部分时间正常,但快速拉伸窗口或者连续快速点击不同按钮时,偶尔崩在窗口过程里,VC6.0 调试器停在某个空指针调用位置。
原因:根容器指针或控件指针存的位置不对,或者生命周期管理出了问题。最常见的是把指针存在 GWL_USERDATA,但窗口销毁时的消息顺序导致取出来的指针已经释放;另一种情况是控件对象在堆上创建,对话框析构时没有先删控件树再销毁窗口,悬空指针被窗口过程的到下一条消息再次调用。
解决:窗口过程头里做空指针保护;消息分发严格按 WM_NCCREATE 保存指针、WM_NCDESTROY 清理指针的顺序执行;销毁对话框时先 delete 根容器,再调用 CDialog 的析构。控件树所有子控件统一由根容器持有并负责释放,外部不要单独 delete 子控件,避免二次释放。
case WM_NCDESTROY: { CDuiRoot* root = (CDuiRoot*)GetWindowLong(hWnd, GWL_USERDATA); if (root) delete root; SetWindowLong(hWnd, GWL_USERDATA, 0); break; }如果你把这段代码迁移到新平台 SDK,习惯性用 GetWindowLongPtr 和 GWLP_USERDATA,因为 GetWindowLong 在 64 位下会截断指针。VC6.0 时代只有 32 位程序,没出过问题,但代码一旦被拷到新版编译器里编译 64 位,这就是定时炸弹。
5.5 中文乱码、字体重叠和文字跑出控件边界
现象:按钮文字显示成“口口口”,或者中文标题和英文混排时行高不对、上下重叠,字体比控件本身还大一圈。
原因:编码和字体参数各占一半。VC6.0 默认走 MBCS,字符串是 char*,如果包内 XML 文件是 UTF-8 编码,解析出来的中文就会错位;CreateFont 创建字体时没有指定字符集,系统用一个默认字体替代,遇到中文直接变方块;高度参数如果传正值,指的是字体总高度,英文字符和汉字的基线计算规则不同,行高容易被撑乱。
解决:编码统一用 GB2312 或 GBK,XML 声明里明确写 encoding="gb2312";字体创建时显式指定字符集和字体名,高度传负值让系统按字符高度换算;文字绘制前先测量字符串宽度,超出控件矩形就做省略号或换行处理。
| CreateFont 参数 | 推荐值 | 说明 |
|---|---|---|
| nHeight | -14 或按 DPI 换算 | 负值表示字符高度,避免字体撑乱 |
| lprfFaceName | “宋体”或“Microsoft YaHei” | 中文字体名必须与系统匹配 |
| nCharSet | GB2312_CHARSET | 缺省会退回默认字符集,中文乱码 |
| nOutPrecision | OUT_DEFAULT_PRECIS | 保持默认即可 |
字体创建后在析构函数里 DeleteObject,和画刷同等级对待。如果项目里大量文字需要自适应宽度,给控件基类加一个 AutoSize 标志,Draw 前调用 GetTextExtentPoint32 重新计算控件矩形,再触发重排,比手工调坐标省心得多。
6. 把 DirectUI 重绘成本打下来:脏矩形、图层缓存与计时验证
界面一复杂,全量遍历绘制就成了瓶颈。拿一个包含几百个控件的窗口来说,每帧把整棵树都画一遍,GDI 在 1024×768 下还能撑住,上到 1920×1080 就开始发飘。我一般会先上两个技巧,效果立竿见影。
第一个是脏矩形。在控件基类里加一个 BOOL m_bDirty,SetText、SetColor、SetRect 时把脏标记置上,同时把控件矩形合并进宿主窗口的待重绘列表。绘制时只遍历脏矩形覆盖到的控件,干净的叶子节点直接跳过。列表里可以用粗粒度合并,相邻控件合并成一个矩形,避免一个按钮刷新导致半屏重绘。
void CDuiRoot::InvalidateRect(CDuiControl* pCtl) { if (!pCtl->m_bDirty) { pCtl->m_bDirty = TRUE; // 合并到全局脏矩形,多个控件相邻时就拼成大块 UnionRect(&m_rcDirty, &m_rcDirty, &pCtl->m_rc); } }第二个是图层缓存。把完全静止的背景、Logo、表格线画到一张离屏位图上,每帧先 BitBlt 这张背景位图,再绘制动态控件。这样动态区域的绘制量被砍到只剩真正需要变化的控件。实现时在根容器里保存一张 m_bgBmp,窗口首次创建和主题切换时重建,平时只负责整张拷贝。
// 每帧绘制前先铺背景位图 BitBlt(hDC, 0, 0, rc.right, rc.bottom, m_bgDC, 0, 0, SRCCOPY); // 再只画动态控件和脏矩形内的变化区域 DrawDirtyItems(hDC);验证优化有没有效果,不要靠肉眼感受。我在每个宿主窗口的 OnPaint 里包一段 GetTickCount 计时,连续记录三十帧的耗时,取平均值;同时用 GetGuiResources 观察 GDI 对象数是否平稳。全量重绘时平均耗时在几十毫秒,脏矩形和图层缓存做完之后,正常交互一般能压到十毫秒以内,GDI 对象数保持在一个稳定值不再上涨。
最后说一个我自己的教训:某工控项目里第一次上 DirectUI 时,我图省事,所有状态变化都直接 InvalidateRect 整个客户区,界面在 1280×1024 下拖一次要响应将近两秒。后来老老实实把脏矩形的计算补上,又给静态背景加了图层缓存,拖拽才回到顺滑。这个方向值不值得投入,我的判断标准很简单:只要你的项目需要在老编译器里做出自定义程度高的界面,DirectUI 就比逐个控件自绘可持续得多。先跑通最小宿主,再逐个加控件,坑基本都能在这篇文章里对号入座,希望帮到你。
本文还有配套的精品资源,点击获取