Win32平台下NOTIFYICONDATA的气泡点击功能的响应与实现

      功能来源:本人实现开启自己开发的应用程序或者在用户点击应用程序的关闭按钮来实现最小化到系统托盘,并且要求弹出气泡通知而且能够支持鼠标点击功能。

      首先, 确定一下我们是在win32平台使用vs2015+C++语言进行开发,在确定了编译平台以及语言环境我们就可以着手准备查找技术资料。


第一部:     

      接下来,我们查找关于win32技术的资料一般推荐MSDN官网技术文档,因为MSDN技术文档是目前为止最准确的技术资料,我们来看看下面两个技术链接为我们提供了关于 NOTIFYICONDATA结构体相关的说明,

  1. https://msdn.microsoft.com/en-us/library/ms910625.aspx
  2. https://docs.microsoft.com/en-us/windows/desktop/api/shellapi/ns-shellapi-_notifyicondataa

其中文献1的 文档说明比较早,是在2005年写的, 对于现在的我们来说比较过时了,而文献2的文档说明正是我们所需要找的资料,那我们就来简单分析一下相关的结构体的各个成员变量含义吧(注:参照相关文档解释)!

typedef struct _NOTIFYICONDATAA {
  DWORD cbSize;
  HWND  hWnd;
  UINT  uID;
  UINT  uFlags;
  UINT  uCallbackMessage;
  HICON hIcon;
  CHAR  szTip[64];
  CHAR  szTip[128];
  DWORD dwState;
  DWORD dwStateMask;
  CHAR  szInfo[256];
  union {
    UINT uTimeout;
    UINT uVersion;
  } DUMMYUNIONNAME;
  CHAR  szInfoTitle[64];
  DWORD dwInfoFlags;
  GUID  guidItem;
  HICON hBalloonIcon;
} NOTIFYICONDATAA, *PNOTIFYICONDATAA;

 DWORD cbSize:

cbSize标识着该结构体的大小,用来确定包含哪些成员变量;不过,好多程序开发人员直接使用sizeof(NOTIFYICONDATA)给该成员赋值会有一定的潜在危险,下文会有相应的代码贴出:

/*
@brief 根据系统自带的动态库来获取关于系统的版本号
*/
ULONGLONG __GetVersion(LPCTSTR lpszDllName)
{
	HINSTANCE hinstDll;
	ULONGLONG dwVersion = 0;
	hinstDll = LoadLibrary(lpszDllName);
	if (hinstDll)
	{
		DLLGETVERSIONPROC pDllGetVersion;
		pDllGetVersion = (DLLGETVERSIONPROC)GetProcAddress(hinstDll, "DllGetVersion");
		if (pDllGetVersion)
		{
			DLLVERSIONINFO dvi;
			HRESULT hr;
			ZeroMemory(&dvi, sizeof(dvi));
			dvi.cbSize = sizeof(dvi);
			hr = (*pDllGetVersion)(&dvi);
			if (SUCCEEDED(hr))
			{
				dwVersion = MAKEDLLVERULL(dvi.dwMajorVersion, dvi.dwMinorVersion,
					dvi.dwBuildNumber, dvi.dwPlatformID);
			}
		}
		FreeLibrary(hinstDll);
	}
	return dwVersion;
}

/*
@brief 确定NOTIFYICONDATA结构体的大小
*/
DWORD __GetSizeNotifyIconData()
{
	TCHAR lpszDllName[128];
	GetWindowsDirectory(lpszDllName, _TRUNCATE);
	_tcsncat_s(lpszDllName, __T("\\System32\\Shell32.dll"), _TRUNCATE);
	ULONGLONG NIDdllVer = __GetVersion(lpszDllName);
	// before windows 2000
	if (NIDdllVer < MAKEDLLVERULL(5, 0, 0, 0)) {
		return NOTIFYICONDATA_V1_SIZE;
	}
	// Windows 2000
	else if (NIDdllVer < MAKEDLLVERULL(6, 0, 0, 0)) {
		return NOTIFYICONDATA_V2_SIZE;
	}
	// Windows XP or 2003
	else if (NIDdllVer < MAKEDLLVERULL(6, 0, 6000, 0)) {
		return NOTIFYICONDATA_V3_SIZE;
	}
	// Windows Vista and later
	else {
		return sizeof(NOTIFYICONDATA);
	}
}

 HWND  hWnd

hWnd标识窗口的句柄。标示的窗口用来接收与托盘图标相关的消息。Shell_NotifyIcon函数调用时,hWnd和uID成员用来标示具体要操作的图标。

UINT  uID

uID应用程序定义的任务栏图标的标识符,hWnd和uID成员用来标示具体要操作的图标。

UINT  uFlags

此成员表明具体哪些其他成员为合法数据(即哪些成员起作用)。此成员可以为以下值的组合:

       NIF_ICON:hIcon成员起作用,NIF_MESSAGE:uCallbackMessage成员起作用。NIF_TIP:szTip成员起作用。

      NIF_STAT:dwState和dwStateMask成员起作用;NIF_INFO:使用气球提示代替普通的工具提示框。szInfo, uTimeout,                szInfoTitle和dwInfoFlags成员起作用。

       NIF_GUID:保留。

UINT  uCallbackMessage

uCallbackMessage:应用程序定义的消息标示。当托盘图标区域发生鼠标事件或者使用键盘选择或激活图标时,系统将使用此标示向由hWnd成员标示的窗口发送消息。消息响应函数的wParam参数标示了消息事件发生的任务栏图标,lParam参数根据事件的不同,包含了鼠标或键盘的具体消息,例如当鼠标指针移过托盘图标时,lParam将为WM_MOUSEMOVE。

HICON hIcon

hIcon:增加、修改或删除的图标的句柄。注意,windows不同版本对于图标有不同要求。Windows XP可支持32位。

CHAR  szTip[64]

szTip:指向一个以\0结束的字符串的指针。字符串的内容为标准工具提示的信息。包含最后的\0字符,szTip最多含有64个字符。

对于Version 5.0 和以后版本,szTip最多含有128个字符(包含最后的\0字符)。

DWORD dwState

dwState:Version 5.0,图标的状态,有两个可选值,如下:

      NIS_HIDDEN:图标隐藏  ; NIS_SHAREDICON:图标共享

DWORD dwStateMask

        dwStateMask:Version 5.0. 指明dwState成员的那些位可以被设置或者访问。比如设置此成员为NIS_HIDDEN,将导致只有hidden状态可以被获取。

CHAR  szInfo[256]

     szInfo:Version 5.0. 指向一个以\0结束的字符串的指针。字符串的内容为气球提示内容。最多含有255个字符。如果要移除已经存在的气球提示信息,设置uFlags成员为NIF_INFO,同时将szInfo设为空。

UINT uTimeout

        uTimeout:和uVersion成员为联合体。uTimeout表示气球提示超时的时间,单位为毫秒,此时间后气球提示将消失。系统默认气球提示的超时时间最小值为10秒,最大值为30秒。如果设置的uTimeout的值小于10将设置最小值,如果大于30将设置最大值。将超时时间分为最大最小两种,是因为解决不同图标的气球提示同时弹出的问题,详细内容请参考MSDN中NOTIFYICONDATA结构体说明的remarks。

UINT uVersion

        uVersion:Version 5.0. 和uTimeout成员为联合体。用来设置使用Windows 95 还是 Windows 2000风格的图标消息接口。请参考Shell_NotifyIcon函数的说明获取更多信息。只有当使用Shell_NotifyIcon函数发送NIM_SETVERSION消息时,此成员才有作用。可选的值如下:

      0:使用Windows 95风格。针对Windows 2000版本之前的windows设计的软件请使用此值。

     NOTIFYICON_VERSION:使用Windows 2000风格。 针对Windows 2000版本以及以后版本的windows设计的软件请使用此        值。

CHAR  szInfoTitle[64]

       szInfoTitle:Version 5.0. 指向一个以\0结束的字符串的指针。字符串的内容为气球提示的标题。此标题出现在气球提示框的上部,最多含有63个字符。

DWORD dwInfoFlags

          dwInfoFlags:Version 5.0. 设置此成员用来给气球提示框增加一个图标。增加的图标出现在气球提示标题的左侧,注意如果szInfoTitle成员设为空字符串,则图标也不会显示。可选值如下:

       NIIF_ERROR:错误图标;NIIF_INFO:信息图标;NIIF_NONE:没有图标;

      NIIF_USER:使用用户使用hIcon成员指明的图标,要求Windows XP Service Pack 2 (SP2)或以后系统;

     NIIF_WARNING:警告图标;NIIF_ICON_MASK:Version 6.0. 保留;

     NIIF_NOSOUND:Version 6.0. 禁止播放相应声音;

GUID  guidItem 

    guidItem:Version 6.0. 保留。

HICON hBalloonIcon

    hBalloonIcon:Windows Vista以后的系统支持,标识为气球的独立句柄。

现在就来说说我们所需要的新特性吧!

我们需要把 支持弹出气泡通知以及实现点击的特性拿出来说一下:

1.对cbSize成员变量根据系统类型获取其相应的赋值方式

2.uCallbackMessage

这个消息传递的参数在uVersion 设置为 NOTIFYICON_VERSION_4后,变为如下的意义。

  • LOWORD(lParam) 包含了消息类型,例如:NIN_BALLOONSHOW,NIN_POPUPOPEN,WM_CONTEXTMENU。有许多人想实现对用户单击气泡的消息响应,就是在这里做消息的响应。
  • HIWORD(lParam) uID,缩短为16位,我们用这个确定是哪一个托盘图标的消息。
  • GET_X_LPARAM(wParam) 返回鼠标消息时,为鼠标的X坐标。如果消息从键盘产生,wParam 包含的坐标为目标ICON的左上角坐标, 其他消息时wParam 未定义 
  • GET_Y_LPARAM(wParam) 返回鼠标消息时,为鼠标的Y坐标。其他意义与GET_X_LPARAM(wParam)相同。

3.uTimeout

该值在Vista及之后系统下无效,此时间由系统决定。在其他情况下:最大值30秒,最小值10秒。单位为毫秒。另外有一点,就是关于XP下气泡提示不会自动消失的情况(至今还没有测试过)。MSDN中也指出这是一个存在的现象,当用户没有操作时(任何操作,例如单击开始菜单之类),气泡是不会消失的(XP)。

4.uVersion

这是一个比较关键的值

  • 0Windows 2000之前的操作系统使用,也是默认值.
  • NOTIFYICON_VERSION使用Windows 2000特性,用于Windows 2000以及之后版本.
  • NOTIFYICON_VERSION_4使用当前系统风格,用于Vista与之后的版本

如果我们要实现气泡上的操作的响应,我们必须设定为NOTIFYICON_VERSION_4,才能在CallbackMessage中响应到关于气泡的消息。然后还要搭配   Shell_NotifyIcon(NIM_SETVERSION,&nid);  设置生效才行。

5. uFlags

     对该变量添加NIFINFO:使用气球提示代替普通的工具提示框。szInfo, uTimeout,  szInfoTitle和dwInfoFlags成员起作用。

6.鼠标点击事件

    WM_RBUTTONDOWN

    WM_RBUTTONUP

    WM_LBUTTONUP

   WM_LBUTTONDOWN

-----------以下为气泡有关事件---------------------------

   NIN_BALLOONUSERCLICK  //气泡点击

  NIN_BALLOONSHOW

  NIN_BALLOONHIDE

  NIN_BALLOONTIMEOUT


第二部:

接下来我们需要了解一下Shell_NotifyIcon函数的相关参数含义以及其功能,其中官方文档如下所示:

       https://docs.microsoft.com/en-us/windows/desktop/api/shellapi/nf-shellapi-shell_notifyicona

BOOL Shell_NotifyIconA(
  DWORD            dwMessage,
  PNOTIFYICONDATAA lpData
);

NIM_ADD

向托盘区域添加一个图标。此时第二个参数lpdata指向的NOTIFYICONDATA结构体中的hWnd和uID成员用来标示这个图标,以便以后再次使用Shell_NotifyIcon对此图标操作。

NIM_DELETE

删除托盘区域的一个图标。此时第二个参数lpdata指向的NOTIFYICONDATA结构体中的hWnd和uID成员用来标示需要被删除的这个图标。

NIM_MODIFY

修改托盘区域的一个图标。此时第二个参数lpdata指向的NOTIFYICONDATA结构体中的hWnd和uID成员用来标示需要被修改的这个图标。

NIM_SETFOCUS

Version 5.0. 设置焦点。比如当用户操作托盘图标弹出菜单,而有按下ESC键将菜单消除后,程序应该使用此消息来将焦点设置到托盘图标上。

NIM_SETVERSION

Version 5.0. 设置任务栏按照第二个参数lpdata指向的NOTIFYICONDATA结构体中的uVersion成员指定的版本号来工作。此消息可以允许用户设置是否使用基于Windows2000的version 5.0的风格。uVersion的缺省值为0,默认指明了使用原始Windows 95图标消息风格。具体这两者的区别请参考msdn中的Shell_NotifyIcon函数说明的Remarks。

lpdata为输入参数,是指向NOTIFYICONDATA结构体指针,结构体内容用来配合第一个参数wMessage进行图标操作。

如果图标操作成功返回TRUE,否则返回FALSE。

如果dwMessage参数设为NIM_SETVERSION,则如果版本设置成功返回TRUE,如果设置的版本不支持返回FALSE。


第三部

具体的结构代码如下所示:

1.首先定义头文件:

#include"ShowTrayIcon.h"


#ifndef _SHOWTRAYICON_H
#define _SHOWTRAYICON_H

#include<windows.h>
#include<shlwapi.h>
#include<tchar.h>
#include<winbase.h>
#include<versionhelpers.h>
#include"resource.h"

#define IDR_PAUSE 12
#define IDR_START 13
#define MINISITE_NEWS_TRAY_ICON_ID 26

ULONGLONG __GetVersion(LPCTSTR lpszDllName);
DWORD __GetSizeNotifyIconData();
LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam);
#endif // !1

 

2.程序实现代码:

#include"ShowTrayIcon.cpp"

#include"ShowTrayIcon.h"

LPCTSTR szAppClassName = TEXT("托盘图标");
LPCTSTR szAppWindowName = TEXT("托盘图标");
HMENU hmenu;//菜单句柄
HINSTANCE _hInstance;
HINSTANCE hInstance_win;

ULONGLONG __GetVersion(LPCTSTR lpszDllName)
{
	HINSTANCE hinstDll;
	ULONGLONG dwVersion = 0;
	hinstDll = LoadLibrary(lpszDllName);
	if (hinstDll)
	{
		DLLGETVERSIONPROC pDllGetVersion;
		pDllGetVersion = (DLLGETVERSIONPROC)GetProcAddress(hinstDll, "DllGetVersion");
		if (pDllGetVersion)
		{
			DLLVERSIONINFO dvi;
			HRESULT hr;
			ZeroMemory(&dvi, sizeof(dvi));
			dvi.cbSize = sizeof(dvi);
			hr = (*pDllGetVersion)(&dvi);
			if (SUCCEEDED(hr))
			{
				dwVersion = MAKEDLLVERULL(dvi.dwMajorVersion, dvi.dwMinorVersion,
					dvi.dwBuildNumber, dvi.dwPlatformID);
			}
		}
		FreeLibrary(hinstDll);
	}
	return dwVersion;
}


DWORD __GetSizeNotifyIconData()
{
	TCHAR lpszDllName[128];
	GetWindowsDirectory(lpszDllName, _TRUNCATE);
	_tcsncat_s(lpszDllName, __T("\\System32\\Shell32.dll"), _TRUNCATE);
	ULONGLONG NIDdllVer = __GetVersion(lpszDllName);
	// before windows 2000
	if (NIDdllVer < MAKEDLLVERULL(5, 0, 0, 0)) {
		return NOTIFYICONDATA_V1_SIZE;
	}
	// Windows 2000
	else if (NIDdllVer < MAKEDLLVERULL(6, 0, 0, 0)) {
		return NOTIFYICONDATA_V2_SIZE;
	}
	// Windows XP or 2003
	else if (NIDdllVer < MAKEDLLVERULL(6, 0, 6000, 0)) {
		return NOTIFYICONDATA_V3_SIZE;
	}
	// Windows Vista and later
	else {
		return sizeof(NOTIFYICONDATA);
	}
}

/*
*@brief消息处理机制
*/
LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
	NOTIFYICONDATA nid;
	UINT WM_TASKBARCREATED;
	POINT pt;//用于接收鼠标坐标
	int xx;//用于接收菜单选项返回值

	if (hwnd==NULL)
	{
		return 0;
	}
	   // 不要修改TaskbarCreated,这是系统任务栏自定义的消息
	WM_TASKBARCREATED = RegisterWindowMessage(TEXT("TaskbarCreated"));
	switch (message)
	{
	case WM_CREATE://窗口创建时候的消息.
		nid.cbSize = __GetSizeNotifyIconData();
		nid.hWnd = hwnd;
		nid.uID = MINISITE_NEWS_TRAY_ICON_ID;
		nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP|NIF_INFO;
		nid.uCallbackMessage = WM_USER;
		nid.hIcon = LoadIcon(_hInstance, MAKEINTRESOURCE(IDB_PNG1));//自己定义了资源文件图标,你可以直接修改为系统提供的图标,或者自己添加自己的资源文件
		nid.uTimeout = 1000;//参照变量详解,有些系统失效
		nid.uVersion = NOTIFYICON_VERSION_4;
		lstrcpy(nid.szTip, szAppClassName);
		lstrcpy(nid.szInfo,L"系统托盘");
		lstrcpy(nid.szInfoTitle, L"实现气泡点击!");
		nid.dwInfoFlags = NIIF_USER;
		Shell_NotifyIcon(NIM_SETVERSION,&nid);
		Shell_NotifyIcon(NIM_ADD, &nid);
		hmenu = CreatePopupMenu();//生成菜单
		AppendMenu(hmenu, MF_STRING, IDR_PAUSE, L"暂停服务");//为菜单添加两个选项
		AppendMenu(hmenu, MF_STRING, IDR_START, L"关于");
		break;
	case WM_USER:
		if ((lParam == WM_LBUTTONDOWN))
			MessageBox(hwnd, TEXT("Win32 API 实现系统托盘程序,点击托盘!"), szAppClassName, MB_OK);
		if (lParam == NIN_BALLOONUSERCLICK)
			MessageBox(hwnd, TEXT("Win32 API 实现气泡提示,点击气泡打开应用!"), szAppClassName, MB_OK);
		if (lParam == NIN_BALLOONTIMEOUT)
		{
			MessageBox(hwnd, TEXT("Win32 API 气泡超时或者关闭!"), szAppClassName, MB_OK);
			CloseHandle(hInstance_win);

		}
			
		if (lParam== NIN_BALLOONSHOW)
			SetTimer(hwnd, TRAYICON_TIMEROUT_ID, 300, NULL);

		//双击托盘的消息,退出.
		if (lParam == WM_LBUTTONDBLCLK)
		{
			SendMessage(hwnd, WM_CLOSE, wParam, lParam);
			CloseHandle(hInstance_win);

		}
			

		if (lParam == WM_RBUTTONDOWN)
		{
			GetCursorPos(&pt);//取鼠标坐标
			::SetForegroundWindow(hwnd);//解决在菜单外单击左键菜单不消失的问题
			EnableMenuItem(hmenu, IDR_PAUSE, MF_GRAYED);//让菜单中的某一项变灰
			xx = TrackPopupMenu(hmenu, TPM_RETURNCMD, pt.x, pt.y, NULL, hwnd, NULL);//显示菜单并获取选项ID
			if (xx == IDR_PAUSE) MessageBox(hwnd, TEXT("111"), szAppClassName, MB_OK);
			if (xx == IDR_START) MessageBox(hwnd, TEXT("222"), szAppClassName, MB_OK);
			if (xx == 0) PostMessage(hwnd, WM_LBUTTONDOWN, NULL, NULL);
			//MessageBox(hwnd, TEXT("右键"), szAppClassName, MB_OK);
		}
		break;

	case WM_DESTROY://窗口销毁时候的消息.
		KillTimer(hwnd, TRAYICON_TIMEROUT_ID);
		Shell_NotifyIcon(NIM_DELETE, &nid);
		PostQuitMessage(0);
		break;
	default:
		/*
		* 防止当Explorer.exe 崩溃以后,程序在系统系统托盘中的图标就消失
		*
		* 原理:Explorer.exe 重新载入后会重建系统任务栏。当系统任务栏建立的时候会向系统内所有
		* 注册接收TaskbarCreated 消息的顶级窗口发送一条消息,我们只需要捕捉这个消息,并重建系
		* 统托盘的图标即可。
		*/
		if (message == WM_TASKBARCREATED)
			SendMessage(hwnd, WM_CREATE, wParam, lParam);
		break;
	}
	return DefWindowProc(hwnd, message, wParam, lParam);
}

int WINAPI WinMain(
	_In_ HINSTANCE hInstance,
	_In_ HINSTANCE hPrevInstance,
	_In_ LPSTR     lpCmdLine,
	_In_ int       nCmdShow
)

{
	HWND hwnd;
	MSG msg;
	WNDCLASS wndclass;
	_hInstance = hInstance;
	HANDLE h = CreateMutex(NULL,FALSE,L"{80A85553-1E05-4323-B4F9-43A4396A4507}");
	if (GetLastError() == ERROR_ALIAS_EXISTS)
	{
		MessageBox(NULL,L"Another Instance Running!",L"Tip",MB_OK);
		CloseHandle(h);
		return 0;
	}
	HWND handle = FindWindow(NULL, szAppWindowName);
	wndclass.style = CS_HREDRAW | CS_VREDRAW;
	wndclass.lpfnWndProc = WndProc;
	wndclass.cbClsExtra = 0;
	wndclass.cbWndExtra = 0;
	wndclass.hInstance = NULL;
	wndclass.hIcon = LoadIcon(NULL, IDI_APPLICATION);
	wndclass.hCursor = LoadCursor(NULL, IDI_APPLICATION);
	wndclass.hbrBackground = (HBRUSH)GetStockObject(WHITE_BRUSH);
	wndclass.lpszMenuName = NULL;
	wndclass.lpszClassName = szAppClassName;

	if (!RegisterClass(&wndclass))
	{
		MessageBox(NULL, TEXT("This program requires Windows NT!"), szAppClassName, MB_ICONERROR);
		return 0;
	}

	// 此处使用WS_EX_TOOLWINDOW 属性来隐藏显示在任务栏上的窗口程序按钮
	hwnd = CreateWindowEx(WS_EX_TOOLWINDOW,
		szAppClassName, szAppWindowName,
		WS_POPUP,
		CW_USEDEFAULT,
		CW_USEDEFAULT,
		CW_USEDEFAULT,
		CW_USEDEFAULT,
		NULL, NULL, NULL, NULL);

	ShowWindow(hwnd, 0);
	UpdateWindow(hwnd);

	//消息循环
	while (GetMessage(&msg, NULL, 0, 0))
	{
		TranslateMessage(&msg);
		DispatchMessage(&msg);
	}
	return 0;
}

         这里有一个很需要注意的地方是,使用Shell_notifyIcon( NIM_MODIFY, &ni );时,托盘会发出NIN_TIMEOUT消息,但是如果你想实现点击气泡上关闭按钮关闭气泡时,你也需要处理NIN_TIMEOUT消息,这样的话,我们需要定义一个变量,每调用一次Shell_NotifyIcon( NIM_MODIFY, &ni );的时候都设置一次,表明当前NIN_TIMEOUT消息是由于Shell_Notify引起的,那么就不处理NIN_TIMEOUT,如果此变量没被设置,证明用户点击了气泡提示的关闭按钮。

最后再说明一下,由于现在大多数人使用的系统都是win7以上的操作系统,所以NOTIFYICONDATA结构体的成员变量utimeout已经失效,我们无法控制气泡存在的生命周期。如果要延长一下通知的超时时间可以通过修改下面提供的注册表的的键值。不过要慎用,因为这是针对系统上所有的通知消息的超时时间。

方法:

        运行 regedit 打开注册表编辑器,展开至 HKEY_CURRENT_USER\Control Panel\Accessibility,选择 Accessibility 后,可以在右边区域找到 MessageDuration,默认值为 5(秒)。

修改它的数据即可修改通知显示时间,单位为秒,修改为 5 以下无效。(注意修改前先选择十进制)。

      在程序中可以通过注册表进行修改,不过不建议!

备注:如果想使通知没有超时,建议可以考虑window10平台的UWP+Toast模块进行研发,不过只能在window10平台运行,window8以下不支持。

 

 

 

 

 

 

猜你喜欢

转载自blog.csdn.net/zjx_cfbx/article/details/81779481