通用GUI编程技术——Win32 原生编程实战(五十五)——系统托盘

发布时间:2026/5/26 12:42:10

通用GUI编程技术——Win32 原生编程实战(五十五)——系统托盘 通用GUI编程技术——Win32 原生编程实战五十五——系统托盘仓库已经开源喜欢的话点个⭐仓库Win32和Win32图形栈的部分目前已完成教程力争做一个完备的GUI教程欢迎各位大佬前来参观https://github.com/Charliechen114514/anatomy_gui上一篇文章我们聊了 Hook 机制——拦截系统级别的键盘和鼠标输入。Hook 是一种底层能力用好了非常强大但大部分时候你不会天天用它。今天我们要聊的东西则恰恰相反——几乎每个正经的桌面程序都会用到但很多人不知道里面有多少细节系统托盘System Tray / Notification Area。你的程序怎么最小化到托盘怎么显示托盘图标和右键菜单怎么弹出气泡通知任务栏崩溃后怎么恢复图标这些问题今天全部搞定。为什么需要系统托盘系统托盘也叫通知区域是 Windows 任务栏右下角的那个区域里面放着时钟、音量、网络状态等图标。很多应用程序也会在这里放一个图标常见的有后台常驻程序杀毒软件、输入法、云同步工具——它们不需要一直显示主窗口但需要在后台运行用户需要时可以通过托盘图标调出。最小化到托盘下载管理器、音乐播放器——用户点击关闭按钮时不是退出程序而是最小化到托盘继续工作。状态通知邮件客户端收到新邮件时弹出气泡通知、系统更新时提示用户。从技术角度说系统托盘本质上就是一个图标 一个回调消息。系统不会帮你管理窗口、不会帮你创建菜单——所有这些都得你自己处理。这也就意味着有很多细节需要注意。环境说明在我们正式开始之前先明确一下我们这次动手的环境平台Windows 10/11开发工具Visual Studio 2019 或更高版本Community 版本就行编程语言CC17 或更新项目类型桌面应用程序Win32 项目额外依赖Shell32.lib第一步——Shell_NotifyIcon 与 NOTIFYICONDATA系统托盘的所有操作都通过一个函数完成Shell_NotifyIcon。BOOLShell_NotifyIcon(DWORD dwMessage,// 操作类型PNOTIFYICONDATA lpData// 数据结构);四种操作dwMessage含义NIM_ADD添加托盘图标NIM_MODIFY修改托盘图标更新图标、提示文字等NIM_DELETE删除托盘图标NIM_SETVERSION设置通知接口版本推荐设置NOTIFYICONDATA 结构这个结构体随着 Windows 版本演进变得越来越长。这里列出最常用的字段// 使用最新的结构体版本typedefstruct{DWORD dwSize;// 结构体大小 sizeof(NOTIFYICONDATA)HWND hWnd;// 接收回调消息的窗口UINT uID;// 图标 ID一个窗口可以有多个托盘图标UINT uFlags;// 指定哪些字段有效UINT uCallbackMessage;// 自定义回调消息 IDHICON hIcon;// 图标句柄WCHAR szTip[128];// 工具提示文字鼠标悬停时显示DWORD dwState;// 图标状态DWORD dwStateMask;// 状态掩码WCHAR szInfo[256];// 气泡通知文字union{UINT uTimeout;// 气泡超时时间毫秒UINT uVersion;// 版本号NIM_SETVERSION 时用};WCHAR szInfoTitle[64];// 气泡标题DWORD dwInfoFlags;// 气泡图标类型GUID guidItem;// 图标 GUID用于识别HICON hBalloonIcon;// 自定义气泡图标}NOTIFYICONDATA;uFlags 常用标志标志含义NIF_MESSAGEuCallbackMessage 有效NIF_ICONhIcon 有效NIF_TIPszTip 有效NIF_INFO气泡通知相关字段有效NIF_SHOWTIP显示工具提示Win2000 要求显式启用⚠️ 注意dwSize 必须正确用sizeof(NOTIFYICONDATA)初始化。如果大小不正确Shell_NotifyIcon会失败。不要手动填写这个值。第二步——添加和删除托盘图标添加托盘图标#defineWM_TRAYICON(WM_APP100)#defineID_TRAY_ICON1BOOLAddTrayIcon(HWND hwnd,HINSTANCE hInst){NOTIFYICONDATA nid{};nid.dwSizesizeof(NOTIFYICONDATA);nid.hWndhwnd;nid.uIDID_TRAY_ICON;nid.uFlagsNIF_ICON|NIF_MESSAGE|NIF_TIP;nid.uCallbackMessageWM_TRAYICON;nid.hIconLoadIcon(NULL,IDI_APPLICATION);// 使用系统默认图标wcscpy_s(nid.szTip,L托盘示例程序);if(!Shell_NotifyIcon(NIM_ADD,nid)){returnFALSE;}// 设置通知版本为 NOTIFYICON_VERSION_4// 这样回调消息的 wParam/lParam 语义更清晰nid.uVersionNOTIFYICON_VERSION_4;Shell_NotifyIcon(NIM_SETVERSION,nid);returnTRUE;}删除托盘图标voidRemoveTrayIcon(HWND hwnd){NOTIFYICONDATA nid{};nid.dwSizesizeof(NOTIFYICONDATA);nid.hWndhwnd;nid.uIDID_TRAY_ICON;Shell_NotifyIcon(NIM_DELETE,nid);}⚠️ 注意程序退出前必须删除托盘图标。如果你不调用Shell_NotifyIcon(NIM_DELETE, ...)图标会一直留在托盘里直到用户把鼠标移上去——这时系统才会发现你的程序已经不在了然后删除图标。这会显得很不专业。第三步——处理托盘回调消息当用户在托盘图标上操作时单击、双击、右键等系统会向你指定的窗口发送uCallbackMessage消息。NOTIFYICON_VERSION_4 的消息格式如果设置了NOTIFYICON_VERSION_4推荐回调消息的参数如下caseWM_TRAYICON:{// wParam 图标 ID即 uID// lParam 鼠标事件或通知事件switch(LOWORD(lParam)){caseWM_LBUTTONUP:// 左键单击——通常用来还原/显示主窗口break;caseWM_RBUTTONUP:// 右键单击——通常用来显示弹出菜单break;caseWM_LBUTTONDBLCLK:// 左键双击——通常用来还原/显示主窗口break;caseNIN_BALLOONUSERCLICK:// 用户点击了气泡通知break;caseNIN_BALLOONTIMEOUT:// 气泡通知超时消失break;caseNIN_POPUPOPEN:// 鼠标悬停在图标上弹出提示break;caseNIN_SELECT:// 通知区域图标被选中键盘导航break;}return0;}右键菜单右键点击托盘图标时显示弹出菜单是最常见的交互模式。这里有几个坑需要注意voidShowTrayContextMenu(HWND hwnd){POINT pt;GetCursorPos(pt);HMENU hMenuCreatePopupMenu();AppendMenu(hMenu,MF_STRING,IDM_RESTORE,L显示主窗口);AppendMenu(hMenu,MF_SEPARATOR,0,NULL);AppendMenu(hMenu,MF_STRING,IDM_SETTINGS,L设置...);AppendMenu(hMenu,MF_SEPARATOR,0,NULL);AppendMenu(hMenu,MF_STRING,IDM_EXIT,L退出);// 重要必须设置前台窗口否则菜单可能不会正确关闭SetForegroundWindow(hwnd);// 显示弹出菜单TrackPopupMenu(hMenu,TPM_RIGHTBUTTON|TPM_BOTTOMALIGN|TPM_LEFTALIGN,pt.x,pt.y,0,hwnd,NULL);// 重要再次设置前台窗口确保菜单能正确响应SetForegroundWindow(hwnd);DestroyMenu(hMenu);}⚠️ 注意SetForegroundWindow 的必要性如果不调用SetForegroundWindow弹出菜单可能不会在用户点击其他地方时自动关闭。这是 Windows 的一个已知行为——TrackPopupMenu 需要调用它的线程拥有前台焦点。在托盘图标上右键时焦点在 Shell 上而不是你的程序上所以需要先抢一下前台。第四步——最小化到托盘关闭按钮不退出而是最小化到托盘是托盘程序最常见的行为模式。核心思路用户点击关闭按钮时拦截 WM_CLOSE隐藏主窗口而不是销毁添加托盘图标用户双击托盘图标时显示主窗口并删除托盘图标LRESULT CALLBACKWndProc(HWND hwnd,UINT uMsg,WPARAM wParam,LPARAM lParam){staticBOOL g_inTrayFALSE;switch(uMsg){caseWM_CLOSE:// 不销毁窗口而是隐藏并最小化到托盘if(!g_inTray){AddTrayIcon(hwnd,((LPCREATESTRUCT)GetWindowLongPtr(hwnd,GWLP_WNDPROC))-hInstance);g_inTrayTRUE;}ShowWindow(hwnd,SW_HIDE);return0;// 不调用 DefWindowProc阻止销毁caseWM_TRAYICON:{switch(LOWORD(lParam)){caseWM_LBUTTONDBLCLK:caseWM_LBUTTONUP:// 还原窗口ShowWindow(hwnd,SW_SHOW);SetForegroundWindow(hwnd);RemoveTrayIcon(hwnd);g_inTrayFALSE;break;caseWM_RBUTTONUP:ShowTrayContextMenu(hwnd);break;}return0;}caseWM_COMMAND:{switch(LOWORD(wParam)){caseIDM_RESTORE:ShowWindow(hwnd,SW_SHOW);SetForegroundWindow(hwnd);RemoveTrayIcon(hwnd);g_inTrayFALSE;break;caseIDM_EXIT:RemoveTrayIcon(hwnd);DestroyWindow(hwnd);break;}return0;}caseWM_DESTROY:RemoveTrayIcon(hwnd);PostQuitMessage(0);return0;}returnDefWindowProc(hwnd,uMsg,wParam,lParam);}第五步——气泡通知气泡通知Balloon Notification是从托盘图标弹出的一个小气泡框用于向用户显示提示信息。显示气泡voidShowBalloonNotification(HWND hwnd,constwchar_t*title,constwchar_t*text,DWORD infoFlags){NOTIFYICONDATA nid{};nid.dwSizesizeof(NOTIFYICONDATA);nid.hWndhwnd;nid.uIDID_TRAY_ICON;nid.uFlagsNIF_INFO;nid.dwInfoFlagsinfoFlags;wcscpy_s(nid.szInfoTitle,title);wcscpy_s(nid.szInfo,text);Shell_NotifyIcon(NIM_MODIFY,nid);}// 使用示例ShowBalloonNotification(hwnd,L提示,L操作已成功完成,NIIF_INFO);ShowBalloonNotification(hwnd,L警告,L磁盘空间不足,NIIF_WARNING);ShowBalloonNotification(hwnd,L错误,L无法连接到服务器,NIIF_ERROR);dwInfoFlags 图标类型值图标NIIF_NONE无图标NIIF_INFO信息蓝色 iNIIF_WARNING警告黄色 !NIIF_ERROR错误红色 XNIIF_USER使用 hBalloonIcon 字段的自定义图标气泡 vs Toast在 Windows 10/11 上气泡通知可能会被系统转换为 Toast 通知从屏幕右侧弹出。这取决于用户的系统设置。你无法控制这个转换——如果你的目标是 Windows 10建议直接使用 Windows.UI.Notifications APIToast 通知框架它是更现代的通知方式。第六步——WM_TASKBARCREATED处理任务栏重启这是一个很多人忽略但非常重要的细节。Windows 的任务栏explorer.exe偶尔会崩溃并重启。当它重启时所有托盘图标都会消失——但你的程序还在运行用户再也看不到你的图标了。解决方案注册一个自定义消息WM_TASKBARCREATED。当任务栏重启时系统会向所有顶级窗口广播这个消息。你收到后重新添加托盘图标即可。// 在全局或静态变量中保存消息 IDUINT g_uTaskbarRestart0;// 在 WinMain 中注册g_uTaskbarRestartRegisterWindowMessage(LTaskbarCreated);// 在 WndProc 中处理// 由于这是注册的消息不能用 switch-case必须用 ifif(uMsgg_uTaskbarRestart){// 任务栏重启了重新添加托盘图标if(g_inTray){AddTrayIcon(hwnd,hInstance);}return0;}注意RegisterWindowMessage每次调用返回同一个值对于同一个字符串所以可以在任何地方调用。第七步——完整示例这个示例把今天所有知识整合起来一个窗口关闭时最小化到托盘有右键菜单支持双击还原处理任务栏重启。#ifndefUNICODE#defineUNICODE#endif#includewindows.h#includeshellapi.h#pragmacomment(lib,shell32.lib)#defineWM_TRAYICON(WM_APP100)#defineID_TRAY_ICON1// 菜单命令#defineIDM_RESTORE2001#defineIDM_ABOUT2002#defineIDM_EXIT2003// 全局变量UINT g_uTaskbarRestart0;BOOL g_inTrayFALSE;HWND g_hWndNULL;// 前向声明BOOLAddTrayIcon(HWND hwnd);voidRemoveTrayIcon(HWND hwnd);voidShowTrayContextMenu(HWND hwnd);BOOLAddTrayIcon(HWND hwnd){NOTIFYICONDATA nid{};nid.dwSizesizeof(NOTIFYICONDATA);nid.hWndhwnd;nid.uIDID_TRAY_ICON;nid.uFlagsNIF_ICON|NIF_MESSAGE|NIF_TIP|NIF_SHOWTIP;nid.uCallbackMessageWM_TRAYICON;nid.hIconLoadIcon(NULL,IDI_APPLICATION);wcscpy_s(nid.szTip,L托盘示例 - 双击还原窗口);if(!Shell_NotifyIcon(NIM_ADD,nid))returnFALSE;nid.uVersionNOTIFYICON_VERSION_4;Shell_NotifyIcon(NIM_SETVERSION,nid);returnTRUE;}voidRemoveTrayIcon(HWND hwnd){NOTIFYICONDATA nid{};nid.dwSizesizeof(NOTIFYICONDATA);nid.hWndhwnd;nid.uIDID_TRAY_ICON;Shell_NotifyIcon(NIM_DELETE,nid);}voidShowTrayContextMenu(HWND hwnd){POINT pt;GetCursorPos(pt);HMENU hMenuCreatePopupMenu();AppendMenu(hMenu,MF_STRING,IDM_RESTORE,L还原窗口);AppendMenu(hMenu,MF_SEPARATOR,0,NULL);AppendMenu(hMenu,MF_STRING,IDM_ABOUT,L关于);AppendMenu(hMenu,MF_SEPARATOR,0,NULL);AppendMenu(hMenu,MF_STRING,IDM_EXIT,L退出);SetForegroundWindow(hwnd);TrackPopupMenu(hMenu,TPM_RIGHTBUTTON,pt.x,pt.y,0,hwnd,NULL);SetForegroundWindow(hwnd);DestroyMenu(hMenu);}LRESULT CALLBACKWndProc(HWND hwnd,UINT uMsg,WPARAM wParam,LPARAM lParam){// 处理任务栏重启消息不能用 switchif(uMsgg_uTaskbarRestart){if(g_inTray)AddTrayIcon(hwnd);return0;}switch(uMsg){caseWM_CREATE:{// 创建提示文本CreateWindowEx(0,LSTATIC,L点击窗口关闭按钮最小化到系统托盘。\r\n\r\nL托盘图标右键菜单可还原或退出。\r\nL双击托盘图标还原窗口。,WS_CHILD|WS_VISIBLE|SS_LEFT,20,20,340,80,hwnd,NULL,((LPCREATESTRUCT)lParam)-hInstance,NULL);return0;}caseWM_CLOSE:// 隐藏窗口并添加托盘图标if(!g_inTray){AddTrayIcon(hwnd);g_inTrayTRUE;// 首次最小化时显示气泡提示NOTIFYICONDATA nid{};nid.dwSizesizeof(NOTIFYICONDATA);nid.hWndhwnd;nid.uIDID_TRAY_ICON;nid.uFlagsNIF_INFO;nid.dwInfoFlagsNIIF_INFO;wcscpy_s(nid.szInfoTitle,L托盘示例);wcscpy_s(nid.szInfo,L程序已最小化到系统托盘双击图标可还原);Shell_NotifyIcon(NIM_MODIFY,nid);}ShowWindow(hwnd,SW_HIDE);return0;caseWM_TRAYICON:{switch(LOWORD(lParam)){caseWM_LBUTTONDBLCLK:ShowWindow(hwnd,SW_SHOW);SetForegroundWindow(hwnd);RemoveTrayIcon(hwnd);g_inTrayFALSE;break;caseWM_RBUTTONUP:ShowTrayContextMenu(hwnd);break;}return0;}caseWM_COMMAND:{switch(LOWORD(wParam)){caseIDM_RESTORE:ShowWindow(hwnd,SW_SHOW);SetForegroundWindow(hwnd);RemoveTrayIcon(hwnd);g_inTrayFALSE;break;caseIDM_ABOUT:MessageBox(hwnd,L系统托盘示例程序\r\n版本 1.0\r\n\r\nL演示 Shell_NotifyIcon 的用法,L关于,MB_OK|MB_ICONINFORMATION);break;caseIDM_EXIT:RemoveTrayIcon(hwnd);DestroyWindow(hwnd);break;}return0;}caseWM_DESTROY:RemoveTrayIcon(hwnd);PostQuitMessage(0);return0;}returnDefWindowProc(hwnd,uMsg,wParam,lParam);}intWINAPIwWinMain(HINSTANCE hInstance,HINSTANCE hPrevInstance,PWSTR pCmdLine,intnCmdShow){g_uTaskbarRestartRegisterWindowMessage(LTaskbarCreated);WNDCLASS wc{};wc.lpfnWndProcWndProc;wc.hInstancehInstance;wc.lpszClassNameLTrayDemoClass;wc.hbrBackground(HBRUSH)(COLOR_WINDOW1);wc.hCursorLoadCursor(NULL,IDC_ARROW);wc.hIconLoadIcon(NULL,IDI_APPLICATION);RegisterClass(wc);g_hWndCreateWindowEx(0,LTrayDemoClass,L系统托盘示例,WS_OVERLAPPED|WS_CAPTION|WS_SYSMENU|WS_MINIMIZEBOX,CW_USEDEFAULT,CW_USEDEFAULT,400,180,NULL,NULL,hInstance,NULL);if(g_hWnd){ShowWindow(g_hWnd,nCmdShow);UpdateWindow(g_hWnd);MSG msg{};while(GetMessage(msg,NULL,0,0)){TranslateMessage(msg);DispatchMessage(msg);}}return0;}代码要点解析WM_CLOSE 拦截不调用 DefWindowProc它会 DestroyWindow而是隐藏窗口并添加托盘图标。首次最小化时还弹一个气泡通知。NOTIFYICON_VERSION_4设置后回调消息的 lParam 低 16 位是鼠标消息高 16 位是图标坐标某些场景下。推荐总是设置这个版本。SetForegroundWindow显示右键菜单前后各调用一次确保菜单行为正常。WM_TASKBARCREATED用 RegisterWindowMessage 注册在 WndProc 中用 if不是 switch检查。WM_DESTROY 兜底即使走了 IDM_EXIT 之外的路径比如 Task Manager 强制关闭WM_DESTROY 也会确保删除托盘图标。常见陷阱陷阱一cbSize / dwSize 字段错误NOTIFYICONDATA 结构体在不同 Windows SDK 版本中大小不同。必须用sizeof(NOTIFYICONDATA)初始化不要手动填写数值。陷阱二程序退出未删除图标在 WM_DESTROY 中一定要调用Shell_NotifyIcon(NIM_DELETE, ...)。否则图标会留在托盘里直到用户把鼠标移上去触发系统清理——很不专业。陷阱三hIcon 资源管理NOTIFYICONDATA.hIcon是共享引用——系统会复制图标所以你可以在设置后安全地DestroyIcon如果你是自己 LoadImage 创建的。但如果你传的是通过LoadIcon加载的系统图标如IDI_APPLICATION不要DestroyIcon——那些是系统资源。陷阱四64 位/32 位跨进程拖放64 位程序的托盘图标右键菜单如果使用了 drag-drop 功能需要额外的消息过滤处理。这是一个冷门但棘手的兼容性问题。后续可以做什么到这里系统托盘的知识就讲完了。你现在应该能够添加/删除托盘图标、处理托盘回调消息单击、双击、右键菜单、显示气泡通知、正确处理任务栏重启、实现最小化到托盘的行为模式。下一篇文章我们会聊一个在文件操作场景中非常实用的功能——拖放Drag Drop。你将学会如何让你的窗口接受文件拖入WM_DROPFILES以及如何实现完整的 OLE 拖放协议IDropTarget。在此之前建议你做一些练习巩固今天的知识基础练习修改示例给托盘图标使用自定义图标从资源文件加载而不是系统默认图标进阶练习实现一个番茄钟托盘程序——设置 25 分钟倒计时时间到了弹出气泡通知右键菜单可以暂停/重置/退出挑战练习让托盘图标动态变化比如在工作和休息状态之间切换不同图标并通过图标变化反映当前状态相关资源Shell_NotifyIcon function - Microsoft LearnNOTIFYICONDATA structure - Microsoft LearnTaskbar Notifications - Microsoft LearnRegisterWindowMessage function - Microsoft Learn相关阅读现代Qt开发教程新手篇1.15——正则与文本处理 - 相似度 100%通用GUI编程技术——Win32 原生编程实战五十四——Hook 机制 - 相似度 100%通用GUI编程技术——图形渲染实战四十四——D3D12命令列表、队列与围栏GPU同步核心 - 相似度 100%

相关新闻