MFC 实现托盘图标菜单图标功能

💡 MFC 实现托盘图标菜单图标功能

在开发 Windows 应用程序时,我们经常会使用托盘(系统通知区域)图标作为程序的入口,并在其上弹出右键菜单。很多初学者在尝试为托盘菜单项添加图标 时,会陷入一个误区:为什么我用了 AppendMenu(MF_STRING, ...) 加了文字,却无法加上图标?明明图标也加载了,却看不到。

答案是:你需要使用 MF_OWNERDRAW 才能实现托盘菜单带图标的效果。


🎯 一、AppendMenu 和 InsertMenu 中 lpNewItem 的陷阱

在 MFC 或 Win32 API 中,我们常使用如下函数添加菜单项:

cpp 复制代码
BOOL AppendMenu(
  HMENU hMenu,
  UINT uFlags,
  UINT_PTR uIDNewItem,
  LPCTSTR lpNewItem
);

其中最后一个参数 lpNewItem 表示"菜单项的内容",但这个参数含义随 uFlags 改变而改变,这是很多人第一次没搞懂的地方。

🧠 各种 uFlagslpNewItem 的影响如下:

uFlags 包含标志 lpNewItem 含义
MF_STRING(默认) 指向一个字符串,用作菜单显示文本
MF_BITMAP 位图句柄(HBITMAP),用于菜单图标
MF_OWNERDRAW 任意值,由开发者在自绘时解释(一般传 ID)
MF_SEPARATOR 忽略 lpNewItem,用作分隔线
MF_POPUP 忽略 lpNewItemuIDNewItem 为子菜单句柄

🧩 二、MF_STRING 的局限性:无法显示图标

当你这样写时:

cpp 复制代码
menu.AppendMenu(MF_STRING, ID_TRAY_EXIT, _T("退出程序"));
  • ✅ 系统会自动显示一行带"退出程序"的菜单项;
  • ❌ 但你无法设置图标 ,即使用 SetMenuItemInfo 设置 MIIM_BITMAP 也无效;
  • ❌ 也不会调用你的 OnDrawItemOnMeasureItem,因为它不是"自绘"菜单项。

结论是:MF_STRING 菜单项由系统自动绘制,你无法干预它的样式。


✅ 三、为什么使用 MF_OWNERDRAW 可以实现图标菜单?

当你这样写:

cpp 复制代码
menu.AppendMenu(MF_OWNERDRAW, ID_TRAY_EXIT, (LPCTSTR)ID_TRAY_EXIT);

此时告诉系统:这个菜单项由我自己绘制!

随后你会收到:

  • WM_MEASUREITEM 消息 → 你告诉系统菜单项的高度与宽度;
  • WM_DRAWITEM 消息 → 你用 GDI 画背景、图标、文字;

你可以在 OnDrawItem 中使用:

cpp 复制代码
DrawIconEx(pDC->GetSafeHdc(), x, y, hIcon, 16, 16, 0, NULL, DI_NORMAL);

从而绘制你想要的图标、选中背景、分隔线、文字样式等内容。


✨ 四、HBMMENU_CALLBACK 配合 MF_OWNERDRAW 使用的关键

除了 MF_OWNERDRAW,我们还需要用 SetMenuItemInfo 设置菜单图标绘制方式为:

cpp 复制代码
mii.fMask = MIIM_BITMAP;
mii.hbmpItem = HBMMENU_CALLBACK;
menu.SetMenuItemInfo(ID_TRAY_EXIT, &mii);

这告诉系统:"菜单图标我自己来画(callback)"。这一步对实现图标显示非常关键。

⚠️ 注意:HBMMENU_CALLBACK 只有在 MF_OWNERDRAW 下才会被调用!


📌 五、完整对比示例:有图 VS 无图

🚫 传统写法(只能显示文字):

cpp 复制代码
menu.AppendMenu(MF_STRING, ID_TRAY_EXIT, _T("退出程序"));

✅ 支持图标的写法:

cpp 复制代码
menu.AppendMenu(MF_OWNERDRAW, ID_TRAY_EXIT, (LPCTSTR)ID_TRAY_EXIT);

// 设置图标绘制方式
MENUITEMINFO mii = { sizeof(MENUITEMINFO) };
mii.fMask = MIIM_BITMAP;
mii.hbmpItem = HBMMENU_CALLBACK;
menu.SetMenuItemInfo(ID_TRAY_EXIT, &mii);

// 响应 WM_MEASUREITEM 与 WM_DRAWITEM

🧪 六、你必须响应的两个函数

cpp 复制代码
void OnMeasureItem(...) // 设置菜单项大小(宽度、高度)
void OnDrawItem(...)    // 绘制图标 + 文字 + 背景等

OnDrawItem 中可以自由绘制图标,例如:

cpp 复制代码
DrawIconEx(hdc, rc.left + 4, rc.top + 4, hIconExit, 16, 16, 0, NULL, DI_NORMAL);

再配合 DrawText 绘制文字,形成如下效果:

📌 🛑 退出程序

📌 🔄 恢复窗口


📃 七、完整代码

cpp 复制代码
// SGMeasurementDlg.h: 头文件
//

#pragma once

// CSGMeasurementDlg 对话框
class CSGMeasurementDlg : public CDialogEx
{
// 构造
public:
	CSGMeasurementDlg(CWnd* pParent = nullptr);	// 标准构造函数

// 对话框数据
#ifdef AFX_DESIGN_TIME
	enum { IDD = IDD_SGMEASUREMENT_DIALOG };
#endif

	protected:
	virtual void DoDataExchange(CDataExchange* pDX);	// DDX/DDV 支持

// 实现
protected:
	HICON m_hIcon;

	// 生成的消息映射函数
	virtual BOOL OnInitDialog();
	afx_msg void OnSysCommand(UINT nID, LPARAM lParam);
	afx_msg void OnPaint();
	afx_msg HCURSOR OnQueryDragIcon();
	afx_msg void OnMeasureItem(int nIDCtl, LPMEASUREITEMSTRUCT lpMeasureItemStruct);
	afx_msg void OnDrawItem(int nIDCtl, LPDRAWITEMSTRUCT lpDrawItemStruct);
	afx_msg void OnClose();
	afx_msg LRESULT OnTrayIconClick(WPARAM wParam, LPARAM lParam);
	afx_msg void OnTrayRestore();
	afx_msg void OnTrayExit();
	DECLARE_MESSAGE_MAP()

private:
	// === 托盘图标管理 ===

	/**
	 * @brief 托盘图标相关数据结构(NOTIFYICONDATA)
	 */
	NOTIFYICONDATA m_trayIconData;

	/**
	 * @brief 托盘图标的唯一 ID
	 */
	UINT m_nTrayIconID;

	/**
	 * @brief 标记托盘图标是否已成功创建
	 */
	BOOL m_bTrayIconCreated;

	/**
	 * @brief 标记程序是否通过托盘图标退出
	 */
	BOOL m_bExitingFromTray;
};
cpp 复制代码
// SGMeasurementDlg.cpp: 实现文件
//

#include "pch.h"
#include "framework.h"
#include "SGMeasurement.h"
#include "SGMeasurementDlg.h"
#include "afxdialogex.h"

#ifdef _DEBUG
#define new DEBUG_NEW
#endif

// 托盘图标 ID 与消息宏
#define ID_TRAY_RESTORE		   2001				// 恢复窗口
#define ID_TRAY_EXIT		   2002				// 退出程序
#define WM_TRAY_ICON_NOTIFY    (WM_USER + 1000) // 托盘图标回调消息 ID

// 托盘提示文本宏
#define TRAY_ICON_TOOLTIP_TEXT  _T("SGMeasurement")

// 计时宏定义
#define MEASURE_FUNC_START() \
	clock_t __startClock = clock();

#define MEASURE_FUNC_END() \
	do { \
		clock_t __endClock = clock(); \
		double __elapsedMs = 1000.0 * (__endClock - __startClock) / CLOCKS_PER_SEC; \
		CString __strElapsed; \
		__strElapsed.Format(_T("%s 执行耗时:%.1f ms"), _T(__FUNCTION__), __elapsedMs); \
		AppendLogLineRichStyled(__strElapsed, LOG_COLOR_SUCCESS); \
	} while (0)

class CAboutDlg : public CDialogEx
{
public:
	CAboutDlg();

// 对话框数据
#ifdef AFX_DESIGN_TIME
	enum { IDD = IDD_ABOUTBOX };
#endif

	protected:
	virtual void DoDataExchange(CDataExchange* pDX);    // DDX/DDV 支持

// 实现
protected:
	DECLARE_MESSAGE_MAP()
};

CAboutDlg::CAboutDlg() : CDialogEx(IDD_ABOUTBOX)
{
}

void CAboutDlg::DoDataExchange(CDataExchange* pDX)
{
	CDialogEx::DoDataExchange(pDX);
}

BEGIN_MESSAGE_MAP(CAboutDlg, CDialogEx)
END_MESSAGE_MAP()

CSGMeasurementDlg::CSGMeasurementDlg(CWnd* pParent /*=nullptr*/)
	: CDialogEx(IDD_SGMEASUREMENT_DIALOG, pParent)
	, m_nTrayIconID(0)
	, m_bTrayIconCreated(FALSE)
	, m_bExitingFromTray(FALSE)
{
	m_hIcon = AfxGetApp()->LoadIcon(IDR_MAINFRAME);
}

void CSGMeasurementDlg::DoDataExchange(CDataExchange* pDX)
{
	CDialogEx::DoDataExchange(pDX);
}

BEGIN_MESSAGE_MAP(CSGMeasurementDlg, CDialogEx)
	ON_WM_SYSCOMMAND()
	ON_WM_PAINT()
	ON_WM_QUERYDRAGICON()
	ON_WM_MEASUREITEM()
	ON_WM_DRAWITEM()
	ON_WM_CLOSE()
	ON_MESSAGE(WM_TRAY_ICON_NOTIFY, &CSGMeasurementDlg::OnTrayIconClick)
	ON_COMMAND(ID_TRAY_RESTORE, &CSGMeasurementDlg::OnTrayRestore)
	ON_COMMAND(ID_TRAY_EXIT, &CSGMeasurementDlg::OnTrayExit)
END_MESSAGE_MAP()

BOOL CSGMeasurementDlg::OnInitDialog()
{
	CDialogEx::OnInitDialog();

	// 将"关于..."菜单项添加到系统菜单中。

	// IDM_ABOUTBOX 必须在系统命令范围内。
	ASSERT((IDM_ABOUTBOX & 0xFFF0) == IDM_ABOUTBOX);
	ASSERT(IDM_ABOUTBOX < 0xF000);

	CMenu* pSysMenu = GetSystemMenu(FALSE);
	if (pSysMenu != nullptr)
	{
		BOOL bNameValid;
		CString strAboutMenu;
		bNameValid = strAboutMenu.LoadString(IDS_ABOUTBOX);
		ASSERT(bNameValid);
		if (!strAboutMenu.IsEmpty())
		{
			pSysMenu->AppendMenu(MF_SEPARATOR);
			pSysMenu->AppendMenu(MF_STRING, IDM_ABOUTBOX, strAboutMenu);
		}
	}

	// 设置此对话框的图标。  当应用程序主窗口不是对话框时,框架将自动
	//  执行此操作
	SetIcon(m_hIcon, TRUE);			// 设置大图标
	SetIcon(m_hIcon, FALSE);		// 设置小图标

	// TODO: 在此添加额外的初始化代码
	// 托盘图标初始化
	m_trayIconData.cbSize = sizeof(NOTIFYICONDATA);  				// 设置托盘图标数据结构的大小
	m_trayIconData.hWnd = m_hWnd;                    				// 设置窗口句柄
	m_trayIconData.uID = m_nTrayIconID;              				// 设置托盘图标 ID
	m_trayIconData.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;  		// 设置托盘图标的标志(图标、消息、提示文本)
	m_trayIconData.uCallbackMessage = WM_TRAY_ICON_NOTIFY;   		// 设置回调消息 WM_TRAY_ICON_NOTIFY
	m_trayIconData.hIcon = AfxGetApp()->LoadIcon(IDR_MAINFRAME);  	// 加载托盘图标
	lstrcpy(m_trayIconData.szTip, TRAY_ICON_TOOLTIP_TEXT);   		// 设置托盘提示文本

	// 添加托盘图标
	Shell_NotifyIcon(NIM_ADD, &m_trayIconData);
	m_bTrayIconCreated = TRUE;

	return TRUE;  // 除非将焦点设置到控件,否则返回 TRUE
}

void CSGMeasurementDlg::OnSysCommand(UINT nID, LPARAM lParam)
{
	if ((nID & 0xFFF0) == IDM_ABOUTBOX)
	{
		CAboutDlg dlgAbout;
		dlgAbout.DoModal();
	}
	else
	{
		CDialogEx::OnSysCommand(nID, lParam);
	}
}

void CSGMeasurementDlg::OnPaint()
{
	if (IsIconic()) {
		CPaintDC dc(this);

		SendMessage(WM_ICONERASEBKGND, reinterpret_cast<WPARAM>(dc.GetSafeHdc()), 0);

		// 使图标在工作区矩形中居中
		int cxIcon = GetSystemMetrics(SM_CXICON);
		int cyIcon = GetSystemMetrics(SM_CYICON);
		CRect rect;
		GetClientRect(&rect);
		int x = (rect.Width() - cxIcon + 1) / 2;
		int y = (rect.Height() - cyIcon + 1) / 2;

		// 绘制图标
		dc.DrawIcon(x, y, m_hIcon);
	}
	else {
		CDialogEx::OnPaint();
	}
}

//当用户拖动最小化窗口时系统调用此函数取得光标显示。
HCURSOR CSGMeasurementDlg::OnQueryDragIcon()
{
	return static_cast<HCURSOR>(m_hIcon);
}

void CSGMeasurementDlg::OnMeasureItem(int nIDCtl, LPMEASUREITEMSTRUCT lpMeasureItemStruct)
{
	if (lpMeasureItemStruct->CtlType == ODT_MENU) {
		lpMeasureItemStruct->itemHeight = 24;
		lpMeasureItemStruct->itemWidth = 140;
	}
}

void CSGMeasurementDlg::OnDrawItem(int nIDCtl, LPDRAWITEMSTRUCT lpDrawItemStruct)
{
	if (lpDrawItemStruct->CtlType != ODT_MENU) { 
		return;
	}

	CDC* pDC = CDC::FromHandle(lpDrawItemStruct->hDC);
	CRect rc = lpDrawItemStruct->rcItem;
	UINT id = lpDrawItemStruct->itemID;

	// 背景
	COLORREF bgColor = (lpDrawItemStruct->itemState & ODS_SELECTED) ? RGB(200, 220, 255) : RGB(255, 255, 255);
	pDC->FillSolidRect(rc, bgColor);

	// 图标
	HICON hIcon = nullptr;
	if (id == ID_TRAY_RESTORE) {
		hIcon = AfxGetApp()->LoadIcon(IDI_ICON_RESTORE);
	}

	if (id == ID_TRAY_EXIT) {
		hIcon = AfxGetApp()->LoadIcon(IDI_ICON_EXIT);
	}

	if (hIcon) {
		DrawIconEx(pDC->GetSafeHdc(), rc.left + 4, rc.top + 4, hIcon, 16, 16, 0, NULL, DI_NORMAL);
	}

	// 文本
	CString str;
	if (id == ID_TRAY_RESTORE) { 
		str = _T("恢复界面");
	}

	if (id == ID_TRAY_EXIT) { 
		str = _T("退出程序");
	}

	pDC->SetBkMode(TRANSPARENT);
	pDC->SetTextColor(RGB(0, 0, 0));
	pDC->DrawText(str, CRect(rc.left + 28, rc.top, rc.right, rc.bottom), DT_SINGLELINE | DT_VCENTER | DT_LEFT);
}

void CSGMeasurementDlg::OnClose()
{
	// TODO: 在此添加消息处理程序代码和/或调用默认值
	if (m_bExitingFromTray) {
		// 从托盘退出流程
		ExitApplication();
	}
	else {
		// 正常关闭按钮
		int nResult = AfxMessageBox(_T("是否最小化到托盘?"), MB_YESNO | MB_ICONQUESTION);
		if (nResult == IDYES) {
			ShowWindow(SW_HIDE);
		}
		else {
			ExitApplication();
		}
	}
}

LRESULT CSGMeasurementDlg::OnTrayIconClick(WPARAM wParam, LPARAM lParam) {
	if (wParam == m_nTrayIconID) {
		if (LOWORD(lParam) == WM_LBUTTONUP) {
			// 左键点击恢复窗口
			ShowWindow(SW_SHOW);
			SetForegroundWindow();
		}
		else if (LOWORD(lParam) == WM_RBUTTONUP) {
			// 右键点击弹出菜单
			CMenu menu;
			menu.CreatePopupMenu();
			menu.AppendMenu(MF_OWNERDRAW, ID_TRAY_RESTORE, (LPCTSTR)ID_TRAY_RESTORE);
			menu.AppendMenu(MF_OWNERDRAW, ID_TRAY_EXIT, (LPCTSTR)ID_TRAY_EXIT);

			// 加载图标
			HICON hIconRestore = (HICON)::LoadImage(AfxGetInstanceHandle(), MAKEINTRESOURCE(IDI_ICON_RESTORE), IMAGE_ICON, 16, 16, LR_SHARED);
			HICON hIconExit = (HICON)::LoadImage(AfxGetInstanceHandle(), MAKEINTRESOURCE(IDI_ICON_EXIT), IMAGE_ICON, 16, 16, LR_SHARED);

			// 设置图标到菜单项
			MENUITEMINFO mii = { sizeof(MENUITEMINFO) };
			mii.fMask = MIIM_BITMAP;

			// 恢复菜单项图标
			mii.hbmpItem = HBMMENU_CALLBACK;
			menu.SetMenuItemInfo(ID_TRAY_RESTORE, &mii);

			// 退出菜单项图标
			mii.hbmpItem = HBMMENU_CALLBACK;
			menu.SetMenuItemInfo(ID_TRAY_EXIT, &mii);

			// 获取鼠标当前位置,并显示菜单
			POINT pt;
			GetCursorPos(&pt);
			SetForegroundWindow();
			menu.TrackPopupMenu(TPM_LEFTALIGN | TPM_RIGHTBUTTON, pt.x, pt.y, this);
		}
	}
	return 0;
}

void CSGMeasurementDlg::OnTrayRestore()
{
	ShowWindow(SW_SHOW);  	// 恢复窗口
	SetForegroundWindow();  // 将窗口置于前端
}

void CSGMeasurementDlg::OnTrayExit()
{
	// 从托盘图标菜单选择"退出程序"
	if (AfxMessageBox(_T("确定要退出程序吗?"), MB_YESNO | MB_ICONQUESTION) == IDYES) {
		m_bExitingFromTray = TRUE;
		PostMessage(WM_CLOSE);
	}
}

✅ 八、总结与推荐

使用方式 是否支持图标 适合场景
MF_STRING 普通菜单项
MF_BITMAP ⚠️(位图,失真严重) 已过时,不推荐
MF_OWNERDRAW + HBMMENU_CALLBACK ✅ 支持完整图标绘制 推荐!托盘菜单、图标菜单项

✅ 推荐实践:

  • 想让托盘菜单显示图标:请务必使用 MF_OWNERDRAW
  • 搭配 WM_MEASUREITEM / WM_DRAWITEM 精准绘制菜单外观;
  • lpNewItem 的内容只是标识 ID,可强转 (LPCTSTR)ID_MENU
  • 如果不做自绘,系统不会调用你的菜单绘制函数!

🏁 参考阅读与补充