Win32 API 自定义托盘菜单
Windows 系统托盘(System Tray / Notification Area)是应用程序常驻后台的常见方式。通过 Win32 API 的 Shell_NotifyIcon 接口,可以创建托盘图标并自定义右键菜单,实现应用的最小化隐藏、状态显示、快速操作等功能。
本文将从传统 TrackPopupMenu 方式讲起,再介绍一种更现代的方案——用 WebView2 渲染自定义托盘面板,兼具原生性能与前端灵活性。
准备工作
在开始开发前,需要了解以下背景:
- API 依赖:
shell32.lib— 托盘图标(Shell_NotifyIcon)user32.lib— 窗口与菜单comctl32.lib— 通用控件WebView2LoaderStatic.lib(可选)— 现代托盘面板方案
- 头文件:
<windows.h>、<shellapi.h>、<commctrl.h>
核心概念
NOTIFYICONDATA 结构体
托盘图标的创建、修改、删除均通过 NOTIFYICONDATA 结构体与 Shell_NotifyIcon 函数交互。
| 字段 | 说明 |
|---|---|
cbSize | 结构体大小,必须设置为 sizeof(NOTIFYICONDATA) |
hWnd | 接收托盘消息的窗口句柄 |
uID | 托盘图标唯一标识(多个图标时区分来源) |
uFlags | 标志位,控制哪些字段生效(NIF_ICON、NIF_TIP、NIF_MESSAGE 等) |
uCallbackMessage | 自定义回调消息(如 WM_USER + 1),托盘事件通过此消息发送到 hWnd |
hIcon | 托盘显示的图标句柄 |
szTip | 鼠标悬停时的 Tooltip 文字(最多 128 字符) |
Shell_NotifyIcon 操作
| 操作码 | 说明 |
|---|---|
NIM_ADD | 向托盘添加图标 |
NIM_DELETE | 从托盘移除图标 |
NIM_MODIFY | 更新图标 / Tooltip / 回调消息 |
窗口消息机制
托盘图标的交互完全依赖 Windows 消息循环。定义一个自定义消息 ID:
#define WM_TRAYICON (WM_USER + 1)将此值赋给 NOTIFYICONDATA.uCallbackMessage,之后所有托盘事件(点击、右键等)都会以 WM_TRAYICON 消息的形式发送到目标窗口,在 WndProc 中统一处理。
TaskbarCreated 消息
当 explorer.exe 重启时,所有托盘图标都会消失。需要在程序中重新注册:
UINT WM_TASKBARCREATED = RegisterWindowMessage(L"TaskbarCreated");
// 在 WndProc 中:
if (message == WM_TASKBARCREATED) {
InitTrayIcon(hWnd);
return 0;
}第一步:创建最小托盘应用
从零搭建一个带窗口 + 系统托盘图标的最简程序:
注册窗口类(
RegisterClassEx)创建窗口(
CreateWindowEx)——对于纯后台应用,可创建隐藏窗口初始化
NOTIFYICONDATA并调用Shell_NotifyIcon(NIM_ADD, ...):cppvoid InitTrayIcon(HWND hWnd) { NOTIFYICONDATA nid = { 0 }; nid.cbSize = sizeof(NOTIFYICONDATA); nid.hWnd = hWnd; nid.uID = 1; // 托盘图标 ID nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP; nid.uCallbackMessage = WM_TRAYICON; nid.hIcon = LoadIcon(nullptr, IDI_APPLICATION); lstrcpy(nid.szTip, L"我的应用"); Shell_NotifyIcon(NIM_ADD, &nid); }实现消息循环(
GetMessage→DispatchMessage)退出时调用
Shell_NotifyIcon(NIM_DELETE, &nid)清理
单实例保护
实际项目中通常需要用互斥量确保只有一个实例运行:
const wchar_t* mutexName = L"Global\\MyApp_Mutex_UniqueID";
HANDLE hMutex = CreateMutexW(NULL, TRUE, mutexName);
if (GetLastError() == ERROR_ALREADY_EXISTS) {
// 已有实例在运行,找到窗口并激活
HWND hWnd = FindWindowW(NULL, L"MyAppWindow");
if (hWnd) {
PostMessage(hWnd, WM_TRAYICON, 0, WM_LBUTTONUP);
}
if (hMutex) CloseHandle(hMutex);
return 0;
}第二步:响应托盘事件
在 WndProc 中处理 WM_TRAYICON 自定义消息:
case WM_TRAYICON:
{
if (lParam == WM_LBUTTONUP)
{
// 左键单击:显示/还原主窗口
ShowWindow(g_mainHwnd, SW_SHOW);
SetForegroundWindow(g_mainHwnd);
}
else if (lParam == WM_RBUTTONUP)
{
// 右键单击:弹出菜单 / 托盘面板
ShowTrayMenu();
}
break;
}常见 lParam 值:
| 事件 | 说明 |
|---|---|
WM_LBUTTONDOWN | 左键按下 |
WM_LBUTTONUP | 左键弹起(推荐用于"显示窗口") |
WM_LBUTTONDBLCLK | 左键双击(常用作打开主界面) |
WM_RBUTTONDOWN | 右键按下 |
WM_RBUTTONUP | 右键弹起(推荐用于"弹出菜单") |
wParam 为托盘图标 ID(即 NOTIFYICONDATA.uID),多图标场景下可据此区分来源。
第三步:创建托盘菜单
方案一:传统 Win32 菜单 — CreatePopupMenu + TrackPopupMenu
这是最经典的方案:
void ShowTrayMenu(HWND hWnd)
{
POINT pt;
GetCursorPos(&pt);
HMENU hMenu = CreatePopupMenu();
// 添加菜单项
InsertMenu(hMenu, -1, MF_BYPOSITION | MF_STRING, IDM_OPEN, L"打开主界面");
InsertMenu(hMenu, -1, MF_BYPOSITION | MF_STRING, IDM_PAUSE, L"暂停同步");
InsertMenu(hMenu, -1, MF_BYPOSITION | MF_SEPARATOR, 0, NULL);
InsertMenu(hMenu, -1, MF_BYPOSITION | MF_STRING, IDM_SETTING, L"设置");
InsertMenu(hMenu, -1, MF_BYPOSITION | MF_STRING, IDM_EXIT, L"退出");
// 设置默认项(粗体显示)
SetMenuDefaultItem(hMenu, IDM_OPEN, FALSE);
// ★ 关键:弹出前必须设为前台窗口,否则菜单点击空白处不会自动消失
SetForegroundWindow(hWnd);
TrackPopupMenu(
hMenu,
TPM_LEFTALIGN | TPM_BOTTOMALIGN | TPM_RIGHTBUTTON,
pt.x, pt.y,
0,
hWnd,
NULL
);
DestroyMenu(hMenu);
}菜单项选中后会发送 WM_COMMAND 消息——在 WndProc 中根据 LOWORD(wParam) 分发:
case WM_COMMAND:
{
switch (LOWORD(wParam))
{
case IDM_OPEN: ShowWindow(hWnd, SW_SHOW); break;
case IDM_EXIT: DestroyWindow(hWnd); break;
}
break;
}方案二:WebView2 自定义托盘面板(现代方案)
传统菜单功能有限——无法自定义复杂 UI、不支持动画、无法动态渲染。如果你的项目已经用了 WebView2,可以直接用一个 弹出式 WebView2 子窗口 来取代右键菜单,前端同学可以用 HTML/CSS/JS 自由设计面板样式。
核心思路:创建一个 WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_POPUP 样式的隐藏窗口,内嵌 WebView2。右键托盘时,在鼠标/任务栏上方显示这个窗口。
创建托盘面板窗口
// 托盘面板窗口(默认隐藏)
HWND hTrayWnd = CreateWindowEx(
WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOREDIRECTIONBITMAP,
szWindowClass,
L"",
WS_POPUP,
0, 0, 280, 40, // 初始位置和大小
nullptr, nullptr, hInstance, nullptr
);WS_EX_TOPMOST— 保证面板在所有窗口之上WS_EX_TOOLWINDOW— 不显示在任务栏WS_EX_NOREDIRECTIONBITMAP— 配合 WebView2 透明背景
初始化 WebView2
CreateCoreWebView2EnvironmentWithOptions(nullptr, userFolder.c_str(), nullptr,
Callback<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler>(
[](HRESULT result, ICoreWebView2Environment* env) -> HRESULT {
env->CreateCoreWebView2Controller(g_trayHwnd,
Callback<ICoreWebView2CreateCoreWebView2ControllerCompletedHandler>(
[](HRESULT result, ICoreWebView2Controller* controller) -> HRESULT {
// 透明背景
wil::com_ptr<ICoreWebView2Controller2> controller2;
controller->QueryInterface(&controller2);
COREWEBVIEW2_COLOR transparentColor = { 0, 0, 0, 0 };
controller2->put_DefaultBackgroundColor(transparentColor);
// 获取 WebView 指针
controller->get_CoreWebView2(&coreWebView2);
coreWebView2->QueryInterface(&g_trayWebview);
// 加载托盘面板页面
g_trayWebview->Navigate(L"https://myapp.local/tray-panel.html");
return S_OK;
}).Get());
return S_OK;
}).Get());显示托盘面板
void ShowTrayMenu()
{
POINT pt;
GetCursorPos(&pt);
// 获取任务栏位置,计算面板应在哪里弹出
APPBARDATA abd = { sizeof(APPBARDATA) };
int panelHeight = CalculatePanelHeight(); // 根据内容动态计算高度
int posY = pt.y - panelHeight;
if (SHAppBarMessage(ABM_GETTASKBARPOS, &abd)) {
// 面板始终显示在任务栏上方
posY = abd.rc.top - panelHeight;
}
// 动态调整面板大小 + 定位 + 显示
SetWindowPos(
g_trayHwnd,
HWND_TOPMOST,
pt.x - 280, posY,
280, panelHeight,
SWP_SHOWWINDOW
);
SetForegroundWindow(g_trayHwnd);
}失焦自动隐藏
case WM_ACTIVATE:
{
if (LOWORD(wParam) == WA_INACTIVE && hWnd == g_trayHwnd) {
ShowWindow(g_trayHwnd, SW_HIDE);
}
break;
}两种方案对比:
| 维度 | TrackPopupMenu | WebView2 面板 |
|---|---|---|
| 开发难度 | 简单,纯 Win32 API | 较高,需要集成 WebView2 |
| 视觉效果 | 系统原生菜单样式 | 任意 HTML/CSS 样式、动画 |
| 动态内容 | 有限(文字、勾选、禁用) | 无限(图表、列表、图片) |
| 包体积 | 零额外依赖 | ~5MB WebView2 Runtime |
| 适用场景 | 简单工具、配置项少 | 需富交互的产品级应用 |
第四步:增强托盘体验
传统菜单增强
菜单项图标
MENUITEMINFO mii = { sizeof(MENUITEMINFO) };
mii.fMask = MIIM_BITMAP | MIIM_STRING | MIIM_ID;
mii.hbmpItem = LoadBitmap(hInst, MAKEINTRESOURCE(IDB_MENU_ICON));
mii.dwTypeData = L" 打开主界面";
mii.wID = IDM_OPEN;
InsertMenuItem(hMenu, -1, TRUE, &mii);选中状态 / 复选框
// 勾选(开机自启类选项)
CheckMenuItem(hMenu, IDM_AUTOSTART, MF_CHECKED);
// 单选圆点(多选一类选项)
CheckMenuRadioItem(hMenu, IDM_LANG_CN, IDM_LANG_EN, IDM_LANG_CN, MF_BYCOMMAND);动态启用/禁用
EnableMenuItem(hMenu, IDM_PAUSE, MF_BYCOMMAND | (isConnected ? MF_ENABLED : MF_GRAYED));气泡通知(Balloon Tooltip)
NOTIFYICONDATA nid = { sizeof(NOTIFYICONDATA) };
nid.hWnd = g_trayHwnd;
nid.uID = TRAY_UID;
nid.uFlags = NIF_INFO;
nid.dwInfoFlags = NIIF_INFO; // NIIF_WARNING / NIIF_ERROR / NIIF_NONE
lstrcpy(nid.szInfoTitle, L"更新提醒");
lstrcpy(nid.szInfo, L"发现新版本 v2.0.0,点击查看详情");
nid.uTimeout = 5000; // 毫秒
Shell_NotifyIcon(NIM_MODIFY, &nid);在 WndProc 中处理气泡点击:
case WM_TRAYICON:
if (lParam == NIN_BALLOONUSERCLICK) {
// 用户点击了气泡
}
break;第五步:高级话题
自定义绘制托盘菜单(Owner-Drawn Menu)
当需要比系统菜单更好的视觉效果,但又不希望引入 WebView2 时,可以使用 Owner Draw:
- 设置
MFT_OWNERDRAW类型 - 处理
WM_MEASUREITEM(告知系统菜单项尺寸)和WM_DRAWITEM(自行绘制背景、文字、图标) - 使用 GDI / GDI+ 绘制
异常保护
托盘程序通常是常驻后台进程,必须做好异常兜底,避免崩溃后托盘图标残留。
托盘图标清理
case WM_DESTROY:
Shell_NotifyIcon(NIM_DELETE, &g_nid);
PostQuitMessage(0);
break;兼容性处理
- Windows 版本:XP 使用
NOTIFYICONDATAW_V1_SIZE,Vista+ 结构更大——直接使用sizeof(NOTIFYICONDATA)可自动适配 - GUID 标识(Vista+):
NOTIFYICONDATA.guidItem可以替代uID作为唯一标识,更稳定可靠 - 高 DPI 适配:使用
LoadImage配合LR_DEFAULTSIZE加载多尺寸图标,或用GetSystemMetrics(SM_CXSMICON)动态获取托盘图标尺寸
完整示例
以下为传统 TrackPopupMenu 方式的完整骨架(约 150 行):
#include <windows.h>
#include <shellapi.h>
#define WM_TRAYICON (WM_USER + 1)
#define IDM_OPEN 1001
#define IDM_EXIT 1002
HINSTANCE g_hInst;
HWND g_hWnd;
NOTIFYICONDATA g_nid;
UINT WM_TASKBARCREATED;
void InitTrayIcon(HWND hWnd) { /* 见第一步示例代码 */ }
void ShowContextMenu(HWND hWnd) { /* 见第三步方案一示例代码 */ }
LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wp, LPARAM lp)
{
if (msg == WM_TASKBARCREATED) { InitTrayIcon(hWnd); return 0; }
switch (msg)
{
case WM_TRAYICON:
if (lp == WM_LBUTTONUP)
ShowWindow(hWnd, SW_SHOW);
else if (lp == WM_RBUTTONUP)
ShowContextMenu(hWnd);
break;
case WM_COMMAND:
if (LOWORD(wp) == IDM_OPEN) ShowWindow(hWnd, SW_SHOW);
if (LOWORD(wp) == IDM_EXIT) DestroyWindow(hWnd);
break;
case WM_DESTROY:
Shell_NotifyIcon(NIM_DELETE, &g_nid);
PostQuitMessage(0);
break;
}
return DefWindowProc(hWnd, msg, wp, lp);
}
int WINAPI WinMain(HINSTANCE hInst, HINSTANCE, LPSTR, int)
{
g_hInst = hInst;
// 1. 注册窗口类
WNDCLASSEX wc = { sizeof(WNDCLASSEX), CS_HREDRAW | CS_VREDRAW,
WndProc, 0, 0, hInst, LoadIcon(NULL, IDI_APPLICATION),
LoadCursor(NULL, IDC_ARROW), (HBRUSH)(COLOR_WINDOW + 1),
NULL, L"TrayDemoClass", NULL };
RegisterClassEx(&wc);
// 2. 创建隐藏窗口
g_hWnd = CreateWindow(L"TrayDemoClass", L"TrayDemo", WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT, 0, 400, 300, NULL, NULL, hInst, NULL);
// 3. 注册 TaskbarCreated + 初始化托盘
WM_TASKBARCREATED = RegisterWindowMessage(L"TaskbarCreated");
InitTrayIcon(g_hWnd);
// 4. 消息循环
MSG msg;
while (GetMessage(&msg, NULL, 0, 0))
DispatchMessage(&msg);
return 0;
}常见问题
托盘图标残留
现象:程序异常退出后,鼠标移到托盘区域仍能看到图标,点击无响应。
原因:未正常执行 NIM_DELETE。托盘图标由 explorer 管理,程序崩溃时没有机会清理。
解决:
- 在
WM_DESTROY中强制NIM_DELETE - 注册
SetUnhandledExceptionFilter兜底 - 使用
WM_TASKBARCREATED机制——explorer 重启后图标自动消失
右键菜单弹出后点击空白处不消失
原因:TrackPopupMenu 前未正确设置前台窗口。
解决:在 TrackPopupMenu 之前调用:
SetForegroundWindow(hWnd);WebView2 托盘面板不显示
常见排查点:
- 确保安装了 WebView2 Runtime 或打包了 Fixed Version
- 检查窗口样式:必须包含
WS_EX_TOPMOST - 检查
CreateCoreWebView2EnvironmentWithOptions的userDataFolder路径是否有写入权限
64 位 / 32 位兼容性
NOTIFYICONDATA.cbSize 必须正确设置——直接用 sizeof(NOTIFYICONDATA) 是最安全的做法,编译器会根据目标平台自动计算。
高 DPI 下图标模糊
- 使用
GetSystemMetrics(SM_CXSMICON)动态获取托盘图标尺寸 - 用
LoadImage配合LR_DEFAULTSIZE加载多尺寸图标资源(16×16、24×24、32×32、48×48)