VC++6.0 MFC入门:消息驱动、消息映射与GDI绘图实战
简介:这份VC++6.0 MFC入门文档面向已具备一定C/C++基础、准备转向Windows桌面开发的初学者,重点解决从语言语法到MFC框架、从控制台程序到消息驱动编程的过渡问题。内容围绕学习路径展开,先强调字符界面练习与C++特性掌握,再讲解Windows消息的组成、窗口句柄、消息队列、窗口过程与默认消息处理,并说明MFC消息映射如何把消息分派到成员函数,同时涉及Application Wizard、ClassWizard及参考书选择建议。压缩包共1个doc文件,约521KB,篇幅集中,适合作为入门阶段的要点梳理和查阅材料。已有731人学习,读者可借此建立VC++6.0与MFC的整体认知,理解消息机制和消息映射的关键概念,并对照文中学习建议规划后续实践。
1. 从零认识 MFC:它到底是窗口库还是一套思维方式
很多人第一次听到 VC++6.0 MFC 入门这几个字,脑子里浮出来的画面是一屏看不懂的头文件,加上 HWND、UINT、LPARAM 一堆怪词。这种发怵很正常,因为 MFC 入门真正卡人的地方不是语法,而是要先接受一个反直觉的事实:你写的不是「从头跑到尾的程序」,而是「一个被系统不停叫醒、问你要不要处理这件事的程序」。MFC 是一层架在 C++ 之上的类库,把窗口、按钮、画笔、字体这些系统里的东西包装成类,让你用对象的方式跟系统打交道,而不用直接面对句柄和函数指针。它出生得早,所以大量工业上位机、教学课件、老旧设备配套软件里至今还能见到它。适合谁学?想弄明白 Windows 桌面程序底细的人、要接手维护老工程的人,以及单纯想体验一次「消息驱动」到底什么感觉的人。
2. 补地基:C++ 底子和 Windows 消息机制先弄明白
2.1 为什么建议先写几个字符界面程序再碰窗口
刚接触 VC 的时候最容易犯的错,是打开向导生成一个窗口工程,然后对着满屏自动生成的代码发呆,改一行编译一次,报错了也不知道是自己写错还是向导写错。我的建议是往回退半步:先写几个纯字符界面的小程序,把 C++ 的语法、指针、数组边界、字符串处理这些基本功练到不用想就能写出来,再回来碰窗口。这一步看着绕路,其实是省时间,因为后面 MFC 里到处是CString、CRect、CPoint这些类,你对 C++ 的对象、构造、成员函数不熟,看代码就像看天书。
下面这个小练习不用窗口,就一个控制台程序,但把数组、循环、字符处理全用上了,是后面操作字符串的底子:
// 小练习:统计一段文字里每个英文字母出现的次数 // 先不碰窗口,把循环、数组、字符判断练熟 #include <iostream> #include <string> using namespace std; int main() { string line; int count[26] = {0}; // 26 个字母的计数器,先全部清零 getline(cin, line); // 读一整行,空格也算进去 for (size_t i = 0; i < line.size(); ++i) { char c = line[i]; if (c >= 'a' && c <= 'z') count[c - 'a']++; // 小写字母归类 if (c >= 'A' && c <= 'Z') count[c - 'A']++; // 大写字母归到同一格 } for (int i = 0; i < 26; ++i) if (count[i] > 0) cout << char('a' + i) << " : " << count[i] << endl; return 0; }这段代码里,count[c - 'a']这一句是关键,它用字符减字符得到下标,把字母映射到数组位置上,这种「用减法把范围压到下标」的思路,后面处理消息 ID、控件 ID 时会反复出现。getline而不是cin >>是为了把空格也读进来,不然统计结果永远少一截。写这种小程序的真正目的不是完成作业,而是让你在还没被窗口代码淹没的时候,先习惯「自己写的 bug 自己找」这件事。
我第一次学的时候还干过一件笨事:把上面这个程序改成了统计单词、再改成按字母排序输出,来回改了三遍。改到第三遍时明显感觉不一样了,指针和数组不再是背下来的知识,而是手上有数的工具。这种手感,是后面看懂 MFC 那一大堆成员函数的前提。
2.2 消息是什么:一个名字加两个参数
Windows 是一个消息驱动的系统。这句话听着抽象,理解成一个场景就容易了:系统是个前台,窗口是各个办公室,用户点一下鼠标,前台就往对应办公室送一张纸条,纸条上写着「什么事」和「两个附加说明」。MFC 里把这张纸条叫消息,结构非常固定:
| 组成部分 | 类型 | 作用 | 举例 |
|---|---|---|---|
| 消息名称 | UINT | 说明发生了什么类型的动作 | WM_PAINT、WM_LBUTTONDOWN、WM_COMMAND |
| 参数一 | WPARAM | 附加信息,通常是数值或句柄 | 菜单命令的 ID 号放在它的高字里 |
| 参数二 | LPARAM | 附加信息,常放坐标等复合数据 | 鼠标点击位置打包成两个 16 位坐标 |
比如菜单被选中时会发出 WM_COMMAND,命令的 ID 号就在HIWORD(wParam)里;鼠标左键按下时是 WM_LBUTTONDOWN,点击的坐标被压进 LPARAM 中,MFC 已经帮你拆成了CPoint。窗口需要重画时是 WM_PAINT,窗口被创建时是 WM_CREATE,尺寸变了是 WM_SIZE,定时器到点了是 WM_TIMER。这几个名字建议直接背下来,属于入门阶段最常用的词汇表。
消息还有一个很重要的性质:你不处理它,它也不会消失得莫名其妙。系统给每个窗口准备了一份默认的窗口过程,负责处理那些你懒得管的杂事——拖动窗口时刷屏、关闭时清理、边框重绘,全在默认流程里。所以你写代码的心态应该是「我只挑感兴趣的几条拦下来,其余的放行」,而不是「所有消息我都要接住」。这个心态差别,决定了你后面写的代码是几十行还是几百行。
2.3 窗口句柄和消息队列:把「谁收信」讲透
消息必须送到某个具体窗口,系统靠窗口句柄来认门牌号。句柄(HWND)本质上就是一个整数编号,系统在内部用它唯一标识一个窗口,你把消息发出去时必须带上这个编号,系统才知道该往哪个窗口过程送。想象一下两个窗口用的是同一份处理代码,你在窗口一上按了鼠标,消息通过窗口一的句柄送到窗口一,而不是隔壁那个长得一模一样的窗口二。这就是句柄存在的意义:代码可以共享,身份不能共享。
除了窗口句柄,还有设备环境句柄、画笔句柄、字体句柄等等,统称 GUI 句柄。入门阶段不用把它们全记住,只要建立一个意识:句柄是系统给你的「取件码」,你拿着它才能对那个对象做事,对象销毁了取件码就作废。
再看消息循环。每个运行中的程序都有自己的消息队列,系统把产生的消息放进队列,程序在一个循环里逐条取出来,按句柄分派到对应的窗口过程:
// 消息循环的骨架(伪代码,只为看清结构) while (true) { MSG msg; if (GetMessage(&msg, NULL, 0, 0) == 0) // 取到退出信号就返回 0,跳出循环 break; TranslateMessage(&msg); // 把按键消息翻译成字符消息 DispatchMessage(&msg); // 按 msg.hwnd 找到窗口过程,把消息送过去 }关键在GetMessage这一句:队列里没有消息时它不返回,而是把 CPU 让给系统,所以你的程序空闲时几乎不占资源。这也是为什么在消息处理函数里写死循环会造成整个界面失去响应——循环卡在窗口过程里,消息循环根本没机会回去取新消息,系统怎么点你都没反应。这个坑后面第 5 章还会专门讲。
3. 消息映射:三行宏替代一长串 switch
3.1 先看手写版本长什么样
在用 MFC 之前,得先知道它替你省掉了什么。传统的做法是给窗口写一个窗口过程函数,里面用 switch 把各种消息分开处理:
// 传统写法的窗口过程(伪代码),MFC 就是把这套东西包起来了 LRESULT CALLBACK MyWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_PAINT: DoPaint(hWnd); // 窗口需要重画时走这里 break; case WM_LBUTTONDOWN: DoClick(LOWORD(lParam), HIWORD(lParam)); // 拆出点击坐标 break; case WM_COMMAND: if (HIWORD(wParam) == ID_MY_MENU) // 判断是哪个菜单命令 DoMenu(); break; default: return DefWindowProc(hWnd, msg, wParam, lParam); // 不管的交给默认过程 } return 0; }这种写法能跑,但问题很明显:所有消息挤在一个函数里,加一个功能就往 switch 里塞一个 case,几百行下去谁也看不清;而且这个函数必须是全局的或者静态的,想访问某个对象的成员变量就得来回传指针。这就是为什么很多老代码看着像一锅粥,不是作者水平不行,是那套机制逼的。
3.2 MFC 版本:把 switch 藏进宏里
MFC 的做法是把「消息」和「成员函数」的对应关系写进一张表,用宏展开成 switch。你只需要在类里声明一个带afx_msg前缀的成员函数,然后在消息映射表里加一行ON_WM_xxx,剩下的分派工作编译期就替你拼好了:
// 第一步:在视图类的头文件里声明处理函数 protected: //{{AFX_MSG(CMyDemoView) afx_msg void OnLButtonDown(UINT nFlags, CPoint point); // 处理鼠标左键按下 //}}AFX_MSG DECLARE_MESSAGE_MAP()// 第二步:在实现文件里把消息和函数对上号 BEGIN_MESSAGE_MAP(CMyDemoView, CView) //{{AFX_MSG_MAP(CMyDemoView) ON_WM_LBUTTONDOWN() // 收到 WM_LBUTTONDOWN 就调用 OnLButtonDown //}}AFX_MSG_MAP END_MESSAGE_MAP() // 第三步:写具体的处理逻辑 void CMyDemoView::OnLButtonDown(UINT nFlags, CPoint point) { CString s; s.Format(_T("你点在了 (%d, %d)"), point.x, point.y); // 拼一段提示文字 AfxMessageBox(s); // 弹个框,确认消息真的到了 CView::OnLButtonDown(nFlags, point); // 自己不处理的部分交回父类 }三个地方值得说清楚。afx_msg本身是个空宏,写它只是给编译器和 ClassWizard 一个标记,告诉你「这是消息处理函数,不是普通成员函数」。BEGIN_MESSAGE_MAP和END_MESSAGE_MAP之间那张表才是真正干活的,ON_WM_LBUTTONDOWN()括号里是空的,因为 MFC 已经按消息名约定好了函数签名,你不用手写参数。最后那一行CView::OnLButtonDown(...)是「放行」,如果不写,某些默认行为就断了,比如视图的焦点处理。
还有一个细节:代码里那两对//{{AFX_MSG和//}}AFX_MSG注释,看着像废话,其实是 ClassWizard 认的标记。它靠这对花括号找到该往哪儿插代码。血泪经验是别手动删它、别把它们挪位置,一旦破坏,向导就找不到插入点,你得全部手写,很麻烦。
3.3 跑通第一个例子:点一下鼠标弹个框
具体操作按这个顺序来,一步都别跳:
- 打开 VC++6.0,新建工程,类型选 MFC AppWizard(exe),工程名随便取一个,比如 MyDemo。
- 第二步选 Single document(单文档),其他保持默认,后面几步直接点 Finish。
- 编译运行一次,确认这个空壳工程能正常出窗口,这一步是基准线,出不来就别往下走。
- 菜单里打开 ClassWizard(或者按 Ctrl+W),在 Message 列表里找到 WM_LBUTTONDOWN,双击,它会自动帮你把声明和映射表都填好。
- 在弹出的函数里写上面那三行代码,重新编译运行。
- 在窗口客户区随便点一下,应该弹出提示框显示坐标。
如果第 6 步没反应,先别怀疑代码,去实现文件里确认 ON_WM_LBUTTONDOWN() 那一行是不是真在里面。我第一次做这个例子时就翻车了,原因是我手贱把//{{AFX_MSG_MAP那行注释删了,向导生成的内容插到了错误的位置,编译能过,但消息永远到不了函数里。这种「编译通过但不生效」的问题,是 MFC 入门期最耗时间的类型。
4. 第一个 Demo:在窗口里画出线、框和文字
4.1 从向导生成一个单文档骨架
有了上面的手感,就可以做一个稍微像样点的东西了。还是那个单文档工程,我们要在客户区画一条线、一个填充矩形、一行文字。工程本身不用改,所有工作都在视图类的OnDraw成员函数里完成。
OnDraw的调用时机是「窗口需要重画」,系统会在窗口首次显示、被遮挡后露出、被拖动、尺寸变化时自动调用它。参数CDC* pDC就是设备环境,你可以把它理解成一张画布,所有绘图动作都通过它发出去。这里有个很值钱的设计:打印和打印预览时,传进来的 pDC 对应的是打印机而不是屏幕,所以你写在这一个函数里的绘图代码,窗口显示、打印预览、实际打印三处都能用,一份代码三份效果。这也是入门阶段值得花力气理解 OnDraw 的原因。
4.2 在 OnDraw 里画第一条线
把下面这段整体替换掉视图类里原来的 OnDraw 内容:
void CMyDemoView::OnDraw(CDC* pDC) { CMyDemoDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); // 1. 画一条深灰色的粗线 CPen penLine; penLine.CreatePen(PS_SOLID, 2, RGB(128, 128, 128)); // 实线,宽度 2,灰色 CPen* pOldPen = pDC->SelectObject(&penLine); // 选进画布,同时记住旧笔 pDC->MoveTo(20, 20); // 起点 pDC->LineTo(300, 120); // 画到终点 pDC->SelectObject(pOldPen); // 用完换回旧笔 penLine.DeleteObject(); // 再销毁这支笔 // 2. 画一个矩形并填充浅蓝底色 CBrush brushFill; brushFill.CreateSolidBrush(RGB(220, 235, 255)); CBrush* pOldBrush = pDC->SelectObject(&brushFill); pDC->Rectangle(CRect(20, 150, 300, 260)); // 左、上、右、下四个坐标 pDC->SelectObject(pOldBrush); brushFill.DeleteObject(); // 3. 在矩形里写一行字 CFont fontText; fontText.CreateFont(24, 0, 0, 0, FW_BOLD, FALSE, FALSE, 0, ANSI_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, DEFAULT_QUALITY, DEFAULT_PITCH | FF_SWISS, _T("宋体")); CFont* pOldFont = pDC->SelectObject(&fontText); pDC->SetBkMode(TRANSPARENT); // 文字背景透明 pDC->SetTextColor(RGB(0, 0, 128)); // 深蓝色字 pDC->TextOut(40, 170, _T("第一个 MFC 例子")); pDC->SelectObject(pOldFont); fontText.DeleteObject(); }逐段解释一下。CreatePen三个参数分别是线型、线宽、颜色,RGB(128,128,128)是灰色,三个分量都是 0 到 255。CRect(20, 150, 300, 260)的四个数是左上角 x、左上角 y、右下角 x、右下角 y,不是宽和高,这个顺序新手经常记反,画出来的框位置离谱多半就是这里错了。SetBkMode(TRANSPARENT)让文字背景透明,不设的话字后面会拖一块底色,压在图形上很丑。
TextOut只能输出单行,坐标是文字左上角。想输出多行、想居中、想自动换行,就得换DrawText,它接收一个矩形区域和一组格式开关:
| 格式开关 | 作用 |
|---|---|
| DT_SINGLELINE | 只输出一行,遇到回车也不换行 |
| DT_CENTER | 水平居中 |
| DT_VCENTER | 垂直居中,必须和 DT_SINGLELINE 一起用 |
| DT_WORDBREAK | 超出行宽时在单词之间自动折行 |
| DT_END_ELLIPSIS | 放不下时结尾显示省略号 |
| DT_CALCRECT | 不画,只计算出这段文字需要多大的矩形 |
DT_CALCRECT特别实用:先算尺寸再决定画在哪儿,或者用它来给一行文字做居中的位置计算。几个开关要组合时用竖线连起来,比如DT_CENTER | DT_SINGLELINE | DT_VCENTER就是单行完全居中。
4.3 钢笔、刷子、字体:GDI 对象的三条规矩
上面代码里每段都有「选进去、画、换回来、销毁」这个套路,看着啰嗦,但必须遵守,否则程序会以各种玄学方式出问题。规矩归纳起来三条:
第一条,用之前必须先创建成功。CreatePen、CreateSolidBrush、CreateFont这些函数都有返回值,虽然大多是失败才返回假,但入门阶段养成「创建完就用」的直线思路,别在创建和使用之间插一堆逻辑。
第二条,选进设备环境时要保存旧的句柄。SelectObject会返回被替换下来的那个对象指针,你必须接住它,画完再选回去。原因是设备环境里本来就有默认的笔、刷、字体,你不还原,系统就一直拿着一支已经被销毁的笔,后面的绘制全乱。
第三条,同一个对象重新创建之前必须先销毁。DeleteObject释放的是对象占的系统资源;一个CPen对象只能对应一支笔,想换颜色就得先 Delete 再 Create,不能直接再调一次 CreatePen。这也是为什么很多老程序跑久了会变慢甚至报错——GDI 资源是有限额的,漏掉几十个就可能让整个进程的绘图失效。
提示:如果只是想快速用个默认颜色,不必自己创建对象,用
pDC->SelectStockObject(BLACK_PEN)这类库存对象更省事。库存对象不需要你销毁,用完不还原也不会出事,适合试验阶段。
5. 避坑清单:新手最容易翻车的 5 个地方
5.1 一运行就弹 Debug Assertion Failed
现象:程序启动或点击几下之后弹出断言失败对话框,提示信息里带着某个文件名和行号,点重试能看到调用栈。
原因:八成是 GDI 对象没还回去就销毁了。典型写法是创建一支笔、选进设备环境、画完直接让笔对象出了作用域,旧笔没选回来。对象析构时笔被销毁,可设备环境里还留着它的引用,下一次绘制就撞上断言。
解决:把「选进去、记住旧指针、画完选回来、再 Delete」当成一个不可拆的整体写,别为了省一行把还原语句删掉。已经出问题的代码,重点检查 OnDraw 里每个 SelectObject 后面有没有对应的还原。
5.2 改了代码,界面一点变化都没有
现象:明明改了绘制代码,重新编译也没报错,跑起来还是老样子。
原因:VC6 的增量编译有时候判断不准,尤其是改的是头文件或者资源文件。另一种常见情况是断点打在了 Release 配置下,符号对不上,看着像没执行。
解决:先做一次 Rebuild All,把所有中间文件清掉重来,这一招能解决大半「改了没反应」。再确认右上角配置下拉框选的是 Debug 而不是 Release。这个坑我踩过不止一次,后来养成了习惯:只要现象诡异,先 Rebuild 一遍再说,比盯着代码找半小时高效。
5.3 中文输出成乱码或者问号
现象:TextOut 出去的中文显示成一堆乱七八糟的符号,或者干脆只显示第一个字。
原因:字符串编码和工程设置不匹配。一种情况是源码里直接写了双引号中文,而工程被设成了 Unicode 编译,窄字符串被当宽字符串用;另一种是从别处拷贝来的代码用了char*却塞进CString的宽字符接口。
解决:字符串统一用_T("...")包起来,让编译器按工程设置自动选窄或宽;检查工程设置里字符集那一项前后一致,别一半代码按 Unicode 写、一半按多字节写。入门阶段建议就盯着一种设置用到底,别两边混。
5.4 消息处理函数写了,但断点从来不进
现象:函数体写得好好的,编译也过,运行起来点击、按键都没反应,断点打上去是空心圆。
原因:三个地方最容易漏。头文件里函数的声明忘了加afx_msg;消息映射表里没写对应的ON_WM_xxx那一行;或者曾经手动动过//{{AFX_MSG_MAP那对花括号注释,导致向导生成的内容插错位置。
解决:别手写,用 ClassWizard 加消息,它会把声明、映射、函数体三处一次填好。已经乱了的话,把函数声明和映射表那几行对照着一个能跑的工程抄一遍结构。判断标准很简单:映射表里没有那一行,函数写得再对也永远不会被调用。
5.5 点一下按钮,整个窗口卡住不动
现象:触发某个操作后窗口变成白板,拖不动也关不掉,任务管理器里显示程序「无响应」。
原因:在消息处理函数里写了耗时操作或者死循环。前面说过消息循环是单线程的,你的处理函数不返回,循环就没法去取新消息,界面自然全部僵住。哪怕只是一个看起来很短的循环,只要里面有等待或者大量计算,都会造成这个结果。
解决:入门阶段最省事的办法是把耗时任务拆小,用定时器一段一段做;稍微进阶一点就开一个工作线程,让界面线程只负责刷新。另外排查时可以在可疑函数里加几行日志,看看到底卡在哪一句,别靠猜。
6. 下一步学什么:从跑通一个例子到能看懂别人的工程
跑通上面那个小 Demo 之后,最容易出现的状态是「离开了教程就写不出东西」。这不是能力问题,是路径还没铺开。我给自己的学生安排顺序时,一般按下面这个表往下走,每一步都能独立验证,不至于一次摊太多:
| 阶段 | 重点内容 | 怎么算过关 |
|---|---|---|
| 起步 | 消息名称、窗口句柄、消息映射三件套 | 能说清点击一次鼠标经过了哪几步 |
| 上手 | CRect、CPoint、CString 三个基础类 | 不看资料能算出矩形中心点、拼出带变量的字符串 |
| 加厚 | 对话框、控件、按钮响应 | 能自己做一个输入框加按钮的小工具 |
| 进阶 | 文档视图结构、文件读写 | 能把数据存成文件,下次打开还能读回来 |
| 独立 | 看懂别人工程的主干 | 拿到一个陌生工程,能在一小时内说清它大概怎么跑起来的 |
几个具体建议。第一,把 Help 用起来,别一遇到不认识的函数就上网搜。VC6 自带的手册里,每个函数都有参数说明和返回值,CreateFont那十几个参数我就是一条一条查下来的,查过一遍比看十篇教程都牢。第二,刻意记一些高频词:WM_PAINT、WM_COMMAND、WM_LBUTTONDOWN、WM_CREATE、WM_SIZE、WM_TIMER,还有AfxMessageBox、AfxGetApp这几个全局函数,它们出现的频率高到你不得不熟。第三,学会看别人的代码,找一个能跑的小工程,从InitInstance开始顺着读,遇到不懂的类就去查它的继承关系,你会发现大部分类都是从那么几个基类派出来的,读通一个就通一片。
关于买书这件事,我的教训挺深。刚入门那阵子我一口气买了四本,每本翻了不到五十页,最后真正看完的只有一本。后来明白过来,入门阶段需要的是「内容全面、代码有详细讲解」的那一类,一本就够,翻烂它比摆一排有用。等你能独立写点东西了,再去挑那种讲原理讲得透的,那时候你才知道自己缺什么。
还有一个习惯我坚持到现在:每次动手做一个新例子之前,先在纸上把「谁发消息、谁收消息、我在哪个函数里干活」这三件事写下来,写完再敲键盘。这个动作看着慢,实际省时间,因为它拦住了大部分「写了一下午发现方向错了」的情况。从那以后我每次开新工程都强制走一遍这个流程,哪怕是三行代码的小实验。希望这些能帮到你,别怕开头慢,慢过那一段就顺了。
本文还有配套的精品资源,点击获取