ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

MFC入门先打通三条链路:消息机制、消息映射与GDI资源管理

MFC入门先打通三条链路:消息机制、消息映射与GDI资源管理

简介:面向准备进入 VC++6.0 与 MFC 开发的学习者,这份入门资料以一篇 doc 文档梳理从 C/C++ 基础到 Windows 消息驱动编程的关键路径,适合刚接触 Windows 桌面开发、需要建立整体认知的初中级读者。压缩包内仅 1 个 doc 文件,约 521KB,篇幅集中,便于通读与检索。内容围绕扎实 C/C++ 基础、理解消息组成与消息循环、窗口句柄及 GUI 句柄用途、掌握 MFC 消息映射等要点展开,并补充借助 Help Online 查资料、阅读他人代码、按阶段选择参考书等学习方法。文中还讨论了 MFC 借助 C++ 封装、继承与消息映射简化 Windows 开发的优势,可帮助读者少走弯路,形成可继续深入的学习框架。已有 731 人学习。

1. 结论先摆出来:MFC 入门的瓶颈不在类库,在三条链路

第一次打开 VC++6.0 建 MFC 工程,ClassWizard 弹出一堆复选框,我盯了半天点了"完成",编译运行,出来一个空白窗口,然后彻底卡住——不知道下一行该写哪儿。后来我把 MFC 的类层次硬背了一遍,还是写不出一个能响应按钮的程序。真正让我开窍的,是把"消息怎么从系统跑到我那个函数里"这条链路自己画了三遍。MFC 入门听着像"学一个类库",实际考的是三件事:C++ 底子够不够、Windows 消息机制懂不懂、消息映射会不会看。下面按我踩过的顺序摊开,新手能照着排学习路线,已经在写的能对上号找到自己卡在哪一环。

2. 打底这一关:字符界面练手、常用类速查、程序骨架怎么挑

2.1 先写三个字符界面程序,再打开 ClassWizard

很多人学 VC 的第一反应是赶紧拖控件、赶紧出界面,我当年也这么干,结果是被一堆编译错误和空白窗口反复教育。字符界面程序的价值在于:错了有明确报错,崩了有明确行号。MFC 里错了往往是一个窗口没画出来、一个按钮没反应,你连从哪儿查都不知道。

要给 MFC 打底,字符界面阶段至少要练透四样东西。第一是类与对象的生命周期,构造函数什么时候跑、析构函数什么时候跑、成员对象和指针成员的区别——MFC 里 CPen、CBrush 这些 GDI 对象的创建销毁顺序出问题,程序就是直接异常。第二是指针和引用的传参,MFC 的函数签名满屏都是CDC* pDC、LPCTSTR lpszText、CRect& rect,分不清值传递和引用传递,改了半天发现改的是副本。第三是继承与虚函数,CPen 继承自 CGdiObject、CView 继承自 CWnd,不理解基类指针调派生实现,消息映射那章根本读不下去。第四是运算符重载和 const 正确性,CRect能和CPoint相加、CString的+等价于 strcat,这些都是重载出来的。

我一般会建议按这个顺序写三个小东西:一个用链表或数组做的成绩管理(练指针、内存和结构体)、一个"图形基类 + 圆 + 矩形"的求面积程序(练虚函数,正好给后面的 GDI 铺垫)、一个字符串处理小工具(练自己封装一个类似 CString 的类,体会自动扩容的难点在哪)。三个写完再打开 ClassWizard,你会发现它生成的代码你至少能看懂一半了。

提示:字符界面阶段不要用任何框架,纯控制台。这一步省下的时间,后面会用几倍的调试时间还回去。

2.2 CRect、CPoint、CString 速查:每天都要用的那几个成员

MFC 的类有几百个,但真正每天都在敲的就那么几个。把下面这张表记住,写绘图和字符串处理代码的速度会明显不一样。

类常用成员说明
CRectWidth()/Height()/Size()取宽、高、尺寸(CSize)
CRectTopLeft()/BottomRight()/CenterPoint()取左上角、右下角、中心点,返回 CPoint
CRect四种构造四个坐标;一个 RECT;一个 LPCRECT;左上角点 + 尺寸;两个对角点
CPointx/y两个公有成员变量,可直接读写
CPointoperator+点和点相加做位移
CStringGetLength()/GetAt(int)取长度、取指定位置字符
CStringFormat(LPCTSTR, ...)等价于 sprintf,但目标是自己
CStringFind/Compare/CompareNoCase查找、比较、忽略大小写比较
CStringMakeUpper()/MakeLower()转大写、转小写
CStringArrayAdd/RemoveAt/RemoveAll增、删指定位置、清空
CStringArrayGetAt/SetAt/InsertAt取、改、指定位置插入

几个容易记混的点:CRect 和 CPoint 相加是整体位移,CRect 和另一个 CRect 相加是求并集,不是逐坐标相加;CString 的内存自己管,不要拿free去释放它的缓冲区;CStringArray 里存的是 CString 对象的拷贝,GetAt返回的是引用,改它等于改数组里的元素。

2.3 单文档、对话框、CFormView:按交互复杂度挑骨架

新手最容易犯的错是"默认选了 MDI,然后一路硬扛"。原文给的那套选择方法我基本认同,整理成一张按需求查的表更直观:

程序特征建议骨架理由
要读写文件,输入输出简单单文档视(SDI)文档/视图分离,文件操作有现成位置放
交互为主,文件读写也简单对话框为基础省掉文档模板,控件直接摆
交互为主,文件读写复杂以 CFormView 为基础视的 SDI既能摆控件,又有文档管理
需要在多个文档之间传数据多文档视(MDI)这是 MDI 真正不可替代的场景
要多视图但不需要多文档分隔条做 SDI 多视图比 MDI 简单得多

除了结构选择,还有几条我吃过大亏的原则。多文档多视结构能不用就不用,它的消息路由和视图同步足够让新手怀疑人生;不要用多重继承,MFC 本身大量依赖单继承加虚函数,你再加一层多继承,调试器里的调用栈会变得很难读;一个类里不要塞太多功能,宁可多拆几个自定义子窗口,把一组控件封装进去,也比在一个 View 类里写两千行强。自定义子窗口这个技巧值得早学,它让"封装"这个概念从书本上的词变成你手上能用的东西。

2.4 选书:初学者要全面,进阶要原理,买前一定翻三页

关于参考书,我的看法和原文一致但要说得更具体。初学者选书看三条:内容覆盖面够全(别买只讲对话框的)、章节安排是循序渐进的(前后有依赖关系,不是知识点罗列)、代码有逐行讲解(只有代码没有解释的书等于电子版示例库)。翻译的书通常语言更轻松、更易懂,作为入门读物是合适的。

已经能写程序的阶段,选书标准反过来:要找讲系统原理和技术要点讲得透的,最好带一点应用示例,但示例代码要精简——几千行的示例工程你根本读不完,只会增加挫败感。买之前一定要在线翻三页正文,看它讲一个函数的时候是只列参数,还是会告诉你什么场景下该用它、用错了会怎样。买到一本不合用的书,损失的不只是钱,是学习积极性,这个亏我吃过不止一次。

3. 消息机制与消息映射:把黑匣子拆到 switch 那一层

3.1 一条消息的解剖:UINT 加两个参数,配一个窗口句柄

Windows 是消息驱动的系统,这句话谁都会背,但真正理解它要落到结构上:一条消息就是一个消息名称(UINT)加两个参数(WPARAM、LPARAM)。消息名称决定"发生了什么",两个参数承载"跟这件事有关的数据"。用户输入、窗口状态变化,系统都会往某个窗口投递消息。

窗口句柄(HWND)是这套机制的地址簿。系统靠它在整个系统里唯一标识一个窗口,投递消息时必须指定句柄,才能保证消息进到正确的窗口过程。举个容易想通的例子:两个窗口共用同一份窗口过程代码,你在窗口一上按鼠标,消息通过窗口一的句柄送进去,窗口二收不到——代码是同一份,但句柄不同,实例就不同。

WM_COMMAND 是新手最常打交道的一条。菜单被选中、按钮被点击,都会走这条消息,命令 ID 藏在HIWORD(wParam)里。菜单项的 ID 就是资源编辑器里给它起的那个值,按钮的通知码藏在LOWORD(wParam)里,lParam则是发出通知的控件句柄。这三个位置分清楚了,你写"点击按钮改标题"这类代码就不会再靠猜。

// 窗口过程:所有消息最终都会走到这个 switch 上 LRESULT CALLBACK MyWndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { switch (uMsg) { case WM_PAINT: // 窗口需要重绘 DoPaint(hWnd); return 0; case WM_LBUTTONDOWN: // 左键按下,lParam 低字是 x,高字是 y DoClick(LOWORD(lParam), HIWORD(lParam)); return 0; case WM_COMMAND: // 菜单/按钮,HIWORD(wParam) 是命令 ID OnCommand(LOWORD(wParam), HIWORD(wParam), (HWND)lParam); return 0; default: return DefWindowProc(hWnd, uMsg, wParam, lParam); // 不关心的交给系统 } }

这段伪代码的关键在最后那个default分支。系统为每个窗口准备了默认窗口过程,你不想处理的消息全都交给它,窗口照样能拖动、能最小化、能响应系统菜单。这一点很重要:MFC 之所以能让你只写自己关心的那几个函数,底层靠的就是这个"不处理就下沉"的机制。LOWORD和HIWORD是把 32 位参数拆成两个 16 位值的宏,鼠标坐标这种"一个参数塞两个值"的场合到处都是。

3.2 消息映射宏展开后到底是什么

理解了窗口过程,再看 MFC 的消息映射就不玄了。你写的是宏,编译器展开的是一张"消息到成员函数"的分派表。

// 你在 .cpp 里写的 BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) ON_WM_CREATE() // 对应 WM_CREATE ON_COMMAND(ID_FONT_DROPDOWN, OnFontDropdown) // 对应 WM_COMMAND 且 ID 匹配 END_MESSAGE_MAP()

展开之后大致是这样一层结构(真实实现更复杂,这里只保留分支关系):

// 编译器展开后的等效逻辑 LRESULT CMainFrame::WindowProc(UINT uMsg, WPARAM wParam, LPARAM lParam) { switch (uMsg) { case WM_CREATE: return OnCreate((CREATESTRUCT*)lParam); // 参数原样转给成员函数 case WM_COMMAND: if (HIWORD(wParam) == ID_FONT_DROPDOWN) // 命令 ID 对上才进自己的函数 { OnFontDropdown(); return 0; } break; } return CFrameWnd::WindowProc(uMsg, wParam, lParam); // 都不匹配就交给父类 }

BEGIN_MESSAGE_MAP的第二个参数就是"父类",展开后变成最后那一行"都没命中就交给父类"的调用。这就是为什么你只写十几个函数,窗口却能响应上百种消息——没写的全顺着继承链往上走了。ON_WM_CREATE()这种不带参数的宏,靠的是固定的命名约定:WM_CREATE 对应OnCreate,WM_PAINT 对应OnPaint,签名也必须对得上。搞错一个参数类型,编译能过,运行时直接崩。

3.3 消息循环与 16 位、32 位队列的差别

消息产生后进队列,程序从队列里取,取到就派发。这个循环是所有 Windows 程序的骨架:

MSG msg; while (GetMessage(&msg, NULL, 0, 0)) // 队列空时在这里阻塞,不占 CPU { TranslateMessage(&msg); // 把 WM_KEYDOWN 翻成 WM_CHAR DispatchMessage(&msg); // 按 hwnd 找到窗口过程并调用 } return msg.wParam; // GetMessage 返回 0 表示收到 WM_QUIT

GetMessage在没有消息时会挂起,把控制权交还系统,所以你的程序空闲时 CPU 占用接近零。TranslateMessage是把按键消息翻译成字符消息的那一步,少了它,编辑框里敲键盘出不来字。DispatchMessage负责按句柄找到窗口过程,这一步是系统做的,你插不上手。

16 位系统和 32 位系统的区别值得提一句,因为它解释了为什么老程序一卡全卡。16 位系统全系统只有一个消息队列,系统必须等当前任务处理完消息才轮到下一个,某个程序陷入死循环,整个系统就失去响应,这种叫协同式多任务。32 位系统每个运行的程序有自己的消息队列,系统可以在多个队列之间切换,不必等某个程序处理完,这种叫抢先式多任务。所以你在自己程序里写了一个耗时循环,受害的只是你自己的窗口,别的程序照常跑。理解这一层,你就明白为什么长时间计算要拆成小块、或者放到别的线程里去做。

4. GDI 对象与 DC:绘图不崩的三条规矩

4.1 DC 是抽象的作图环境,OnDraw 一份代码顶三处

DC(设备环境)是一个抽象的作图环境,它可能对应屏幕,也可能对应打印机。这个抽象带来的好处叫设备无关性:同一套作图代码,换个 DC 就换个输出设备,作图方式一行都不用改。

MFC 把这份好处落在了OnDraw上。窗口需要重绘时它自动被调用,传进来的CDC* pDC就是当前该用的 DC。打印时传进来的是打印机 DC,打印预览时传进来的是一个专门做预览的 DC 派生类。所以你只要把绘图代码写在OnDraw里,窗口显示、打印预览、实际打印三件事一次做完。这是 MFC 里少见的"写一遍省三遍"的地方,值得早点养成习惯。

输出文字用两个函数就够了。TextOut(x, y, str)只能出单行,坐标是起点;DrawText(str, lpRect, nFormat)在一个矩形里排版,能多行、能对齐、能省略。nFormat是一堆DT_标志的位或组合:DT_SINGLELINE单行、DT_CENTER水平居中、DT_VCENTER垂直居中(必须配单行)、DT_WORDBREAK按单词折行、DT_END_ELLIPSIS尾部超长显示省略号、DT_NOPREFIX关掉&的助记符处理。文字颜色用SetTextColor,背景色用SetBkColor,想去掉背景色块就用SetBkMode(TRANSPARENT)。

行距怎么算?GetTextExtent(str)拿到这个字符串输出时的宽和高,手工排多行就靠它。要更精确的字体度量用GetTextMetrics,填出来的结构体能描述字体的各种属性。这两个函数配合,比拍脑袋写死行高靠谱得多。

4.2 画笔、刷子、字体:创建、选入、恢复的顺序不能乱

画笔决定线条,刷子决定填充,字体决定文字长相。三者的使用流程是同一套:创建合法对象 → 选进 DC 并保存原对象 → 用完恢复原对象 → 销毁对象。

创建画笔用CreatePen(nPenStyle, nWidth, crColor)。风格里PS_SOLID实线、PS_DASH虚线、PS_DOT点线、PS_DASHDOT点划线、PS_NULL空线(画了等于没画)。这里有一个必须记住的限制:虚线、点线、点划线这几种,宽度只能是 1,填 2 以上系统会画成实线,不报错,就是效果不对,查起来很费时间。

字体是最麻烦的一个。两种创建方式:CreateFontIndirect填一个 LOGFONT 结构体,或者直接调CreateFont传一长串参数。常用的几个参数含义如下:

参数含义常用取值
nHeight字符逻辑高度0 表示用系统默认高度并做匹配
nWidth字符逻辑宽度0 表示按可用字体的宽高比匹配
nWeight字重(粗细)FW_NORMAL=400,FW_BOLD=700
bItalic / bUnderline / cStrikeOut斜体 / 下划线 / 删除线0 或 1
nCharSet字符集ANSI_CHARSET、DEFAULT_CHARSET、GB2312 相关
nQuality输出质量DEFAULT_QUALITY 够用,PROOF_QUALITY 用于精确排版
lpszFacename字体名用EnumFontFamiliesEx枚举系统已装字体

颜色和背景色不属于字体属性,它们是 DC 的属性,别在字体上找。

4.3 库存对象是最好的后悔药,DeleteObject 的时机要卡准

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 = pDC->SelectObject(&pen1); // 选入,同时记住 DC 里原来的笔 DrawWithGray(pDC); // 这一段用 pen1 pDC->SelectObject(&pen2); // 换成 pen2 DrawWithOlive(pDC); pen1.DeleteObject(); // 同一对象重新创建前必须先销毁 pen1.CreatePen(PS_SOLID, 2, RGB(0, 0, 0)); pDC->SelectObject(&pen1); DrawWithBlack(pDC); pDC->SelectObject(pOldPen); // 收尾:把原来的笔还回 DC }

三个动作的因果必须清楚。第一,SelectObject会返回 DC 里原来的对象,这个返回值一定要存下来,不存就没法恢复。第二,同一个 CPen 对象在重新CreatePen之前必须先DeleteObject,否则底层句柄泄漏,短时间看不出问题,长时间运行会失败。第三,DC 里不能留着一个已经销毁的对象——如果你把局部 CPen 选进 DC,函数返回时它析构了,DC 里就是一个野句柄,下一次绘图直接异常。这类异常没有明确行号,排查起来就是玄学,所以顺序必须从一开始就写对。

库存对象是最好的后悔药。系统预置了一批固定颜色的刷子、画笔和基本字体,BLACK_BRUSH、WHITE_BRUSH、HOLLOW_BRUSH、NULL_BRUSH、BLACK_PEN、WHITE_PEN、NULL_PEN、SYSTEM_FONT、ANSI_FIXED_FONT等,用SelectStockObject(nIndex)直接选进去。这些对象常驻系统,不会因为你没销毁而泄漏,所以既能当省事的默认值,也能当恢复现场时的兜底。

5. 避坑与排查:五条血泪经验,每条都能省你半天

5.1 画笔第二次创建就异常

现象:程序第一次绘图正常,第二次调用同一个 CPen 重新CreatePen时弹异常或者直接崩溃。

原因:同一个 GDI 对象没调用DeleteObject就再次创建,底层句柄冲突。另一种情况是对象被选在 DC 里的时候把它销毁了,DC 里留下野句柄。

解决:把"创建—选入—恢复—销毁"当成一个固定模板来写,每次创建前先if (m_pen.GetSafeHandle()) m_pen.DeleteObject();保险一下;成员 CPen 不要放在OnDraw里临时创建,挪到OnInitialUpdate里创建一次,OnDraw里只做选入和恢复。

5.2 加了处理函数却没反应

现象:手写了一个OnBnClickedOk,签名看着没错,编译也过了,点按钮就是没进断点。

原因:函数写了但消息映射里没加对应条目。ON_COMMAND(IDC_BTN_OK, OnBnClickedOk)或者ON_BN_CLICKED那行漏了。另一种是宏写对了但 ID 拼错,或者函数签名跟宏要求的参数类型不一致,展开后类型不匹配,进不去。

解决:优先用 ClassWizard 添加处理函数,它会同时改 .h 声明和 .cpp 的消息映射表;手写的时候,改完立刻在映射表里搜一遍那个函数名,搜不到就是没加。签名对不上时,从 ClassWizard 生成一个同名空函数,把里面的代码搬过去,这是最快的确诊方式。

5.3 文字只出一行或被截断

现象:DrawText输出长文本,只有第一行,后面全没了;或者文字旁边多出奇怪的下划线。

原因:忘了加DT_WORDBREAK,多行文字不会自动折行;或者 lpRect 给的高度不够。下划线那个是&被当成助记符前缀处理了,&&才会输出一个&。

解决:多行输出固定用DT_WORDBREAK或DT_SINGLELINE二选一,别都不给;排版前先用DT_CALCRECT让DrawText算出需要多高,再按算出来的高度绘制;不想让&被解释就加DT_NOPREFIX。

5.4 窗口一拖动,画的东西全没了

现象:客户区画的图形,窗口被其他窗口遮住再恢复、或者拖动改变大小之后,图形消失。

原因:绘图代码没有写在OnDraw(或OnPaint)里,而是塞在按钮响应或者其他函数里。窗口重绘时系统只走OnPaint这条链,你写在别处的代码不会被重新执行。

解决:所有针对客户区的绘图都放进OnDraw;需要主动刷新时调用Invalidate()或InvalidateRect()请求重绘,不要直接去调绘图函数;需要立即刷新就补一句UpdateWindow()。

5.5 照书抄,函数名和注释对不上

现象:参考书上写MakeUpper是"改为小写"、MakeLower是"改为大写",照着理解写逻辑,结果大小写正好反过来。

原因:参考书和官方文档里的笔误、翻译错误,或者版本差异导致函数行为和书中描述不一致。

解决:以在线帮助(Help Online)的原厂说明为准,书只用来建立框架理解,具体函数签名和行为一律现查。养成这个习惯之后你会发现,查一次的时间比调试半小时短得多。

6. 从能跑到能读:一个我坚持了很多年的动作

现在回头看,学 MFC 期间收益最高的一个动作,不是把类库背下来,而是逼自己"写代码时不翻参考书,只翻在线帮助"。刚开始极其痛苦,查一个函数要在索引里翻半天,还经常查不到。但坚持两三个月之后,你会建立起一种依赖——相信查得到、习惯去查、并且知道该往哪个关键词上查。这个能力直接决定了你后面能不能自学新东西。

具体的做法很简单:遇到不认识的函数,先按 F1 或者打开在线帮助,看它的原型、参数、返回值、备注里的限制条件。特别注意备注段,那些"这个标志只有配合某某标志才有效""宽度必须为 1""返回值为空不代表失败"的限制,参考书上经常不写,但真出问题全在这儿。常用的消息名称和参数意义也要刻意记,WM_COMMAND 的两个参数里装什么、WM_PAINT 的 wParam 什么时候有用,记住这几条,读别人代码的速度是量级差别。

第二件事是学会看别人的代码。找一段能跑起来的、结构干净的 MFC 示例,从 WinMain 或 InitInstance 往下跟,看它怎么注册窗口类、怎么建主窗口、消息映射表里挂了哪些条目、每个条目最后落到哪个函数。跟三遍,你对 MFC 的运行顺序就有了肌肉记忆。我一般还会把消息映射表抄一遍,抄的时候把每个宏对应的消息名写在旁边,抄完基本就记住了。

那份入门笔记本身的价值也在这儿:它把消息机制的讲法、GDI 对象的使用顺序、常用类的成员清单、以及程序结构怎么选,按从底到上的顺序串成了一条线,没有跳到高级话题上去炫技。如果你正卡在"窗口出来了但不知道下一步写什么",把它的 1.1 到 2.3 照着走一遍,比我上面说的任何一条都管用。

到现在我写任何一段涉及资源创建和销毁的代码,都会强制走一遍"创建—选入—保存旧值—恢复—销毁"这五步,哪怕只是临时画一条线。这个习惯救过我太多次,早年间查过的一个 GDI 泄漏,从发现到定位花了整整两天,最后就是少了一行DeleteObject。希望帮到你。

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

RELATED READING

延伸阅读

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