Skip to content

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_ICONNIF_TIPNIF_MESSAGE 等)
uCallbackMessage自定义回调消息(如 WM_USER + 1),托盘事件通过此消息发送到 hWnd
hIcon托盘显示的图标句柄
szTip鼠标悬停时的 Tooltip 文字(最多 128 字符)

Shell_NotifyIcon 操作

操作码说明
NIM_ADD向托盘添加图标
NIM_DELETE从托盘移除图标
NIM_MODIFY更新图标 / Tooltip / 回调消息

窗口消息机制

托盘图标的交互完全依赖 Windows 消息循环。定义一个自定义消息 ID:

cpp
#define WM_TRAYICON (WM_USER + 1)

将此值赋给 NOTIFYICONDATA.uCallbackMessage,之后所有托盘事件(点击、右键等)都会以 WM_TRAYICON 消息的形式发送到目标窗口,在 WndProc 中统一处理。

TaskbarCreated 消息

explorer.exe 重启时,所有托盘图标都会消失。需要在程序中重新注册:

cpp
UINT WM_TASKBARCREATED = RegisterWindowMessage(L"TaskbarCreated");

// 在 WndProc 中:
if (message == WM_TASKBARCREATED) {
    InitTrayIcon(hWnd);
    return 0;
}

第一步:创建最小托盘应用

从零搭建一个带窗口 + 系统托盘图标的最简程序:

  1. 注册窗口类(RegisterClassEx

  2. 创建窗口(CreateWindowEx)——对于纯后台应用,可创建隐藏窗口

  3. 初始化 NOTIFYICONDATA 并调用 Shell_NotifyIcon(NIM_ADD, ...)

    cpp
    void 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);
    }
  4. 实现消息循环(GetMessageDispatchMessage

  5. 退出时调用 Shell_NotifyIcon(NIM_DELETE, &nid) 清理

单实例保护

实际项目中通常需要用互斥量确保只有一个实例运行:

cpp
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 自定义消息:

cpp
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

这是最经典的方案:

cpp
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) 分发:

cpp
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。右键托盘时,在鼠标/任务栏上方显示这个窗口。

创建托盘面板窗口

cpp
// 托盘面板窗口(默认隐藏)
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

cpp
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());

显示托盘面板

cpp
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);
}

失焦自动隐藏

cpp
case WM_ACTIVATE:
{
    if (LOWORD(wParam) == WA_INACTIVE && hWnd == g_trayHwnd) {
        ShowWindow(g_trayHwnd, SW_HIDE);
    }
    break;
}

两种方案对比

维度TrackPopupMenuWebView2 面板
开发难度简单,纯 Win32 API较高,需要集成 WebView2
视觉效果系统原生菜单样式任意 HTML/CSS 样式、动画
动态内容有限(文字、勾选、禁用)无限(图表、列表、图片)
包体积零额外依赖~5MB WebView2 Runtime
适用场景简单工具、配置项少需富交互的产品级应用

第四步:增强托盘体验

传统菜单增强

菜单项图标

cpp
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);

选中状态 / 复选框

cpp
// 勾选(开机自启类选项)
CheckMenuItem(hMenu, IDM_AUTOSTART, MF_CHECKED);

// 单选圆点(多选一类选项)
CheckMenuRadioItem(hMenu, IDM_LANG_CN, IDM_LANG_EN, IDM_LANG_CN, MF_BYCOMMAND);

动态启用/禁用

cpp
EnableMenuItem(hMenu, IDM_PAUSE, MF_BYCOMMAND | (isConnected ? MF_ENABLED : MF_GRAYED));

气泡通知(Balloon Tooltip)

cpp
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 中处理气泡点击:

cpp
case WM_TRAYICON:
    if (lParam == NIN_BALLOONUSERCLICK) {
        // 用户点击了气泡
    }
    break;

第五步:高级话题

自定义绘制托盘菜单(Owner-Drawn Menu)

当需要比系统菜单更好的视觉效果,但又不希望引入 WebView2 时,可以使用 Owner Draw:

  • 设置 MFT_OWNERDRAW 类型
  • 处理 WM_MEASUREITEM(告知系统菜单项尺寸)和 WM_DRAWITEM(自行绘制背景、文字、图标)
  • 使用 GDI / GDI+ 绘制

异常保护

托盘程序通常是常驻后台进程,必须做好异常兜底,避免崩溃后托盘图标残留。

托盘图标清理

cpp
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 行):

cpp
#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 管理,程序崩溃时没有机会清理。

解决

  1. WM_DESTROY 中强制 NIM_DELETE
  2. 注册 SetUnhandledExceptionFilter 兜底
  3. 使用 WM_TASKBARCREATED 机制——explorer 重启后图标自动消失

右键菜单弹出后点击空白处不消失

原因TrackPopupMenu 前未正确设置前台窗口。

解决:在 TrackPopupMenu 之前调用:

cpp
SetForegroundWindow(hWnd);

WebView2 托盘面板不显示

常见排查点

  • 确保安装了 WebView2 Runtime 或打包了 Fixed Version
  • 检查窗口样式:必须包含 WS_EX_TOPMOST
  • 检查 CreateCoreWebView2EnvironmentWithOptionsuserDataFolder 路径是否有写入权限

64 位 / 32 位兼容性

NOTIFYICONDATA.cbSize 必须正确设置——直接用 sizeof(NOTIFYICONDATA) 是最安全的做法,编译器会根据目标平台自动计算。

高 DPI 下图标模糊

  • 使用 GetSystemMetrics(SM_CXSMICON) 动态获取托盘图标尺寸
  • LoadImage 配合 LR_DEFAULTSIZE 加载多尺寸图标资源(16×16、24×24、32×32、48×48)

参考资料

持续学习,不断进步