1. 从一个"收不到消息"的下午说起:MFC 消息机制要补的到底是什么
我最近在帮一个朋友排查一个挺典型的 MFC 问题:Dialog 上放了一个 TreeCtrl,想在右键点击节点时弹出上下文菜单,代码写了、编译过了、扩展消息映射也加了,跑起来就是没反应。拆到后来发现,onClick 那条ON_NOTIFY挂在了错误的窗口类上,消息路由根本没走到那边去。这类问题几乎每个接触 MFC 的人都会撞上一次两次,因为大多数资料只讲过消息映射"怎么写",却很少讲清"消息是怎么走到这里的"。
MFC 消息机制包含了消息映射表、消息分流、命令路由、消息反射、子类化等环节,它解释的是一件很具体的事:一个 Windows 消息从消息队列被取出之后,经过了哪些判定,最后停在哪个函数上。搞懂它的意义不在于"背下来",而在于你写了一个处理函数却死活不触发的时候,知道要往哪一层去看。
这篇文章适合已经写过一段 MFC 代码、但被各种"消息不响应""回调不进来""反射宏无效"卡过的开发者。我会从宏展开开始,一路讲到已有的消息流转路径、各种映射宏的实际差异,以及几个调试时可以马上用上的手段。全程围绕 MFC 消息机制这个主题,尽量把每个"为什么这么设计"讲透。
2. 把宏掰开看:消息映射表的真实数据结构
2.1 SDK 时代的巨型 switch 与它的代价
在没有 MFC 的年代,写一个 Windows 窗口程序的标准做法是WndProc里挂一个巨大的switch(message)。想给某个按钮加个处理,就多写一个case。这种写法本身没错,Win32 SDK 到今天也还是这套结构,但它带来的维护问题非常现实:一个主窗口的WndProc长到几百行是常态,不同窗口类之间的处理逻辑复用起来只能靠复制粘贴或者手写函数指针表。
MFC 的解法是把这段switch从"代码"变成"数据"。每个CWnd派生类在这一步不再需要写分支判断,而是声明一张静态的映射表,表里一行就是一条"消息 + 处理函数"的记录。运行时框架拿着收到的消息去表里顺序比对,匹配到了就调用对应函数,匹配不到就交给基类继续找,全找完还没有就落到DefWindowProc。复杂度从 O(分支数) 的代码变成了 O(表项数) 的线性查找,但换来的是类与类之间的处理逻辑可以完全解耦。
这个选择背后还有个容易被忽略的点:因为它是数据表,所以编译期就能把表静态初始化好,不需要运行时注册,也不需要虚函数表膨胀。一个窗口类有多少条映射记录,在.rdata段里就是一串连续的结构体,缓存友好,启动也快。
2.2 DECLARE_MESSAGE_MAP 在类里塞进了什么
头文件里写的那行DECLARE_MESSAGE_MAP(),展开之后其实只有两个成员:
protected: static const AFX_MSGMAP* PASCAL GetThisMessageMap(); virtual const AFX_MSGMAP* GetMessageMap() const;第一个是静态函数,返回本类的消息映射表描述结构体;第二个是虚函数,默认实现就是转去调第一个。为什么要有这两个?因为查找的时候是在基类指针上调用的,GetMessageMap()是虚的,所以能拿到实际类型的那张表;而GetThisMessageMap()是静态的,用于在BEGIN_MESSAGE_MAP里把"父类的表"直接填进去,不需要实例化就能建立链。
这个设计很关键:父类链是编译期硬编码进去的,不是在运行时通过继承关系动态推导。所以你如果在两个不相关的类之间复制粘贴BEGIN_MESSAGE_MAP(A, B)却把B写错了,编译器不会报错,但消息查找时会在一条错误的链上跑,表现就是某些父类能处理的消息在本类里丢了。
2.3 BEGIN_MESSAGE_MAP 构造的那张静态数组
BEGIN_MESSAGE_MAP(CMyDlg, CDialogEx)展开后做了三件事:定义一个static const AFX_MSGMAP_ENTRY数组,把GetThisMessageMap实现出来,并在数组第一个位置放了父类链信息。中间那些ON_XXX宏就是往这个数组里塞条目,最后END_MESSAGE_MAP()补一个全零的哨兵条目表示结束。
数组元素的类型定义大致长这样:
struct AFX_MSGMAP_ENTRY { UINT nMessage; // 消息号,如 WM_COMMAND UINT nCode; // 通知码,普通窗口消息填 0 UINT nID; // 控件 ID 下界 UINT nLastID; // 控件 ID 上界,单 ID 时等于 nID UINT_PTR nSig; // 签名枚举,决定怎么调 pfn AFX_PMSG pfn; // 处理函数的成员函数指针 };nID和nLastID同时存在,是为了支持范围映射。ON_CONTROL_RANGE、ON_COMMAND_RANGE就是靠这两个字段一次覆盖一段 ID,查找时的判断条件是nID >= entry.nID && nID <= entry.nLastID。
nSig这个字段最容易被忽视,它其实是一个枚举值,编码了"这个处理函数是什么签名"。比如无参无返回、接收一个UINT参数、接收WPARAM/LPARAM返回LRESULT、接收NMHDR*和LRESULT*。框架在调用前根据nSig决定怎么把消息参数强转成函数指针再调用。这样做的代价是类型安全被推到了运行期,好处是映射表能容纳不同形态的处理函数而不需要虚函数。
2.4 签名编码带来的两个隐藏陷阱
第一个陷阱是函数签名写错,编译期不报,运行期崩溃或者行为诡异。比如给ON_MESSAGE挂了一个返回void的函数,或者给ON_COMMAND挂了一个带参数的函数。因为宏里做了强制类型转换,编译器在有static_cast的情况下往往能拦住明显错误,但遇到重载函数、虚函数与普通函数混用、或者参数类型只是"看起来兼容"时,就可能放过。跑起来的表现是栈被踩,或者this指针错位,调试器里看调用栈完全对不上。
第二个陷阱是范围映射的处理函数必须多一个UINT nID参数。ON_COMMAND_RANGE(id1, id2, &CMyWnd::OnRangeCmd),对应函数签名必须是void OnRangeCmd(UINT nID),不是无参的。这一点很多人第一次用范围映射时会踩,因为 IDE 的类向导不支持生成这种函数,只能手写。我个人的习惯是:只要用了范围宏,就在函数上方加一行注释写清签名要求,三个月后回来看不至于又懵一次。
再补一个实操提醒:映射表是顺序查找的,条目顺序会影响匹配结果。如果同一条WM_COMMAND既有单 ID 条目又有范围条目,而且范围覆盖了这个单 ID,那么谁写在前面谁生效。虽然正常情况下不应该这么写,但接手别人代码时确实会遇到,排查时值得检查一下顺序。
3. 一条消息的完整旅程:从消息泵到处理函数
3.1 PumpMessage 里的三行关键代码
应用启动后,CWinThread::Run里是一个标准的消息循环,核心三步:GetMessage、PreTranslateMessage、TranslateMessage + DispatchMessage。这三步里PreTranslateMessage属于 MFC 额外加的钩子,另外两步是 Win32 原生。
GetMessage从线程消息队列取消息,取到之后先给PreTranslateMessage过一遍。这个函数在CWinThread和CWnd上都有,顺序是先调线程的,再调目标CWnd的。它返回TRUE表示"消息我已经处理了,别再往下走",返回FALSE表示继续正常的TranslateMessage和DispatchMessage。
DispatchMessage最终会把消息送到目标窗口的WindowProc。MFC 窗口的WindowProc被替换成了AfxWndProc,这是一个全局的函数,它根据窗口句柄把CWnd*捞出来,然后调用CWnd::WindowProc,再进OnWndMsg,真正的主战场就在这里。这条路看起来绕,但它解决了一个很麻烦的问题:AfxWndProc是统一注册的窗口过程,所有 MFC 窗口共用,靠 HWND 反查对象指针,这就实现了"一个窗口类可以有很多实例而不用重复注册窗口类"。
值得说明的是TranslateMessage这一步。它做的事是把WM_KEYDOWN/WM_KEYUP翻译成WM_CHAR,然后PostMessage到队列里。所以PreTranslateMessage能拦到原始的按键消息,而OnChar处理的是翻译后的字符消息。想在PreTranslateMessage里做全局快捷键,拦的是前者。
3.2 PreTranslateMessage 的真实定位与使用边界
PreTranslateMessage是我见过被滥用最多的一个函数。它的原意是给对话框做IsDialogMessage的预处理,让 Tab 键、方向键、回车键在对话框里表现得符合习惯。但很多人把它当成"万能拦截器",在上面挂一堆if (pMsg->message == WM_KEYDOWN && pMsg->wParam == VK_ESCAPE) return TRUE;。
这么写本身能跑,但要清楚两个后果。第一,返回TRUE意味着这条消息彻底消失,DispatchMessage不会被调用,目标窗口的任何处理函数都不会收到它,包括你想让子控件自己处理的场景。第二,这个函数调得非常频繁,所有消息都要过一遍,写在这里的代码如果耗时,整个界面会卡顿。我见过有人在这里做字符串处理,一秒钟几百条鼠标移动消息,直接把 CPU 拉满。
比较稳妥的做法是:只在真正需要"抢先于所有控件"的地方用它,比如整个窗口的快捷键拦截、Enter键行为改写;单一的控件级处理一律走消息映射。另外在PreTranslateMessage里要记得先if (CDialogEx::PreTranslateMessage(pMsg)) return TRUE;让基类先处理,不然对话框的默认键盘导航会失效。
3.3 OnCommand 与 OnNotify 的特殊分支
进入OnWndMsg之后,框架并不是一上来就查映射表,而是对两类消息做了特殊处理。
WM_COMMAND走的是命令路由,不是普通的映射查找。CWnd::OnCommand会先解析出nID和通知码,然后调用OnCmdMsg。OnCmdMsg是CCmdTarget的虚函数,它会先在自身映射表里找ON_COMMAND条目,找不到就交给m_pRoutingObject(通常是父窗口),一路向上走,直到 Frame、Doc、View、App 这一条命令链。这就是为什么你在 View 里写ON_COMMAND(ID_FILE_SAVE, ...),而菜单在 Frame 上,消息依然能到达——菜单触发时 Frame 接到WM_COMMAND,自己没处理,就沿着路由链找到了 View。
WM_NOTIFY走的是另一套。OnNotify先从NMHDR里取出通知码和发送者句柄,然后尝试消息反射(下一节细讲),反射不成功才回到映射表里按ON_NOTIFY条目查找。这里有个细节:ON_NOTIFY的第三个参数必须和控件实际发出的通知码一致,TreeCtrl的右键菜单是NM_RCLICK,不是NM_CLICK,写错了匹配不上,表现就是"完全没反应",也不报错。
3.4 没被处理的消息最后去了哪
如果一路查找都没命中,OnWndMsg返回FALSE,CWnd::WindowProc就调用DefWindowProc,交给系统默认处理。这一步非常重要,忘了它会出事。
典型例子是WM_ERASEBKGND。有人为了做双缓冲,在OnEraseBkgnd里画完背景后return TRUE,这没问题。但如果某条消息你手写了个ON_MESSAGE处理函数,里面画了东西然后return 0,而没有让系统处理,某些控件就会出现绘制残影或者滚动条失灵。判断标准很简单:你是要"完全接管"还是"在默认行为前后插一脚"。前者可以自己return,后者必须调Default()或者在最后转给基类。
4. 消息映射宏的选型与实操细节
4.1 ON_COMMAND 与 ON_UPDATE_COMMAND_UI 的配合
ON_COMMAND处理的是"被点击了",ON_UPDATE_COMMAND_UI处理的是"菜单项/工具栏按钮该不该亮、文字该写什么"。后者的触发时机很有意思,它不是在消息循环里主动触发的,而是靠空闲时间。
具体来说,CWinThread::OnIdle会调用OnIdleUpdateCmdUI,后者遍历菜单和工具栏上的每一项,向命令目标发CN_UPDATE_COMMAND_UI请求,问它"你现在的状态是什么"。这就解释了为什么有时候改了某个BOOL变量,菜单的Enable状态要等一会儿才变——它得等到空闲。
实操上有两个建议。一是OnUpdateCmdUI里只做状态判断,别做耗时操作,因为它被调用的频率非常高,鼠标晃一下可能就触发好几轮。二是如果你的程序在持续处理消息,空闲时间可能来不了,界面状态就不刷新。这时候可以主动PostMessage(WM_IDLEUPDATECMDUI)或者在自己处理完业务逻辑之后手动调CFrameWnd::OnIdleUpdateCmdUI来强制刷新一次。
还有个组合技巧:在文档/视图结构里,命令路由会自动找到当前激活的 View,所以ON_UPDATE_COMMAND_UI写在 View 里就会随着活动的 View 切换而自动变化,不需要手写任何同步代码。这是命令路由带来的红利,用好了能省掉一大片状态同步逻辑。
4.2 ON_NOTIFY 的参数与返回值约定
ON_NOTIFY(通知码, 控件ID, &CMyDlg::OnXxx)对应的函数签名固定是:
afx_msg void OnTreeRClick(NMHDR* pNMHDR, LRESULT* pResult);函数内部要做的第一件事永远是NMHDR转具体结构体:
void CMyDlg::OnTreeRClick(NMHDR* pNMHDR, LRESULT* pResult) { auto* pTreeView = reinterpret_cast<LPNMTREEVIEW>(pNMHDR); // 用完记得置零 *pResult = 0; }*pResult这个坑值得单独说。并不是所有通知都要求你设置它,但如果你不设,它保留的是栈上的随机值,某些控件会拿这个值判断"要不要继续默认处理"。我自己的习惯是无论用不用,进来先*pResult = 0;,避免出现莫名其妙的行为差异。
另外一个容易被忽略的是通知码的取值来源。控件文档里会列出它支持哪些通知,但要注意这些宏名通常是NM_XXX加前缀,比如 ListCtrl 用LVN_,TreeCtrl 用TVN_。但在ON_NOTIFY里填的时候,用的是这个具体值,而不是NM_CLICK这种通用值(除了 Standard 的几个)。写错了不报错、不触发,只能靠对照文档慢慢试。
4.3 ON_MESSAGE、ON_REGISTERED_MESSAGE 与 ID 区间规划
自定义消息的时候有两条路。一条是ON_MESSAGE(WM_MY_EVENT, ...),其中WM_MY_EVENT自己定义成WM_USER + n。另一条是RegisterWindowMessage拿到一个系统分配的全局 ID,配合ON_REGISTERED_MESSAGE使用。
什么时候用哪个?如果消息只在自家进程内部发,WM_USER + n就够了,写起来简单。但要注意WM_USER到 0x7FFF 这一段其实是"窗口类私有"的语义,如果你把这个消息广播出去(HWND_BROADCAST),别的程序可能也在用同一个值,行为就不可控了。跨进程通信或者需要广播的场景,必须走RegisterWindowMessage,它返回的是 0xC000 到 0xFFFF 区间,系统保证全局唯一。
数值区间的规划我踩过一次坑。当时一个老项目里自己定义了一堆WM_USER+100到WM_USER+150,后来又引入了一个第三方控件,它内部也占用了WM_USER+120,结果那条消息被两边的处理函数同时抢。后来我改用WM_APP + n(0x8000 起),并且约定"自定义消息一律从WM_APP + 0x100开始,按模块分段",才算彻底解决。
顺便提一下控件 ID。MFC 内部保留了一部分 ID 用在框架控件上,自己定义对话框控件 ID 时建议避开 0xE000 到 0xEFFF 这一段,从 0x1000 往后排,同时把范围按功能分区(比如 1000-1999 给主界面按钮,2000-2999 给列表控件),后面做范围映射的时候会非常省事。
4.4 范围映射与批量改造
ON_COMMAND_RANGE和ON_CONTROL_RANGE的价值在批量场景下特别明显。比如动态生成的工具栏上有几十个按钮,每个对应一个功能,传统写法要写几十个ON_COMMAND,还要写几十个switch分支。用范围映射就变成一条:
// 假设按钮 ID 从 IDC_CMD_BASE 连续排到 IDC_CMD_BASE+19 ON_COMMAND_RANGE(IDC_CMD_BASE, IDC_CMD_BASE + 19, &CMainFrame::OnCmdRange) void CMainFrame::OnCmdRange(UINT nID) { const int idx = static_cast<int>(nID - IDC_CMD_BASE); if (idx < 0 || idx >= m_cmdHandlers.size()) return; m_cmdHandlers[idx](); }这里有个前提必须强调:ID 必须是连续的。如果中间断号,范围条目仍然会匹配到对应的 ID,但你的索引计算就会越界。我一般会在初始化时加一条断言ASSERT(nID - IDC_CMD_BASE < count),把问题挡在开发期。
ON_CONTROL_RANGE则是给一组控件挂同一个通知处理,典型场景是一排 CheckBox 都在同一个组里,BN_CLICKED用同一个函数处理,靠nID区分是哪个。用途不多,但在就是需要它的场合能省不少代码。
5. 反射、子类化与钩子:不修改控件源码的前提下动手
5.1 SubclassWindow 与 SubclassDlgItem 的区别与顺序风险
子类化的本质是"把已有的 HWND 挂到自己的 CWnd 对象上",这样AfxWndProc反查时可以找到你的对象,你的消息映射就能生效。
SubclassDlgItem(UINT nID, CWnd* pParent)是按控件 ID 找,内部先GetDlgItem拿句柄再SubclassWindow。SubclassWindow(HWND hWnd)是直接给句柄。两者都要注意解绑:对象销毁前必须UnsubclassWindow(),否则窗口被销毁时框架可能会访问已经析构的对象,典型表现是退出程序时崩溃,调用栈在AfxWndProc里。
顺序问题也踩过。如果我先SubclassDlgItem了某个控件,然后在同一个 ID 上又挂了一次,第二次会因为句柄已经被接管而在ASSERT里挂掉。更隐蔽的是:如果在OnInitDialog之前就子类化,那时候控件还没创建,GetDlgItem返回空,SubclassDlgItem直接失败但不报错,后面所有消息都收不到。我现在一律在OnInitDialog里、CDialogEx::OnInitDialog()之后再做子类化,这是最稳的时机。
5.2 WM_NOTIFY_REFLECT 反射机制的完整回路
反射要解决的问题是:控件自己产生的通知,本来应该由控件自己最清楚怎么处理,但 Win32 的通知机制是全发给父窗口的,父窗口被迫承载了本属于控件内部的逻辑。
MFC 的方案是走一圈再回来。控件发出WM_NOTIFY后,父窗口的OnNotify先不急着查自己的映射表,而是调用ReflectLastMsg,把通知码加上一个固定偏移(OCM_BASE)后用SendMessage反投给控件自身。控件收到这个反射消息后,识别出它是反射回来的,减掉偏移还原成原始通知码,再在自己的映射表里查找——这时候匹配到的就是ON_NOTIFY_REFLECT定义的条目。
所以要写一个自绘的列表控件,不需要父窗口配合任何代码:
// CMyListCtrl.h class CMyListCtrl : public CListCtrl { DECLARE_MESSAGE_MAP() public: afx_msg void OnCustomDraw(NMHDR* pNMHDR, LRESULT* pResult); }; // CMyListCtrl.cpp BEGIN_MESSAGE_MAP(CMyListCtrl, CListCtrl) ON_NOTIFY_REFLECT(NM_CUSTOMDRAW, &CMyListCtrl::OnCustomDraw) END_MESSAGE_MAP()同样的套路,命令消息的反射用ON_CONTROL_REFLECT。判断"该用反射还是该用父窗口处理"的标准很简单:这段逻辑是不是这个控件的固有属性。行颜色、单元格绘制、内部编辑行为,都属于控件自身;而"点了这个按钮要打开某个对话框",那是父窗口的事。
有个排查经验值得记一下。反射宏不生效,常见原因是控件类本身没有正确注册到运行时类信息里,或者控件是通过Create动态创建的但没走Create而用了CreateEx漏了某个参数。还有一种情况是父窗口重写了OnNotify但没有调基类,把反射这一环直接掐断了。
5.3 全局钩子的代价
SetWindowsHookEx能拦到全系统的消息,但它在现代 Windows 上代价很大:注入到其他进程意味着兼容性风险、性能开销,还有可能被安全软件拦下来。如果只是想在自家进程内做消息拦截,优先考虑PreTranslateMessage;如果只是想拦某个窗口的消息,SubclassWindow比钩子干净得多。
我一般只在两种场景下考虑钩子:一是需要监听其他程序窗口的状态变化(比如监控某个外部程序是否在前台),二是需要拦截某些特定的系统级操作。即便如此,也倾向于用局部钩子(WH_CALLWNDPROC配合线程 ID)而不是全局钩子,能少注入一个进程就少注入一个。
6. 跨线程投递消息:PostMessage 与 SendMessage 的真实语义
6.1 两者的阻塞模型差异
PostMessage是把消息放进目标线程的消息队列后立刻返回,不等待处理结果。SendMessage则是同步调用,如果是同一个线程内,相当于直接调用目标窗口过程;如果是跨线程,则发送线程会阻塞,直到目标线程处理完并返回结果。
这个差异在单线程程序里感受不到,一上多线程就会变成死锁源头。举个最容易复现的场景:线程 A 持有锁 L1,然后SendMessage给线程 B;线程 B 此时正在处理别的消息,手里拿着锁 L2,也想拿 L1,同时对线程 ASendMessage。两边互相等待,界面彻底冻住,任务管理器里显示"无响应"。
规避思路有两条。一是能用PostMessage就别用SendMessage,尤其跨线程;二是必须同步等待结果时,用SendMessageTimeout加超时,或者在设计上把"请求-响应"改成"投递-回调"的消息对,避免两个线程互相阻塞。
6.2 参数生命周期与悬垂指针
PostMessage的另一个坑是参数的生命周期。因为它是异步的,投递出去那一刻函数就返回了,如果lParam指向的是栈上的局部变量,等目标线程处理时这块内存早就没了。
// 错误示范 void CWorker::Notify() { CString msg = _T("done"); ::PostMessage(m_hWnd, WM_MY_DONE, 0, reinterpret_cast<LPARAM>(&msg)); // 悬垂 } // 正确做法一:堆分配 + 接收方释放 void CWorker::Notify() { auto* pMsg = new CString(_T("done")); ::PostMessage(m_hWnd, WM_MY_DONE, 0, reinterpret_cast<LPARAM>(pMsg)); } afx_msg LRESULT CMainDlg::OnDone(WPARAM, LPARAM lParam) { std::unique_ptr<CString> pMsg(reinterpret_cast<CString*>(lParam)); // 使用 *pMsg return 0; }如果不想手动管理内存,另一个做法是投递窗口句柄和值语义的参数,接收方自己去查。或者更省事一点,用PostMessage只传一个索引,实际数据放在成员变量里,靠std::mutex保护。哪种都行,关键是不要在跨线程异步消息里传裸栈指针。
还有一点:给窗口投递消息之前要确认窗口还活着。工作线程结束时主窗口可能已经销毁,PostMessage到无效句柄虽然不会崩,但消息就这么丢了。稳妥写法是先IsWindow(m_hWnd),或者用InterlockedExchangePointer把句柄置空,两边都检查。
7. 排查实录:消息收不到的五类原因与速查表
消息收不到是最常见的求助,但原因其实就那么几类。我把自己记录过的排查顺序整理成下面这张表,按命中率从高到低排列。
| 序号 | 现象 | 高概率原因 | 验证方式 |
|---|---|---|---|
| 1 | 完全无反应,断点不进 | 映射宏里的控件 ID 或通知码写错 | 对照资源头文件里的#define逐字比较 |
| 2 | 同样无反应 | 处理函数所在的类不是命令路由链上的一环 | 在OnCmdMsg里加断点看是否经过该类 |
| 3 | 偶尔触发,偶尔不触发 | 用了PreTranslateMessage返回TRUE吞掉了 | 检查所有return TRUE的分支条件 |
| 4 | 反射宏不生效 | 父窗口重写了OnNotify且没调基类 | 在父窗口OnNotify里断点看是否被截断 |
| 5 | 子类化后消息全丢 | SubclassWindow顺序问题或对象已析构 | 检查OnInitDialog时机、检查UnsubclassWindow |
除了查原因,调试手段也值得说几个。
Spy++是首要工具,它能直接看到某个窗口实际收到了哪些消息、参数是什么。当你不确定控件到底有没有发出通知时,用它盯一眼最省事。Visual Studio 里消息映射的断点也很好用,但要注意 MFC 的映射函数是普通成员函数,断点打在函数体第一行即可,不要打在宏上。
日志埋点在处理函数体第一行加一句TRACE(_T("enter %s\n"), __FUNCTION__),配合 DebugView 看输出,比反复断点快得多。还有一种办法是在CWnd::WindowProc上打断点,看消息有没有到达窗口过程——到达了说明是映射表没匹配上,没到达说明问题更靠前,在消息循环或者PreTranslateMessage那一层。
最后分享一个我自己的习惯:接手任何一份 MFC 代码,先搜一遍所有BEGIN_MESSAGE_MAP,把每个类的映射表扫一眼,重点看有没有重复的 ID、有没有写错的父类、有没有范围映射和单条映射交叉。这三件事占了我排查时间的六成以上,扫一遍比出问题再翻要划算得多。