C++ Win32系统托盘程序开发:从Shell_NotifyIcon到消息循环实战
1. 项目概述为什么需要自己动手写系统托盘程序做Windows桌面开发的朋友对系统托盘System Tray这个区域肯定不陌生。就是屏幕右下角时钟旁边那一小块区域QQ、微信、迅雷这些软件的图标都缩在那里。很多后台服务、工具软件为了不占用任务栏空间都会选择把自己“藏”进托盘里。市面上成熟的框架像Qt、MFC甚至一些第三方库都提供了现成的托盘类。那为什么我们还要用纯C和Windows API从头实现一个呢我自己的体会是用现成的库固然快但一旦遇到定制化需求或者想深入理解Windows消息机制和GUI编程的底层逻辑就有点抓瞎了。比如你想做一个带渐变效果、支持动态图标、或者有复杂右键菜单交互的托盘程序现成库的接口可能就不够灵活。更重要的是自己实现一遍你对Shell_NotifyIcon这个核心API的理解对WM_USER消息的处理对窗口过程Window Procedure的掌控会完全不一样。这不仅仅是完成一个功能更是一次对Windows GUI编程核心原理的深度探索。对于C开发者尤其是希望技术栈更扎实、能处理更底层问题的朋友这个项目非常有价值。2. 核心原理与Windows API深度解析系统托盘的本质是Windows Shell提供的一个特殊UI组件。我们的程序需要与这个组件通信告诉它“嘿我这里有个图标想放在托盘区”。这个通信过程完全依赖于Windows API。2.1 核心APIShell_NotifyIcon一切始于Shell_NotifyIcon函数。它位于shellapi.h头文件中是操作托盘的唯一入口。这个函数非常“直白”它只做三件事添加NIM_ADD、修改NIM_MODIFY、删除NIM_DELETE一个托盘图标。#include shellapi.h BOOL Shell_NotifyIcon( DWORD dwMessage, PNOTIFYICONDATA lpdata );关键在于第二个参数一个NOTIFYICONDATA结构体。这个结构体就像一张“申请表”你把图标信息、提示文本、关联窗口、回调消息ID等等都填在这张表里交给Shell_NotifyIcon去处理。这个结构体在不同版本的Windows SDK中有不同的大小主要因为GUID成员和hBalloonIcon等字段的加入。为了兼容性我们必须在代码开始时明确指定我们使用的结构体版本这是新手最容易栽跟头的地方。// 在包含头文件后定义我们使用的版本这里使用NOTIFYICONDATA_V2_SIZE对应Vista及以后 #define _WIN32_WINNT 0x0600 // 目标系统为Vista或更高 #include windows.h #include shellapi.h // 或者在声明结构体实例后设置其cbSize成员为结构体实际大小 NOTIFYICONDATA nid {}; nid.cbSize sizeof(NOTIFYICONDATA); // 对于更老的编译器或需要兼容XP可能需要用NOTIFYICONDATA_V1_SIZE // nid.cbSize NOTIFYICONDATA_V1_SIZE;为什么版本这么重要如果你在Windows 10系统上却用了老版本结构体的大小系统可能会因为找不到它期望的后续字段比如hBalloonIcon而操作失败或者行为异常。这属于典型的“未定义行为”调试起来非常痛苦。2.2 消息循环与窗口过程托盘的“心脏”托盘图标不是孤立的。它必须绑定一个隐藏的窗口HWND。这个窗口是托盘图标与用户交互的桥梁。当用户在托盘图标上点击左键、右键、移动鼠标时Windows Shell会向这个绑定的窗口发送我们自定义的消息。创建隐藏窗口我们首先需要注册一个窗口类并创建一个不可见的窗口。这个窗口不需要有样式WS_VISIBLE但它必须有一个窗口过程WNDPROC。定义回调消息我们需要定义一个自定义消息ID通常是在WM_USER0x0400基础上增加一个值例如#define WM_MY_TRAY_ICON (WM_USER 1)。这个ID将填入NOTIFYICONDATA的uCallbackMessage字段。处理消息在我们的窗口过程中需要处理WM_MY_TRAY_ICON消息。其lParam参数包含了具体的鼠标事件比如WM_LBUTTONDOWN、WM_RBUTTONUP等。LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { switch (uMsg) { case WM_MY_TRAY_ICON: { if (lParam WM_RBUTTONUP) { // 显示右键菜单 ShowContextMenu(hwnd); } else if (lParam WM_LBUTTONDBLCLK) { // 双击左键恢复主窗口 ShowMainWindow(); } return 0; } case WM_COMMAND: { // 处理右键菜单项的命令 OnMenuCommand(LOWORD(wParam)); return 0; } case WM_DESTROY: { // 程序退出前务必移除托盘图标 RemoveTrayIcon(hwnd); PostQuitMessage(0); return 0; } default: return DefWindowProc(hwnd, uMsg, wParam, lParam); } }这里有个关键细节uCallbackMessage指定的消息不仅会传递鼠标事件其wParam参数会被设置为托盘图标的IDNOTIFYICONDATA::uID这在你一个程序有多个托盘图标时用于区分是哪个图标产生的事件。2.3NOTIFYICONDATA结构体关键字段详解把这个结构体理解透了托盘编程就掌握了一大半。cbSize: 如前所述结构体大小必须正确设置。hWnd: 接收托盘消息的窗口句柄。这是必须的。uID: 托盘图标的唯一标识符。如果你只有一个图标可以设为任意值如1。如果你的程序有多个图标需要用这个ID来区分。uFlags: 一个标志位组合告诉APINOTIFYICONDATA中哪些字段是有效的。这是另一个极易出错的地方。NIF_ICON:hIcon字段有效。NIF_MESSAGE:uCallbackMessage字段有效。NIF_TIP:szTip字段有效鼠标悬停提示。NIF_STATE:dwState和dwStateMask字段有效用于隐藏图标。NIF_INFO:szInfo,szInfoTitle等字段有效用于显示气球提示Win2000/XP时代常用现代UI已不推荐。NIF_GUID: 使用guidItem而非uID作为标识Vista。NIF_SHOWTIP: (Win10 1809) 启用现代悬停提示。重要原则在调用Shell_NotifyIcon进行NIM_ADD添加时NIF_MESSAGE和NIF_ICON是必须包含的。NIF_TIP可选但建议加上。在NIM_MODIFY修改时你只设置你想修改的字段并相应地设置uFlags。比如只想改图标就只设NIF_ICON和hIcon。uCallbackMessage: 自定义消息ID如前所述。hIcon: 要显示的图标句柄HICON。通常从资源加载LoadIcon或从图像文件创建。szTip: 鼠标悬停时显示的提示文本长度有限制旧版64字符新版128字符。务必注意字符串终止符。dwState/dwStateMask: 控制图标状态如隐藏。guidItem: (Vista) 替代uID的GUID标识符更稳定。hBalloonIcon: (Vista) 气球提示的自定义图标。3. 从零开始一个最小化可工作的托盘程序理论说再多不如动手写一遍。下面我们抛开任何框架用纯Win32 API实现一个最基础的托盘程序。这个程序启动后主窗口最小化到托盘右键点击图标可以退出。3.1 项目创建与基础设置首先创建一个空的C Win32项目非控制台。确保链接了shell32.libShell_NotifyIcon所需和user32.lib等基础库。在现代Visual Studio中通常不需要手动指定。定义我们的常量、全局变量和函数原型#include windows.h #include shellapi.h #define WM_MY_TRAY_ICON (WM_USER 100) // 自定义托盘消息 #define ID_TRAY_ICON 1 // 托盘图标ID #define IDM_EXIT 1001 // 右键菜单“退出”项ID // 函数声明 LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM); void AddTrayIcon(HWND hwnd); void RemoveTrayIcon(HWND hwnd); void ShowContextMenu(HWND hwnd); // 全局图标句柄方便释放 HICON g_hIcon NULL;3.2 创建隐藏窗口与消息循环这是Win32程序的骨架。int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { // 1. 注册窗口类 const wchar_t CLASS_NAME[] LMinimalTrayWindowClass; WNDCLASS wc {}; wc.lpfnWndProc WndProc; wc.hInstance hInstance; wc.lpszClassName CLASS_NAME; wc.hIcon LoadIcon(NULL, IDI_APPLICATION); // 可加载自定义图标 wc.hCursor LoadCursor(NULL, IDC_ARROW); if (!RegisterClass(wc)) { MessageBox(NULL, L窗口类注册失败, L错误, MB_ICONERROR); return 0; } // 2. 创建窗口不显示 HWND hwnd CreateWindowEx( 0, CLASS_NAME, L最小化托盘示例, WS_OVERLAPPEDWINDOW, // 样式即使不显示也建议用标准样式 CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, NULL, NULL, hInstance, NULL ); if (hwnd NULL) { MessageBox(NULL, L窗口创建失败, L错误, MB_ICONERROR); return 0; } // 3. 加载托盘图标这里使用系统默认图标示例实际应从资源加载 g_hIcon LoadIcon(NULL, IDI_INFORMATION); // 4. 添加托盘图标 AddTrayIcon(hwnd); // 5. 主消息循环 MSG msg {}; while (GetMessage(msg, NULL, 0, 0)) { TranslateMessage(msg); DispatchMessage(msg); } // 6. 清理资源 if (g_hIcon) { DestroyIcon(g_hIcon); } return 0; }3.3 实现AddTrayIcon与RemoveTrayIcon这是核心功能函数。void AddTrayIcon(HWND hwnd) { NOTIFYICONDATA nid {}; nid.cbSize sizeof(NOTIFYICONDATA); // 关键确保大小正确 nid.hWnd hwnd; // 关联窗口 nid.uID ID_TRAY_ICON; // 图标ID // 设置有效字段标志图标、消息、提示文本 nid.uFlags NIF_ICON | NIF_MESSAGE | NIF_TIP | NIF_SHOWTIP; nid.uCallbackMessage WM_MY_TRAY_ICON; // 自定义消息 nid.hIcon g_hIcon; // 图标句柄 // 设置悬停提示文本 wcscpy_s(nid.szTip, L这是一个最小化托盘示例); // 调用API添加图标 if (!Shell_NotifyIcon(NIM_ADD, nid)) { // 添加失败处理可以记录日志 // 常见失败原因nid.cbSize不对hWnd无效uFlags设置错误 } // NIM_SETVERSION (Win2000)告诉系统我们期望的托盘行为版本 // 对于现代WindowsVista推荐使用NOTIFYICON_VERSION_4 nid.uVersion NOTIFYICON_VERSION_4; Shell_NotifyIcon(NIM_SETVERSION, nid); } void RemoveTrayIcon(HWND hwnd) { NOTIFYICONDATA nid {}; nid.cbSize sizeof(NOTIFYICONDATA); nid.hWnd hwnd; nid.uID ID_TRAY_ICON; // 删除操作只需要hWnd和uID或guidItem来标识要删除哪个图标 Shell_NotifyIcon(NIM_DELETE, nid); }注意NIM_SETVERSION非常重要。它指定了托盘图标支持的功能集。NOTIFYICON_VERSION_4Win8支持高分辨率图标、上下文菜单现代UI等。如果不设置系统可能使用旧版兼容模式导致一些新特性无法使用。3.4 实现窗口过程与右键菜单窗口过程负责处理所有消息包括我们的托盘消息。LRESULT CALLBACK WndProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { switch (uMsg) { case WM_CREATE: { // 窗口创建时可以做一些初始化 break; } case WM_MY_TRAY_ICON: { // wParam 是图标ID (uID), lParam 是鼠标消息 UINT uIconID (UINT)wParam; UINT uMouseMsg (UINT)lParam; if (uIconID ID_TRAY_ICON) { switch (uMouseMsg) { case WM_RBUTTONUP: case WM_CONTEXTMENU: // 处理键盘上下文菜单键 ShowContextMenu(hwnd); break; case WM_LBUTTONDBLCLK: // 示例双击显示一个消息框 MessageBox(hwnd, L托盘图标被双击, L提示, MB_OK); break; } } return 0; } case WM_COMMAND: { WORD wmId LOWORD(wParam); switch (wmId) { case IDM_EXIT: DestroyWindow(hwnd); // 触发WM_DESTROY break; } return 0; } case WM_DESTROY: { // 程序退出前必须移除托盘图标否则残影会留在托盘区直到鼠标掠过 RemoveTrayIcon(hwnd); PostQuitMessage(0); return 0; } default: return DefWindowProc(hwnd, uMsg, wParam, lParam); } } void ShowContextMenu(HWND hwnd) { // 获取当前光标位置在此位置显示菜单 POINT pt; GetCursorPos(pt); // 创建弹出式菜单 HMENU hMenu CreatePopupMenu(); if (hMenu) { // 插入菜单项 AppendMenu(hMenu, MF_STRING, IDM_EXIT, L退出(Q)); // 必须设置窗口为前台窗口否则菜单可能无法正常消失 SetForegroundWindow(hwnd); // 显示菜单并跟踪选择 // TPM_RIGHTBUTTON 表示同时响应鼠标左右键 // TPM_RETURNCMD 让TrackPopupMenu返回选中的命令ID而不是直接发送WM_COMMAND UINT uSelected TrackPopupMenu(hMenu, TPM_RIGHTBUTTON | TPM_RETURNCMD | TPM_NONOTIFY, pt.x, pt.y, 0, hwnd, NULL); DestroyMenu(hMenu); // 如果用户选择了菜单项手动发送WM_COMMAND消息 if (uSelected ! 0) { PostMessage(hwnd, WM_COMMAND, MAKEWPARAM(uSelected, 0), 0); } // 这行很重要允许菜单正确关闭 PostMessage(hwnd, WM_NULL, 0, 0); } }至此一个最基础的、功能完整的托盘程序就完成了。编译运行后你会在托盘区看到一个图标右键点击可以选择“退出”。4. 进阶功能与实战技巧基础功能跑通后我们可以考虑更实际、更复杂的需求。下面分享几个我实践中总结的进阶技巧。4.1 动态图标与状态指示很多程序需要用托盘图标反映状态比如下载软件的进度、即时通讯软件的在线状态。这需要通过NIM_MODIFY消息动态更新hIcon。void UpdateTrayIcon(HWND hwnd, HICON hNewIcon, const wchar_t* szNewTip) { NOTIFYICONDATA nid {}; nid.cbSize sizeof(NOTIFYICONDATA); nid.hWnd hwnd; nid.uID ID_TRAY_ICON; nid.uFlags 0; // 先清零 if (hNewIcon) { nid.uFlags | NIF_ICON; nid.hIcon hNewIcon; // 注意这里我们修改了图标但系统不会自动销毁旧的。 // 如果hNewIcon是新创建的需要管理旧图标的生命周期。 } if (szNewTip) { nid.uFlags | NIF_TIP; wcscpy_s(nid.szTip, szNewTip); } if (nid.uFlags ! 0) { // 至少修改了一项 Shell_NotifyIcon(NIM_MODIFY, nid); } } // 示例创建一个表示“忙碌”的红色图标假设已加载 HICON hBusyIcon LoadIconFromResource(IDI_BUSY); UpdateTrayIcon(g_hMainWnd, hBusyIcon, L系统忙碌中...);生命周期管理陷阱当你用NIM_MODIFY更换图标时系统只是引用新的HICON句柄不会帮你销毁旧的。如果你是从资源重复加载同一个图标Windows可能会返回相同的句柄问题不大。但如果你每次都用CreateIcon或从图像文件创建新图标就必须在更换前用DestroyIcon销毁旧的图标句柄否则会造成GDI资源泄漏。4.2 优雅的右键菜单与UI交互上面的示例菜单太简陋。一个成熟的托盘程序菜单可能有多个项、分隔线、单选/复选框甚至子菜单。void ShowEnhancedContextMenu(HWND hwnd) { POINT pt; GetCursorPos(pt); HMENU hMenu CreatePopupMenu(); AppendMenu(hMenu, MF_STRING, IDM_SHOW, L显示主窗口(S)); AppendMenu(hMenu, MF_STRING, IDM_HIDE, L隐藏到托盘(H)); AppendMenu(hMenu, MF_SEPARATOR, 0, NULL); // 分隔线 // 创建一个带图标的菜单项需要加载位图 // HBITMAP hBmp LoadBitmap(...); // AppendMenu(hMenu, MF_STRING | MF_BITMAP, IDM_ABOUT, (LPCTSTR)hBmp); // 注意需要自行管理位图资源且MF_BITMAP方式文本显示可能有问题。 HMENU hSubMenu CreatePopupMenu(); AppendMenu(hSubMenu, MF_STRING, IDM_SUB_ITEM1, L子项1); AppendMenu(hSubMenu, MF_STRING, IDM_SUB_ITEM2, L子项2); AppendMenu(hMenu, MF_STRING | MF_POPUP, (UINT_PTR)hSubMenu, L更多选项(M)); AppendMenu(hMenu, MF_SEPARATOR, 0, NULL); AppendMenu(hMenu, MF_STRING, IDM_EXIT, L退出(X)); // 设置菜单项状态比如勾选、禁用 // CheckMenuItem(hMenu, IDM_SHOW, MF_BYCOMMAND | MF_CHECKED); // EnableMenuItem(hMenu, IDM_HIDE, MF_BYCOMMAND | MF_GRAYED); SetForegroundWindow(hwnd); TrackPopupMenu(hMenu, TPM_RIGHTBUTTON, pt.x, pt.y, 0, hwnd, NULL); DestroyMenu(hMenu); // DestroyMenu会自动销毁其子菜单 PostMessage(hwnd, WM_NULL, 0, 0); }关于SetForegroundWindow和PostMessage(hwnd, WM_NULL, 0, 0)这是一个经典的Win32菜单显示问题。如果不调用SetForegroundWindow菜单可能不会获得焦点导致点击其他地方菜单不消失。而TrackPopupMenu返回后发送WM_NULL是为了解决一个历史遗留问题确保菜单能正确接收并处理WM_EXITMENULOOP消息从而完全清理。虽然现代Windows可能不那么严格但加上这两句是良好的习惯。4.3 处理高DPI与多显示器在现代Windows环境下高DPI屏幕很常见。如果你的图标在4K屏幕上显得模糊那就是没处理好DPI感知。声明DPI感知在清单文件.manifest或通过APISetProcessDpiAwareness声明程序是DPI感知的。对于纯Win32程序可以在WinMain开头调用SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE);注意SetProcessDpiAwareness需要Windows 8.1并链接shcore.lib加载多尺寸图标图标资源.ico文件可以包含多个位图如16x16, 32x32, 48x48, 256x256。使用LoadImage函数并指定LR_DEFAULTSIZE标志系统会根据当前DPI自动选择最合适的尺寸。g_hIcon (HICON)LoadImage( hInstance, MAKEINTRESOURCE(IDI_MY_ICON), IMAGE_ICON, 0, 0, // 宽度和高度设为0使用系统建议尺寸 LR_DEFAULTSIZE | LR_SHARED );更好的做法是根据GetDpiForWindow获取窗口DPI然后手动计算所需逻辑像素大小并用LoadImage指定具体尺寸加载。托盘图标尺寸系统托盘区域本身会根据系统缩放设置调整。你提供的图标系统会自动缩放。但为了获得最佳效果你应该提供一个包含多种尺寸特别是16x16和32x32的.ico文件。4.4 单实例运行与托盘交互很多托盘程序希望只运行一个实例。可以通过创建命名的互斥体Mutex或查找窗口类来实现。如果检测到已运行则可以将已运行的实例窗口激活并可能通过托盘图标发送一个自定义消息需要进程间通信如PostMessage或SendMessage来通知它“显示自己”。// 程序启动时检查 HANDLE hMutex CreateMutex(NULL, TRUE, LMyUniqueTrayAppMutex); if (hMutex GetLastError() ERROR_ALREADY_EXISTS) { // 已经有一个实例在运行 // 1. 找到前一个实例的窗口 HWND hPrevWnd FindWindow(LMinimalTrayWindowClass, NULL); if (hPrevWnd) { // 2. 激活它例如从最小化恢复并置顶 ShowWindow(hPrevWnd, SW_RESTORE); SetForegroundWindow(hPrevWnd); // 3. (可选) 发送自定义消息告诉前一个实例“我试图再次启动你” // PostMessage(hPrevWnd, WM_APP 1, 0, 0); } ReleaseMutex(hMutex); CloseHandle(hMutex); return 0; // 退出当前实例 } // 否则继续正常启动...5. 常见问题、调试技巧与避坑指南即使原理清晰实际编码中还是会遇到各种奇怪的问题。下面是我踩过的一些坑和解决方法。5.1 图标不显示或一闪而过这是最常见的问题。检查1cbSize是否正确这是头号杀手。务必使用sizeof(NOTIFYICONDATA)。如果为了兼容性使用旧版大小要明确定义。检查2uFlags设置了吗NIM_ADD时必须包含NIF_ICON和NIF_MESSAGE。检查3窗口有效吗确保传递给Shell_NotifyIcon的hWnd是一个已创建的有效窗口句柄并且该窗口的消息循环正在运行。检查4消息循环在跑吗如果窗口创建后没有进入GetMessage循环或者循环提前退出窗口过程就无法处理消息系统可能会认为程序异常而移除图标。检查5资源加载成功了吗检查LoadIcon或LoadImage的返回值是否为NULL。使用GetLastError()获取错误码。检查6图标格式支持吗确保图标文件.ico是有效的并且包含系统支持的位图格式如32位带Alpha通道的PNG压缩图标在旧系统上可能有问题。5.2 右键菜单显示异常或无法点击SetForegroundWindow和PostMessage(WM_NULL)如前所述这两步必不可少。菜单命令不响应确保TrackPopupMenu的最后一个参数hWnd正确设置为接收WM_COMMAND的窗口句柄。并且该窗口的窗口过程正确处理了WM_COMMAND消息。菜单显示位置不对GetCursorPos(pt)获取的是屏幕坐标TrackPopupMenu也接受屏幕坐标。确保没有进行错误的坐标转换。5.3 程序退出后托盘图标残留必须在WM_DESTROY中调用NIM_DELETE这是铁律。窗口销毁消息是清理资源的最佳时机。PostQuitMessage之前必须移除图标。异常崩溃导致未清理如果程序崩溃图标可能残留。一个健壮的做法是在程序启动时WinMain开始也尝试用相同的uID和hWnd如果之前的窗口已不存在可能需要先FindWindow发送一次NIM_DELETE清理可能的僵尸图标。但这属于“防御性编程”不能替代正常的退出清理。5.4 调试技巧使用OutputDebugString在关键步骤如AddTrayIcon、RemoveTrayIcon、收到WM_MY_TRAY_ICON消息时输出日志可以在Visual Studio的“输出”窗口或使用DebugView工具查看。#include strsafe.h void DebugLog(const wchar_t* format, ...) { wchar_t buffer[256]; va_list args; va_start(args, format); StringCchVPrintf(buffer, 256, format, args); va_end(args); OutputDebugString(buffer); } // 调用示例 DebugLog(LAddTrayIcon called, hWnd0x%p\n, hwnd);检查API返回值Shell_NotifyIcon返回BOOL失败时检查GetLastError()。使用SpyVisual Studio自带可以查看系统中所有窗口的消息流。找到你的隐藏窗口查看它是否收到了WM_MY_TRAY_ICON消息以及lParam的值是否正确。5.5 关于封装成类对于实际项目将托盘功能封装成一个C类是非常好的做法。类可以管理图标句柄的生命周期、自动在析构函数中调用NIM_DELETE、提供更友好的接口如SetTooltip、ChangeIcon。但封装时要注意窗口句柄的归属这个类应该持有窗口句柄吗还是由外部传入通常窗口的创建和消息循环由主程序控制类只负责操作图标。窗口句柄通过构造函数或Init方法传入。消息映射类如何接收窗口消息有两种常见模式子类化Subclass让主窗口将WM_MY_TRAY_ICON消息转发给类的某个方法。这需要类持有窗口句柄并可能使用SetWindowLongPtr设置新的窗口过程或者在主窗口过程中显式调用类的方法。静态回调实例指针在窗口类注册时将this指针通过CreateWindow的lpParam传入在WM_CREATE中通过CREATESTRUCT取出并设置为窗口的GWLP_USERDATA。然后在静态窗口过程中通过GetWindowLongPtr获取this指针调用其成员函数。这种方法更面向对象但稍复杂。资源管理类应该负责加载和销毁它用到的图标资源HICON。在析构函数中务必先NIM_DELETE再DestroyIcon。自己动手实现一遍Windows系统托盘程序虽然比直接用现成库繁琐但收获是巨大的。你不仅得到了一个完全可控的功能模块更重要的是你深入理解了Windows GUI编程中消息驱动、资源管理、API兼容性等核心概念。下次再遇到任何奇怪的UI问题你都有了从底层去分析和解决的能力。这大概就是C和Win32编程的魅力所在——它给你最直接的控制力也要求你最清晰的思考。