Win32追踪提示不显示?详解指针陷阱与解决方案
2025-05-08 11:29:51
追踪工具提示 (Tracking Tooltip) 为啥不显示?Win32 开发踩坑实录
写 Windows 桌面应用,有时候需要个能实时跟着鼠标跑的提示框,也就是 Tracking Tooltip。这玩意儿比静态绑定到某个控件上的普通 Tooltip 要灵活不少。理论上照着微软的文档示例,应该不难实现。但现实是,代码CV(复制粘贴)一番,咦,它就是不出来!静态的 Tooltip 倒是顺利得很,可偏偏这个会追踪的“小跟屁虫”各种罢工。抓狂!
咱先看看出问题的代码长啥样(精简后的核心逻辑和用户提供的一致):
#include <windows.h>
#include <windowsx.h>
#include <commctrl.h>
#include <stdio.h>
// 全局变量,用来放 Tooltip 句柄和信息
HWND g_hwndTrackingTT;
TOOLINFO g_toolItem; // 注意这里,后面会讲到
BOOL g_TrackingMouse = FALSE; // 鼠标是否在窗口内的标记
// 创建 Tracking Tooltip 的函数
HWND CreateTrackingToolTip(HWND hParentWnd) {
HWND hwndTT = CreateWindowEx(WS_EX_TOPMOST, TOOLTIPS_CLASS, NULL,
WS_POPUP | TTS_NOPREFIX | TTS_ALWAYSTIP,
CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT,
hParentWnd, NULL, NULL, NULL);
if (!hwndTT) return NULL;
// 初始化 TOOLINFO 结构体
ZeroMemory(&g_toolItem, sizeof(TOOLINFO)); // 好习惯,清零
g_toolItem.cbSize = sizeof(TOOLINFO);
// TTF_IDISHWND 表示 uId 是一个窗口句柄,这里是父窗口句柄
// TTF_TRACK 表示这是个 Tracking Tooltip
// TTF_ABSOLUTE 表示 TTM_TRACKPOSITION 里的坐标是屏幕坐标
g_toolItem.uFlags = TTF_IDISHWND | TTF_TRACK | TTF_ABSOLUTE;
g_toolItem.hwnd = hParentWnd; // Tooltip 的父窗口,也接收通知
g_toolItem.hinst = NULL; // 使用 TTF_IDISHWND 时,hinst 应为 NULL
g_toolItem.lpszText = "Tracking..."; // 初始文本
g_toolItem.uId = (UINT_PTR)hParentWnd; // 将父窗口句柄作为 Tool ID
SendMessage(hwndTT, TTM_ADDTOOL, 0, (LPARAM)(LPTOOLINFO)&g_toolItem);
return hwndTT;
}
// 窗口过程函数
LRESULT CALLBACK WndProc(HWND hwnd, UINT Message, WPARAM wParam, LPARAM lParam) {
static int oldX, oldY;
int newX, newY;
switch (Message) {
case WM_CREATE:
g_hwndTrackingTT = CreateTrackingToolTip(hwnd);
break;
case WM_MOUSEMOVE: {
// 获取鼠标当前客户区坐标
newX = GET_X_LPARAM(lParam);
newY = GET_Y_LPARAM(lParam);
if (!g_TrackingMouse) { // 鼠标刚进入窗口范围
TRACKMOUSEEVENT tme = {sizeof(TRACKMOUSEEVENT)};
tme.hwndTrack = hwnd;
tme.dwFlags = TME_LEAVE; // 当鼠标离开时,发送 WM_MOUSELEAVE
TrackMouseEvent(&tme);
// 激活 Tooltip
SendMessage(g_hwndTrackingTT, TTM_TRACKACTIVATE, (WPARAM)TRUE, (LPARAM)&g_toolItem);
g_TrackingMouse = TRUE;
}
if ((newX != oldX) || (newY != oldY)) { // 鼠标位置变化了
oldX = newX;
oldY = newY;
// 问题关键点:准备更新 Tooltip 文本
char coords[32]; // 局部变量,存储坐标字符串
snprintf(coords, sizeof(coords) -1, "坐标: %d, %d", newX, newY);
// 更新全局 g_toolItem 里的文本指针
g_toolItem.lpszText = coords; // coords 是局部变量的地址!
SendMessage(g_hwndTrackingTT, TTM_SETTOOLINFO, 0, (LPARAM)&g_toolItem);
// 更新 Tooltip 位置
POINT pt = {newX, newY};
ClientToScreen(hwnd, &pt); // 转换到屏幕坐标
// TTM_TRACKPOSITION 的坐标是相对于屏幕左上角的
SendMessage(g_hwndTrackingTT, TTM_TRACKPOSITION, 0, (LPARAM)MAKELONG(pt.x + 15, pt.y + 10));
// SetWindowTextA(hwnd, coords); // 这行调试代码工作正常,说明 coords 内容没问题
}
return 0; // 用 return 0 表示处理了消息
}
case WM_MOUSELEAVE: // 鼠标离开了窗口
SendMessage(g_hwndTrackingTT, TTM_TRACKACTIVATE, (WPARAM)FALSE, (LPARAM)&g_toolItem);
g_TrackingMouse = FALSE;
// 重置 TrackMouseEvent,以便下次鼠标进入时能再次触发
// TrackMouseEvent(&tme); // 如果需要反复触发TME_LEAVE, 再次调用TrackMouseEvent时需要设置TME_CANCEL再设置新的
break;
case WM_DESTROY:
PostQuitMessage(0);
break;
default:
return DefWindowProc(hwnd, Message, wParam, lParam);
}
return 0;
}
// WinMain 入口函数
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) {
WNDCLASSEX wc;
HWND hwnd;
MSG msg;
INITCOMMONCONTROLSEX icex;
// 初始化公共控件,Tooltip 属于公共控件
icex.dwSize = sizeof(INITCOMMONCONTROLSEX);
icex.dwICC = ICC_WIN95_CLASSES | ICC_STANDARD_CLASSES; // ICC_TOOLTIP_CLASSES 也包含在 WIN95_CLASSES 里
InitCommonControlsEx(&icex);
// ... (窗口类注册和创建窗口的代码,和原文类似,此处省略) ...
wc.cbSize = sizeof(WNDCLASSEX);
wc.style = 0;
wc.lpfnWndProc = WndProc;
wc.cbClsExtra = 0;
wc.cbWndExtra = 0;
wc.hInstance = hInstance;
wc.hIcon = LoadIcon(NULL, IDI_APPLICATION);
wc.hCursor = LoadCursor(NULL, IDC_ARROW);
wc.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1);
wc.lpszMenuName = NULL;
wc.lpszClassName = "MyTrackingTooltipApp";
wc.hIconSm = LoadIcon(NULL, IDI_APPLICATION);
if(!RegisterClassEx(&wc)) {
MessageBox(NULL, "窗口类注册失败!", "错误", MB_ICONEXCLAMATION | MB_OK);
return 0;
}
hwnd = CreateWindowEx(
WS_EX_CLIENTEDGE,
"MyTrackingTooltipApp",
"Tracking Tooltip 演示",
WS_OVERLAPPEDWINDOW | WS_VISIBLE,
CW_USEDEFAULT, CW_USEDEFAULT, 640, 480,
NULL, NULL, hInstance, NULL);
if(hwnd == NULL) {
MessageBox(NULL, "窗口创建失败!", "错误", MB_ICONEXCLAMATION | MB_OK);
return 0;
}
ShowWindow(hwnd, nCmdShow);
UpdateWindow(hwnd);
while (GetMessage(&msg, NULL, 0, 0) > 0) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
return msg.wParam;
}
调试的时候发现,WM_MOUSEMOVE
消息确实收到了,坐标也在变,甚至用 SetWindowTextA
把坐标显示在标题栏都没问题。但 Tooltip 就是不肯露面。
一、问题根源剖析:指针的“有效期陷阱”
仔细排查下来,最可疑,也是最终的“元凶”,指向了 TOOLINFO
结构体里的 lpszText
成员,以及它指向的文本缓冲区的生命周期。
在 WM_MOUSEMOVE
消息处理中,我们这样更新 Tooltip 的文本:
// WM_MOUSEMOVE 内部
char coords[32]; // 这是个局部栈变量!
snprintf(coords, sizeof(coords) -1, "坐标: %d, %d", newX, newY);
g_toolItem.lpszText = coords; // g_toolItem.lpszText 指向了局部变量 coords
SendMessage(g_hwndTrackingTT, TTM_SETTOOLINFO, 0, (LPARAM)&g_toolItem);
看到问题了没?coords
是一个定义在 WM_MOUSEMOVE
消息处理内的局部数组。当 WM_MOUSEMOVE
这个case执行完毕,coords
所在的栈空间就被释放了(或者说,不再保证其内容有效)。
g_toolItem
是一个全局变量,g_toolItem.lpszText = coords;
使得这个全局结构体里的文本指针指向了一个短暂的局部内存地址。TTM_SETTOOLINFO
消息发送时,Tooltip 控件(g_hwndTrackingTT
)会收到这个 g_toolItem
结构体的指针,并记录下里面的信息,包括 lpszText
指向的那个地址。
关键在于,Tooltip 控件不会 立即在 TTM_SETTOOLINFO
调用时就复制 lpszText
指向的字符串内容。它很可能只是保存了这个指针。等到下一次需要绘制 Tooltip 的时候(比如处理 TTM_TRACKPOSITION
之后,或者某个绘制事件),它才会尝试去读取那个 lpszText
指向的内存。那时候,coords
的内存区域早就物是人非,内容是未定义的垃圾数据,甚至可能是不可访问的内存。结果自然是 Tooltip 显示不出来,或者显示乱码,甚至程序崩溃。
二、解决方案:给文本找个“长久之家”
知道了问题所在,解决起来就简单了:确保 TOOLINFO
结构中的 lpszText
始终指向一块有效的、包含期望文本的内存区域。
方案 1:使用全局或静态文本缓冲区
这是最直接的修改方式,和现有代码结构改动最小。
原理与作用:
将原本用于存储坐标字符串的局部数组 coords
改为全局变量或静态局部变量。这样,它的生命周期就能覆盖整个程序的运行时间(全局变量)或直到程序退出(静态变量),保证 Tooltip 控件在任何时候通过 lpszText
访问到的都是有效数据。
代码示例:
-
在全局作用域定义一个字符数组:
// ... 其他全局变量 ... char g_szTooltipCoordsText[64]; // 全局文本缓冲区
-
修改
WM_MOUSEMOVE
中的文本更新逻辑:// 在 WM_MOUSEMOVE 消息处理中 // ... if ((newX != oldX) || (newY != oldY)) { oldX = newX; oldY = newY; // 使用全局缓冲区 g_szTooltipCoordsText snprintf(g_szTooltipCoordsText, sizeof(g_szTooltipCoordsText) -1, "坐标: %d, %d", newX, newY); g_toolItem.lpszText = g_szTooltipCoordsText; // 指向全局缓冲区 SendMessage(g_hwndTrackingTT, TTM_SETTOOLINFO, 0, (LPARAM)&g_toolItem); POINT pt = {newX, newY}; ClientToScreen(hwnd, &pt); SendMessage(g_hwndTrackingTT, TTM_TRACKPOSITION, 0, (LPARAM)MAKELONG(pt.x + 15, pt.y + 10)); } // ...
操作步骤:
如上代码所示,声明一个全局的 char
数组 g_szTooltipCoordsText
,然后在 WM_MOUSEMOVE
中使用 snprintf
将坐标信息格式化到这个全局数组中。之后,让 g_toolItem.lpszText
指向这个全局数组。
安全建议:
使用 snprintf
而不是 sprintf
是个好习惯,它可以防止缓冲区溢出,指定了最大写入字符数。确保缓冲区大小足够容纳你想要显示的文本。
方案 2:使用 LPSTR_TEXTCALLBACK
和 TTN_GETDISPINFO
通知
这是一种更“高级”或者说更推荐的 Win32 风格的做法,尤其当 Tooltip 文本需要动态生成或者来源比较复杂时。
原理与作用:
不直接给 lpszText
赋值一个字符串指针,而是给它赋一个特殊的值 LPSTR_TEXTCALLBACK
。同时,在 TOOLINFO
的 uFlags
中去掉 TTF_IDISHWND
(如果之前是为了用父窗口句柄作 uId
的话,此时应提供一个唯一的 UINT_PTR
ID),或者保持并确保父窗口能处理通知。
当 Tooltip 控件需要显示文本时,它会给 TOOLINFO
结构中 hwnd
成员指定的窗口发送一个 WM_NOTIFY
消息,其中 NMHDR
的 code
成员是 TTN_GETDISPINFO
。我们在这个通知的处理中,按需提供文本。
代码示例:
-
修改
CreateTrackingToolTip
:HWND CreateTrackingToolTip(HWND hParentWnd) { // ... CreateWindowEx 创建 hwndTT ... if (!hwndTT) return NULL; ZeroMemory(&g_toolItem, sizeof(TOOLINFO)); g_toolItem.cbSize = sizeof(TOOLINFO); g_toolItem.uFlags = TTF_TRACK | TTF_ABSOLUTE; // 去掉 TTF_IDISHWND (可选,看 uId 如何定义) // 如果你的 uId 不再是 HWND,就需要去掉 TTF_IDISHWND // 若仍希望 hParentWnd 接收通知且父窗口作为 Tool,可以保留 TTF_IDISHWND g_toolItem.hwnd = hParentWnd; // Tooltip 的父窗口,接收 TTN_GETDISPINFO g_toolItem.hinst = NULL; // 一般为 NULL g_toolItem.lpszText = LPSTR_TEXTCALLBACK; // 关键! g_toolItem.uId = 0; // 给这个 Tool 一个唯一的 ID,例如 0 // 或者,若保留 TTF_IDISHWND, g_toolItem.uId = (UINT_PTR)hParentWnd; 依然可以 // 给 Tooltip 分配一个足够大的内部缓冲区,如果文本可能很长 // SendMessage(hwndTT, TTM_SETMAXTIPWIDTH, 0, (LPARAM)300); // 可选 SendMessage(hwndTT, TTM_ADDTOOL, 0, (LPARAM)(LPTOOLINFO)&g_toolItem); return hwndTT; }
-
在窗口过程
WndProc
中处理WM_NOTIFY
:// 用于 TTN_GETDISPINFO 的静态缓冲区,确保生命周期 static char s_szTooltipCallbackText[64]; LRESULT CALLBACK WndProc(HWND hwnd, UINT Message, WPARAM wParam, LPARAM lParam) { // ... switch (Message) { // ... WM_CREATE, WM_MOUSEMOVE, WM_MOUSELEAVE ... // 在 WM_MOUSEMOVE 中,不再需要直接更新 g_toolItem.lpszText // SendMessage(g_hwndTrackingTT, TTM_SETTOOLINFO,...) 调用可以去掉 // 只需要在鼠标移动时激活和定位即可 // TTM_UPDATETIPTEXT (如果之前ADDTOOL的ID是对的)也可以触发重新请求文本 // 或者 TTM_SETTOOLINFO 也能触发,即使lpszText是CALLBACK case WM_NOTIFY: { LPNMHDR lpnmhdr = (LPNMHDR)lParam; if (lpnmhdr->hwndFrom == g_hwndTrackingTT && lpnmhdr->code == TTN_GETDISPINFO) { LPNMTTDISPINFO lpnmtdi = (LPNMTTDISPINFO)lParam; // 确保是我们之前添加的那个 Tool (通过 uId 和 hwnd 检查) // 如果 g_toolItem.uId 是0, 并且 hwnd 是父窗口,那么就匹配。 if (lpnmtdi->hdr.idFrom == g_toolItem.uId && lpnmtdi->hdr.hwndFrom == g_hwndTrackingTT) { // 动态生成文本 // 获取当前鼠标位置 (这里演示,实际应从合适地方获取,如全局变量) POINT ptMouse; GetCursorPos(&ptMouse); ScreenToClient(hwnd, &ptMouse); // 转为客户区坐标,如果需要 snprintf(s_szTooltipCallbackText, sizeof(s_szTooltipCallbackText)-1, "Callback: %d, %d", ptMouse.x, ptMouse.y); lpnmtdi->lpszText = s_szTooltipCallbackText; // 提供文本 // 如果文本内容可能变化很快,设置此标志可以防止 tooltip 闪烁 // lpnmtdi->uFlags |= TTF_DI_SETITEM; } return 0; // 处理了 } break; } // ... WM_DESTROY ... default: return DefWindowProc(hwnd, Message, wParam, lParam); } return 0; }
在
WM_MOUSEMOVE
中,就不需要再手动调用TTM_SETTOOLINFO
来更新文本了。TTM_TRACKACTIVATE
激活后,当 Tooltip 需要显示或更新时,它会自己发TTN_GETDISPINFO
。如果确实需要在WM_MOUSEMOVE
中强制 Tooltip 更新文本(而不是仅仅更新位置),可以发送TTM_UPDATETIPTEXT
消息,或者再次发送TTM_SETTOOLINFO
(即使lpszText
是LPSTR_TEXTCALLBACK
)。
进阶使用技巧:
使用 LPSTR_TEXTCALLBACK
的好处是,文本的生成逻辑被集中到了 TTN_GETDISPINFO
通知处理中。你可以根据 Tool ID (lpnmtdi->hdr.idFrom
) 来为不同的 Tool 提供不同的文本,非常灵活。还可以设置 lpnmtdi->hinst
和 lpnmtdi->szText
(一个固定大小的缓冲区)来让系统管理字符串资源。
安全建议:
在 TTN_GETDISPINFO
中提供的 lpszText
指向的缓冲区,同样需要保证其生命周期至少覆盖到 Tooltip 控件完成本次显示。通常使用静态缓冲区或者一个在 Tooltip 可见期间都有效的成员变量字符串。lpnmtdi->szText
是一个固定大小(通常80个字符)的缓冲区,可以直接把文本拷贝到这里,这样就不必担心自己提供的 lpszText
的生命周期。
// TTN_GETDISPINFO 处理中使用 lpnmtdi->szText
if (lpnmtdi->hdr.idFrom == g_toolItem.uId && lpnmtdi->hdr.hwndFrom == g_hwndTrackingTT) {
POINT ptMouse;
GetCursorPos(&ptMouse);
ScreenToClient(hwnd, &ptMouse);
snprintf(lpnmtdi->szText, sizeof(lpnmtdi->szText)-1, "CB (szText): %d, %d", ptMouse.x, ptMouse.y);
// 注意:此时不需要再设置 lpnmtdi->lpszText
// lpnmtdi->uFlags |= TTF_DI_SETITEM; // 告知tooltip control我们修改了其内部toolinfo的内容
}
三、核对基础配置:公共控件初始化
虽然本次问题的直接原因是文本指针,但也别忘了 Tracking Tooltip 能正常工作的一个基本前提:
原理与作用:
Tooltip 控件是 Windows 公共控件 (Common Controls) 的一部分。使用任何公共控件之前,都需要通过调用 InitCommonControlsEx
函数来加载和注册相应的控件类。如果忘了这一步,CreateWindowEx
创建 TOOLTIPS_CLASS
时会失败。
命令行指令或操作步骤:
确保在你的 WinMain
函数早期,或者任何窗口创建之前,调用 InitCommonControlsEx
。
代码示例:
// 在 WinMain 函数中
INITCOMMONCONTROLSEX icex;
icex.dwSize = sizeof(INITCOMMONCONTROLSEX);
// ICC_WIN95_CLASSES 包含了 ICC_TOOLTIP_CLASSES
// 或者更明确指定: icex.dwICC = ICC_TOOLTIP_CLASSES;
icex.dwICC = ICC_WIN95_CLASSES;
if (!InitCommonControlsEx(&icex)) {
// 处理初始化失败的情况
MessageBox(NULL, "公共控件初始化失败!", "错误", MB_OK | MB_ICONERROR);
return 1;
}
// 然后再进行窗口类注册和窗口创建...
你的代码里已经包含了 InitCommonControlsEx(&icc);
,这点是做对了的。这是所有 Tooltip 功能正常工作的基础。
排查这类界面问题,往往需要细心和耐心。指针、内存生命周期、消息顺序,这些C/C++与Win32 API交互时的经典“雷区”,一不小心就可能踩到。希望这次的分享能帮到遇到类似困扰的朋友们。