WPF 系统托盘与全局热键
大约 9 分钟约 2776 字
WPF 系统托盘与全局热键
简介
系统托盘(NotifyIcon)允许应用最小化到任务栏通知区域继续运行。全局热键让应用在非激活状态下也能响应快捷键。WPF 没有内置 NotifyIcon,需要使用 WinForms 的 NotifyIcon 组件或第三方库 Hardcodet.NotifyIcon.Wpf。
特点
系统托盘 — 使用 Hardcodet.NotifyIcon.Wpf
安装
dotnet add package Hardcodet.NotifyIcon.Wpf基本用法
<!-- 在 App.xaml 中声明 TaskbarIcon -->
<Application x:Class="MyApp.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:tb="http://www.hardcodet.net/taskbar">
<Application.Resources>
<!-- 托盘图标定义 -->
<tb:TaskbarIcon x:Key="TrayIcon"
IconSource="/Assets/app.ico"
ToolTipText="我的应用"
TrayMouseDoubleClick="TrayIcon_DoubleClick">
<tb:TaskbarIcon.ContextMenu>
<ContextMenu>
<MenuItem Header="显示主窗口" Click="ShowWindow_Click"/>
<MenuItem Header="设置" Click="Settings_Click"/>
<Separator/>
<MenuItem Header="关于" Click="About_Click"/>
<Separator/>
<MenuItem Header="退出" Click="Exit_Click"/>
</ContextMenu>
</tb:TaskbarIcon.ContextMenu>
</tb:TaskbarIcon>
</Application.Resources>
</Application>/// <summary>
/// App.xaml.cs — 管理托盘图标和窗口生命周期
/// </summary>
public partial class App : Application
{
private TaskbarIcon? _notifyIcon;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// 从资源获取托盘图标
_notifyIcon = (TaskbarIcon)FindResource("TrayIcon");
// 显示气泡通知
_notifyIcon?.ShowBalloonTip("提示", "应用已启动,最小化到托盘", BalloonIcon.Info);
}
protected override void OnExit(ExitEventArgs e)
{
_notifyIcon?.Dispose();
base.OnExit(e);
}
// 双击托盘图标 — 显示/隐藏窗口
private void TrayIcon_DoubleClick(object sender, RoutedEventArgs e)
{
ToggleMainWindow();
}
// 显示主窗口
private void ShowWindow_Click(object sender, RoutedEventArgs e)
{
ShowMainWindow();
}
private void Settings_Click(object sender, RoutedEventArgs e)
{
var settingsWindow = new SettingsWindow();
settingsWindow.Show();
}
private void About_Click(object sender, RoutedEventArgs e)
{
MessageBox.Show("我的应用 v1.0", "关于");
}
// 退出
private void Exit_Click(object sender, RoutedEventArgs e)
{
_notifyIcon?.Dispose();
Shutdown();
}
private void ShowMainWindow()
{
var window = Current.MainWindow;
if (window == null) return;
window.Show();
window.WindowState = WindowState.Normal;
window.Activate();
}
private void ToggleMainWindow()
{
var window = Current.MainWindow;
if (window == null) return;
if (window.IsVisible)
{
window.Hide();
}
else
{
window.Show();
window.WindowState = WindowState.Normal;
window.Activate();
}
}
}关闭时最小化到托盘
/// <summary>
/// 主窗口 — 关闭时最小化到托盘而非退出
/// </summary>
public partial class MainWindow : Window
{
private bool _isReallyClosing;
public MainWindow()
{
InitializeComponent();
}
// 拦截关闭事件 — 最小化到托盘
protected override void OnClosing(CancelEventArgs e)
{
if (!_isReallyClosing)
{
e.Cancel = true;
Hide();
}
base.OnClosing(e);
}
// 真正关闭应用
public void ReallyClose()
{
_isReallyClosing = true;
Close();
}
}使用 WinForms NotifyIcon
无第三方依赖方式
/// <summary>
/// 使用 System.Windows.Forms.NotifyIcon
/// </summary>
public class TrayIconService : IDisposable
{
private readonly NotifyIcon _notifyIcon;
public TrayIconService()
{
_notifyIcon = new NotifyIcon
{
Icon = SystemIcons.Application,
Text = "我的应用",
Visible = true
};
// 右键菜单
var menu = new ContextMenuStrip();
menu.Items.Add("显示主窗口", null, (s, e) => ShowMainWindow?.Invoke());
menu.Items.Add("设置", null, (s, e) => OpenSettings?.Invoke());
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("退出", null, (s, e) => ExitApplication?.Invoke());
_notifyIcon.ContextMenuStrip = menu;
// 双击事件
_notifyIcon.DoubleClick += (s, e) => ShowMainWindow?.Invoke();
// 气泡通知
_notifyIcon.BalloonTipTitle = "提示";
_notifyIcon.BalloonTipText = "应用已最小化到托盘";
_notifyIcon.ShowBalloonTip(3000);
}
public event Action? ShowMainWindow;
public event Action? OpenSettings;
public event Action? ExitApplication;
public void ShowNotification(string title, string message)
{
_notifyIcon.BalloonTipTitle = title;
_notifyIcon.BalloonTipText = message;
_notifyIcon.ShowBalloonTip(3000);
}
public void UpdateIcon(Icon icon)
{
_notifyIcon.Icon = icon;
}
public void Dispose()
{
_notifyIcon.Visible = false;
_notifyIcon.Dispose();
}
}
// 注册
// builder.Services.AddSingleton<TrayIconService>();全局热键
注册全局热键
/// <summary>
/// 全局热键 — 即使应用不在前台也能响应
/// 使用 Win32 API 注册
/// </summary>
public class GlobalHotKey : IDisposable
{
[DllImport("user32.dll")]
private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
[DllImport("user32.dll")]
private static extern bool UnregisterHotKey(IntPtr hWnd, int id);
private readonly Dictionary<int, Action> _hotKeys = new();
private HwndSource? _source;
private int _currentId;
// 修饰键
public const uint MOD_ALT = 0x0001;
public const uint MOD_CONTROL = 0x0002;
public const uint MOD_SHIFT = 0x0004;
public const uint MOD_WIN = 0x0008;
/// <summary>
/// 注册全局热键
/// </summary>
public void Register(Window window, uint modifiers, Key key, Action callback)
{
if (_source == null)
{
_source = PresentationSource.FromVisual(window) as HwndSource;
_source?.AddHook(HwndHook);
}
var virtualKey = KeyInterop.VirtualKeyFromKey(key);
_currentId++;
if (RegisterHotKey(_source!.Handle, _currentId, modifiers, (uint)virtualKey))
{
_hotKeys[_currentId] = callback;
}
}
private IntPtr HwndHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
const int WM_HOTKEY = 0x0312;
if (msg == WM_HOTKEY)
{
var id = wParam.ToInt32();
if (_hotKeys.TryGetValue(id, out var callback))
{
callback();
handled = true;
}
}
return IntPtr.Zero;
}
public void Dispose()
{
if (_source != null)
{
foreach (var id in _hotKeys.Keys)
{
UnregisterHotKey(_source.Handle, id);
}
_source.RemoveHook(HwndHook);
_source = null;
}
_hotKeys.Clear();
}
}使用全局热键
/// <summary>
/// 在 MainWindow 中注册全局热键
/// </summary>
public partial class MainWindow : Window
{
private readonly GlobalHotKey _hotKey = new();
private readonly TrayIconService _trayService;
public MainWindow(TrayIconService trayService)
{
InitializeComponent();
_trayService = trayService;
Loaded += MainWindow_Loaded;
}
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
// 注册 Ctrl+Alt+S — 显示/隐藏窗口
_hotKey.Register(this, GlobalHotKey.MOD_CONTROL | GlobalHotKey.MOD_ALT, Key.S, () =>
{
if (IsVisible)
Hide();
else
{
Show();
WindowState = WindowState.Normal;
Activate();
}
});
// 注册 Ctrl+Alt+P — 截屏
_hotKey.Register(this, GlobalHotKey.MOD_CONTROL | GlobalHotKey.MOD_ALT, Key.P, () =>
{
TakeScreenshot();
});
}
private void TakeScreenshot()
{
// 截屏逻辑
_trayService.ShowNotification("截屏", "截图已保存到剪贴板");
}
protected override void OnClosed(EventArgs e)
{
_hotKey.Dispose();
base.OnClosed(e);
}
}常用托盘功能
气泡通知
/// <summary>
/// 不同类型的气泡通知
/// </summary>
public class NotificationService
{
private readonly TaskbarIcon _icon;
public NotificationService(TaskbarIcon icon)
{
_icon = icon;
}
public void ShowInfo(string title, string message)
=> _icon.ShowBalloonTip(title, message, BalloonIcon.Info);
public void ShowWarning(string title, string message)
=> _icon.ShowBalloonTip(title, message, BalloonIcon.Warning);
public void ShowError(string title, string message)
=> _icon.ShowBalloonTip(title, message, BalloonIcon.Error);
// 自定义通知弹出
public void ShowCustomNotification(string title, string message)
{
var popup = new Window
{
Title = title,
Width = 300,
Height = 100,
WindowStyle = WindowStyle.None,
AllowsTransparency = true,
Background = new SolidColorBrush(Color.FromArgb(230, 44, 62, 80)),
Topmost = true,
ShowInTaskbar = false
};
var screen = SystemParameters.WorkArea;
popup.Left = screen.Right - 320;
popup.Top = screen.Bottom - 120;
var text = new TextBlock
{
Text = message,
Foreground = Brushes.White,
FontSize = 14,
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(15)
};
popup.Content = text;
popup.Show();
// 3秒后自动关闭
Task.Delay(3000).ContinueWith(_ =>
{
Dispatcher.Invoke(() => popup.Close());
});
}
}优点
缺点
总结
系统托盘和全局热键是桌面应用的常见需求。推荐使用 Hardcodet.NotifyIcon.Wpf 实现托盘功能,全局热键通过 Win32 API 注册。核心原则:关闭窗口时最小化到托盘,提供右键菜单和气泡通知,全局热键增强操作效率。
关键知识点
- 先分清主题属于界面层、ViewModel 层、线程模型还是设备接入层。
- WPF 文章真正的价值在于把 UI、数据、命令、线程和资源关系讲清楚。
- 上位机场景里,稳定性和异常恢复常常比界面花哨更重要。
- WPF 主题往往要同时理解依赖属性、绑定、可视树和命令系统。
项目落地视角
- 优先明确 DataContext、绑定路径、命令源和线程切换位置。
- 涉及设备时,补齐超时、重连、日志、告警和资源释放策略。
- 复杂控件最好提供最小可运行页面,便于后续复用和排障。
- 优先确认 DataContext、绑定路径、命令触发点和资源引用来源。
常见误区
- 把大量逻辑堆进 code-behind,导致页面难测难维护。
- 在后台线程直接操作 UI,或忽略 Dispatcher 切换。
- 只验证正常路径,不验证设备掉线、权限缺失和资源泄漏。
- 在 code-behind 塞太多状态与业务逻辑。
进阶路线
- 继续向 MVVM 工具链、控件可复用性、性能优化和视觉树诊断深入。
- 把主题放回真实设备流程,思考启动、连接、采集、显示、告警和恢复。
- 沉淀成控件库、通信层和诊断工具,提高整套客户端的复用度。
- 继续补齐控件库、主题系统、诊断工具和启动性能优化。
适用场景
- 当你准备把《WPF 系统托盘与全局热键》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合桌面业务系统、监控看板、工控上位机和设备交互界面。
- 当系统需要同时处理复杂 UI、后台任务和硬件通信时,这类主题尤为关键。
落地建议
- 优先明确 View、ViewModel、服务层和设备层边界,避免代码隐藏过重。
- 涉及实时刷新时,提前设计 UI 线程切换、节流和资源释放。
- 对硬件通信、日志、告警和异常恢复建立标准流程。
排错清单
- 先检查 DataContext、绑定路径、INotifyPropertyChanged 和命令状态是否正常。
- 排查 Dispatcher 调用、死锁、后台线程直接操作 UI 的问题。
- 出现内存增长时,优先检查事件订阅、图像资源和窗口生命周期。
复盘问题
- 如果把《WPF 系统托盘与全局热键》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《WPF 系统托盘与全局热键》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《WPF 系统托盘与全局热键》最大的收益和代价分别是什么?
MVVM 模式实现托盘功能
ViewModel 完整实现
/// <summary>
/// 系统托盘 ViewModel — MVVM 模式
/// </summary>
public class TrayIconViewModel : ObservableObject, IDisposable
{
private readonly TaskbarIcon _notifyIcon;
private readonly GlobalHotKey _hotKey;
private bool _isReallyClosing;
// 绑定属性
private string _statusText = "就绪";
public string StatusText
{
get => _statusText;
set => SetProperty(ref _statusText, value);
}
private bool _isRunning;
public bool IsRunning
{
get => _isRunning;
set => SetProperty(ref _isRunning, value);
}
private int _messageCount;
public int MessageCount
{
get => _messageCount;
set => SetProperty(ref _messageCount, value);
}
// 命令
public ICommand ShowWindowCommand { get; }
public ICommand ExitCommand { get; }
public ICommand ToggleStatusCommand { get; }
public ICommand OpenSettingsCommand { get; }
public TrayIconViewModel(TaskbarIcon notifyIcon)
{
_notifyIcon = notifyIcon;
_hotKey = new GlobalHotKey();
ShowWindowCommand = new RelayCommand(ShowMainWindow);
ExitCommand = new RelayCommand(ExitApplication);
ToggleStatusCommand = new RelayCommand(ToggleStatus);
OpenSettingsCommand = new RelayCommand(OpenSettings);
// 绑定托盘图标事件
_notifyIcon.TrayMouseDoubleClick += OnTrayDoubleClick;
}
private void OnTrayDoubleClick(object sender, RoutedEventArgs e)
{
ShowMainWindow();
}
private void ShowMainWindow()
{
Application.Current.Dispatcher.Invoke(() =>
{
var window = Application.Current.MainWindow;
if (window == null) return;
window.Show();
window.WindowState = WindowState.Normal;
window.Activate();
window.Topmost = true;
window.Topmost = false; // 激活后取消置顶
});
}
private void ExitApplication()
{
_isReallyClosing = true;
Application.Current.Dispatcher.Invoke(() =>
{
Application.Current.Shutdown();
});
}
private void ToggleStatus()
{
IsRunning = !IsRunning;
StatusText = IsRunning ? "运行中" : "已暂停";
UpdateToolTip();
}
private void OpenSettings()
{
Application.Current.Dispatcher.Invoke(() =>
{
var settingsWindow = new SettingsWindow();
settingsWindow.Owner = Application.Current.MainWindow;
settingsWindow.ShowDialog();
});
}
public void UpdateToolTip()
{
var toolTip = $"我的应用\n状态: {StatusText}";
if (MessageCount > 0)
toolTip += $"\n未读消息: {MessageCount}";
_notifyIcon.ToolTipText = toolTip;
}
public void ShowNotification(string title, string message, BalloonIcon icon = BalloonIcon.Info)
{
_notifyIcon.ShowBalloonTip(title, message, icon);
}
public void RegisterHotKeys(Window window)
{
_hotKey.Register(window, GlobalHotKey.MOD_CONTROL | GlobalHotKey.MOD_ALT, Key.S, () =>
{
ShowMainWindow();
});
_hotKey.Register(window, GlobalHotKey.MOD_CONTROL | GlobalHotKey.MOD_ALT, Key.Q, () =>
{
ExitApplication();
});
}
public void Dispose()
{
_hotKey.Dispose();
_notifyIcon?.Dispose();
}
}动态上下文菜单
<!-- 动态上下文菜单 — 支持命令绑定 -->
<tb:TaskbarIcon x:Key="TrayIcon"
IconSource="/Assets/app.ico"
ToolTipText="我的应用"
TrayMouseDoubleClick="TrayIcon_DoubleClick">
<tb:TaskbarIcon.ContextMenu>
<ContextMenu>
<MenuItem Header="{Binding StatusText}"
Command="{Binding ToggleStatusCommand}"
IsCheckable="True"
IsChecked="{Binding IsRunning}"/>
<Separator/>
<MenuItem Header="显示主窗口"
Command="{Binding ShowWindowCommand}"
InputGestureText="Ctrl+Alt+S"/>
<MenuItem Header="设置"
Command="{Binding OpenSettingsCommand}"/>
<Separator/>
<MenuItem Header="未读消息"
HeaderStringFormat="未读消息 ({0})"
Visibility="{Binding MessageCount, Converter={StaticResource CountToVisibilityConverter}}">
<MenuItem.Icon>
<Path Data="{StaticResource BellIcon}"
Fill="Orange"
Width="16" Height="16"/>
</MenuItem.Icon>
</MenuItem>
<Separator/>
<MenuItem Header="退出"
Command="{Binding ExitCommand}"
InputGestureText="Ctrl+Alt+Q">
<MenuItem.Icon>
<Path Data="{StaticResource CloseIcon}"
Fill="Red"
Width="16" Height="16"/>
</MenuItem.Icon>
</MenuItem>
</ContextMenu>
</tb:TaskbarIcon.ContextMenu>
</tb:TaskbarIcon>托盘图标的动态切换
/// <summary>
/// 动态切换托盘图标 — 反映应用状态
/// </summary>
public class TrayIconManager : IDisposable
{
private readonly TaskbarIcon _notifyIcon;
private readonly Dictionary<string, Icon> _icons = new();
public TrayIconManager(TaskbarIcon notifyIcon)
{
_notifyIcon = notifyIcon;
LoadIcons();
}
private void LoadIcons()
{
// 预加载所有状态图标
_icons["normal"] = new Icon("Assets/app.ico");
_icons["warning"] = new Icon("Assets/app_warning.ico");
_icons["error"] = new Icon("Assets/app_error.ico");
_icons["busy"] = CreateStatusIcon(Colors.Orange);
_icons["idle"] = CreateStatusIcon(Colors.Gray);
_icons["success"] = CreateStatusIcon(Colors.Green);
}
public void SetStatus(AppStatus status)
{
var iconKey = status switch
{
AppStatus.Running => "success",
AppStatus.Warning => "warning",
AppStatus.Error => "error",
AppStatus.Busy => "busy",
AppStatus.Idle => "idle",
_ => "normal"
};
_notifyIcon.Icon = _icons[iconKey];
_notifyIcon.ToolTipText = $"我的应用 - {GetStatusText(status)}";
}
private static Icon CreateStatusIcon(Color color)
{
// 使用 DrawingVisual 动态生成图标
var width = 32;
var height = 32;
var renderTarget = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32);
var visual = new DrawingVisual();
using (var context = visual.RenderOpen())
{
var brush = new SolidColorBrush(color);
context.DrawEllipse(brush, null, new Point(16, 16), 14, 14);
}
renderTarget.Render(visual);
// 转换为 Icon
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(renderTarget));
using var ms = new MemoryStream();
encoder.Save(ms);
ms.Position = 0;
return new Icon(ms);
}
private static string GetStatusText(AppStatus status) => status switch
{
AppStatus.Running => "运行中",
AppStatus.Warning => "警告",
AppStatus.Error => "错误",
AppStatus.Busy => "忙碌",
AppStatus.Idle => "空闲",
_ => "未知"
};
public void Dispose()
{
foreach (var icon in _icons.Values)
icon.Dispose();
}
}
public enum AppStatus { Running, Warning, Error, Busy, Idle }