Windows托盘气球通知无效排查:Shell_NotifyIcon字段与系统策略
托盘图标出现在任务栏右下角,右键菜单响应正常,双击也能唤出主窗口,唯独调Shell_NotifyIcon发出去的那个气球通知,屏幕上一点动静都没有——而且返回值还是TRUE。这个现象我前后遇到过三四回,每次根因都不一样:有一次是uFlags漏了NIF_INFO,有一次是程序被改成后台常驻方式导致图标压根没挂到 explorer 上,还有一次是测试机上把"通知和操作"的总开关关掉了。所以看到"气球通知无效"这种描述,先别急着翻代码,得先把"无效"这两个字拆开,判断它到底属于哪一类。
这篇是写给正在做 Windows 桌面端常驻托盘程序的人看的,以 C++(Win32)为主,顺带把 C#(WinForms)和 WPF 封装层上的等价坑一起摆出来。读完至少能做到两件事:一是清楚Shell_NotifyIcon发气泡时到底是哪几个字段在起作用,二是手上有一套能自己往下查的链路,而不是对着屏幕干瞪眼改参数。气球通知这东西看着只有几行 API,实际上牵扯到结构体版本、字符集、窗口消息循环、系统通知策略、会话隔离五个层面,任何一层出问题表现都是"没反应",所以分层定位比试错重要得多。
1. 三种"不弹",故障点完全不同
1.1 图标压根没进托盘区
这是最容易被跳过的一步。很多人在排查气泡的时候默认"图标都在那儿了,肯定是气泡的问题",但实际情况里,图标显示正常并不代表NIM_ADD成功过——有一种情况是图标显示出来了,但那是上一次进程残留的"僵尸图标",鼠标划过就消失,点它也没反应。真正判断图标是否属于当前进程,最简单的办法是把鼠标悬停在图标上,看szTip里设置的提示文字有没有出现;如果提示文字是空白的或者显示的是别的程序的文字,那基本可以确定当前进程根本没加上图标,气泡自然无从谈起。
NIM_ADD失败的原因里,hIcon为空是最常见的一个。Win32 里常见写法是先LoadIcon或者LoadImage拿一个HICON,再填进nid.hIcon。这里有个真实存在的坑:如果这个HICON是通过CreateIconIndirect、CopyIcon这类函数动态生成的,而代码里又在NIM_ADD之后顺手调了DestroyIcon,托盘图标会立刻变成一块空白或者花屏。原因是 shell 并没有把图标数据拷贝走,它只是持有了这个句柄,你销毁了,它那边就指向了一块已经失效的内存。正确的释放时机是NIM_DELETE之后再DestroyIcon,顺序不能反。
还有一种更隐蔽的:程序被注册成了服务,或者用计划任务以"不管用户是否登录都运行"的方式启动。这种进程运行在会话 0(Session 0),而会话 0 是没有交互式桌面的,Shell_NotifyIcon可能返回成功,图标却永远不可能出现在用户眼前。判断方法很简单,任务管理器里看这个进程属于哪个会话,如果不是当前登录用户的会话号,那所有 UI 相关的 API 都是白搭。
另外提一句,Windows 11 的托盘区把默认图标折叠进了溢出面板,用户不点开小箭头是看不到的。这不是故障,但排查时容易被误判成"图标没加进去",先确认一下托盘设置里的"隐藏的图标菜单"。
1.2 图标在,气泡死活不出来
这一类才是标题里说的情况。此时NIM_ADD是成功的,szTip能正常显示,右键菜单也能弹,唯独气泡不出来。常见原因按出现频率排下来大概是:uFlags没带NIF_INFO、NIM_MODIFY之前图标状态已经丢了、szInfo为空字符串、系统通知开关被关闭、发送气泡时消息循环处于阻塞状态。
uFlags这个问题我在代码评审里见过不止一次。有人写了一个UpdateTrayIcon()函数,把NIF_ICON | NIF_MESSAGE | NIF_TIP全填上,然后在这个函数末尾顺手填了szInfo和szInfoTitle,以为这样气泡就会跟着图标一起更新。实际上Shell_NotifyIcon是严格按uFlags位掩码来决定读哪些字段的,没置NIF_INFO位,szInfo那一段内存它连看都不看。同理,dwInfoFlags的图标类型、NIIF_NOSOUND这类标志也都只在NIF_INFO置位时才生效。
还有一种是"先删后没加"。某些程序在响应WM_ENDSESSION或者显示器的WM_DISPLAYCHANGE时会先NIM_DELETE再重新NIM_ADD,如果中间某次NIM_ADD因为窗口句柄无效而失败,后续的NIM_MODIFY就会一直返回失败,气泡当然也发不出来。所以我习惯把"是否已添加"做成一个显式的状态位,任何一次NIM_ADD失败都要记录下来,后续发气泡前先检查这个状态。
1.3 气泡弹了,但标题是空的或者全是乱码
严格来说这不算"无效",但它经常和"无效"混为一谈,因为用户看到一片方块或者空白,会直接反馈"通知没显示"。乱码的根因几乎只有一个:结构体用了 ANSI 版本而字符串传的是宽字符,或者反过来。NOTIFYICONDATA在 SDK 头文件里是通过UNICODE宏展开成NOTIFYICONDATAW或NOTIFYICONDATAA的,如果工程属性里的字符集是"使用多字节字符集",而你代码里写的是L"...",编译器要么报错要么悄悄截断,最终szInfo里只有一个字符加个结尾。
这个坑在迁移老项目时特别容易踩到。老工程当年是 MBCS,后来为了支持多语言整体改成 Unicode,结果某个自己封装的托盘类里还留着char缓冲区和新结构体拼接,编译能过,跑起来就是乱码。定位办法是直接看sizeof(NOTIFYICONDATA)的值,Unicode 版本在 x64 下大概是 976 字节这个量级,如果在调试器里看到cbSize明显偏小,那基本就是字符集或者结构体定义对不上。
2. NOTIFYICONDATA 里决定气泡生死的字段
2.1 uFlags、szInfo、szInfoTitle 三者的联动关系
先把一段能用的代码摆出来,后面所有的讨论都围绕它展开。下面这段是添加图标的初始化,注意uFlags里没有NIF_INFO,因为添加阶段不需要气泡:
NOTIFYICONDATA nid = { 0 }; nid.cbSize = sizeof(NOTIFYICONDATA); nid.hWnd = hMainWnd; nid.uID = IDI_TRAY; nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP; nid.uCallbackMessage = WM_TRAY_CALLBACK; nid.hIcon = hTrayIcon; StringCchCopy(nid.szTip, ARRAYSIZE(nid.szTip), L"常驻托盘工具"); if (!Shell_NotifyIcon(NIM_ADD, &nid)) { // 这里一定要留日志,后面排查全靠它 }发气泡是另一段独立逻辑,uFlags只保留NIF_INFO:
bool ShowBalloon(HWND hWnd, UINT id, const wchar_t* title, const wchar_t* text, DWORD infoFlags) { NOTIFYICONDATA nid = { 0 }; nid.cbSize = sizeof(NOTIFYICONDATA); nid.hWnd = hWnd; nid.uID = id; nid.uFlags = NIF_INFO; nid.dwInfoFlags = infoFlags; StringCchCopy(nid.szInfoTitle, ARRAYSIZE(nid.szInfoTitle), title); StringCchCopy(nid.szInfo, ARRAYSIZE(nid.szInfo), text); return Shell_NotifyIcon(NIM_MODIFY, &nid) != FALSE; }这里有两个细节值得说。第一,NIM_MODIFY阶段uFlags只写NIF_INFO是完全正确的,语义是"这次我只改气泡部分,其它字段别动",不需要把NIF_ICON再写一遍。第二,szInfo传空字符串(注意不是空指针,是长度为 0 的字符串)的语义是移除当前显示的气泡,不是"什么都不做"。有些代码在错误分支里填了个空字符串当兜底,结果就是看起来什么都没发生。
字段长度也有硬上限:szInfo是 256 个WCHAR,szInfoTitle是 64 个WCHAR,都包含结尾的空字符。超长会被截断,用StringCchCopy这类安全函数能保证不出界,但内容确实会丢。我见过标题写了一长串路径导致后面全被切掉的,看起来像"标题显示不全",其实是长度超限。
2.2 cbSize、uVersion 与那个共用体的历史包袱
cbSize是所有NOTIFYICONDATA故障里最隐蔽的一个。这个字段的用途是让 shell 知道调用方用的是哪个版本的结构体,因为从 Windows 2000 到现在这个结构体加过好几轮字段。写sizeof(NOTIFYICONDATA)在正常情况下永远是对的,问题出在两种场景:一是工程引用了很老的第三方 SDK 头文件,结构体定义本身就缺字段,sizeof算出来偏小;二是用 C# 或其它语言手工定义StructLayout时字段漏了或者顺序错了。
第二种场景我踩过。C# 里为了不依赖 WinForms 的NotifyIcon,有人会自己DllImport一个Shell_NotifyIcon,然后手写一个NOTIFYICONDATA结构体。少写一个guidItem或者把uTimeout的位置写错,编译一点问题没有,运行起来cbSize对不上,NIM_ADD直接返回FALSE。这种情况在调试器里看一眼cbSize和预期值差多少,基本就能定位。
再说uVersion。NOTIFYICONDATA里uTimeout和uVersion是共用同一块内存的:
union { UINT uTimeout; UINT uVersion; };也就是说,你不能在同一次调用里既设超时又设版本号。设置版本要用NIM_SETVERSION,把uVersion填成NOTIFYICON_VERSION_4,这个动作会改变后续回调消息的编码方式——lParam的低字是消息 ID,高字是图标 ID。很多人加了NIM_SETVERSION之后发现右键菜单不弹了,就是因为回调处理还在按老格式解析lParam。所以这条命令不是"加了更好",而是"加了就得改回调"。
NOTIFYICON_VERSION_4还有另一个副作用:uTimeout被彻底忽略,气泡停留时长完全由系统的辅助功能设置决定。如果你有代码依赖精确控制气泡显示 3 秒还是 10 秒,那在 v4 下是做不到的。
2.3 dwInfoFlags 里几个会"反向生效"的标志
dwInfoFlags决定气泡左侧那个小图标的样式,常见取值是NIIF_NONE、NIIF_INFO、NIIF_WARNING、NIIF_ERROR、NIIF_USER。前四个都是系统内置图标,直接写就行,NIIF_USER则需要额外配合hBalloonIcon字段。
这里有个真实的坑:NIIF_USER置位但hBalloonIcon留空时,不同 Windows 版本的处理不一致,有的版本回退到hIcon,有的版本干脆画一个默认图标,还有的版本会导致整个气泡不显示。我现在的做法是,要么不用NIIF_USER,用就老老实实把hBalloonIcon填上,别指望系统帮你兜底。
另一个有意思的标志是NIIF_RESPECT_QUIET_TIME,Windows 7 引入的。置位之后,如果系统处于"安静时间"(现在叫专注助手),这条气泡会被直接丢弃,不是延迟显示,是丢弃。这个标志的设计初衷是"尊重用户不想被打扰的意愿",但如果你的程序发的是关键提醒,置了这个位就等于主动放弃了显示机会。反过来,如果你不置这个位,系统在某些版本上仍然会按全局策略抑制横幅——这一点各版本行为不完全一致,所以不要用它来做"能不能显示"的开关。
NIIF_NOSOUND相对简单,加上就不响提示音。有个细节是它不影响气泡本身的显示,只影响声音,所以别把它和"静音模式下气泡不显示"混为一谈。
3. 代码没问题但屏幕没反应:系统层拦在哪
3.1 通知总开关、专注助手与安静时间
这是排查时最容易漏掉的一层,因为它不在你的代码里,而在用户的机器上。Windows 10 之后,系统设置里有一个"通知和操作"的总开关,关掉之后所有应用的通知全部静默;下面还有按应用细分的开关,以及"专注助手"的几种模式(关闭、仅优先通知、仅闹钟)。
Shell_NotifyIcon发出的气泡在 Windows 8 之后已经被系统当作通知来管理了,所以它会完整地受这些开关影响。表现是:Shell_NotifyIcon返回TRUE,气泡不出现,通知中心里也找不到。这种情况你改一万行代码都没用。
排查手段是让测试机恢复到默认状态:通知总开关打开、专注助手关闭、当前应用在通知列表里处于允许状态。还有一点,如果程序以管理员权限运行,而系统设置里的通知是按应用配置的,某些版本下管理员进程的通知配置和普通进程不是同一份,表现就是普通权限下能弹、提权后不弹。这个差异在不同系统版本上表现不一致,我的做法是测试矩阵里固定包含"普通权限"和"管理员权限"两行,别只测一种。
老系统上还有一个注册表开关:HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced下的EnableBalloonTips,值为 0 时禁用气球提示。这个键在新系统上是否仍然生效取决于版本,但在 Windows 7 时代它是标准的关闭方式,排查老环境时值得看一眼。组策略里也有对应的"关闭通知气球提示"项,域环境下的机器可能被统一下发。
3.2 会话与用户身份:Session 0 和 RunAs 的死角
前面提过会话 0 的问题,这里展开说。Windows 的服务宿主机制决定了服务进程运行在会话 0,那是一个没有交互式桌面的独立会话。任何在会话 0 里调用的 UI 相关 API,包括Shell_NotifyIcon、MessageBox、CreateWindow,要么失败,要么创建一个用户永远看不到的窗口。
这个问题在实际项目里通常表现为"开发机上好好的,装到客户机上就没通知"。区别在于开发机是双击 exe 运行的,客户机上是通过服务或者计划任务启动的。定位方法是在任务管理器里加一列"会话 ID",看进程对应的数字是不是和当前登录用户一致。如果不一致,那问题不在代码,在于部署方式——要么改成用户登录后自启动,要么用会话隔离机制把 UI 部分拆成一个运行在用户会话里的独立进程。
还有一种情况是以另一个用户身份运行(比如右键"以其他用户身份运行",或者用凭据管理器启动)。这时图标会加到那个用户的托盘里,当前登录用户看不到,但那个用户如果同时在线(快速用户切换),在他自己的桌面里是能看到图标的。现象就是"我这边没图标,但换个账号登录就有了",很容易被误判成偶发 bug。
3.3 Windows 8 之后气球改头换面成 Toast
这条属于"知道就行,不知道会绕远路"的范畴。从 Windows 8 开始,Shell_NotifyIcon发出的气球通知在视觉上已经变成了 toast 样式——从屏幕右下角滑入、停留几秒、然后收进通知中心。接口没变,样式变了。
这个变化带来两个实际影响。第一,通知中心会成为这些气泡的归档地,用户在通知中心里能看到历史消息,这意味着"重复发同一条消息"的行为和老系统不一样了,老系统上重复发会不断弹新的,新系统上可能出现合并或者覆盖。第二,某些系统版本上,如果程序没有设置AppUserModelID,通知在系统里的归属会是一个默认的宿主,表现是通知列表里看不到你的应用名,用户在设置里想允许你的通知都找不到对应条目。设置AppUserModelID的 API 是SetCurrentProcessExplicitAppUserModelID,在进程启动早期调用。
如果对通知的可靠性要求比较高,其实可以考虑直接走 Toast 通知那套接口,代价是需要注册快捷方式、需要处理激活回调,工程复杂度上一个台阶。托盘气球胜在简单,二十行代码就能跑;Toast 胜在可控,能拿到点击、能进通知中心归档、能被用户精细管理。选哪个取决于你的通知是"顺手提示一下"还是"业务流程的一环"。
4. 一条能自己走完的排查链路
4.1 用三十行最小工程把业务代码摘出去
我处理这类问题的固定第一步是:新建一个空的 Win32 工程,只保留一个隐藏主窗口、一个托盘图标、一个定时器,定时器每三秒发一次气泡。这个工程大概三十行,写完不超过十分钟。之所以要先做这一步,是因为在真实项目里,"气泡不显示"可能是业务逻辑、线程调度、窗口生命周期三者叠加出来的,你直接在里面改参数,改一次跑一次,变量太多,判断不了哪个改动起了作用。
最小工程的骨架大致是这样:
LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_CREATE: AddTrayIcon(hWnd); SetTimer(hWnd, TIMER_BALLOON, 3000, nullptr); return 0; case WM_TIMER: if (wParam == TIMER_BALLOON) { ShowBalloon(hWnd, IDI_TRAY, L"测试标题", L"这是一条测试气泡", NIIF_INFO); } return 0; case WM_DESTROY: RemoveTrayIcon(hWnd); PostQuitMessage(0); return 0; } return DefWindowProc(hWnd, msg, wParam, lParam); }如果这个最小工程能弹气泡,说明 API 用法和系统环境都没问题,问题在你的业务代码里;如果弹不出来,说明问题在环境或者调用方式上,接下来往系统层查。这一步能省掉一半的排查时间。
4.2 返回值、窗口消息、监视工具三处取证
最小工程也不弹的时候,取证要分三路走。
第一路看返回值。Shell_NotifyIcon返回FALSE说明 shell 明确拒绝了,这时基本可以锁定字段错误(cbSize不对、hWnd无效、uID冲突)。返回TRUE但无显示,说明接收端没问题,问题在展示端(系统策略、会话、桌面可见性)。这两条分支的排查方向完全相反,所以返回值必须看。
第二路看窗口消息。如果要做气泡点击响应,uCallbackMessage指定的消息会在气泡被点击时发到你的窗口。用一个能抓窗口消息的工具挂在目标窗口上,观察发气泡前后有没有WM_USER+范围内的消息流动。这一步主要是确认 shell 到底有没有收到请求。
第三路看系统状态。通知设置、专注助手、会话 ID、是否提权,这四项各花一分钟确认,比读一天文档有用。我现在的习惯是在程序的"关于"页面里放一个隐藏的诊断入口,一键打印这几项状态,客户反馈问题时让他截个图,能省掉好几轮来回。
| 现象 | 优先怀疑 | 验证方式 |
|---|---|---|
NIM_ADD返回 FALSE | cbSize、hIcon为空、窗口句柄无效 | 调试器里看cbSize与sizeof是否一致 |
| 图标在,气泡无,返回值 TRUE | uFlags漏NIF_INFO | 断点检查发送时的uFlags值 |
| 图标不在,气泡无 | 会话 0、图标句柄被提前释放 | 任务管理器看会话 ID,检查DestroyIcon时机 |
| 气泡出但标题空白 | 字符集不匹配 | 看sizeof(NOTIFYICONDATA)与工程字符集设置 |
| 时好时坏 | 消息循环被阻塞、定时器被抢占 | 检查主线程是否有长耗时同步操作 |
| 特定机器必现 | 通知开关、专注助手、组策略 | 重置通知设置为默认后复测 |
4.3 把"什么时候不弹"记录成日志
这一点听起来平淡,但它决定了后续排查是几天还是几小时。我在托盘模块里加了一组固定的日志点:NIM_ADD的结果、NIM_DELETE的结果、每次发气泡前的uFlags和dwInfoFlags值、发气泡的返回值、当前进程的会话 ID 和是否提权。这些信息加起来不到二十行代码,但客户现场出问题时,看一眼日志就能把范围缩小到一两个方向。
有个细节:日志里不要把szInfo全文打进去,用户数据可能很长,也可能包含不该落盘的内容,打个长度和标题就够了。
5. 托管封装层:WinForms 与 WPF 的等价坑
5.1 NotifyIcon.ShowBalloonTip 的前置条件
C# 的 WinForms 里,NotifyIcon类把Shell_NotifyIcon包了一层,接口看着简单,坑一样不少。ShowBalloonTip(int timeout)要能生效,至少得满足三个条件:Visible为true、Icon不为null、图标已经被系统接受。
第一个条件是硬性的,Visible = false时调ShowBalloonTip不抛异常也不显示,最容易漏。第二个条件很多人以为只要给Icon赋值就行,实际上如果你是从资源文件动态加载图标流,用完顺手把StreamDispose掉了,Icon对象的内部句柄可能已经失效,表现就是托盘图标显示成一块空白,气泡也跟着不显示。
第三个条件在窗体生命周期早期容易出问题。在Form_Load里立刻调ShowBalloonTip,有些机器上不生效,因为这时窗口句柄刚创建,托盘图标还没真正挂上去。稳妥做法是Load之后用BeginInvoke推迟到消息队列下一轮再调,或者在Shown事件里调。
timeout参数在 Vista 之后基本被忽略了,实际时长由系统的辅助功能设置决定。这一点和老系统不一样,如果产品文档里写了"气泡显示 5 秒",最好改成"约 5 秒,实际由系统设置决定",免得后面被当成 bug 提。
5.2 消息泵被堵住时气泡会"迟到"
Shell_NotifyIcon内部是通过窗口消息和任务栏通信的,这意味着它依赖调用线程的消息循环。如果主线程正在跑一个长耗时同步任务,比如读一个大文件、等一个网络请求、跑一段密集计算,那么即使你在另一个线程里调ShowBalloonTip,调用也可能被卡住,直到主线程的消息泵重新转起来。
现象很有迷惑性:日志显示"已发送气泡",但用户是在几秒甚至几十秒之后才看到,或者干脆看不到(因为已经过了时效)。这类问题在"后台任务完成时弹通知"的场景里特别常见,因为后台任务完成的时刻,主线程往往正在刷新界面。
处理办法是有的:把耗时任务放到后台线程,主线程只负责 UI;或者在不依赖NotifyIcon对象的场景下,用一个独立的隐藏窗口在自己的线程里跑消息循环来处理托盘消息。后者工程量大些,但如果托盘逻辑要脱离主窗口独立存活(比如主窗口可以关闭但程序不退出),这个结构反而更清晰。
WPF 这边没有内置的托盘图标控件,社区里常见的做法是引用 WinForms 的NotifyIcon或者用第三方库。第三方库如果自己实现了Shell_NotifyIcon的互操作,那前面说的cbSize、uFlags、字符集三件事依然适用,换语言不换原理。选库的时候可以看一眼它有没有处理NOTIFYICON_VERSION_4,没处理的话右键菜单的坐标计算在某些机器上会偏。
6. 把这套东西做成不容易再出问题的结构
6.1 用一个带状态位的发送函数收口
在项目里,我最后的做法是把托盘的"添加""删除""发气泡"三个动作集中在一个小模块里,模块内部维护一个bool added状态位,任何一次NIM_ADD成功才置位,NIM_DELETE后清零。发气泡的入口函数第一件事就是检查这个状态位,没置位就先尝试添加一次。这样不管上层怎么调用,都不会出现"图标没了还在发气泡"的静默失败。
同时这个模块对外只暴露一个ShowBalloon(title, text, level)函数,level是个枚举,映射到NIIF_INFO、NIIF_WARNING、NIIF_ERROR。这样上层不需要知道dwInfoFlags的存在,后面如果要换实现(比如换成 Toast 通知),改动面只在一个文件里。
还有一个小设计:发气泡函数返回一个枚举,区分"发送成功"、"发送失败"、"系统策略可能抑制"三种结果。第三种判断不了那么准,可以根据会话 ID 和通知开关做个粗略推断。有这个返回值,上层在做关键提醒时就能选择走别的通道(比如闪烁任务栏、改托盘图标颜色),而不是把所有希望都押在气泡上。
6.2 退出时的清理和僵尸图标
NIM_DELETE一定要在窗口销毁之前调,而且要用和NIM_ADD完全相同的hWnd和uID。我见过填错uID导致图标删不掉的情况,表现是程序退出后图标还挂在那儿,鼠标划过才消失。这不仅仅是观感问题——残留的图标会占用那个uID,下次启动如果复用同一个 ID,行为可能不可预期。
另一个清理时机是WM_ENDSESSION,系统关机或者注销时,程序不一定能走完正常的退出流程。在这个消息里补一次NIM_DELETE能减少残留。还有崩溃场景,进程直接挂掉不会执行任何清理代码,图标一定残留,这个没办法,只能靠系统自己回收。
如果程序支持多实例,uID的分配要小心。两个实例用同一个hWnd和uID是不可能的(hWnd天然不同),但如果用了自己生成的guidItem标识,两个实例可能会互相覆盖。多实例场景建议用单实例机制直接禁掉,或者在guidItem里加入进程相关的区分信息。
6.3 版本差异下的降级策略
不同 Windows 版本对气泡的处理差异,实际项目里没必要逐一适配,但有三条底线值得守住。
第一条,不要在NIM_ADD的时候一次性把NIF_INFO也带上试图"顺便弹个气泡",添加和修改分成两次调用,逻辑清晰,出问题也好定位。第二条,NIM_SETVERSION只在确实需要 v4 的回调格式时才调,调了就同步改回调解析逻辑,两者必须一起改。第三条,uTimeout不要依赖,需要"多停留一会儿"的需求,用重复发送或者改成带交互的通知来实现更好,别指望这个字段。
我在实际项目里的体会是,托盘气泡这个功能的问题密度和代码量完全不成比例——二十行代码的功能,可能需要几百行的排查经验来兜底。所以能把它封装干净就封装干净,别让业务逻辑直接碰NOTIFYICONDATA,后面换人维护或者系统升级,改一个文件就够了。