1. 为什么 MFC 对话框控件的 ToolTip 总是不生效如果你用 VC 的 MFC AppWizard 生成过 SDI 或 MDI 程序会发现工具条按钮和菜单项的 ToolTip 几乎是白送的资源里写好提示文字框架自动帮你弹出来。但一旦把场景换成对话框上的普通控件比如按钮、编辑框、静态文本情况就完全不一样了——鼠标悬停上去毫无反应状态栏也不显示任何帮助信息。这不是你代码写错了而是 MFC 的默认机制决定的。工具条和菜单走的是CFrameWnd那一套TTN_NEEDTEXT通知链框架在OnToolTipText里已经帮你处理好了。而对话框控件没有自动挂上CToolTipCtrl也没有人替你把WM_SETCURSOR转成 ToolTip 的触发时机。所以你要自己补上这条链路在鼠标移动时判断当前落在哪个控件上取出该控件的字符串资源一半送给状态栏一半送给 ToolTip。这篇就按 AppWizard 生成骨架 → 编辑字符串资源 → 消息映射 →WM_SETCURSOR里接入CToolTipCtrl→ 验证效果 的顺序把每一步的可复制代码给全。适合正在用 VC/MFC 做桌面工具、需要给对话框控件加悬停提示的开发者。核心检索词就三个ToolTip、VC、MFC外加 AppWizard 和 WM_SETCURSOR 这两个关键动作。2. 前置准备AppWizard 骨架与字符串资源约定2.1 用 AppWizard 生成 SDI/MDI 骨架打开 Visual Studio新建 MFC 应用程序选择「单个文档」或「多个文档」其余保持默认。生成后你会得到一个带主框架、状态栏、工具条的工程。这里的关键点是状态栏必须存在因为后面SetStatusText要靠AFX_IDW_STATUS_BAR找到它。AppWizard 默认会勾选「初始状态栏」别取消。生成完先编译运行一次确认骨架正常。这一步不是走过场——很多人后面 ToolTip 不弹回头查半天结果是状态栏被自己删了。2.2 字符串资源的格式约定MFC 的 ToolTip 文本来源是控件的字符串资源。约定格式是; 资源文件 String Table 中的写法 IDC_DBBUTTON1 这是按钮的状态栏提示\n这是 ToolTip 文本\n前面那段显示在状态栏\n后面那段作为 ToolTip 弹出。这个约定来自 MFC 对工具条提示的默认解析逻辑AfxExtractSubString按\n切分索引 0 是状态栏文本索引 1 是 ToolTip 文本。如果你只想要 ToolTip、不想要状态栏提示把前半段留空即可但\n不能省否则切分索引会错位。注意字符串资源 ID 必须和控件 ID 一致。比如按钮 ID 是IDC_DBBUTTON1字符串表里就得有同名条目否则AfxLoadString取不到内容。3. 可复制配置消息映射与初始化代码3.1 头文件声明在对话框类的.h文件里加入三个成员函数声明。注意OnSetCursor和OnTipNotify是消息处理函数SetStatusText是自己的辅助函数protected: void SetStatusText(UINT nID 0); //{{AFX_MSG(CFileOp1) afx_msg void OnDestroy(); afx_msg BOOL OnSetCursor(CWnd* pWnd, UINT nHitTest, UINT message); //}}AFX_MSG afx_msg BOOL OnTipNotify(UINT id, NMHDR* pNMHDR, LRESULT* pResult); DECLARE_MESSAGE_MAP()3.2 消息映射表在.cpp的BEGIN_MESSAGE_MAP和END_MESSAGE_MAP之间加入BEGIN_MESSAGE_MAP(CFileOp1, CDialog) //{{AFX_MSG_MAP(CFileOp1) ON_WM_DESTROY() ON_WM_SETCURSOR() //}}AFX_MSG_MAP ON_NOTIFY_EX(TTN_NEEDTEXT, 0, OnTipNotify) END_MESSAGE_MAP()ON_NOTIFY_EX是这里的关键它让对话框能接收TTN_NEEDTEXT通知。普通ON_NOTIFY不会返回BOOLToolTip 文本填不进去。3.3 在 WM_SETCURSOR 中接入 CToolTipCtrl这是整篇的核心。WM_SETCURSOR在鼠标每次移动时触发正好用来判断当前悬停在哪个控件上然后手动派发TTN_NEEDTEXTBOOL CFileOp1::OnSetCursor(CWnd* pWnd, UINT nHitTest, UINT message) { if (pWnd this) { SetStatusText(); } else { TOOLTIPTEXT m_psttt; m_psttt.hdr.hwndFrom m_hWnd; m_psttt.hdr.idFrom pWnd-GetDlgCtrlID(); m_psttt.hdr.code TTN_NEEDTEXT; m_psttt.uFlags TTF_IDISHWND; SetStatusText(pWnd-GetDlgCtrlID()); this-SendMessage(WM_NOTIFY, m_psttt.hdr.idFrom, (LPARAM)m_psttt); } return CDialog::OnSetCursor(pWnd, nHitTest, message); }TTF_IDISHWND标志告诉通知处理函数idFrom里装的是窗口句柄而不是控件 ID所以后面OnTipNotify里要用::GetDlgCtrlID再转一次。3.4 状态栏文本与 ToolTip 文本的填充void CFileOp1::SetStatusText(UINT nID) { if (nID 0) nID AFX_IDS_IDLEMESSAGE; CWnd* pWnd AfxGetMainWnd()-GetDescendantWindow(AFX_IDW_STATUS_BAR); if (pWnd) { AfxGetMainWnd()-SendMessage(WM_SETMESSAGESTRING, nID); pWnd-SendMessage(WM_IDLEUPDATECMDUI); pWnd-UpdateWindow(); } } BOOL CFileOp1::OnTipNotify(UINT id, NMHDR* pNMHDR, LRESULT* pResult) { TOOLTIPTEXT* pTTT (TOOLTIPTEXT*)pNMHDR; UINT nID pNMHDR-idFrom; if (pTTT-uFlags TTF_IDISHWND) { nID ::GetDlgCtrlID((HWND)nID); if (nID) { TCHAR szFullText[256]; CString StrTipText; AfxLoadString(nID, szFullText); AfxExtractSubString(StrTipText, szFullText, 1, \n); if (!StrTipText.IsEmpty()) strcpy(pTTT-lpszText, StrTipText); pTTT-hinst AfxGetResourceHandle(); return TRUE; } } return FALSE; }AfxExtractSubString的第三个参数是子串索引1表示取\n后面那段也就是 ToolTip 文本。状态栏文本在SetStatusText里通过WM_SETMESSAGESTRING单独设置取的是索引 0 那段。3.5 别忘了 afxpriv.h在StdAfx.h里补上两个头文件否则TOOLTIPTEXT和AFX_IDS_IDLEMESSAGE会报未定义#include afxpriv.h #include afxres.h4. 验证请求与成功结果4.1 把对话框挂到主框架AppWizard 生成的 SDI/MDI 主框架默认显示视图不是对话框。你需要把这个对话框作为主框架的子窗口。最简单的做法是在InitInstance里创建对话框并SetWindowPos铺满客户区或者直接把对话框资源作为主视图。挂好之后编译运行把鼠标移到按钮上。预期结果状态栏左侧显示字符串资源\n前的那段文字鼠标悬停约半秒后控件旁边弹出 ToolTip内容是\n后的那段文字鼠标移开控件状态栏恢复空闲提示。4.2 用 Spy 确认消息流如果没弹出来别急着改代码。打开 Spy选中你的对话框窗口看WM_SETCURSOR有没有进来。如果进来了但 ToolTip 没弹说明TTN_NEEDTEXT没被正确派发如果WM_SETCURSOR根本没进来说明对话框没挂到主框架上或者鼠标事件被别的窗口截走了。5. 本篇常见错排查5.1 ToolTip 不弹出状态栏正常大概率是ON_NOTIFY_EX写成了ON_NOTIFY。前者返回BOOLMFC 靠这个返回值判断文本是否已填充后者返回voidlpszText永远是空的。5.2 弹出空白 ToolTip检查字符串资源里\n的位置。如果写成提示文字没有\nAfxExtractSubString取索引 1 会得到空串。正确写法是状态栏文字\nToolTip文字。5.3 编译报 TOOLTIPTEXT 未定义StdAfx.h里漏了#include afxpriv.h。这个头文件不在默认包含链里必须手动加。5.4 状态栏文字不刷新SetStatusText里少了pWnd-SendMessage(WM_IDLEUPDATECMDUI)。状态栏的更新依赖空闲消息不手动触发的话文字会停留在上一次的内容。5.5 鼠标移到子控件上时状态栏闪回空闲提示OnSetCursor里pWnd this的分支会调SetStatusText()把状态栏重置。如果子控件的WM_SETCURSOR没有正确冒泡到对话框就会先触发父窗口的重置。确认对话框的OnSetCursor确实被调用必要时在子控件上也不拦截WM_SETCURSOR。6. 把 ToolTip 链路接进你的日常开发流上面这套代码在 VC/MFC 工程里是稳定的但每次新建对话框都要手抄一遍消息映射和三个函数时间长了容易漏。我的做法是把这段抽成一个CTipDialog基类所有需要 ToolTip 的对话框都从它继承字符串资源按ID 状态栏\nToolTip的格式统一维护。如果你在调这段代码时遇到TTN_NEEDTEXT不触发、或者想确认某个控件的通知到底走没走通可以到 TaoToken 的模型对话里把 Spy 抓到的消息序列贴进去让它帮你比对 MFC 的通知链预期路径比翻 MSDN 快不少。接入相关的 Key 和文档在这里模型对话https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentmodel_chatAPI Keyshttps://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentapi_keys接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentdoc长期在 VC 工程里做编码和 Agent 辅助的可以看 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcoding_plan最后留一个我踩过的坑strcpy(pTTT-lpszText, StrTipText)这行lpszText指向的是 MFC 内部缓冲区长度有限。如果你的 ToolTip 文本超过 80 个字符建议改成pTTT-lpszText (LPTSTR)(LPCTSTR)StrTipText并配合hinst使用或者自己维护一个静态CString保证生命周期。这个细节在短文本下不会暴露一旦提示写长了就会截断甚至越界。