ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

MFC入门核心:Windows消息机制、消息映射与GDI资源管理

MFC入门核心:Windows消息机制、消息映射与GDI资源管理

简介:一份面向已具备 C/C++ 基础、准备进入 Windows 桌面开发的初学者的 VC++6.0 MFC 入门文档,围绕「如何学好 VC」这一核心问题给出可执行的学习路线。内容从语言基本功切入,强调先写字符界面程序以熟悉语法与 C++ 特性,再逐步过渡到消息机制的组成、窗口句柄(HWND)与各类 GUI 句柄的用途,以及 MFC 消息映射如何把消息与成员函数关联起来。文中还配有 WNDPROC 与消息循环的伪代码,说明未处理消息如何交由默认窗口过程处理,并对比 16 位与 32 位系统的消息队列差异,帮助读者建立对 Windows 消息驱动模型的整体认识;同时给出参考书选择原则、Help Online 查阅与阅读他人代码的练习建议。资源包仅含 1 个 doc 文件,约 521KB,篇幅紧凑、便于随查随看。目前已有 731 人学习下载,适合作为 MFC 入门阶段的方法指引与概念速查。

1. MFC 入门:为什么"先写字符界面程序"反而是最快的路

MFC 入门这件事,多数人的第一反应是赶紧拖控件、让一个窗口弹出来,但真正拦住你的从来不是"窗口怎么显示",而是消息从哪来、句柄指向谁、GDI 对象什么时候必须还回去。VC++6.0 这套东西的价值恰恰在于它把 Windows 的消息驱动模型压缩成了一个能读完的骨架:向导生成的代码量小到可以一行行看完,ClassWizard 写下的消息映射宏展开后就是一段 switch-case。反直觉的结论是——先花两周老老实实写字符界面的 C++ 程序,再回来碰窗口,进度反而更快,因为 MFC 的坑九成不在界面,而在你对 C++ 对象生命周期和消息分派的理解深度。把这套机制拆开看,崩溃就不再是玄学,而是一条条能推演的路径。

2. Windows 消息机制:一条消息从产生到被处理的完整路径

Windows 是消息驱动的操作系统,这句话被念了太多遍,以至于很少有人真去追问:消息到底长什么样,谁决定它归哪个窗口,没被处理的消息又去哪了。这三个问题答不上来,后面看 MFC 的消息映射就只能靠背。这一章把一条消息的完整生命周期走一遍,从产生、入队、取出、分派,到最终落进某个窗口过程里被消化掉或者退回给系统。

理解这条链路的意义在于:MFC 后来做的所有封装——消息映射表、OnPaint、OnLButtonDown——本质上都是在替你在链路的末端挂上钩子。你先知道钩子挂在哪儿,才知道它为什么有时候挂不上。

2.1 消息的三段式结构:UINT、WPARAM、LPARAM 各装了什么

一条消息最核心的部分只有三样:一个消息名称(UINT类型),和两个参数(WPARAM、LPARAM)。消息名称是常量,比如WM_PAINT、WM_LBUTTONDOWN、WM_COMMAND;两个参数的含义完全由消息名称决定,同一个wParam在WM_COMMAND里是命令 ID,在WM_CHAR里就变成了字符编码。

// MSG 结构:入队的消息比"三段式"多了窗口句柄和时间、位置信息 typedef struct tagMSG { HWND hwnd; // 目标窗口句柄,决定这条消息最终归谁 UINT message; // 消息名,如 WM_PAINT、WM_COMMAND WPARAM wParam; // 随消息变化的参数,32 位 LPARAM lParam; // 随消息变化的参数,32 位 DWORD time; // 消息入队的时间戳 POINT pt; // 入队时的鼠标屏幕坐标 } MSG;

WM_COMMAND是最典型的复合参数消息,拆开看能明白为什么老手能一眼读出意图:HIWORD(wParam)是菜单项或控件的命令 ID,LOWORD(wParam)是控件的通知码(菜单消息时无意义),lParam在控件场景下是控件窗口句柄、在菜单场景下是 0。记住这一组拆法,调试菜单没响应的问题时能省掉大量瞎猜。

用户也可以自定义消息名称,惯例是从WM_USER往上加偏移,比如WM_USER + 100。自定义消息常见于工作线程向主窗口报告进度——线程不能直接碰界面对象,只能PostMessage一条消息回去,这是跨线程更新 UI 的标准做法。

2.2 窗口句柄与窗口过程:谁决定这条消息归谁

系统用窗口句柄(HWND)在整个系统范围内唯一标识一个窗口。发送消息时必须指定一个窗口句柄,这等于告诉系统"这条消息由谁接收"。句柄本身不是一个可以随便解引用的指针,它是内核对象表里的一个索引,所以伪造或者复用已销毁的句柄会引发难以定位的异常。

每个窗口都绑定一个窗口过程(WNDPROC),消息最终在这里被分析、分派。关键在于:窗口过程是代码,句柄是实例。两个窗口完全可以共用同一份窗口过程代码,靠传入的hWnd区分自己是谁。鼠标在窗口一上按下,消息带着窗口一的句柄进来,窗口过程的switch里处理的就一定是窗口一的状态。

// 窗口过程的标准骨架:所有消息都从这里过一遍 LRESULT CALLBACK YourWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_PAINT: // 客户区需要重绘,在这里做输出 return 0; case WM_LBUTTONDOWN: // lParam 低字是 x 坐标,高字是 y 坐标 return 0; case WM_COMMAND: // 菜单/控件命令:wParam 高字才是命令 ID if (HIWORD(wParam) == ID_FONT_DROPDOWN) { DoNothing(); return 0; } break; default: break; } // 不感兴趣的消息全部交还给系统的默认窗口过程 return DefWindowProc(hWnd, msg, wParam, lParam); }

这里最关键的一行是末尾的DefWindowProc。系统为窗口准备了一个默认窗口过程,负责处理那些你没写的消息——拖动窗口产生的一连串消息、非客户区绘制、系统菜单响应,全归它管。正因为有这个兜底,你才不必对每条消息都表态。漏掉这一句的后果很直接:窗口拖不动、边框画不出来、关闭按钮失灵。

2.3 消息循环与消息队列:GetMessage 不返回时 CPU 在干什么

系统维护一个或多个消息队列,产生的消息先进队列,再由各程序自己的消息循环取出、翻译、分派。没有消息时GetMessage不会返回,而是挂起等待——这是新手最容易误解的地方:不返回不等于在空转,它是在内核里等一个事件对象,CPU 占用是零。

MSG msg; // GetMessage 取到 WM_QUIT 时返回 0 退出;出错时返回 -1 while (GetMessage(&msg, NULL, 0, 0) > 0) { // 把 WM_KEYDOWN/WM_KEYUP 组合成 WM_CHAR,不做这一步就收不到字符输入 TranslateMessage(&msg); // 按 msg.hwnd 找到对应窗口过程并调用,返回值写回 msg.wParam DispatchMessage(&msg); } return msg.wParam;

while (GetMessage(...))这种写法埋着一个经典隐患:GetMessage出错时返回 -1,在布尔上下文里被当成真,循环继续跑,于是变成死循环。写成> 0才是稳妥的。

TranslateMessage的作用常被忽视。键盘按下产生的原始消息是WM_KEYDOWN,里面只有虚拟键码;经过翻译后才多出一条WM_CHAR,携带真正的字符编码。只处理WM_KEYDOWN而不调用TranslateMessage,你在编辑框里敲字母会毫无反应。

DispatchMessage是链路里真正"投递"的一步:拿hwnd去内核里查窗口对象,取出登记的窗口过程地址,把四个参数原样传进去。到这一步,消息才真正落到 2.2 节那段switch上。

2.4 16 位协同式与 32 位抢先式:一个死循环为什么能卡死整个系统

消息队列的组织方式,直接决定了一个程序卡住时会不会拖垮别人。16 位系统全局只有一个消息队列,系统必须等当前任务把消息处理完,才能把下一条发给别的程序。这种模型叫协同式多任务——多个程序靠"自觉"轮流让出控制权。任一程序陷入死循环或者做长耗时运算,系统就拿不回控制权,整个机器僵住。早期的 16 位 Windows 就是这套模型。

32 位系统把消息队列拆到了每个 GUI 线程一份。常见的实现思路是:线程创建窗口时系统为它准备一个消息队列,GetMessage只从自己线程的队列里取。于是 A 程序卡死,只卡住它自己那个线程,系统仍然可以在多个队列之间切换,把控制权交给别人。这套模型叫抢先式多任务。

理解了这一层,OnDraw里写Sleep(1000)的后果就清楚了:不是"画面慢一秒",而是这一个线程整整一秒不处理任何消息,窗口被遮挡后重绘请求堆积、鼠标点击石沉大海、标题栏显示"无响应"。耗时计算要么拆小、要么扔进工作线程,绘制函数里只留绘制。

3. MFC 消息映射:宏是怎么把 switch-case 藏起来的

手写窗口过程的问题不在于难,而在于重复。每加一个窗口就要抄一遍switch,每加一个菜单项就要在WM_COMMAND分支里多一个if,写到最后没人愿意维护。MFC 的解法是用一组宏,在编译期把散落在各处的成员函数"织"进一张静态表,运行时再靠这张表把消息分派给正确的成员函数。这一章拆开宏的外壳,看里面的数据结构长什么样。

3.1 从 BEGIN_MESSAGE_MAP 到 END_MESSAGE_MAP:宏展开后的真实形态

ClassWizard 自动生成的消息映射块长这样,中间那些带花括号的注释是向导的标记位,它靠这些标记定位,手改很容易破坏。

BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) //{{AFX_MSG_MAP(CMainFrame) ON_WM_CREATE() // 映射 WM_CREATE 到 OnCreate //}}AFX_MSG_MAP ON_COMMAND(ID_FONT_DROPDOWN, DoNothing) // 映射某命令 ID 到 DoNothing END_MESSAGE_MAP()

概念上,这段声明等价于给CMainFrame生成一个专属的窗口过程,逻辑上就是一张大的分派表:

// 编译后的等价形态(简化示意,真实实现是一张静态表而非 switch) LRESULT CMainFrame::WindowProc(UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_CREATE: // ON_WM_CREATE 展开的结果 return OnCreate((CREATESTRUCT*)lParam); case WM_COMMAND: // ON_COMMAND 展开的结果 if (HIWORD(wParam) == ID_FONT_DROPDOWN) { DoNothing(); return 0; } break; } return CFrameWnd::WindowProc(msg, wParam, lParam); // 交回基类继续找 }

凡是这个类的实例创建的窗口,窗口过程都指向这一份代码。派生类的处理函数先在子类里找,找不到就调基类的WindowProc,一路往上冒泡到CWnd,最后由CWnd::DefWindowProc兜底。这就是为什么你重写了OnPaint却不用管其他几十种消息——它们仍在基类那条链上被消化。

3.2 ON_WM_XXX 与 ON_COMMAND:两条分发路径的差别

映射宏分两类,走的是两条不同的查找路径,混用会直接导致处理函数不触发。

第一类是ON_WM_XXX,比如ON_WM_CREATE、ON_WM_PAINT、ON_WM_LBUTTONDOWN。这类宏只按消息名匹配,处理函数的签名是固定的——OnCreate必须收LPCREATESTRUCT,OnPaint必须无参,OnLButtonDown必须收(UINT, CPoint)。ClassWizard 生成时会自动写对签名,手写时签名错了编译期不一定报,运行期就崩在栈上。

第二类是ON_COMMAND、ON_CONTROL,它们按命令 ID 匹配。ON_COMMAND(ID_FONT_DROPDOWN, DoNothing)的意思是:任何WM_COMMAND消息,只要HIWORD(wParam)等于ID_FONT_DROPDOWN,就调DoNothing。这就是 2.1 节那段参数拆解的实际用途。

两类宏在底层都会走进同一张表,只是匹配的字段不同:前者比nMessage,后者比nMessage + nCode + nID区间。

3.3 表驱动而不是虚函数:MFC 为什么选宏

这是全篇最值得停下来想的设计取舍。消息种类数以百计,而一个具体的窗口类通常只关心其中十几种。如果每种消息都在CWnd里挂一个虚函数,虚函数表会被撑得极大,每个窗口对象都背着一份根本用不上的分派开销。

MFC 的选择是表驱动。宏展开后生成一张静态的映射条目数组,每条记录把"消息名 + 通知码 + 命令 ID 区间"对应到一个成员函数指针:

// 编译后实际生成的条目结构(简化示意) struct AFX_MSGMAP_ENTRY { UINT nMessage; // 消息名,如 WM_CREATE UINT nCode; // 通知码,WM_COMMAND 场景才用 UINT nID; // 命令 ID 下界 UINT nLastID; // 命令 ID 上界,支持一段连续 ID 映射到同一函数 UINT nSig; // 函数签名编码,用于把 pfn 还原成正确类型的成员函数指针 AFX_PMSG pfn; // 处理函数的实际地址 };

查表时从当前类的表开始,逐条比对消息名和 ID 区间,命中就按nSig把pfn转成对应签名的成员函数指针并调用;没命中就顺着一张指向基类表的指针上溯,直至CWnd。nSig这个字段是必要的妥协——各处理函数签名完全不同,统一存成同一种指针类型后再按签名转回去,绕开了类型系统但保住了正确性。

代价也很真实:宏在编译期展开,调试器无法在宏本身下断点,只能在展开后的处理函数里下;映射不生效时错误往往是静默的,程序照跑,只是那件事没人做。这一条在避坑章节还会再遇到。

4. GDI 对象与设备环境:为什么"选入 DC 再恢复"是不能省的

绘图部分的坑密度最高,因为它同时牵涉三种资源:C++ 对象(栈上、自动析构)、GDI 句柄(系统资源、必须显式释放)、设备环境(DC,一个抽象的画布)。三者生命周期不同步,一旦错位,现象就表现为"界面偶尔花掉"这种极难复现的玄学。这一章把三者的关系理清。

4.1 DC 的设备无关性:一份 OnDraw 代码怎么同时喂给屏幕和打印机

DC(设备环境)是一个抽象的作图环境,它可能对应屏幕、打印机、内存位图。同一套作图指令——MoveTo、LineTo、TextOut——作用在不同 DC 上,输出到不同设备。这就是 Windows 的设备无关性:好比同一幅画,用照相机拍和用复印机印,画本身不用改。

MFC 把这份能力集中体现在OnDraw(CDC* pDC)上。窗口需要重绘时框架调它,传进来的是屏幕 DC;用户选打印时框架还是调它,传进来的是打印机 DC;打印预览时传进来的是一个预览专用的 DC。你只写一份绘制代码,窗口、预览、真实打印三处全部复用。这也是为什么初学者把绘图代码写到OnPaint里而不是OnDraw里,打印功能就整个失效——OnPaint收到的永远是屏幕 DC。

4.2 创建、选入、恢复、销毁:四步一步都不能少

GDI 对象的使用有固定套路:先创建,再选入 DC,用完把 DC 里原来的对象选回去,最后销毁自己创建的对象。

void CMyView::OnDraw(CDC* pDC) { CPen penGray; penGray.CreatePen(PS_SOLID, 2, RGB(128, 128, 128)); // 创建:这时才真正分配 GDI 句柄 CPen* pOldPen = pDC->SelectObject(&penGray); // 选入:返回 DC 里原来的画笔 pDC->MoveTo(10, 10); pDC->LineTo(200, 10); pDC->SelectObject(pOldPen); // 恢复:先把旧的换回去 penGray.DeleteObject(); // 销毁:GDI 句柄配额有限,必须归还 // 同一个 CPen 变量可以复用,但重新创建前必须先删掉旧的 penGray.CreatePen(PS_SOLID, 2, RGB(0, 0, 0)); CPen* pOldPen2 = pDC->SelectObject(&penGray); pDC->MoveTo(10, 30); pDC->LineTo(200, 30); pDC->SelectObject(pOldPen2); penGray.DeleteObject(); }

顺序里最容易被省略的是"恢复"。penGray是栈上对象,函数返回时析构函数会跑,但它对应的 GDI 句柄此时可能还挂在 DC 里——DC 拿着一个指向已销毁对象的指针,下一次重绘就会画出不可预期的结果,或者直接异常。稳妥的顺序是先恢复再销毁。

也不要在同一个对象上连续调两次CreatePen,第二次之前必须先DeleteObject,否则第一次分配的句柄就永久泄漏了。进程的 GDI 对象配额是有上限的,几千次泄漏之后所有绘图接口集体失灵,界面上表现为大片空白。

4.3 库存对象:为什么它留在 DC 里是安全的

系统预置了一批库存 GDI 对象:黑白灰各种固定颜色的刷子、单像素的画笔、几种标准字体。它们由系统持有,你不需要创建也不需要销毁。

pDC->SelectStockObject(NULL_BRUSH); // 空心刷子:画矩形只描边不填充 pDC->SelectStockObject(BLACK_PEN); // 黑色单像素画笔 pDC->Rectangle(10, 10, 200, 200);

库存对象的价值有两层。一层是省事:画个空心矩形不必专门创建再销毁一个刷子。另一层更重要——它是"恢复"操作的可靠兜底。当你不确定pOldPen是不是有效,或者旧对象本身已经出了问题,把库存对象选进 DC 至少能保证状态是干净且合法的。库存对象不会被销毁,留在 DC 里也不会有悬空指针。

SelectStockObject接受BLACK_BRUSH、GRAY_BRUSH、HOLLOW_BRUSH、NULL_BRUSH、BLACK_PEN、NULL_PEN、WHITE_PEN、SYSTEM_FONT等一批固定索引,够应付绝大多数临时绘图场景。

4.4 文字输出:TextOut 与 DrawText 的排版差异

单行文字用TextOut(x, y, str),按坐标硬画。要折行、要对齐、要省略号,就得用DrawText(str, lpRect, nFormat),它的nFormat是一组可以按位或组合的标志。

CRect rc(10, 10, 300, 60); // 第一次调用只算不画:返回折行后需要的矩形高度 int h = pDC->DrawText(_T("一段需要在矩形内自动折行的文字"), rc, DT_LEFT | DT_WORDBREAK | DT_CALCRECT); // 按算好的高度把矩形底部撑开,再正式输出 rc.bottom = rc.top + h; pDC->DrawText(_T("一段需要在矩形内自动折行的文字"), rc, DT_LEFT | DT_WORDBREAK);

DT_CALCRECT是个很实用的组合——它让DrawText只测量不输出,返回文字占用的高度。手工排多行文字时,先算后画能避免行距错位。常用标志还有:DT_SINGLELINE强制单行、DT_CENTER/DT_RIGHT水平对齐、DT_VCENTER垂直居中(必须配合单行)、DT_END_ELLIPSIS尾部省略号、DT_NOPREFIX关闭&助记符处理、DT_TABSTOP设置制表位宽度(高位字节存字符数)。

字体创建走两条路。库存字体用SelectStockObject直接拿;自定义字体要填一个LOGFONT结构再调CreateFontIndirect,或者调参数多达十四个的CreateFont。LOGFONT里lfHeight用负值表示字符本身的高度、正值表示单元格高度;lfWeight决定粗细,FW_NORMAL是 400、FW_BOLD是 700;lfFaceName是本地化的字体名,硬编码英文名在某些环境下匹配不到。

需要精确排版时,GetTextExtent(str)返回字符串输出后的宽高,GetTextMetrics(&tm)填充TEXTMETRIC结构,给出字符平均宽度、行高、基线偏移这些精细数据。多行手工排版靠这两个函数算行距才靠谱。

5. 避坑清单:MFC 新手最容易翻车的五个地方

下面这些每一条都是能复现的真实故障,现象、原因、解决三件套写清楚,遇到时对着排。

5.1 GDI 资源相关:花屏、白屏、跑一会就画不出来

现象:程序启动后界面正常,运行几分钟后窗口某些区域变成白色,鼠标划过才恢复,或者干脆整片空白,重启程序又可以了。

原因:绘图代码里反复CreatePen、CreateFont却从不DeleteObject。每调一次OnDraw泄漏一个 GDI 句柄,累计到接近进程配额上限时,所有绘图接口静默失败——不抛异常,只是画不上东西。

解决:把"创建—选入—恢复—销毁"当四件套固定下来,检查每一处CreateXxx是否都有配对的DeleteObject,并且删除之前已经把旧对象选回 DC。一时改不完的话,用pDC->SaveDC()/pDC->RestoreDC(n)把 DC 状态整体存栈,比一个个记录旧指针稳妥。

现象:调试版跑得好好的,换到发布版随机崩溃,堆栈还指在一些莫名其妙的地方。

原因:ASSERT在发布版里被整体编译掉,如果你把带副作用的表达式写进了断言——比如在ASSERT里调用一个会创建对象的函数——发布版下这段代码压根不执行,后续逻辑全部错位。TRACE同理,只在调试版输出。

解决:断言只放纯判断表达式,绝不放赋值、函数调用这类有副作用的写法。确实需要在两种构建下都求值的用VERIFY,它在发布版下依然会执行括号里的表达式。

5.2 消息与生命周期相关:改了没反应、拖动就卡死

现象:明明在类里写了OnPaint,也加了ON_WM_PAINT(),编译链接都过,运行时就是不进断点。

原因:消息映射需要三处保持一致——头文件里的afx_msg函数声明、cpp 里BEGIN_MESSAGE_MAP与END_MESSAGE_MAP之间的映射条目、以及函数实现。用 ClassWizard 生成时三处会自动同步,手改就很容易漏掉其中一处。另一种可能是DECLARE_MESSAGE_MAP()宏被误删或被放到了错误位置。

解决:出问题时按这三处逐一核对,重点看带{{AFX_MSG_MAP注释的标记块有没有被破坏——向导靠这些花括号定位,破坏了它就不再往里面加新条目。

现象:窗口一拖动就花屏,标题栏显示"无响应",松手后恢复正常。

原因:OnDraw或OnPaint里塞了耗时计算,把消息线程占死了。WM_PAINT本身是低优先级消息,只在消息队列空了才合成,队列被堵住它就永远排不上,界面自然不刷新。

解决:绘制函数里只留绘制,耗时的计算挪到工作线程;线程算完后PostMessage一条自定义消息回主窗口,主窗口收到后Invalidate触发重绘。绝对不能在工作线程里直接操作窗口对象。

现象:程序里新建一个字体,明明CreateFontIndirect成功了,输出却还是原来的字体。

原因:创建了CFont对象却忘了SelectObject选进 DC。画笔和刷子也一样——GDI 对象创建之后只是存在,不选入 DC 就不参与任何绘制,当前生效的永远是 DC 里那支。

解决:记住这条因果链:DC 里当前选中的对象决定绘制效果,SelectObject才是"切换"的动作。创建只是准备好素材。

6. 设计取舍:MFC 封装掉的东西,什么时候该重新捡回来

MFC 替开发者挡掉了两样东西:各种句柄的具体操作,和消息分派的样板代码。这两样挡得对不对,值得单独算一笔账。

虚函数方案和表驱动方案摆在一起看:

维度每个消息一个虚函数宏 + 静态映射表
分派开销每次调用一次间接跳转查表比对,未命中还要上溯基类
对象体积虚表随消息数量膨胀,所有窗口都背表是静态的,与对象数量无关
调试体验断点清晰,栈完整宏不可断,错误容易静默
扩展方式重写虚函数即可必须用宏配对声明与实现
未处理消息需要基类默认实现兜底天然沿基类链上溯

结论是清晰的:消息数量远大于单个类实际关心的数量,虚表方案的空间代价是全局的,而查表代价只在收到消息时付一次。MFC 选表驱动是对的,代价转移到了可维护性上——宏让"改了一处忘了另一处"成为常态。

CRect、CPoint、CString、CStringArray这些值类型是另一笔划算的买卖。CString自己管内存、支持Format、Find、CompareNoCase,省掉的是大量手写缓冲区管理;CRect把四个坐标和Width、Height、CenterPoint、矩形相加取并集一次封好,比裸RECT结构好用太多。这些类几乎没有隐藏成本,该用就用。

但封装也有该拆的时候。句柄被藏进对象之后,一旦出现资源泄漏或者句柄失效,堆栈里全是框架代码,黑匣子的感觉就来了。我一般的做法是:怀疑资源问题时,绕过 MFC 的封装,直接查该类型的底层句柄(GetSafeHandle)是否为空、句柄数量是否在异常增长;怀疑消息问题时,在WindowProc里加一句TRACE把消息名打出来,比在映射表里瞎猜快得多。

VC++6.0 这个环境本身也有它的取舍。它老、它对现代 C++ 标准的支持停在很早的阶段,但它的向导生成的代码量小到你能完整读完,ClassWizard把消息映射的三处同步做成了一键。用它理解机制是合适的,用它写新项目就不必了。

血泪经验是这么来的:早年我在一个绘图程序里,OnDraw中每次都用局部CPen画虚线网格,为了省事直接DeleteObject而没把旧画笔选回去,本地跑一整天都正常,交付到另一台机器上半小时就整片白屏。从那以后我每次写涉及 GDI 的代码,都会强制走一遍"创建—选入—恢复—销毁"的四步检查,哪怕只是一个临时画笔,哪怕只画一条线。这类问题没有后悔药可吃,唯一的办法是让正确顺序变成肌肉记忆。希望帮到你。

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

返回列表