在开发Windows桌面应用时,我们经常会遇到一个经典场景:用户点击窗口右上角的"最小化"按钮后,程序窗口从任务栏消失,但用户仍然希望保持应用在后台运行。这时候,系统托盘(System Tray)就成了最佳解决方案。不同于传统的任务栏最小化,系统托盘图标可以让应用在不干扰用户工作区的情况下持续运行,同时提供快速唤醒入口。
我最近为一个医疗行业的WPF应用实现了这个功能。医生在使用过程中经常需要临时查看其他患者资料,但又不想完全关闭当前患者的监护界面。通过系统托盘方案,我们让应用在"最小化"时自动缩到托盘区,双击图标即可恢复窗口,这比传统的任务栏最小化体验更符合医护人员的操作习惯。
WPF本身并没有直接提供系统托盘的控件,我们需要借助Windows API来实现。这里主要用到的是Shell_NotifyIcon函数,它属于shell32.dll库。在.NET中,我们可以通过P/Invoke(平台调用)来访问这些Win32 API。
csharp复制[DllImport("shell32.dll", CharSet = CharSet.Auto)]
public static extern bool Shell_NotifyIcon(int message, NOTIFYICONDATA pnid);
注意:使用P/Invoke时需要特别注意32位和64位系统的兼容性问题。建议在结构体定义时显式指定字段的布局和大小。
虽然可以直接调用API,但为了简化开发,我推荐使用Hardcodet.NotifyIcon.Wpf这个专门为WPF设计的开源库。它封装了所有底层细节,提供了更符合WPF开发习惯的API。
安装命令:
bash复制Install-Package Hardcodet.NotifyIcon.Wpf
这个库的优势在于:
首先在XAML中定义托盘图标资源。建议放在App.xaml的Application.Resources中,这样可以在整个应用生命周期中保持:
xml复制<Application.Resources>
<ResourceDictionary>
<tb:TaskbarIcon x:Key="MyNotifyIcon"
IconSource="/Assets/app.ico"
ToolTipText="我的应用正在运行"
DoubleClickCommand="{Binding ShowWindowCommand}"/>
</ResourceDictionary>
</Application.Resources>
关键属性说明:
重写主窗口的StateChanged事件处理程序:
csharp复制private void Window_StateChanged(object sender, EventArgs e)
{
if (WindowState == WindowState.Minimized)
{
Hide();
var notifyIcon = (TaskbarIcon)FindResource("MyNotifyIcon");
notifyIcon.Visibility = Visibility.Visible;
}
}
这里有几个关键点需要注意:
在ViewModel中实现ShowWindowCommand:
csharp复制public ICommand ShowWindowCommand => new RelayCommand(() =>
{
Application.Current.MainWindow.Show();
Application.Current.MainWindow.WindowState = WindowState.Normal;
var notifyIcon = (TaskbarIcon)Application.Current.FindResource("MyNotifyIcon");
notifyIcon.Visibility = Visibility.Collapsed;
});
这个命令做了三件事:
托盘图标通常需要右键菜单来提供快捷操作:
xml复制<tb:TaskbarIcon.ContextMenu>
<ContextMenu>
<MenuItem Header="打开主界面" Command="{Binding ShowWindowCommand}"/>
<MenuItem Header="退出" Command="{Binding ExitCommand}"/>
<Separator/>
<MenuItem Header="版本信息" Command="{Binding ShowVersionCommand}"/>
</ContextMenu>
</tb:TaskbarIcon.ContextMenu>
实操技巧:菜单项建议使用命令绑定而非事件处理,这样更符合MVVM模式,也便于单元测试。
当应用有重要通知时,可以通过气泡提示来提醒用户:
csharp复制var notifyIcon = (TaskbarIcon)FindResource("MyNotifyIcon");
notifyIcon.ShowBalloonTip("新消息", "您有3条未读警报", BalloonIcon.Info);
气泡通知的最佳实践:
在多显示器环境下,需要特别注意窗口恢复时的位置:
csharp复制// 在恢复窗口时检查显示器可用区域
var screen = Screen.FromHandle(new WindowInteropHelper(this).Handle);
this.Left = screen.WorkingArea.Left + 100;
this.Top = screen.WorkingArea.Top + 100;
问题现象:托盘图标显示为空白或默认图标
典型场景:应用退出后托盘图标仍然残留
csharp复制protected override void OnExit(ExitEventArgs e)
{
var notifyIcon = (TaskbarIcon)FindResource("MyNotifyIcon");
notifyIcon.Dispose();
base.OnExit(e);
}
症状:在高DPI显示器上图标模糊
解决方案:
避免在StateChanged事件中执行耗时操作。如果需要,可以使用Dispatcher:
csharp复制Dispatcher.BeginInvoke(new Action(() => {
// 耗时操作
}), DispatcherPriority.Background);
托盘图标的操作必须在UI线程执行。如果从后台线程调用,需要使用Dispatcher.Invoke:
csharp复制Application.Current.Dispatcher.Invoke(() => {
var notifyIcon = (TaskbarIcon)FindResource("MyNotifyIcon");
notifyIcon.ShowBalloonTip(...);
});
在医疗监控项目中,我们遇到了几个教科书上没提到的问题:
紧急恢复机制:当主界面因异常关闭时,通过托盘图标仍能重新初始化界面。我们在TaskbarIcon的DataContext中增加了健康检查逻辑,定期验证主窗口状态。
权限问题:在受限制的用户账户下,Shell_NotifyIcon可能失败。解决方案是:
多实例处理:当应用允许运行多个实例时,每个实例的托盘图标需要唯一标识。我们的做法是将进程ID编码到ToolTipText中:
csharp复制ToolTipText = $"医疗监控系统 (PID:{Process.GetCurrentProcess().Id})";
csharp复制public void UpdateIconForTheme()
{
var isDark = ThemeHelper.IsDarkThemeEnabled();
var icon = (TaskbarIcon)FindResource("MyNotifyIcon");
icon.IconSource = isDark
? new Uri("pack://application:,,,/Assets/app-dark.ico")
: new Uri("pack://application:,,,/Assets/app-light.ico");
}
这个WPF系统托盘实现方案已经在我们多个生产环境中稳定运行超过2年,每天处理数千次最小化/恢复操作。关键是要处理好边界条件和异常情况,确保在各种使用场景下都能提供一致的用户体验。