
简介一份面向C/C基础较好、正准备转入Windows界面开发的初学者的MFC入门学习笔记。文档从“如何学好VC”谈起先强调通过字符界面程序夯实C功底再系统梳理Windows消息机制消息的组成、窗口过程、默认处理、消息队列与窗口句柄等并对比16位与32位系统的多任务差异。同时说明MFC借助C封装、派生、虚拟特性带来的开发优势以及消息映射用宏定义分派消息的基本实现思路。全文以1个doc文档承载压缩包约521KB篇幅精简但结构完整没有冗杂示例代码更适合作为提纲式导读。已有729人学习浏览尤其适合作为自学VC6.0 MFC的第一份入门材料也可用于快速理解消息驱动与MFC框架核心原理。1. MFC入门别急着拖控件先把消息机制和 C 底子打牢很多人接触 VC 6.0 的第一反应是打开 ApplicationWizard 生成一个对话框工程然后往界面上拖按钮但真正在 MFC 入门阶段拉开差距的往往不是拖控件的手速而是对 Windows 消息机制和 C 对象模型的理解深度。我拆这份入门资料时最有感触的一点是它没有一上来贴代码而是先用一整章讲清楚“消息是什么、消息从哪来、没处理的消息去哪了”这些恰恰是后面理解消息映射、编写窗口过程、排查界面不刷新等问题的地基。这份材料适合两类人一类是有 C/C 语法基础、正要转向 Windows 界面开发的初学者另一类是写过几年业务代码、但始终觉得 MFC 是黑匣子、想补原理的开发者。按它的路线走核心收益是把“照着书抄”变成“知道为什么这么写”。2. 理解 Windows 消息机制消息、句柄与消息队列2.1 消息的组成UINT 名称与两个参数Windows 是消息驱动的操作系统这一点和传统顺序执行的字符界面程序完全不同。一个消息由三部分组成消息名称UINT 类型以及 WPARAM、LPARAM 两个参数。消息名称决定“发生了什么”比如鼠标按下是 WM_LBUTTONDOWN窗口需要重绘是 WM_PAINT菜单被选中是 WM_COMMAND。两个参数则携带上下文信息比如鼠标消息里 WPARAM 可能存放按键状态LPARAM 的低位和高位分别存放鼠标的 x、y 坐标。以菜单消息为例当用户点击菜单项后系统会发送 WM_COMMAND 到窗口这时 WPARAM 的高字HIWORD(wParam)是命令 ID 号对菜单来说就是菜单项的 ID。只要在资源编辑器里给菜单项分配了 ID程序里就能用这个 ID 判断用户点了哪个菜。除了系统预定义消息你还可以用 WM_USER 以上的数值自定义消息用来在窗口之间发送通知或传递数据。这个能力在自绘控件、子窗口回调场景里非常常用。// 一个消息的基本结构伪代码形式 struct tagMSG { HWND hwnd; // 接收消息的窗口句柄 UINT message; // 消息名称如 WM_PAINT、WM_COMMAND WPARAM wParam; // 附加信息语义随消息类型变化 LPARAM lParam; // 附加信息语义随消息类型变化 DWORD time; // 消息产生时间 POINT pt; // 鼠标位置 };注意 WPARAM 和 LPARAM 在 32 位系统里都是 4 字节它们的语义完全由具体消息决定。比如 WM_COMMAND 的 wParam 高字是命令 ID、低字是发送通知码WM_LBUTTONDOWN 的 lParam 低字是 x 坐标、高字是 y 坐标。不要死记硬背每个消息的参数含义而是要在用到某条消息时直接查联机帮助时间长了自然就记住了。这份资料里反复强调“不翻书、多用 Help Online”其实就是在训练你建立查证习惯。2.2 窗口过程谁收到消息没处理的消息去哪了消息必须由一个窗口接收。每个窗口都有一个关联的窗口过程WNDPROC窗口过程本质上是一个回调函数负责对收到的消息做分析、分类、处理。你可以用 switch 语句把感兴趣的消息挑出来写处理逻辑不感兴趣的交给默认窗口过程处理。这里的关键认知是窗口过程不是你自己主动调用的而是系统在消息到达时自动回调的你只负责注册这个过程的入口地址。// 窗口过程的标准骨架伪代码 LONG YourWndProc(HWND hWnd, UINT uMessageType, WPARAM wParam, LPARAM lParam) { switch (uMessageType) { case WM_PAINT: // 窗口需要重新绘制时在这里输出 DoYourWindow(hWnd); break; case WM_LBUTTONDOWN: // 鼠标左键按下时在这里处理 DoYourWork(hWnd, LOWORD(lParam), HIWORD(lParam)); break; default: // 其余消息交给系统默认过程 return CallDefaultWndProc(hWnd, uMessageType, wParam, lParam); } return 0; }这里有个重要细节默认窗口过程是系统提供的它负责处理那些你不关心的消息比如窗口拖动时的 WM_MOVE、窗口大小变化时的 WM_SIZE、点击关闭按钮时的 WM_CLOSE。正因为它存在开发者才能只关注和自己业务相关的消息而不必为每一种系统行为写处理代码。举例来说窗口被拖动时系统会连续发送多条消息你完全不用管系统默认过程会把窗口位置和外观都维护好。2.3 窗口句柄与消息队列系统怎么找到接收者说到消息就不能不提窗口句柄 HWND。系统通过句柄在整个系统中唯一标识一个窗口发送消息时必须指定句柄以此表明消息由哪个窗口接收。两个窗口可以共用同一个窗口过程代码但句柄不同消息就会被正确分发到各自的目标。你可以把句柄理解为窗口的身份证号静态成员函数或全局函数通过它才能定位到具体窗口实例。消息机制的核心是队列。系统维护一个或多个消息队列所有产生的消息都被放入或插入队列中。每个运行的程序有自己的消息循环循环从自己的队列中取出属于本程序的消息根据消息里的接收句柄调用对应的窗口过程。当程序没有消息时getMessage 不会返回程序让出 CPU 控制权这也是 Windows 能同时运行多个任务的基础。// 消息循环的标准写法伪代码 while (GetMessage(msg, NULL, 0, 0)) { if (msg.message WM_QUIT) break; TranslateMessage(msg); // 将虚拟键消息转换为字符消息 DispatchMessage(msg); // 把消息分发给窗口过程 }GetMessage 在队列为空时阻塞等待不占用 CPU 时间TranslateMessage 负责把键盘的按下/抬起消息翻译成字符消息DispatchMessage 才真正调用窗口过程。在 16 位系统里只有一个全局消息队列系统必须等当前任务处理完消息才能发送下一条任务陷入死循环整个系统就卡死这被称为协同式多任务。32 位系统中每个程序都有独立的队列系统可以在各队列间切换而不必等待当前程序完成这就是抢占式多任务。MFC 之所以能在全局上简化开发正是因为它把这套窗口过程、消息循环都封装进了框架的底层但也正因为封装很多人反而搞不清消息到底是怎么流转的。3. MFC 消息映射从宏展开到消息处理函数3.1 MFC 为什么不用虚函数处理消息如果 MFC 给每个类封装几十甚至上百个虚函数来处理消息那每个派生类都要承载巨大的虚函数表而且 C 虚函数机制对消息这种“多对一”的分发场景并不友好——你只想处理两三个消息却要继承一个带几十个虚函数的基类。MFC 选择了消息映射Message Map方案用宏在类内部声明一张静态映射表把消息编号和对应的处理函数指针关联起来查找时按表扫描命中就调用未命中就交给基类的映射表继续查。这样既保持了 C 的封装和派生特性又避免了虚函数表的开销和混乱。要理解 MFC 的消息映射必须回到上一章讲的窗口过程。任何一个 CWnd 派生类创建的窗口其窗口过程入口都是相同的MFC 在这个公共入口里拿到消息后会查找当前对象所属类的消息映射表找到就调用对应的成员函数找不到就沿继承链向上逐级查找基类的映射表最终没找到就交给 DefWindowProc。3.2 宏展开看穿消息映射的本质资料里给了一段非常直观的展开过程。先看源码中的消息映射声明BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) //{{AFX_MSG_MAP(CMainFrame) ON_WM_CREATE() // 处理 WM_CREATE 消息 //}}AFX_MSG_MAP ON_COMMAND(ID_FONT_DROPDOWN, DoNothing) // 处理菜单命令 END_MESSAGE_MAP()这段代码经过预处理后本质上被展开成一个形如 switch 的分发过程虽然实际实现比这复杂但逻辑等价于// 经过展开后的等价逻辑用于示意消息映射机制 LRESULT CMainFrame::WindowProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) { switch (message) { case WM_CREATE: // ON_WM_CREATE 宏展开后的调用 return OnCreate(reinterpret_castLPCREATESTRUCT(lParam)); case WM_COMMAND: // ON_COMMAND 宏展开后的调用 if (HIWORD(wParam) ID_FONT_DROPDOWN) { DoNothing(); return 0; } break; default: break; } // 继续查基类映射表最终交给 DefWindowProc return CFrameWnd::WindowProc(hWnd, message, wParam, lParam); }注意几个细节ON_WM_CREATE 这种宏是预定义的消息映射宏它会自动把 WM_CREATE 消息与 OnCreate 成员函数绑定参数顺序和类型都由宏已经规定好ON_COMMAND 则是命令消息宏需要显式给出命令 ID 和处理函数名它拦截 WM_COMMAND 消息然后比较 wParam 高字中的 ID 是否匹配。窗口过程是所有该类的实例共享的只要这个类的对象创建了窗口消息就会被路由到同一个入口再通过 this 指针找到具体的对象实例调用对应的成员函数。这就是 MFC 能把系统消息“翻译”成 C 成员函数调用的核心机制。3.3 消息分类与 ClassWizard 的维护约定MFC 消息分三类。标准 Windows 消息如 WM_CREATE、WM_PAINT、WM_TIMER由窗口类和视图类处理命令消息 WM_COMMAND来自菜单、工具栏按钮、快捷键由框架、文档、视图、窗口等按路由顺序传递控件通知消息由子控件发送给父窗口如按钮点击的 BN_CLICKED。不同消息的映射宏写法不同一定要区分。ClassWizard 是 VC 6.0 里维护消息映射的可视化工具它会把自动生成的映射条目放在//{{AFX_MSG_MAP和//}}AFX_MSG_MAP注释块中间。这个区间是 ClassWizard 的“自留地”手动在区间外添加映射是安全的但千万不要手工修改区间内的内容否则 ClassWizard 可能识别失败导致以后打开工程时消息映射被覆盖或丢失。// 手动添加一个自定义消息的处理入口 // 常见做法是在类声明中加入 afx_msg 成员函数声明 class CMainFrame : public CFrameWnd { // 处理自定义消息 WM_USER 100 afx_msg LRESULT OnMyMessage(WPARAM wParam, LPARAM lParam); DECLARE_MESSAGE_MAP() }; // 在类实现文件中 BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) //{{AFX_MSG_MAP(CMainFrame) ON_WM_CREATE() //}}AFX_MSG_MAP ON_MESSAGE(WM_USER 100, OnMyMessage) // 手工添加在区间外 END_MESSAGE_MAP()自定义消息用 ON_MESSAGE 宏关联处理函数签名固定为LRESULT (WPARAM, LPARAM)。写到这里要特别提醒消息映射表中不能出现两个相同的消息入口也不要把 ON_COMMAND 和 ON_MESSAGE 混用否则查找时会命中错误的分支这种错误不会报编译错运行期表现也很隐蔽属于典型的难排查问题。4. MFC 入门避坑排查消息映射失效、DC 对象泄漏与 CString 断言4.1 消息处理函数不执行现象在 ClassWizard 里添加了 WM_TIMER 或 ON_COMMAND 的处理函数编译链接都正常但程序运行后断点进不去消息好像被系统直接吞了。原因最常见的有三种。第一种处理函数写在消息映射表里但宏名拼错比如把 ON_WM_TIMER 写成 ON_MESSAGE(WM_TIMER, ...)导致映射表中缺少条目第二种类向导把条目放在//}}AFX_MSG_MAP区间内但你手工编辑过这块破坏了//{{和//}}的配对ClassWizard 无法识别因此没有真正写入映射条第三种命令消息 ON_COMMAND 的 ID 与资源中菜单或按钮的 ID 不一致用户操作时系统发送的是另一个数字。解决先在 BEGIN_MESSAGE_MAP 里搜索对应宏是否存在再检查 ID 是否与资源编辑器里的一致。最有效的排查手段是在窗口过程入口临时加一个断点看消息是否到达了窗口。如果到达了但没进处理函数问题一定在映射表如果没到达就要检查消息来源比如 WM_TIMER 是否真的用 SetTimer 设置了。4.2 DC 中保存了无效的 GUI 对象指针导致绘图异常现象程序运行一段时间后窗口重绘时出现花屏或者拖动窗口时突然崩溃Debug 版本弹出 ASSERT 失败指向 SelectObject 相关的断言。原因这是 GUI 对象选入 DC 后没有正确恢复导致的。常见流程是创建了画笔、刷子SelectObject 选入 DC然后循环里反复创建新对象而没有 DeleteObject 旧的也没有把原来的对象恢复回去。当局部对象生命周期结束后被销毁但 DC 中还持有指向它的指针下次绘图时继续引用就成了野指针。解决严格遵守“创建→选入→保存原对象→使用→恢复原对象→销毁”的顺序。每次使用完临时 GUI 对象后立即 SelectObject 恢复原来的对象再让临时对象销毁。// 规范的画笔使用方式摘自资料中的示例并补充细节 CPen pen1, pen2; pen1.CreatePen(PS_SOLID, 2, RGB(128, 128, 128)); // 创建实线画笔 pen2.CreatePen(PS_SOLID, 2, RGB(128, 128, 0)); // 创建另一支画笔 CPen* pPenOld (CPen*)pDC-SelectObject(pen1); // 选入 pen1保存原画笔 // 用 pen1 绘制... pDC-SelectObject(pen2); // 切换到 pen2不需要再保存 // 用 pen2 绘制... pen1.DeleteObject(); // 销毁 pen1 pen1.CreatePen(PS_DASH, 1, RGB(0, 0, 0)); // 重建为虚线画笔 pDC-SelectObject(pen1); // 再次选入 // 用虚线绘制... pDC-SelectObject(pPenOld); // 最后恢复原始画笔注意一个细节第二次 SelectObject(pen2) 之前理论上也应该保存 pen2 之前的旧对象但在你马上还要换回 pen1 的场景下只需要保证最终恢复最开始保存的 pPenOld 就行。核心原则是 DC 里不能遗留任何指向栈对象的指针且两次创建同一个对象前先销毁。4.3 CString 格式化时崩溃或断言现象调用 CString::Format 时Debug 版本弹出断言提示“字符串溢出”或“无效的格式化字符串”Release 版本直接非法访问。原因CString 的 Format 和 sprintf 类似不检查缓冲区边界也不检查参数个数。常见写法是用str.Format(%s, 整数变量)或者格式串中写了%s但参数实际是CString对象而不是LPCSTR指针。第二种情况在 CString 重载了operator LPCSTR之后看起来能编译通过但某些重载决议下会传递 this 指针而不是数据指针运行期表现很诡异。解决格式化字符串和参数类型严格对应传 CString 时显式转型为(LPCTSTR)str或者直接调用它的 GetString()。另外注意%d和%u在 32 位下对应 int 和 unsigned int长整数要用%ld不要混用。// 错误写法编译期可能没警告运行期容易断言 CString value GetSomeText(); CString message; message.Format(当前值: %s, value); // 某些重载决议下会出错 // 正确写法 message.Format(当前值: %s, (LPCTSTR)value); message.Format(数值: %d, 42);这里还有一坑CString::Format 在 Unicode 工程下格式串要写宽字符版本VC 6.0 默认工程是 ANSI但如果你改了字符集设置格式串中的中文文本必须用_T()包裹否则中文变乱码。如果你用 DT_END_ELLIPSIS 与 DT_MODIFYSTRING 组合调用 DrawText官方文档中明确注明可能修改传入的字符串并触发 CString 重载的断言这种场景下宁可先把文本复制到独立缓冲区里。4.4 画笔宽度为 2 时虚线变实线现象调用 CreatePen(PS_DASH, 2, RGB(0,0,0)) 创建虚线画笔画出来却是一条实线。原因PS_DASH、PS_DOT、PS_DASHDOT 等线型风格在设备单位下只对宽度为 1 的画笔有效。宽度大于 1 时系统会忽略线型而使用实线绘制。这不是 MFC 或驱动的 bug而是 GDI 的规则。解决需要虚线效果时把宽度保持为 1。如果确实需要又宽又虚的线常见做法是使用几何画笔ExtCreatePen或者放弃线型改为手工按长度间隔绘制线段。对入门阶段来说记住“虚线必用 1 号笔”就够了。5. GUI 对象与 DC 绘图实战创建、选入、恢复、销毁的完整套路5.1 DC 是什么GUI 对象为什么必须选入 DCDCDevice Context设备环境是一个抽象绘图环境它可能对应屏幕、打印机、内存位图或其它输出设备。同一个绘图代码只需要更换 DC就能在不同的设备上输出相同的结果这正是 Windows 设备无关性的核心体现。MFC 把 DC 封装成 CDC 类你拿到的 CDC* 可能是屏幕环境、打印预览环境也可能只是内存环境但绘图函数调用完全一致。要绘图必须有三样东西就位一个合法的 DC一支画笔画线用一把刷子填充用以及必要的字体和位图。创建好的 GUI 对象不能直接使用必须调用 SelectObject 把它选入 DC绘图函数才会使用当前 DC 中的对象进行绘制。这里也解释了为什么“创建了对象但画面没变化”——对象没选入 DC或者选入的是另一个 DC。5.2 在 OnDraw 中输出文字与创建字体OnDraw 是 CView 派生类中最重要的绘图入口窗口需要重绘时它会被自动调用。传入的 CDC* 参数在不同的调用场景里对应不同的设备环境屏幕显示时是窗口 DC打印时是打印机 DC打印预览时是 CPreviewDC。所以只要在 OnDraw 里写一份绘制代码屏幕显示、打印预览、打印机输出三处同时生效所见即所得。这与传统的直接在 WM_PAINT 里绘图方式相比省掉了大量重复代码。void CMyView::OnDraw(CDC* pDC) { CString strText _T(Hello, MFC); CRect rectClient; GetClientRect(rectClient); // 设置文字颜色与背景模式 pDC-SetTextColor(RGB(0, 0, 255)); pDC-SetBkMode(TRANSPARENT); // 透明背景避免黑色底色块 // 在客户区居中输出单行文字 pDC-DrawText(strText, rectClient, DT_CENTER | DT_VCENTER | DT_SINGLELINE); }DrawText 相比 TextOut 灵活得多。TextOut 只能输出单行文本DrawText 可以指定一个矩形区域并在其中输出单行或多行文本支持 DT_CENTER、DT_RIGHT、DT_VCENTER、DT_WORDBREAK 等样式组合。注意 DT_VCENTER 必须和 DT_SINGLELINE 一起使用否则垂直居中不生效。DT_CALCRECT 只计算文本所需要占用的矩形大小而不绘制这个特性在做自绘控件、动态布局时非常有用。字体创建则要复杂一些。CFont 可以通过 CreateFontIndirect(LOGFONT) 或直接 CreateFont 创建。LOGFONT 结构体里的字段很多常用的关键参数包括lfHeight 字体高度逻辑单位0 表示使用系统默认高度lfWeight 字体粗细FW_BOLD 为 700、FW_NORMAL 为 400lfItalic、lfUnderline 控制斜体和下划线lfCharSet 在中文环境下必须设成 GB2312_CHARSET 或 DEFAULT_CHARSET否则中文字符显示为乱码或方框lfFaceName 指定字体名如“宋体”“Arial”。// 创建一支 24 磅大小的粗体宋体字体 CFont font; LOGFONT lf {0}; lf.lfHeight -24; // 负值表示按字符高度计算 lf.lfWeight FW_BOLD; // 粗体 lf.lfCharSet GB2312_CHARSET; // 中文环境必须设 strcpy(lf.lfFaceName, 宋体); font.CreateFontIndirect(lf); CFont* pOldFont pDC-SelectObject(font); // 选入字体 pDC-TextOut(100, 100, _T(这是一行测试文字)); pDC-SelectObject(pOldFont); // 恢复字体注意 lfHeight 用负值更稳妥。正值表示的是“字符高度加上行距”的完整行高负值表示按字符本身的像素高度匹配后者更符合直觉也更容易在不同 DPI 下保持一致。创建完字体同样需要选入 DC用完后恢复。表面看这只是准备工作但真正的坑往往藏在字符集不一致和恢复遗漏这两处。5.3 画笔、刷子与库存对象的配合绘图时画点用 SetPixel效率很低连续画几千个点就能感觉到卡顿只适合像素级操作。画线、画矩形、画圆用的是系统当前 DC 中的画笔和刷子画笔决定轮廓、刷子决定填充。创建实线画笔用 PS_SOLID想要虚线、点线、点划线时宽度必须为 1 这个限制前面已经提过这里不再重复。刷子除了颜色还能选择网格样式或者用 8x8 位图创建自定义模板刷子填充时系统会用位图平铺整个区域。还有一种更省事的方案是使用库存对象。系统中预制了一批固定颜色刷子、画笔和基本字体通过 CDC::SelectStockObject 直接选入不需要创建也不会被销毁。它们在 DC 中留下是安全的因此在恢复 DC 原始状态时也常被用来做“兜底”恢复// 用库存对象清理 DC 状态 pDC-SelectStockObject(NULL_BRUSH); // 空心刷子填充区域内部不涂色 pDC-SelectStockObject(BLACK_PEN); // 黑色实线画笔 pDC-SelectStockObject(SYSTEM_FONT); // 恢复系统默认字体当你在自绘控件的 OnPaint 里画了一堆对象后最稳妥的收尾方式是连续选入这几个库存对象把 DC 恢复到干净状态再交给默认处理流程。这比保存和恢复原始指针更不容易出错。6. 进阶用 OnDraw 一份代码打通屏幕、打印预览与打印机走到这一步你对消息机制、消息映射、GUI 对象都有了基本认知可以开始体会 MFC 相对原生 API 的真正优势了。我建议你做一个练习在单文档工程的 CView::OnDraw 里画一个简单的柱状统计图包含坐标轴、若干矩形柱和文字标注。只需要这一份代码屏幕显示、打印预览、打印机输出就全部正确这就是设备无关性最直观的体现。你会发现 OnDraw 里根本不用关心当前是哪一个设备CDC 内部已经处理了坐标映射和分辨率差异。这里有一个打印场景的特有技巧打印时需要为不同页调整输出区域常见做法是在 OnPreparePrinting 里设置页数在 OnPrint 里按页号调用不同的绘制逻辑但无论如何最终都复用 OnDraw 里那段核心代码。我一般会在 OnDraw 开头判断传入的 DC 是否为打印机void CMyView::OnDraw(CDC* pDC) { BOOL bPrinting pDC-IsPrinting(); // 判断当前 DC 是否为打印机 if (bPrinting) { // 打印时用更精细的线型和纯黑颜色省墨且清晰 pDC-SelectStockObject(BLACK_PEN); } // 共用绘制逻辑 DrawChart(pDC); }IsPrinting 这个判断非常实用。屏幕显示可以容忍彩色、渐变和细线但打印机上浅色线条往往打出来一片模糊纯黑加粗体才是可靠方案。这也是我从血泪经验里学到的——第一次把带灰色柱状图的界面直接打印结果柱子和坐标轴几乎看不出来。最后聊聊选书与学习节奏。这份资料里“多看书、少买书、买书前一定要慎重”的建议我一直很认同。MFC 的经典书内容大同小异核心就是消息机制、消息映射、DC、文档视图这四块。选书时优先看目录里这四块是否讲透代码示例是否精简。入门阶段买一本翻译过来的、章节循序渐进的书就够用了剩下的是反复查联机帮助和读别人工程里的代码。从那以后我每次遇到消息处理不生效都会强制走一遍“查映射表→查消息 ID→查窗口句柄归属”这个检查顺序再没被这类问题卡过夜。希望这份拆解能帮你把 MFC 入门的这条弯路走直。本文还有配套的精品资源点击获取