ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

VC++6.0 MFC入门:吃透消息机制与GDI绘图避坑

VC++6.0 MFC入门:吃透消息机制与GDI绘图避坑

简介:这是一份面向VC++6.0与MFC入门学习者的文档型资料,适合已具备C/C++基础、准备转入Windows桌面开发的人群。内容围绕学习路径展开,强调先通过字符界面程序夯实语言与面向对象基础,再理解Windows消息驱动机制、窗口句柄与GUI句柄,并掌握MFC消息映射的作用。文中还给出减少对参考书依赖、善用Help Online、熟记常用消息名称与参数、阅读他人代码及选择参考书的原则,可帮助读者建立从语言基础到MFC框架的入门框架。压缩包共1个doc文件,大小约521KB,便于集中阅读和整理笔记。目前已有731人学习,适合作为入门阶段梳理知识脉络与查缺补漏的参考材料。

1. 先把消息机制啃下来:VC++6.0 的 MFC 入门该从哪儿下手

第一次在 VC++6.0 里点开 ClassWizard,满屏 IDD_、IDR_、ON_WM_ 前缀能把人看懵,不少人就在这一步放下,转去买本厚书,翻到第三章继续放下。MFC 入门真正卡人的不是类多,而是搞不清一条消息从产生到被处理到底经过几道手:谁发、发给谁、谁接、不接又去哪。这四件事想明白了,剩下无非是查 CRect 有几个成员、CFont 怎么建、DC 里选进去的对象要不要还回去。下面按我当年走过的顺序排:C/C++ 底子补到什么程度、消息机制怎么理解才算真懂、消息映射那堆宏展开之后长什么样、GDI 对象和 DC 有几个必踩的坑,最后落到一个能直接抄去用的居中排版技巧。刚上手的人照着顺序走能少绕路,已经能写出窗口但总在绘图上翻车的人,直接看第五章。

2. 底子决定速度:C/C++ 基础补到什么程度才够写 MFC

2.1 先写字符界面程序,别一上来就拖控件

我带过几个刚接触 VC 的人,共同点都是拿到工程就想去资源编辑器里拖按钮,结果连CString和char*混用都搞不清,编译报错一屏,最后归因于「MFC 太难」。其实问题出在语言层:C++ 的类、指针、引用、作用域还没形成肌肉记忆,就一头扎进一个已经封装了两层的东西里,出问题根本定位不到是哪一层。

所以最开始那两三周,我会强迫自己在字符界面下写点小东西:字符串切分、简单链表、文件按行读取统计。目的不是练算法,是让编译器替我把「指针越界」「临时对象生命周期」「const 修饰错位置」这些常见错误反复抽几遍。这种疼必须早挨,早挨便宜。

下面这段是我常拿来做第一课的练习,按分隔符切分并统计词数:

// 字符界面练习:用状态机统计一行文本里的词数 #include <stdio.h> int SplitCount(const char* src, char sep) { int nCount = 0; int bInWord = 0; // 是否正处在一个词内部 for (const char* p = src; *p != '\0'; ++p) { if (*p == sep) { bInWord = 0; // 遇到分隔符,标记当前词结束 } else if (!bInWord) { bInWord = 1; // 从分隔符后进入新词 ++nCount; } } return nCount; } int main() { const char* s = "mfc,message,map,gdi"; printf("words = %d\n", SplitCount(s, ',')); return 0; }

这段代码的价值不在功能,在于三个细节:用指针遍历而不是下标、用一个标志位记录状态而不是回溯、函数参数用const char*表明不修改输入。等到了 MFC 里,你会发现CDC* pDC满天飞、CPen* pOldPen要保存又要还回去,如果对「指针指向的对象什么时候死」没感觉,第五章那些崩溃一个都躲不掉。状态机那套写法在 GUI 里也有直接对应物:比如判断鼠标是否处于拖拽状态,用的就是同一个bInWord式的标志位。

2.2 C++ 的三个特性在 MFC 里落在哪儿

MFC 这层封装之所以能省事,靠的就是 C++ 的三样东西。把它们和具体的类对上号,看文档时就不会觉得是在背天书。

C++ 特性MFC 里的落点具体例子
封装把系统句柄包进类,只暴露对象CWnd包HWND,CDC包HDC,CPen包HPEN
派生从框架类派生出自定义窗口从CView、CFrameWnd、CDialog派生自己的类
虚函数重写框架在固定时机回调你重写OnDraw、OnInitialUpdate、OnPaint

封装这一条最值得说。原始 SDK 里画线要先拿HDC,再SelectObject一支画笔,用完还得还回去,稍不留神就漏一个句柄。MFC 把这些步骤收进CDC::SelectObject的参数和返回值里,你面对的是CPen对象而不是HPEN整数。好处是写起来像写 C++,坏处是句柄被藏起来了——第五章那几个坑,本质上都是「句柄被藏起来之后你忘了它还在 DC 里」。

派生和虚函数是配套的。OnDraw是CView的虚函数,框架在窗口需要重绘时调它;你重写它,就等于把代码挂到了框架的时钟上。理解这一点之后,就会明白为什么在OnDraw里写的代码,窗口被拖动、被遮挡再露出来、点了打印预览,都会重跑一遍。这解释了后面很多现象,比如为什么在OnDraw里创建 GDI 对象要格外小心。

2.3 参考书怎么挑:少买、买对

这条是血泪经验。入门阶段买到一本排版漂亮但示例代码没注释的书,对学习积极性的打击比想象中大——你会以为是自己的问题。

判断一本书值不值得买,我一般会这么看:

阶段选书标准快速验证的方法
入门内容覆盖面全,节奏循序渐进,代码逐行讲解翻目录,有没有独立讲「消息映射」和「GDI 绘图」的章节
入门语言轻松易懂,翻译质量过关的优先随机读两页,看术语是否统一、句子是否拗口
进阶讲原理透彻,示例代码精简找讲窗口过程与消息映射对应关系的那一节,看有没有展开讲
进阶对技术应用有阐述,不只是 API 罗列看目录里有没有完整的工程案例,而不是清一色的函数手册

「多看书、少买书」的意思是:在书店或图书馆站着把目录和其中一章读完,比买回家放在架子上强。入门阶段最忌讳同时开三本书,每本都从第一章开始看。

3. 消息机制这层窗户纸:WM_PAINT 到底是谁发给谁的

3.1 一条消息的三件套:UINT 加两个参数

消息这个东西不好下定义,但拆开看结构其实很朴素:一个消息名称(UINT类型),加上两个参数WPARAM和LPARAM。名称决定「发生了什么」,两个参数携带「相关的数据」。用户点菜单、窗口尺寸改变、鼠标划过,系统都会把对应的消息送到某个窗口。

关键在于参数怎么解读,这完全取决于消息类型。WM_COMMAND的高字部分HIWORD(wParam)是命令 ID,菜单消息里它就是菜单项 ID;WM_LBUTTONDOWN的lParam低字是 x 坐标、高字是 y 坐标。所以「记住常用消息名称和参数意义」不是死记硬背,而是记住每个消息的参数按什么规则切分。

消息触发时机wParamlParam
WM_PAINT窗口需要重绘未用未用
WM_LBUTTONDOWN鼠标左键按下按键与键盘状态标志低字 x,高字 y
WM_COMMAND菜单项或按钮被触发高字为命令 ID控件句柄
WM_CREATE窗口正在创建未用指向CREATESTRUCT的指针
WM_SIZE窗口尺寸改变尺寸变化类型低字宽,高字高
WM_DESTROY窗口正在销毁未用未用

自己定义消息也没问题,取一个大于WM_USER的值当消息名,两个参数爱传什么传什么,用来在窗口之间传通知和数据。

窗口过程(WNDPROC)就是处理这些消息的函数,骨架永远是同一个 switch:

// 窗口过程:系统把每条消息送到这里,关心的处理,其余交回默认过程 LRESULT CALLBACK MyWndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { switch (uMsg) { case WM_PAINT: DoMyPaint(hWnd); // 需要重绘时输出内容 return 0; case WM_LBUTTONDOWN: DoMyClick(LOWORD(lParam), HIWORD(lParam)); // 低字取 x,高字取 y return 0; case WM_DESTROY: PostQuitMessage(0); // 投递退出消息,结束消息循环 return 0; default: return DefWindowProc(hWnd, uMsg, wParam, lParam); // 不认识的交给默认过程 } }

这段伪代码里有两个信息量很大的点。第一,default分支不是可写可不写的装饰,它是整个 Windows 窗口能正常工作的原因之一——窗口被拖动时产生的海量消息你一条都不用管,全交给默认过程。第二,想处理什么就加一个 case,处理完return,不处理就落进默认分支。MFC 的消息映射,说到底就是把这套 switch 自动化了。

3.2 窗口句柄:系统怎么知道这条消息该给哪个窗口

窗口句柄HWND是窗口在整个系统范围内的唯一标识。发消息时必须指定一个句柄,系统才知道这条消息投给谁。这一点最直观的验证场景是:两个窗口共用同一份窗口过程代码,你在窗口一上按下鼠标,系统靠窗口一的句柄把消息送到窗口一的实例,而不是窗口二。同一份代码,靠句柄区分落到谁身上。

跟着句柄走的还有一批 GUI 句柄:HDC是绘图环境,HFONT、HPEN、HBRUSH、HBITMAP分别对应字体、画笔、刷子、位图。这些整数句柄本身没有类型安全,用错了编译器不会拦你,只会在运行时报一个看不懂的错。MFC 用类把它们包起来的直接收益就在这儿:CFont只能传给要字体的地方,CPen只能传给要画笔的地方。

在 MFC 里想拿回底层句柄,用GetSafeHwnd(),它顺手还处理了对象指针为空的情况。写调试日志、调用原始 API 的时候会用到。

3.3 消息队列与消息循环:GetMessage 为什么不占 CPU

系统维护着一个或多个消息队列,所有产生的消息先进队列。每条运行中的程序有自己的消息循环,从队列里取出属于自己的消息,按接收窗口的句柄派发到对应的窗口过程。队列空了怎么办?GetMessage会阻塞,把控制权交回系统,所以它不会空转烧 CPU——这也是为什么一个窗口程序开着不动,CPU 占用接近零。

// 典型消息循环:取消息、翻译、派发 MSG msg; while (GetMessage(&msg, NULL, 0, 0)) // 队列为空时阻塞,不占 CPU { if (msg.message == WM_QUIT) // 收到退出消息就跳出 break; TranslateMessage(&msg); // 把按键消息翻译成字符消息 DispatchMessage(&msg); // 按 hwnd 派发到对应窗口过程 }

三个函数的分工要分清:GetMessage负责取,队列空就挂起;TranslateMessage负责把WM_KEYDOWN组合成WM_CHAR,不做这步就收不到正常的字符输入;DispatchMessage负责按msg.hwnd找到窗口过程并调用。参数NULL, 0, 0表示接收属于本线程的所有消息,过滤条件一般不用改。

16 位系统只有一个消息队列,当前任务处理完消息系统才能给下一个,一个程序陷进死循环全系统就没响应,这叫协同式多任务。32 位系统每个程序一个队列,系统可以在多个队列之间切换,不必等谁处理完,这叫抢先式多任务。理解这个差别,就明白为什么在消息处理函数里写一个长循环会把界面卡死——那个线程的队列堵住了,后面的重绘消息全排着。

4. 消息映射不神秘:ON_WM_ 宏展开后的 switch 长什么样

4.1 BEGIN_MESSAGE_MAP 到窗口过程的对应关系

很多人对消息映射发怵,是因为它长得像宏魔法。其实拆开看,它就是第三章那个 switch 的生成器。

// 你在源文件里写的东西 BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) ON_WM_CREATE() ON_COMMAND(ID_FONT_DROPDOWN, DoNothing) END_MESSAGE_MAP()

编译展开后,大致等价于下面这样(实际实现更复杂,这里只取能说明问题的部分):

// 展开后:一个按消息类型分派的函数,所有该类的窗口实例共用 BOOL CMainFrame::OnWndProc(UINT uMsg, WPARAM wParam, LPARAM lParam, LRESULT* pResult) { switch (uMsg) { case WM_CREATE: // 来自 ON_WM_CREATE() *pResult = OnCreate((CREATESTRUCT*)lParam); return TRUE; // 已处理 case WM_COMMAND: // 来自 ON_COMMAND(...) if (HIWORD(wParam) == ID_FONT_DROPDOWN) // 比对命令 ID { DoNothing(); return TRUE; } break; // ID 不匹配,往下走 } return CFrameWnd::OnWndProc(uMsg, wParam, lParam, pResult); // 交给基类 }

看懂这段展开,几个长期困扰新手的疑问就自动消解了。ON_WM_CREATE()后面不带参数,因为它只按消息类型匹配;ON_COMMAND(ID, Func)后面要带 ID 和函数名,因为它得生成一句HIWORD(wParam)的比较。所有实例共用一个分派函数,所以窗口过程是「类级别」的,不是「实例级别」的。最后的return 基类::OnWndProc(...)对应伪代码里的DefWindowProc,不处理的往上抛。

这也解释了另一件事:为什么响应函数的签名不能自己乱改。ON_WM_LBUTTONDOWN对应的OnLButtonDown(UINT nFlags, CPoint point),参数个数和类型是宏里写死的,你手工添加时少写一个参数,编译可能过,运行直接崩。

4.2 常用消息与响应函数对照

ClassWizard 会自动生成这些函数,手工加的时候最容易漏的是参数和在头文件里声明。

消息响应函数参数
WM_CREATEOnCreateLPCREATESTRUCT lpCreateStruct
WM_PAINTOnPaint无(内部取CPaintDC)
WM_LBUTTONDOWNOnLButtonDownUINT nFlags, CPoint point
WM_MOUSEMOVEOnMouseMoveUINT nFlags, CPoint point
WM_SIZEOnSizeUINT nType, int cx, int cy
WM_COMMANDOnXxx(自定义名)无固定签名,由ON_COMMAND指定

手工添加的完整动作有三步,少一步就白干:在消息映射块里加一行ON_XXX,在头文件的//{{AFX_MSG保护块内声明函数,在源文件里写实现。第二步最容易漏,因为缺了它错误信息往往指向别处。

4.3 工程结构选型:对话框、SDI、CFormView 怎么挑

新建工程时那个向导界面,选项多到让人乱点。按需求倒推会简单得多:

需求特征建议结构理由
要读写文件,输入输出简单单文档视(SDI)文档/视图分离白送,存盘逻辑不用自己写
界面交互为主,文件操作少基于对话框没有文档概念,控件直接摆在对话框上
交互多,文件读写也不简单CFormView的单文档视视图本身就是个可放控件的窗体,两边都占
需要在多个文档间传数据多文档(MDI)框架自带多文档管理
多文档要求不强烈单文档 + 分隔条多视比 MDI 好维护,视图切换自己控制
局部功能想打包自定义子窗口把一组控件封进一个子窗口,复用方便

几条实打实的取舍经验。多文档多视能不用就不用,视图和文档的对应关系一旦变成多对多,UpdateAllViews传什么参数、哪个视图该刷新,很容易乱。一个类里别塞太多功能,子窗口封装比往主窗口里堆控件强得多。多重继承在 MFC 里基本是自找麻烦,CObject那条继承链已经够复杂了。

5. 避坑清单:GDI 对象、DC 和那些画不出来的线

5.1 踩坑一:拖几次窗口就卡死——GDI 对象没还回去

现象:程序刚跑起来正常,把窗口拖来拖去、最小化再还原几十次之后,界面不再重绘,任务管理器里 GDI 对象数一路往上涨,最后崩溃。

原因:SelectObject把一个新建的 GDI 对象选进 DC 之后没恢复原来的对象。DC 里始终挂着你选进去的那支笔,而笔是个局部变量,函数返回时它的析构把底层句柄销毁了,DC 里就留下一个失效句柄。每重绘一次漏一个,漏到系统上限就罢工。

解决:记住「创建、选入并保存旧的、用完恢复」三步,一步都不能省。

void CMyView::OnDraw(CDC* pDC) { CPen penNew; penNew.CreatePen(PS_SOLID, 2, RGB(128, 128, 128)); // 1 创建 CPen* pOldPen = pDC->SelectObject(&penNew); // 2 选入并保存原对象 pDC->MoveTo(10, 10); pDC->LineTo(200, 10); pDC->SelectObject(pOldPen); // 3 恢复原对象 // penNew 在这里析构,此时已不在 DC 中,安全 }

如果同一个对象要反复重建,重建前必须先DeleteObject()把它从「已创建」状态清掉,否则重复创建会失败。恢复这一步还有个偷懒做法:如果不需要精确还原,可以选入一个库存对象(SelectStockObject),比如BLACK_PEN、NULL_BRUSH、SYSTEM_FONT。库存对象归系统所有,留在 DC 里是安全的。

另外,画点用SetPixel循环刷一片区域,效率低得离谱,该用刷子填充就老老实实FillRect。

5.2 踩坑二:文字底下多了一块灰底,或者重绘时闪

现象:在视图里输出文字,字后面跟着一块和窗口背景色不一致的色块;窗口被拖动时闪得厉害。

原因:DC 的背景模式默认是OPAQUE,输出文字前会先用当前背景色把字符单元格填一遍。如果你设置过SetBkColor,填的就是那个颜色;如果那块区域的背景是画上去的图形,就会被这块纯色盖住。

解决:把背景模式设成透明。

void CMyView::OnDraw(CDC* pDC) { pDC->SetBkMode(TRANSPARENT); // 关键:不填充字符背景 pDC->SetTextColor(RGB(32, 32, 32)); // 只改文字颜色,不影响背景 pDC->TextOut(20, 20, _T("MFC 入门")); }

注意区分三个容易混的调用:SetTextColor改文字颜色,SetBkColor改背景颜色,SetBkMode决定背景到底填不填。闪烁则是另一个问题,根源在OnDraw里重绘整片区域,WM_ERASEBKGND先把背景刷一遍再画内容,两次绘制之间露了白。常规做法是在OnEraseBkgnd里直接返回TRUE表示已擦除,把绘制集中到OnDraw。

5.3 踩坑三:明明建了字体,画出来还是默认宋体

现象:代码里调了CreateFont或CreateFontIndirect,SelectObject也写了,输出的字却毫无变化。

原因:CFont对象是局部变量,OnDraw一返回它就析构,底层字体句柄被销毁;DC 里那个句柄变成野的,系统回退到默认字体。和第 5.1 条是同一个病根,只是症状不同。

解决:要么把字体对象做成视图类的成员变量,在OnInitialUpdate里建一次;要么严格三步走,用完立刻恢复,并且让字体对象活到恢复之后。

void CMyView::OnDraw(CDC* pDC) { LOGFONT lf; memset(&lf, 0, sizeof(lf)); lf.lfHeight = -20; // 负值表示字符高度,正值表示单元格高度 lf.lfWeight = FW_BOLD; // 粗细,400 等同正常,700 是粗体 lf.lfItalic = FALSE; lf.lfCharSet = ANSI_CHARSET; // 字符集,中文环境也可用 DEFAULT_CHARSET _tcscpy(lf.lfFaceName, _T("宋体")); CFont font; font.CreateFontIndirect(&lf); // 创建 CFont* pOldFont = pDC->SelectObject(&font); // 选入并保存 pDC->TextOut(10, 10, _T("粗体宋体")); pDC->SelectObject(pOldFont); // 恢复后再析构 }

想更省事,用CreatePointFont(120, _T("宋体")),第一个参数是磅数的十倍,120 就是 12 磅。想枚举系统里有哪些字体,用EnumFontFamiliesEx;想让用户自己挑,直接弹CFontDialog,确定之后从对话框里取LOGFONT就够了,不必自己造界面。

5.4 踩坑四:手工加了 ON_COMMAND,编译过了,点菜单没反应

现象:菜单项点下去毫无反应,不放断点根本不知道进没进函数;有时候更诡异,别的菜单项反而触发了这个函数。

原因集中在四个地方:命令 ID 写错或者两个菜单项用了同一个 ID;消息映射块加在了错误的类里,比如菜单属于框架窗口,代码却写在视图类;头文件里漏了函数声明,或者声明放到了//{{AFX_MSG保护块外面被 ClassWizard 覆盖;ON_COMMAND比较的是HIWORD(wParam),用ON_CONTROL之类写错了匹配规则。

解决:按顺序排查。先用资源编辑器确认菜单项的 ID 值,再确认这个菜单挂在哪个窗口上,然后把ON_COMMAND加到对应类的消息映射块里,最后检查声明位置。ID 重复这种事在多人协作过的老工程里很常见,值得花五分钟全局搜一遍 ID 值。

5.5 踩坑五:CString 拼字符串拼出一堆乱码

现象:CString用+拼接或者Format格式化之后,输出乱码,或者程序在某个ASSERT处停下来。

原因:CString到LPCTSTR的隐式转换很方便,但Format的格式符和实际参数类型不匹配时不会报错。用%s传了一个宽字符指针、用%d传了一个DWORD,结果都不可预期。工程字符集设置不同,CString底层的字符宽度也不一样。

解决:格式符和参数类型逐一对齐,字符串常量统一用_T()包起来。调试期善用TRACE输出中间结果,用ASSERT卡住明显不该出现的值,用VERIFY检查必须在发行版也执行的表达式。这三个宏的手感一旦建立起来,定位这类问题会快很多。

6. 收在一个能直接抄的技巧上:DT_CALCRECT 算完再居中画字

DrawText有一组标志里藏着一个特别好用的组合:DT_CALCRECT。它的作用是只计算、不绘制,把「这段文字在这个宽度下排出来需要多大一个矩形」写回你传进去的CRect,同时把这块高度作为返回值。有了这个尺寸,居中、自动换行、动态调整行高这些事全都能手动算出来,不必再靠GetTextExtent一行行估。

void CMyView::OnDraw(CDC* pDC) { CString sText = _T("MFC 入门:先把消息机制啃下来,再看消息映射"); CRect rcClient; GetClientRect(&rcClient); // 取客户区范围 CFont font; font.CreatePointFont(120, _T("宋体")); // 12 磅 CFont* pOldFont = pDC->SelectObject(&font); // 选入并保存原字体 CRect rcCalc(0, 0, rcClient.Width(), 0); // 宽度给定,高度交给系统算 pDC->DrawText(sText, &rcCalc, DT_CALCRECT | DT_WORDBREAK); // 只算不画 int x = (rcClient.Width() - rcCalc.Width()) / 2; int y = (rcClient.Height() - rcCalc.Height()) / 2; pDC->SetBkMode(TRANSPARENT); pDC->DrawText(sText, CRect(x, y, x + rcCalc.Width(), y + rcCalc.Height()), DT_CENTER | DT_WORDBREAK); // 这次真画 pDC->SelectObject(pOldFont); // 恢复,字体对象才能安全析构 }

几个参数值得单独记一下。DT_CALCRECT只算不画,返回值是文字高度;DT_WORDBREAK让长行在矩形边界内按词折行,没有它就算不出多行的高度;DT_CENTER是水平居中,要垂直居中得靠上面那两行x、y的减法自己算。rcCalc初值里宽度必须给对,高度随便填,系统会覆盖。

这个套路我到现在还在用。窗口大小变了要重排文字、按钮上要画两行标签、状态栏里要显示一段可换行的提示,都是先DT_CALCRECT算一遍尺寸再决定画在哪。它把「排版」这件看起来要靠猜的事,变成了两个整数相减。

回过头看,那些年翻过的车基本都能归到一句话上:句柄藏进类里之后,我忘了它还在被谁持有。从那以后我每次SelectObject都强制走一遍「保存旧的、用完恢复」,写OnDraw之前先问自己一句「这里创建的对象,函数返回时还在 DC 里吗」。这个习惯比记住多少个 API 都管用。这份 MFC 入门的整理稿我按上面的顺序编排过,跟着敲一遍比只看强得多。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进