☰
MFC消息机制与GDI绘图:从消息循环到窗口对象规范
2026/10/14 17:25:08 网站建设 项目流程

简介:对于已具备C/C++基础、想系统学习VC++6.0 MFC开发的开发者,这份doc文档提供了循序渐进的入门思路,既不回避Windows底层机制,也强调实践与代码阅读。作者从“磨刀不误砍柴工”谈起,指出先编写字符界面程序、熟练C++特性是转入MFC的前提,并围绕消息机制、窗口句柄、消息映射等核心概念展开说明,帮助读者理解Windows消息驱动程序的运行方式,减少盲目查阅参考书。文档还示范了窗口过程与消息循环的伪代码,并对MFC借助C++封装、派生、虚拟性带来的开发优势做了梳理,同时提到阅读他人代码、善用Help Online等自学方法,可作为初学者搭建知识框架的参考。资源压缩包仅含1个doc文档,大小521KB,内容集中在入门路径与原理讲解上,适合快速通读;目前已有727人学习下载,对于想厘清MFC学习主线、避免走弯路的读者较有参考价值。

1. 为什么学了几个月 MFC,还是只会拖控件

接触 VC++6.0 和 MFC 的人通常带着一个明确的目标:想尽快写出一个能跑起来的 Windows 窗口程序。但很多人卡在同一个地方——跟着教程建了对话框、拖了几个按钮,代码也能编译通过,可一旦要自己加功能,就完全不知道消息从哪里来、按钮点击后系统做了什么、为什么窗口重绘会白屏。这篇笔记把 MFC 入门路上最容易被跳过的那些底层链路重新梳理一遍,从 Windows 消息机制一直讲到消息映射和 GUI 对象的使用规范。适合两类人:一类是有 C/C++ 语法基础但没写过 Windows 程序的人,另一类是已经会用向导生成代码、但遇到崩溃和乱码时只会瞎试的人。学过一遍,至少能知道代码贴进 OnDraw 和 OnPaint 的区别是什么。

2. 理解 Windows 消息机制:窗口之间靠什么通信

2.1 一条消息的完整生命周期

Windows 是一个消息驱动的系统,用户点击鼠标、按下键盘、窗口被拖动、控件状态变化,系统都会往对应的窗口发送消息。一条消息由三个部分组成:消息名称(UINT)、WPARAM 和 LPARAM。前一个参数通常携带命令相关的标识,后一个通常携带坐标、指针或控件句柄。例如菜单被选中时会发 WM_COMMAND,这时 HIWORD(wParam) 是命令 ID,LOWORD(wParam) 是发送消息的控件或菜单句柄;如果消息来自菜单,LPARAM 通常为 0。

消息的走向也有固定规则:消息必须由某个窗口接收,系统根据窗口句柄(HWND)来决定投递给哪一个窗口过程。每一个窗口都关联一个 WNDPROC,窗口过程中的 switch-case 分支负责判断当前消息是否对应用户关心的类型。下面是一段典型的窗口过程伪代码:

// 窗口过程:系统每收到一条消息就调用一次 LONG yourWndProc(HWND hWnd, UINT uMessageType, WPARAM wParam, LPARAM lParam) { switch (uMessageType) { case WM_PAINT: // 窗口需要重新绘制时调用,例如被遮挡后恢复、窗口大小变化 doYourWindow(hWnd); break; case WM_LBUTTONDOWN: // 鼠标左键按下时处理,wParam 里带按键状态,lParam 低 16 位是 X 坐标 doYourWork(wParam, lParam); break; default: // 不处理的消息全部交给系统默认过程,保证窗口行为正常 callDefaultWndProc(hWnd, uMessageType, wParam, lParam); break; } }

这段代码演示了消息分发的基本形态。注意 switch-case 里的分支只是示例,真正工程中一般不直接在 WNDPROC 里堆业务逻辑,而是根据消息类型转发给不同的处理函数。WPARAM 和 LPARAM 的语义随消息类型变化,不能一概而论,必须查对应消息的文档。新手最容易在这里出错:拿到 WM_LBUTTONDOWN 后用 wParam 去取坐标,结果坐标根本不在 wParam 里,自然得不到预期效果。

2.2 消息队列与消息循环的关系

系统维护一个或多个消息队列,所有输入消息先进入队列,然后由应用程序的消息循环逐个取出并分发。消息循环的核心是 GetMessage 函数,它只有在队列里有消息时才返回,没有消息时阻塞并把控制权交还给系统。这样多个任务才能共享 CPU,不会因为一个程序在忙等而卡死整个系统。伪代码如下:

// 每个 GUI 程序都有一个消息循环 while (1) { MSG msg; // 从消息队列中取出一条消息;如果队列为空,则阻塞等待 if (GetMessage(&msg, NULL, 0, 0) == 0) { break; // 收到 WM_QUIT,退出循环 } // 将键盘消息翻译成字符消息 TranslateMessage(&msg); // 分发给对应窗口的窗口过程 DispatchMessage(&msg); }

注意 GetMessage 在有消息时会填满 MSG 结构体,其中 hwnd 字段指明了这条消息要投递给哪个窗口。DispatchMessage 正是根据这个字段找到对应的窗口过程并调用。所以一个程序哪怕创建了多个窗口,每个窗口各有自己的窗口过程,消息循环也只需要这一个 while 就可以完成全部分发。

2.3 多任务模型的差异:从协同式到抢先式

16 位 Windows(比如 Windows 3.x)只有一个全局消息队列,系统必须等待当前任务把消息处理完,才能把下一条消息发给别的任务。如果一个程序陷入死循环或长时间计算,整个系统就卡住了。这种模型叫协同式多任务。32 位 Windows(Windows 95/NT 及以后)改为每个程序独立消息队列,系统可以在队列之间切换调度,不再依赖某个程序主动让出 CPU,这是抢先式多任务。理解这一点能解释很多 MFC 程序的实际表现:在 OnDraw 里做超过几秒的耗时计算,窗口会拖动不畅或出现“未响应”提示——因为在当前线程里,消息循环被阻塞了。

3. 消息映射机制:MFC 把 switch-case 藏到哪里去了

3.1 从 WNDPROC 到消息映射表的转换

MFC 没有废除窗口过程,它只是把窗口过程中的 switch-case 抽象成了消息映射表。通过一组宏,把消息类型、控件/菜单命令 ID 和响应函数绑定在一起。当窗口收到消息时,MFC 内部统一入口函数(类似前述的 newWndProc)会查找这张映射表,命中后调用对应的成员函数。这样做的直接好处是:不用自己写冗长的 switch,每个消息的处理逻辑归类到独立成员函数里,可读性和可维护性都明显提高。

下面的代码是 MFC 消息映射的常见写法:

// MainFrame.cpp 中的消息映射入口 BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) //{{AFX_MSG_MAP(CMainFrame) ON_WM_CREATE() // 处理 WM_CREATE,窗口创建时触发 //}}AFX_MSG_MAP ON_COMMAND(ID_FONT_DROPDOWN, OnFontDropdown) // 处理指定菜单命令 END_MESSAGE_MAP()

这段代码由三部分组成:BEGIN_MESSAGE_MAP 声明映射开始,并指出当前类和父类;中间的宏条目声明具体消息对应的处理函数;END_MESSAGE_MAP 标记结束。ON_WM_CREATE 是一个消息映射宏,它把 WM_CREATE 映射到当前类的 OnCreate 成员函数,处理函数名是 MFC 规定好的,不能随意改名。ON_COMMAND 则把命令 ID(比如菜单项的资源 ID)映射到任意自定义函数,比如上面的 OnFontDropdown。这里有个容易忽略的点:ON_WM_CREATE 的响应函数签名是固定的,不能带参数;而 ON_COMMAND 的响应函数同样不带参数,也不要试图用这个宏去响应带参数的用户自定义消息。

3.2 映射表的编译展开与查找顺序

BEGIN_MESSAGE_MAP 和 END_MESSAGE_MAP 经过预编译后,会生成一个消息映射表结构,MFC 的窗口过程基类通过这张表去寻找对应的处理函数。展开后的逻辑类似于一个消息分发表,按消息类型做判断,如果命中 WM_CREATE 就调用 OnCreate,如果命中 WM_COMMAND 再进一步匹配命令 ID。这个级别的理解对入门已经足够,不需要深挖内部数组布局。

需要注意两个细节。第一,消息映射查找是有顺序的:先从当前类的映射表查,查不到再去父类映射表查,逐级向上直到 CWnd 的默认处理。第二,如果父类已经处理了某条消息,子类想拦截,必须在自己的映射表里加入对应宏,并把父类的处理函数放在子类处理逻辑中手动调用。下面是一个典型示例,子类在捕获 WM_LBUTTONDOWN 后调用父类默认逻辑:

BEGIN_MESSAGE_MAP(CMyView, CView) ON_WM_LBUTTONDOWN() END_MESSAGE_MAP() void CMyView::OnLButtonDown(UINT nFlags, CPoint point) { // 先处理自己的逻辑:比如记录坐标,或者绘制一个点 CView::OnLButtonDown(nFlags, point); // 再调用父类默认处理 }

这个先子类后父类的顺序是 MFC 消息映射的重要规则。很多入门程序在 CView 派生类里写了 OnLButtonDown 之后,却发现视图没有刷新,回头看没有调 Invalidate,也没有调用父类默认逻辑,导致视图状态没有被正确维护。

3.3 消息映射常见问题

MFC 的 ClassWizard 会自动维护消息映射,但手动添加时经常出问题。最常见的是宏名称拼写错误。ON_WM_CTLCOLOR 和 ON_WM_CTLCOLOR_ 是两个不同的宏,前者是旧版,后者带消息反射,功能不一样。另外 ON_COMMAND 只适合菜单项、按钮等命令消息,如果想处理控件通知(比如 Edit 内容变化发出的 EN_CHANGE),要用 ON_EN_CHANGE 或 ON_CONTROL_REFLECT。硬用 ON_COMMAND 去接控件通知,编译可以通过但运行不会触发。遇到这种问题,先查消息映射表里有没有重复条目,再确认宏类型与消息种类匹配。

还有一种容易翻车的情况:自定义消息。用户在类里定义了 WM_USER + 100 之类的消息,又在消息映射里用 ON_MESSAGE 绑定处理函数,但处理函数签名必须符合 LRESULT (WPARAM, LPARAM) 的形式:

BEGIN_MESSAGE_MAP(CMyWnd, CWnd) ON_MESSAGE(WM_USER + 100, OnMyMessage) END_MESSAGE_MAP() LRESULT CMyWnd::OnMyMessage(WPARAM wParam, LPARAM lParam) { // 自定义消息处理 return 0; }

注意 ON_MESSAGE 不像 ON_COMMAND 那样匹配命令 ID,它直接把整个消息号和函数绑定。自定义消息的 ID 建议单独定义成常量,避免与其他消息号冲突。手动加完映射后一定要在头文件里声明对应函数,否则编译会报无法解析的外部符号。

4. 使用 DC 和 GDI 对象在窗口绘图:对象规范比画图本身更能决定程序稳定性

4.1 先理解 DC:所有绘图操作的统一入口

MFC 中所有显示、打印、预览输出都围绕 CDC 进行。OnDraw 函数接收的 CDC 指针就是当前设备环境。窗口显示时它关联屏幕 DC,打印时它关联打印机 DC,打印预览时它关联 CPreviewDC。所以同一份 OnDraw 代码可以同时用于屏幕显示和打印输出,这是 MFC 最实用的特性之一。

下面是一个最基础的 OnDraw 输出文字的例子:

void CMyView::OnDraw(CDC* pDC) { CString strText = _T("Hello MFC"); CRect rcClient; GetClientRect(&rcClient); // 获取客户区矩形,决定文字摆放范围 // 设置文字颜色为蓝色,背景为透明 pDC->SetTextColor(RGB(0, 0, 255)); pDC->SetBkMode(TRANSPARENT); // 背景透明,避免覆盖原有内容 pDC->DrawText(strText, &rcClient, DT_CENTER | DT_VCENTER | DT_SINGLELINE); }

这里 DrawText 的作用是把字符串按指定格式绘制在矩形区域内,DT_CENTER 表示水平居中,DT_VCENTER 表示垂直居中(必须与 DT_SINGLELINE 连用否则无效),DT_SINGLELINE 表示单行绘制。SetBkMode 设置背景模式,TRANSPARENT 让文字背后的背景色不填充,这样文字叠加到底图上不会被一个色块盖住。这个细节很多人不注意,结果在 OnDraw 里反复输出文字时,背景色块越积越多,最终整个客户区脏得不能看。

灵活运用 DrawText 的标志位,可以做出比 TextOut 更丰富的排版效果。例如需要让长文字在指定矩形内自动换行,可以组合 DT_WORDBREAK 与 DT_LEFT;如果希望文字超出矩形时用省略号收尾,可以加 DT_END_ELLIPSIS。注意 DT_END_ELLIPSIS 可能修改传入字符串,如果不想原字符串被改写,要同时用 DT_MODIFYSTRING 并且传入的必须是可写缓冲区。实际工程中更稳妥的做法是先把字符串复制一份再传进去。

如果需要输出多行文字并且想精确控制行距,可以借助 GetTextExtent 测量文本宽度和高度来推算行高:

void CMyView::DrawMultiLine(CDC* pDC, const CStringArray& lines) { CPoint pt(10, 10); for (int i = 0; i < lines.GetSize(); i++) { pDC->TextOut(pt.x, pt.y, lines[i]); // 测量当前行文本的高度并累加到下一行的起点 CSize sz = pDC->GetTextExtent(lines[i]); pt.y += sz.cy + 2; // 额外留出 2 像素行间距 } }

4.2 画笔和刷子的创建、选入与恢复

画笔用于画点、画线、画边框,刷子用于填充封闭区域。画笔创建方式为 CPen::CreatePen,刷子创建方式为 CBrush::CreateSolidBrush 或 CreateHatchBrush。使用 GDI 对象必须遵守“创建->选入->使用->恢复->销毁”的规范。下面这段代码演示了安全使用多个画笔、中间重新创建的完整流程:

void CMyView::OnDraw(CDC* pDC) { CPen pen1, pen2; pen1.CreatePen(PS_SOLID, 2, RGB(128, 128, 128)); // 灰色实线,宽度 2 像素 pen2.CreatePen(PS_SOLID, 2, RGB(128, 128, 0)); // 深黄色实线 CPen* pOldPen = (CPen*)pDC->SelectObject(&pen1); // 用 pen1 绘制第一组图形 pDC->MoveTo(10, 10); pDC->LineTo(100, 100); pDC->SelectObject(&pen2); // 用 pen2 绘制第二组图形 pDC->MoveTo(10, 50); pDC->LineTo(100, 50); // 先销毁再重新创建,否则旧对象还残留着系统资源 pen1.DeleteObject(); pen1.CreatePen(PS_DASHDOT, 1, RGB(0, 0, 0)); // 黑色点划线 pDC->SelectObject(&pen1); pDC->MoveTo(50, 50); pDC->LineTo(200, 200); // 最后恢复原先的 GDI 对象,避免 DC 被破坏 pDC->SelectObject(pOldPen); }

这里的核心规则有两个。第一,一个 CPen 对象不能在不调用 DeleteObject 的情况下直接再次 CreatePen,旧笔句柄会泄漏。第二,所有 SelectObject 选入的对象使用完毕必须恢复原对象,否则 DC 在析构时可能试图删除一个已经失效的 GDI 句柄,导致随机崩溃。恢复时最保险的办法是保存第一次选入时的返回值,最后一次性选回。

选择库存对象则没有这些烦恼。用 SelectStockObject 选入的黑色画笔、系统字体、白色刷子等不需要销毁,也适合作为 DC 状态的默认恢复目标:选入库存对象相当于把 DC 回归到系统默认状态。

4.3 字体创建的参数解释与常见误区

创建自定义字体主要用 CFont::CreateFont,参数多且容易填错。看到这一长串参数不要慌,最重要的是 nHeight、nWeight、bItalic、bUnderline 和 lpszFacename。nHeight 为 0 表示用系统默认高度;如果指定大于 0 的值,系统会取绝对值后去匹配最接近的可用字体。nWeight 表示粗细,FW_NORMAL 是 400,FW_BOLD 是 700。字符集 nCharSet 对于中文环境务必指定 DEFAULT_CHARSET 或 GB2312_CHARSET,否则中文可能显示为乱码或问号。

下面这段代码展示了典型字体创建与应用:

void CMyView::DrawCustomFont(CDC* pDC) { CFont font; font.CreateFont( 24, // 高度 24 逻辑单位 0, // 宽度自动匹配纵横比 0, // 出口角度 0 度 0, // 字体基线与水平方向夹角 0 度 FW_BOLD, // 粗体 FALSE, // 非斜体 TRUE, // 带下划线 FALSE, // 无删除线 DEFAULT_CHARSET, // 默认字符集,中文环境必用 OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, DEFAULT_QUALITY, DEFAULT_PITCH | FF_SWISS, // 字体族为瑞士体(非衬线) _T("Arial") // 字体名称 ); CFont* pOldFont = (CFont*)pDC->SelectObject(&font); pDC->TextOut(20, 20, _T("自定义字体测试")); pDC->SelectObject(pOldFont); }

字体创建中最常见的翻车点是:没有在调用 CreateFont 之后检查返回值。如果系统没有这个字体,CreateFont 不会失败(返回非零),但绘制时可能自动替换为默认字体。想确认实际用的是什么字体,用 GetTextMetrics 查询候选字体信息,再决定是否手动调整。真正会导致失败的场景是 lpszFacename 传了空指针。

4.4 GDI 对象使用避坑

有些问题初学阶段几乎必踩,在这里集中说明。

现象一:窗口反复刷新后内存占用持续上涨。一般原因是 OnDraw 里重复创建 CPen、CBrush 或 CBitmap,却没有调用 DeleteObject 释放 GDI 对象。Windows 对 GDI 对象总数有限制,达到上限后新对象创建失败,画图全部失效。解决:所有临时 GDI 对象在函数退出前销毁,最好用局部对象并配合 DeleteObject。

现象二:拖动窗口大小后内容闪烁。这是因为没有处理 WM_ERASEBKGND 消息。在视图派生类里重载 OnEraseBkgnd,直接返回 TRUE 可以消除擦除背景造成的闪动。代价是背景不会被自动擦除,需要在 OnDraw 里用背景色填充客户区。示例:

BOOL CMyView::OnEraseBkgnd(CDC* pDC) { // 跳过擦除步骤,减少窗口重绘时白闪 return TRUE; }

现象三:设置了新画笔后线条还是原来样式。这是因为画笔创建后没有选入 DC,或者选入了另一个画笔画完没有切回来。画线前必须用 SelectObject 把当前 DC 的画笔替换成你想用的画笔。检查一下代码里是否每次都执行了 SelectObject,以及 DC 是否被别的模块改动过。如果画笔宽度和样式都设置了却没效果,先确认这条路径确实执行了。

5. 进阶实践技巧:用 MSDN 代替参考书,把学到的机制串起来

学习 MFC 到一定阶段,参考书的作用会越来越小,真正有帮助的是微软的在线帮助(MSDN/Help Online)。养成遇到函数第一个动作查 MSDN 的习惯,能省下大量踩坑时间。以 CDC::DrawText 为例,只看函数签名不知道 DT_VCENTER 必须配合 DT_SINGLELINE,也不知道 DT_MODIFYSTRING 会修改原字符串,这些细节都写在文档的 Remarks 里。刚开始不习惯可以理解,但每查一个函数就记下一个坑,积累一段时间后效率和准确度都会有明显提升。

一个特别适合新手练习的综合实践是:在单文档视图工程里实现一个彩色文字报表。做法也不复杂:

void CMyView::OnDraw(CDC* pDC) { CRect rcClient; GetClientRect(&rcClient); // 第一行:大字号标题,居中,蓝色 CFont titleFont; titleFont.CreateFont(32, 0, 0, 0, FW_BOLD, TRUE, FALSE, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, DEFAULT_QUALITY, DEFAULT_PITCH | FF_SWISS, _T("宋体")); CFont* pOldFont = (CFont*)pDC->SelectObject(&titleFont); pDC->SetTextColor(RGB(0, 0, 255)); pDC->SetBkMode(TRANSPARENT); pDC->DrawText(_T("《MFC 入门》"), &rcClient, DT_CENTER | DT_VCENTER | DT_SINGLELINE); pDC->SelectObject(pOldFont); // 第二行:说明文字,在标题下方左对齐 CRect rcText(10, 80, 400, 120); pDC->SetTextColor(RGB(0, 128, 0)); pDC->DrawText(_T("MSDN 是比参考书更可靠的学习伙伴"), &rcText, DT_LEFT | DT_WORDBREAK); }

这个练习覆盖了字体创建、选入与恢复、文字颜色设置、背景模式、DrawText 多行布局。把这些点打通后,再往窗口里叠加画笔、刷子和位图就会顺畅很多。

熟悉了消息映射和 GDI 对象之后,看别人代码的视角也会发生变化。比如看到一个类里有 ON_MESSAGE 但处理函数没有在头文件声明,马上就能预判链接错误;看到一个 OnDraw 里创建了 CPen 没调 DeleteObject,就能意识到内存隐患。这种“代码一眼扫过去能指出问题”的能力,是初学者和熟练开发者之间最明显的分界线。从那以后我每次写 MFC 的绘图代码,都强制把“创建—选入—使用—恢复—销毁”五步写在注释里核对一遍,这习惯帮我少填了很多坑。希望这些经验对你入门 MFC 也有实际帮助。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询