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

发布时间:2026/11/4 4:50:25
VC++6.0 MFC入门:吃透消息机制与GDI绘图避坑 简介这是一份面向VC6.0与MFC入门学习者的文档型资料适合已具备C/C基础、准备转入Windows桌面开发的人群。内容围绕学习路径展开强调先通过字符界面程序夯实语言与面向对象基础再理解Windows消息驱动机制、窗口句柄与GUI句柄并掌握MFC消息映射的作用。文中还给出减少对参考书依赖、善用Help Online、熟记常用消息名称与参数、阅读他人代码及选择参考书的原则可帮助读者建立从语言基础到MFC框架的入门框架。压缩包共1个doc文件大小约521KB便于集中阅读和整理笔记。目前已有731人学习适合作为入门阶段梳理知识脉络与查缺补漏的参考材料。1. 先把消息机制啃下来VC6.0 的 MFC 入门该从哪儿下手第一次在 VC6.0 里点开 ClassWizard满屏 IDD_、IDR_、ON_WM_ 前缀能把人看懵不少人就在这一步放下转去买本厚书翻到第三章继续放下。MFC 入门真正卡人的不是类多而是搞不清一条消息从产生到被处理到底经过几道手谁发、发给谁、谁接、不接又去哪。这四件事想明白了剩下无非是查 CRect 有几个成员、CFont 怎么建、DC 里选进去的对象要不要还回去。下面按我当年走过的顺序排C/C 底子补到什么程度、消息机制怎么理解才算真懂、消息映射那堆宏展开之后长什么样、GDI 对象和 DC 有几个必踩的坑最后落到一个能直接抄去用的居中排版技巧。刚上手的人照着顺序走能少绕路已经能写出窗口但总在绘图上翻车的人直接看第五章。2. 底子决定速度C/C 基础补到什么程度才够写 MFC2.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包HWNDCDC包HDCCPen包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菜单消息里它就是菜单项 IDWM_LBUTTONDOWN的lParam低字是 x 坐标、高字是 y 坐标。所以「记住常用消息名称和参数意义」不是死记硬背而是记住每个消息的参数按什么规则切分。消息触发时机wParamlParamWM_PAINT窗口需要重绘未用未用WM_LBUTTONDOWN鼠标左键按下按键与键盘状态标志低字 x高字 yWM_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 lpCreateStructWM_PAINTOnPaint无内部取CPaintDCWM_LBUTTONDOWNOnLButtonDownUINT nFlags, CPoint pointWM_MOUSEMOVEOnMouseMoveUINT nFlags, CPoint pointWM_SIZEOnSizeUINT nType, int cx, int cyWM_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或CreateFontIndirectSelectObject也写了输出的字却毫无变化。原因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 入门的整理稿我按上面的顺序编排过跟着敲一遍比只看强得多。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询