🖥️ WPF 概览
Snet.Windows 是一个面向 .NET 8/10 Windows 桌面应用的现代化 WPF UI 库。它提供了干净、可直接用于生产环境的基础设施,帮助您以最少的样板代码构建丰富的 WPF 应用程序。
📦 包
| 包名 | 版本 | 描述 |
|---|---|---|
Snet.Windows.Core |
— | 基础库 — MVVM、主题、本地化、依赖注入、DPI 感知 |
Snet.Windows.Controls |
— | 控件库 — 自定义控件、属性网格、消息框、数据模型 |
目标框架
net8.0-windowsnet10.0-windows
依赖项
| 依赖 | 版本 |
|---|---|
| WPF-UI | 4.3.0 |
| MaterialDesignThemes | 5.3.2 |
| CommunityToolkit.Mvvm | 8.4.2 |
| Snet.Core | — |
| System.Management | 10.0.10 |
| System.Drawing.Common | 10.0.10 |
✨ 功能特性
| 功能 | 描述 |
|---|---|
| MVVM | 基于表达式的属性容器(BindNotify)+ CommunityToolkit.Mvvm |
| 深色/浅色主题 | 一键切换主题,持久化至 config/skin.json |
| 多语言 | 简体中文(zh)/ 英文(en),基于 RESX 资源 |
| 自定义控件 | ButtonControl、TextBoxControl、ComboBoxControl、PropertyControl |
| 属性网格 | 基于特性的属性编辑器,支持导入/导出 |
| DPI 感知 | 通过 WindowBase 实现每个显示器 DPI 感知和 DWM 颜色同步 |
| 系统托盘 | 内置系统托盘支持 |
| DI 集成 | 为 Window、UserControl 和 Page 提供依赖注入 |
许可证
MIT — 个人和商业用途均免费。
▶️ 快速开始
安装控件包 — 它会自动拉取 Snet.Windows.Core:
dotnet add package Snet.Windows.Controls
创建一个支持主题和语言切换的最小化窗口:
MainWindow.xaml
<snet:WindowBase
xmlns:snet="https://snet.cn"
LanguageEnabled="True"
SkinEnabled="True"
LoadAnimationEnabled="True"
TitleLeft="True"
VerEnabled="True">
<Grid>
<snet:ButtonControl
Content="点击我"
Command="{Binding Hello}" />
</Grid>
</snet:WindowBase>
MainWindow.xaml.cs
public partial class MainWindow : WindowBase
{
public MainWindow()
{
InitializeComponent();
}
}
仅此而已——主题切换、语言切换、加载动画和版本显示均由基类自动处理。
⚙️ 安装与配置
NuGet 包
| 包名 | 描述 | 依赖项 |
|---|---|---|
Snet.Windows.Core |
基础功能:MVVM 基础设施、主题、本地化、依赖注入、DPI 助手、系统托盘 | Snet.Core、WPF-UI、MaterialDesignThemes、CommunityToolkit.Mvvm |
Snet.Windows.Controls |
控件:自定义控件、属性网格、消息框、下拉框模型、选项卡模型 | Snet.Windows.Core |
安装命令:
# 仅核心库(用于无界面或自定义 UI)
dotnet add package Snet.Windows.Core
# 控件库(推荐 — 会传递性地包含 Core)
dotnet add package Snet.Windows.Controls
配置文件
库会在运行时写入两个配置文件:
| 文件 | 用途 |
|---|---|
config/skin.json |
持久化用户的主题选择(Dark / Light) |
config/language.json |
持久化用户的语言选择(zh / en) |
无需手动配置,文件会在首次启动时自动创建。
🧠 核心概念
1. MVVM 模式
Snet.Windows 提供了两种互补的 MVVM 方法:
BindNotify — 基于表达式的属性容器
无需声明后台字段。属性值存储在以表达式树为键的内部字典中:
public class MyViewModel : BindNotify
{
public string Name
{
get => GetProperty(() => Name);
set => SetProperty(() => Name, value);
}
public int Count
{
get => GetProperty(() => Count);
set => SetProperty(() => Count, value);
}
}
GetProperty<T>() 和 SetProperty<T>() 使用表达式 () => PropertyName 作为键。这消除了声明私有后台字段的需要,减少了样板代码。
CommunityToolkit.Mvvm
对于需要 ObservableObject、AsyncRelayCommand 或 ObservableValidator 的场景,该库与 CommunityToolkit.Mvvm 完全互操作。BindNotify 本身继承自 ObservableObject。
public class MyViewModel : BindNotify
{
public IAsyncRelayCommand SaveCommand => new AsyncRelayCommand(async () =>
{
// 异步逻辑
});
}
2. 深色/浅色主题
SkinHandler 管理 MaterialDesignThemes 和 WPF-UI 主题资源:
// 设置深色主题
SkinHandler.SetSkin(SkinType.Dark);
// 设置浅色主题
SkinHandler.SetSkin(SkinType.Light);
// 切换
var current = SkinHandler.GetSkin();
SkinHandler.SetSkin(current == SkinType.Dark ? SkinType.Light : SkinType.Dark);
主题选择会自动持久化到 config/skin.json。下次启动时,窗口会恢复已保存的主题。
事件: 订阅 SkinHandler.OnSkinEvent 或 SkinHandler.OnSkinEventAsync 以响应主题变化(例如,重新加载图标)。
3. 多语言
LanguageHandler 在**简体中文(zh)和英文(en)**之间切换:
// 设置语言
LanguageHandler.SetLanguage(LanguageType.zh); // 中文
LanguageHandler.SetLanguage(LanguageType.en); // 英文
// 获取当前语言
var lang = LanguageHandler.GetLanguage();
// 获取本地化文本
var value = LanguageHandler.GetLanguageValue("HelloWorld");
内部使用 LocalizeDictionary 进行基于 WPF 资源的本地化,并结合 RESX 文件存储字符串资源。添加新语言时,只需添加一个包含翻译字符串的 Language.xx.resx 资源文件。
4. 依赖注入
InjectionWpf 为 MVVM 提供了轻量级的 DI 容器:
// 创建 Window 及其 ViewModel
var window = InjectionWpf.Window<MyWindow, MyViewModel>();
// 创建 UserControl 及其 ViewModel
var control = InjectionWpf.UserControl<MyControl, MyViewModel>();
// 创建 Page 及其 ViewModel
var page = InjectionWpf.Page<MyPage, MyViewModel>();
cache 参数控制实例是重用(单例模式)还是每次重新创建。
5. DPI 感知
WindowBase 自动处理每个显示器的 DPI 变化:
- 拦截
WM_GETMINMAXINFO以根据 DPI 缩放调整最小/最大跟踪尺寸 - 将窗口标题栏颜色与当前 DWM 强调色同步
- 无需额外代码 —— 只需继承
WindowBase
6. 自定义控件
Snet.Windows.Controls 包在 MaterialDesign 原生控件之上提供了可扩展的封装。每个控件都是可直接拖放的 UserControl,具有支持数据绑定的依赖属性和可选的图标。
🎓 示例项目(GitHub)
以下三个开源应用均基于 Snet.Windows 构建,是真实的生产应用。直接阅读它们的源码是学习本库实战用法最快的途径——你会看到与本指南完全一致的 WindowBase 根窗口、BindNotify 视图模型、InjectionWpf 装配与 {snet:Loc} 本地化写法,应用于真实业务场景:
| 仓库 | GitHub 链接 | 用途 | 值得学习的点 |
|---|---|---|---|
| Daq | shunnet/Daq | 插件化工业物联网数据采集转发工具(OPC UA/DA、Modbus、MQTT、Kafka、NetMQ、数据库、大部分 PLC) | 插件化热插拔架构、系统托盘 + 单实例、主题感知图表 |
| Debug | shunnet/Debug | 多协议工业通信调试诊断工具(40+ 协议驱动) | 40+ 协议包集成、多标签页文档界面、EventCommand 事件绑定 |
| KMSim | shunnet/KMSim | Windows 可编程键鼠模拟器(脚本化自动化) | 硬件监控(CPU/GPU/RAM)、ScottPlot 实时图表、编辑器控件集成 |
以下所有代码片段均摘自仓库真实源码(
...为省略行),命名空间与类型名与源码逐字符一致。
Daq — 插件化工业物联网数据采集
Snet.Iot.Daq 是基于 Snet 工业通信栈开发的插件化数据采集与转发工具。UI 项目(Snet.Iot.Daq)引用 Snet.Windows.Controls 26.250.2,并采用与本指南完全一致的结构:App.xaml 合并库的默认主题、MainWindow 继承 WindowBase、MainWindowModel 继承 BindNotify。核心工程(Snet.Iot.Daq.Core)承载插件引擎、业务模型与服务,完全不依赖 WPF——可被 Avalonia 等跨平台框架复用。
App.xaml — 合并库的默认主题(三个应用完全一致):
<Application
x:Class="Snet.Iot.Daq.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Snet.Iot.Daq"
Exit="OnExit"
Startup="OnStartup">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<!-- 加载默认样式 -->
<ResourceDictionary Source="pack://application:,,,/Snet.Windows.Core;component/themes/DarkTheme.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
MainWindow.xaml — WindowBase 根窗口 + 导航 + 系统托盘;本地化通过 ResxLocalizationProvider 附加属性与 {snet:Loc} 标记扩展接入:
<snet:WindowBase
x:Class="Snet.Iot.Daq.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:snet="https://snet.cn"
xmlns:uis="http://schemas.lepo.co/wpfui/2022/xaml"
Title="{Binding SystemTitle}"
snet:ResxLocalizationProvider.DefaultAssembly="Snet.Iot.Daq.Core"
snet:ResxLocalizationProvider.DefaultDictionary="Language"
AnimationTime="4000"
LoadAnimationEnabled="True"
VerEnabled="True"
WindowStartupLocation="CenterScreen">
<Grid x:Name="mainGrid">
<uis:NavigationView
x:Name="NavigationViewControls"
FooterMenuItemsSource="{Binding FooterMenuItemsSource}"
IsBackButtonVisible="Auto"
IsPaneToggleVisible="True"
MenuItemsSource="{Binding MenuItemsSource}"
OpenPaneLength="260" />
<!-- 托盘 -->
<snet:NotifyIcon
x:Name="TrayIcon"
FocusOnLeftClick="True"
Icon="/icon.ico"
LeftClick="TrayIcon_LeftClick"
MenuOnRightClick="True"
TooltipText="{snet:Loc SystemTitle}">
<snet:NotifyIcon.Menu>
<ContextMenu x:Name="TrayContextMenu" Style="{StaticResource UiContextMenu}">
<!-- 设备状态项将在代码中动态添加到此分隔符之前 -->
<Separator x:Name="TrayDeviceSeparator" Margin="0,3" />
<uis:MenuItem
snet:ResxLocalizationProvider.DefaultAssembly="Snet.Iot.Daq.Core"
snet:ResxLocalizationProvider.DefaultDictionary="Language"
Command="{Binding ShowWindow}"
Header="{snet:Loc 显示窗口}"
Icon="{uis:SymbolIcon Symbol=Window20}" />
<!-- ... -->
</ContextMenu>
</snet:NotifyIcon.Menu>
</snet:NotifyIcon>
</Grid>
</snet:WindowBase>
MainWindow 还重写了 WindowBase.OnClosing,实现"关闭时隐藏到系统托盘而非退出":
/// <summary>
/// 重写窗口关闭行为:非强制关闭时,将窗口隐藏到系统托盘而非真正关闭
/// </summary>
/// <param name="e">关闭事件参数,可通过 Cancel 属性取消关闭</param>
protected override void OnClosing(CancelEventArgs e)
{
if (!IsForceClose)
{
e.Cancel = true;
Hide();
ShowInTaskbar = false;
return;
}
base.OnClosing(e);
}
MainWindowModel.cs — BindNotify 视图模型:使用 GetProperty/SetProperty 表达式属性,通过 WpfUiHandler.CreationControl 构建导航菜单,并通过 LanguageHandler.OnLanguageEventAsync 响应语言切换刷新标题:
public class MainWindowModel : BindNotify
{
public MainWindowModel(SettingsHandler settings)
{
// 初始化菜单项数据源
MenuItemsSource = MenuItemsOperate(App.LanguageOperate);
FooterMenuItemsSource = FooterMenuItemsOperate(App.LanguageOperate);
this._settings = settings;
LanguageHandler.OnLanguageEventAsync += LanguageHandler_OnLanguageEventAsync;
LanguageHandler_OnLanguageEventAsync(this, new EventLanguageResult()).Wait();
}
/// <summary>
/// 系统标题
/// </summary>
public string SystemTitle
{
get => GetProperty(() => SystemTitle);
set => SetProperty(() => SystemTitle, value);
}
/// <summary>
/// 创建主菜单项集合
/// </summary>
public ObservableCollection<object> MenuItemsOperate(LanguageModel model) => new(){
WpfUiHandler.CreationControl("主页", SymbolRegular.Home24, typeof(Home),true,model),
WpfUiHandler.CreationControl("插件浏览", SymbolRegular.GlobeSurface24, typeof(PluginBrowse),true,model),
// ...
WpfUiHandler.CreationControl("控制台", SymbolRegular.WindowConsole20, typeof(Snet.Iot.Daq.view.Console),true,model),
};
}
App.xaml.cs — 启动流程:单实例保护 → 全局异常捕捉 → 依赖注入 → 图标加载 → 插件初始化 → 打开主窗口:
private void OnStartup(object sender, StartupEventArgs e)
{
//判断是不是唯一打开
SingleInstance(e);
// 初始化依赖注入、数据库、插件等
Init();
// 启动全局异常捕捉
RegisterEvents();
// 加载本地自定义图标资源
IconsHandler.Loading("pack://application:,,,/Snet.Iot.Daq;component/resources/icons.xaml");
// 打开主窗口
MainWindow window = InjectionWpf.Window<MainWindow, MainWindowModel>(true);
window.Show();
// Show() 之后窗口的 HWND 才真正创建
// 此时立即缓存句柄,后续即使窗口 Hide 到托盘也能唤醒
_singleInstance.RegisterMainWindow(window);
}
private void Init()
{
// 注入参数设置控件
PropertyControl control = new PropertyControl();
control.ButtonVisibility = Visibility.Visible;
InjectionWpf.AddService(s =>
{
s.AddSingleton(control);
});
// ...
// 加载并初始化所有已配置的插件
ObservableCollection<PluginListModel> plugins = PluginHandlerCore.GetPluginUIConfig<ObservableCollection<PluginListModel>>(GlobalConfigModel.UI_PluginListConfigPath) ?? new();
//初始化插件
foreach (var item in plugins)
{
PluginHandlerCore.PluginOperate.InitPlugin(item.PluginDetails.Path, string.Format(GlobalConfigModel.InterfaceFullName, item.Type));
}
// ...
//注入系统操作
InjectionWpf.AddService(s =>
{
s.AddSingleton(new SettingsHandler());
});
}
插件引擎位于 Snet.Iot.Daq.Core 的 PluginHandlerCore 中:它封装 Snet.Core.plugin 的 PluginOperate,通过泛型扩展方法按接口创建采集(IDaq)或传输(IMq)实例,用于测试读写/生产等操作:
/// <summary>
/// 通过插件配置创建新的设备实例
/// </summary>
public static async Task<T?> CreateNewObjectAsync<T>(this PluginConfigModel plugin)
=> await PluginOperate.CreateAsync<T>(plugin.Name, plugin.Param, plugin.Type);
Debug — 多协议调试诊断工具
Snet.Iot.Debug 是 Snet 工业通信栈的配套调试工具:除 UI 包外,它还引用了全套协议包(Snet.Modbus、Snet.Siemens、Snet.Opc、Snet.Mqtt、Snet.RabbitMQ、Snet.Kafka 等 40+ 驱动)。其协议页面(Daq.xaml、Mq.xaml、OpcUaNodeBrowsing.xaml 等)展示了库控件的典型用法——TextBoxControl、ButtonControl、ComboBoxControl、PropertyControl 配合 Hint/Icon 与 {snet:Loc} 本地化字符串:
<!-- 节点地址 -->
<snet:TextBoxControl
Grid.Row="0"
Grid.Column="0"
Margin="5,0,0,0"
Hint="{snet:Loc 地址}"
Icon="{DynamicResource Address}"
Text="{Binding Address}" />
<!-- 读取 -->
<snet:ButtonControl
Grid.Row="0"
Grid.Column="2"
Width="80"
Margin="10,0,10,0"
Command="{Binding Read}"
Content="{snet:Loc 读取}"
CornerRadius="{DynamicResource WindowCornerRadius}"
Icon="{DynamicResource Read}" />
参数网格是绑定到模型的 PropertyControl:
<snet:PropertyControl
Margin="10,0,0,0"
BasicsData="{Binding BasicsData}"
ExpCommand="{x:Null}"
IncCommand="{x:Null}" />
所有协议页面以 Transient 方式注册进 DI 容器,主窗口通过 InjectionWpf.Window 打开:
private void OnStartup(object sender, StartupEventArgs e)
{
// 注入参数设置控件
PropertyControl control = new PropertyControl();
control.ButtonVisibility = Visibility.Visible;
InjectionWpf.AddService(s =>
{
s.AddSingleton(control);
s.AddSingleton<About>();
s.AddTransient<Daq>();
s.AddTransient<OpcUaService>();
s.AddTransient<Mq>();
s.AddTransient<MqttService>();
s.AddTransient<MqttWebSocketService>();
s.AddTransient<NettyService>();
s.AddTransient<Communication>();
s.AddTransient<CommunicationService>();
s.AddTransient<Svg>();
s.AddTransient<Gif>();
s.AddTransient<OpcUaNodeBrowsing>();
});
//启动全局异常捕捉
RegisterEvents();
//加载本地自定义图标
IconsHandler.Loading(IconResourcePath);
//打开主窗口
InjectionWpf.Window<MainWindow, MainWindowModel>(true).Show();
}
Debug 还展示了两种进阶绑定技巧。其一,用库的 EventCommand 配合 Microsoft.Xaml.Behaviors 触发器,把没有 Command 属性的路由事件(Loaded、SelectionChanged)桥接给视图模型命令:
<uis:NavigationView
x:Name="NavigationViewControls"
FooterMenuItemsSource="{Binding FooterMenuItemsSource}"
IsBackButtonVisible="Auto"
IsPaneToggleVisible="True"
MenuItemsSource="{Binding MenuItemsSource}"
OpenPaneLength="260">
<i:Interaction.Triggers>
<i:EventTrigger EventName="Loaded">
<snet:EventCommand Command="{Binding NavigationView_Loaded}" />
</i:EventTrigger>
<i:EventTrigger EventName="SelectionChanged">
<snet:EventCommand Command="{Binding NavigationView_SelectionChanged}" />
</i:EventTrigger>
</i:Interaction.Triggers>
</uis:NavigationView>
其二,多标签页文档界面由 BindNotify 模型驱动——混合使用表达式式 GetProperty 与带后台字段的 SetProperty(ref ...),并通过泛型 AsyncRelayCommand<T> 执行标签操作:
public class TabDeviceControlModel : BindNotify
{
/// <summary>
/// 设备点位
/// </summary>
public ObservableCollection<TabControlDeviceModel> Devices
{
get => devices;
set => SetProperty(ref devices, value);
}
private ObservableCollection<TabControlDeviceModel> devices = new ObservableCollection<TabControlDeviceModel>();
/// <summary>
/// 选中的设备项
/// </summary>
public TabControlDeviceModel SelectedDevicesItem
{
get => GetProperty(() => SelectedDevicesItem);
set => SetProperty(() => SelectedDevicesItem, value);
}
/// <summary>
/// 关闭 tab 项
/// </summary>
public IAsyncRelayCommand CloseTabCommand => closeTabCommand ??= new AsyncRelayCommand<TabControlDeviceModel>(CloseTabCommandAsync);
private IAsyncRelayCommand? closeTabCommand;
private async Task CloseTabCommandAsync(TabControlDeviceModel? tab)
{
if (tab != null)
{
Devices.Remove(tab);
await tab.DisposeAsync();
AutoCheckTabControlVisibility();
}
}
}
协议包引用(完整列表见 Snet.Iot.Debug.csproj,共 40+ 个驱动):
<ItemGroup>
<PackageReference Include="ScottPlot.WPF" Version="5.1.59" />
<PackageReference Include="Snet.Windows.Controls" Version="26.250.2" />
<PackageReference Include="Snet.Modbus" Version="26.250.1" />
<PackageReference Include="Snet.Siemens" Version="26.250.1" />
<PackageReference Include="Snet.Opc" Version="26.250.1" />
<PackageReference Include="Snet.Mqtt" Version="26.250.1" />
<PackageReference Include="Snet.RabbitMQ" Version="26.250.1" />
<!-- ... -->
</ItemGroup>
KMSim — 可编程键鼠模拟器
Snet.Windows.KMSim 是脚本驱动的键鼠自动化工具。它将 Snet.Windows.Controls UI 与 LibreHardwareMonitorLib(硬件监控)、ScottPlot.WPF(实时图表)组合使用,并演示了构造函数注入:MainWindow 从容器接收 GlobalKeyboardHook,视图模型通过 PeriodicTimer 循环轮询 CPU/GPU/RAM 使用率并推入图表。
MainWindow.xaml.cs — 窗口构造函数注入 + 编辑器装配:
public partial class MainWindow : WindowBase
{
public MainWindow(GlobalKeyboardHook hook)
{
InitializeComponent();
new EditHandler(edit, App.EditModels, maxCompletionRows: 10, color: ("#414141", "#FEFEFE"));
this.Closing += (object? sender, System.ComponentModel.CancelEventArgs e) =>
{
_ = this.DataContext.GetSource<MainWindowViewModel>().ExitAsync().ConfigureAwait(false);
};
}
}
MainWindow.xaml — 脚本编辑器(库控件 + 附加绑定处理器)与本地化菜单:
<Menu FontSize="14">
<uis:MenuItem Header="{snet:Loc 文件}" Icon="{uis:SymbolIcon Document20, Filled=True}">
<uis:MenuItem
Command="{Binding Save}"
Header="{snet:Loc 保存}"
Icon="{uis:SymbolIcon Symbol=Save20,
Filled=True}"
InputGestureText="CTRL+S" />
<!-- ... -->
</uis:MenuItem>
<uis:MenuItem
Command="{Binding Start}"
Foreground="{DynamicResource PaletteGreenBrush}"
Header="{x:Null}"
Icon="{uis:SymbolIcon Symbol=Play20,
Filled=True}"
InputGestureText="CTRL+F10"
ToolTip="{snet:Loc 开始}" />
<!-- ... -->
</Menu>
<snet:TextEditorControl x:Name="edit" h:EditBindingHandler.EditText="{Binding EditText}" />
MainWindowViewModel.cs — BindNotify 视图模型:表达式属性 + 图表控件属性(后台字段风格)+ IAsyncRelayCommand 命令:
public class MainWindowViewModel : BindNotify
{
/// <summary>
/// 控件
/// </summary>
public WpfPlot ChartControl
{
get => chartControl;
set => SetProperty(ref chartControl, value);
}
private WpfPlot chartControl = new WpfPlot();
/// <summary>
/// 系统标题
/// </summary>
public string SystemTitle
{
get => GetProperty(() => SystemTitle);
set => SetProperty(() => SystemTitle, value);
}
/// <summary>
/// 信息事件
/// </summary>
public string Info
{
get => GetProperty(() => Info);
set => SetProperty(() => Info, value);
}
}
构造函数中初始化三条图表曲线(CPU / GPU / RAM):
public MainWindowViewModel(GlobalKeyboardHook hook)
{
// 界面消息处理
uiMessage.OnInfoEventAsync += async (object? sender, Model.data.EventInfoResult e) => Info = e.Message;
uiMessage.StartAsync().ConfigureAwait(false);
// ...
// 图表操作
chartOperate = ChartOperate.Instance(new()
{
ChartControl = ChartControl,
LineAdjust = true,
HideGrid = true,
YCrosshairText = true,
RefreshTime = _interval
});
chartOperate.On();
chartOperate.Create(new() { SN = "Cpu", Title = "处理器", TitleEN = "Cpu", Color = "#4CAF50" });
chartOperate.Create(new() { SN = "Gpu", Title = "显卡", TitleEN = "Gpu", Color = "#F44336" });
chartOperate.Create(new() { SN = "RAM", Title = "内存", TitleEN = "RAM", Color = "#2196F3" });
// 系统监控
systemMonitoring = SystemMonitoring.Instance();
// 更新系统检测值
UpdateSystemMonitoringValueAsync(globalToken.Token).ConfigureAwait(false);
// ...
}
硬件监控轮询循环(UpdateSystemMonitoringValueAsync——使用 PeriodicTimer 代替 Task.Delay 以获得稳定间隔):
private async Task UpdateSystemMonitoringValueAsync(CancellationToken token = default)
{
try
{
await Task.Run(async () =>
{
// 在循环外分配字典,避免每次迭代产生 GC 压力
ConcurrentDictionary<string, double> values = new ConcurrentDictionary<string, double>();
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(_interval * 10));
while (await timer.WaitForNextTickAsync(token))
{
HardwareData hardwareData = systemMonitoring.GetInfo();
// ...
if (values.Count == 3)
{
foreach (var item in values)
{
double value = Math.Round(item.Value, 2);
UpdateLineSeriesData(item.Key, value);
switch (item.Key)
{
case "Cpu":
Cpu = value;
break;
case "Gpu":
Gpu = value;
break;
case "RAM":
RAM = value;
break;
}
}
}
}
}, token).ConfigureAwait(false);
}
catch (TaskCanceledException) { }
catch (OperationCanceledException) { }
catch (Exception ex) { await uiMessage.ShowAsync(ex.Message); }
}
📚 API 参考
Snet.Windows.Core — WindowBase
依赖属性
| 属性 | 类型 | 默认值 | 描述 |
|---|---|---|---|
LanguageEnabled |
bool |
true |
在标题栏中启用语言切换按钮 |
SkinEnabled |
bool |
true |
在标题栏中启用主题切换按钮 |
TitleLeft |
bool |
true |
将窗口标题移至左侧 |
VerEnabled |
bool |
true |
在标题栏中显示版本号 |
LoadAnimationEnabled |
bool |
false |
窗口首次加载时播放淡入动画 |
AnimationTime |
int |
2000 |
加载动画持续时间(毫秒) |
MaximizeBorderThickness |
Thickness |
new Thickness(0) |
窗口最大化时的边框厚度(最大化时动态计算) |
方法
| 方法 | 签名 | 描述 |
|---|---|---|
WindowShake |
static void WindowShake(System.Windows.Window window = null) |
抖动窗口以提示错误或吸引注意 |
LoadAnimationAsync |
Task LoadAnimationAsync(bool status, CancellationToken cancellationToken = default) |
触发加载动画(status 启用) |
Snet.Windows.Core.mvvm — BindNotify
继承自 ObservableObject。无需显式声明后台字段。
方法(protected — 供派生类使用)
| 方法 | 描述 |
|---|---|
T GetProperty<T>(Expression<Func<T>> expression) |
使用表达式作为键检索属性值 |
bool SetProperty<T>(Expression<Func<T>> expression, T value) |
设置属性值并触发 PropertyChanged;返回值是否变更 |
bool SetProperty<T>(Expression<Func<T>> expression, T value, Action<T> changedCallback) |
设置属性值并在变更后执行回调 |
bool SetProperty<T>(Expression<Func<T>> expression, T value, Action? changedCallback) |
设置属性值并在变更后执行无参回调 |
与 ObservableObject.SetProperty 的关键区别: BindNotify.SetProperty 不需要后台字段。属性值存储在以表达式成员名称为键的内部 Dictionary<string, object> 中。这意味着您只需声明属性访问器——无需单独的字段声明。
Snet.Windows.Core.mvvm — EventCommand
将路由事件绑定到 ICommand 对象——适用于需要绑定没有 Command 属性的事件的场景。
| 属性 | 类型 | 描述 |
|---|---|---|
Command |
ICommand |
事件触发时执行的命令 |
CommandParameter |
object |
传递给命令的可选参数 |
UseEventCommandArgs |
bool |
是否将事件参数包装为 EventCommandArgs 再传给命令(默认 false) |
在 XAML 中使用:
<snet:EventCommand Command="{Binding MouseDownCommand}" />
Snet.Windows.Core.handler — SkinHandler
管理应用程序主题的静态类。
方法
| 方法 | 签名 | 描述 |
|---|---|---|
SetSkin |
void SetSkin(SkinType skinType, bool notice = true) |
切换主题;notice 控制是否触发 OnSkinEvent |
GetSkin |
SkinType GetSkin() |
返回当前主题 |
ReplaceResources |
void ReplaceResources(ResourceDictionary newDict, ResourceDictionary? oldDict) |
替换资源字典(两个参数均为必填) |
事件
| 事件 | 类型 | 描述 |
|---|---|---|
OnSkinEvent |
EventHandler<EventSkinResult>? |
主题更改后同步触发 |
OnSkinEventAsync |
EventHandlerAsync<EventSkinResult>? |
主题更改后异步触发 |
主题颜色
| 主题 | 主色 | 强调色 |
|---|---|---|
| Dark(深色) | #505050 |
MaterialDesign 深紫色(默认) |
| Light(浅色) | #F5F5F5 |
MaterialDesign 深紫色(默认) |
Snet.Windows.Core.handler — LanguageHandler
管理应用内本地化的静态类。
方法
| 方法 | 签名 | 描述 |
|---|---|---|
GetLanguage |
LanguageType GetLanguage() |
返回当前语言 |
SetLanguage |
void SetLanguage(LanguageType type) |
切换语言并重新加载资源 |
GetLanguageValue |
string? GetLanguageValue(string key, Snet.Model.data.LanguageModel? languageModel = null) |
按键查找本地化字符串;可选语言模型覆盖 |
GetLanguageValueAsync |
Task<string?> GetLanguageValueAsync(string key, Snet.Model.data.LanguageModel? languageModel = null, CancellationToken token = default) |
异步变体,用于延迟查找 |
支持的语言
| 语言 | 枚举值 |
|---|---|
| English(英文) | LanguageType.en |
| 简体中文 | LanguageType.zh |
Snet.Windows.Core.handler — IconsHandler
线程安全的图标缓存系统,支持主题变更感知。
方法
| 方法 | 签名 | 描述 |
|---|---|---|
GetIcon |
static DrawingImage? GetIcon(string key, string? path = null) |
返回缓存的图标;缓存未命中时从 path 加载 |
Loading |
static void Loading(string resourceFile) |
加载图标资源到缓存 |
LoadingAsync |
static Task LoadingAsync(string resourceFile) |
Loading 的异步变体 |
使用 ConcurrentDictionary 实现线程安全的缓存。缓存会在主题更改时(通过 SkinHandler.OnSkinEventAsync)失效,从而确保图标以正确的颜色变体重新加载。
Snet.Windows.Core.handler — InjectionWpf
用于 MVVM 三元组创建的轻量级依赖注入容器。
方法
| 方法 | 签名 | 描述 |
|---|---|---|
Window<T,M> |
T Window<T,M>(bool cache = false) |
创建类型为 T 的 Window,使用类型为 M 的 ViewModel |
WindowAsync<T,M> |
Task<T> WindowAsync<T,M>(bool cache = false, CancellationToken token = default) |
Window<T,M> 的异步变体 |
UserControl<T,M> |
T UserControl<T,M>(bool cache = false) |
创建类型为 T 的 UserControl,使用类型为 M 的 ViewModel |
UserControlAsync<T,M> |
Task<T> UserControlAsync<T,M>(bool cache = false, CancellationToken token = default) |
UserControl<T,M> 的异步变体 |
Page<T,M> |
T Page<T,M>(bool cache = true) |
创建类型为 T 的 Page,使用类型为 M 的 ViewModel(默认缓存) |
PageAsync<T,M> |
Task<T> PageAsync<T,M>(bool cache = true, CancellationToken token = default) |
Page<T,M> 的异步变体 |
AddService |
void AddService(Action<IServiceCollection> action) |
向 DI 容器注册自定义服务(继承自 InjectionHandler) |
Snet.Windows.Core.@enum — SkinType
| 值 | 名称 | 描述 |
|---|---|---|
0 |
Dark |
深色主题 — 主色 #505050 |
1 |
Light |
浅色主题 — 主色 #F5F5F5 |
Snet.Windows.Controls — ButtonControl
可自定义的按钮,支持可选的图标和圆角。
依赖属性
| 属性 | 类型 | 默认值 | 描述 |
|---|---|---|---|
CornerRadius |
CornerRadius |
(8,8,8,8) |
边框圆角 |
Command |
ICommand |
null |
点击按钮时执行的命令 |
Content |
string |
"" |
按钮上显示的文字 |
Icon |
ImageSource |
null |
显示在文字左侧的图标 |
布局
[图标 (15x15)] + [8px 间距] + [文字]
Snet.Windows.Controls — TextBoxControl
带可选图标、提示文字和清除按钮的文本输入框。
依赖属性
| 属性 | 类型 | 默认值 | 描述 |
|---|---|---|---|
Height |
double |
30 |
控件高度 |
Icon |
ImageSource |
null |
显示在文本框内的图标 |
Text |
object |
null |
绑定的文本值(默认为双向绑定) |
Hint |
string |
"" |
占位提示文字 |
ClearButtonEnabled |
bool |
true |
当文本非空时显示清除(X)按钮 |
Snet.Windows.Controls — ComboBoxControl
带可选图标和提示文字的下拉选择器。
依赖属性
| 属性 | 类型 | 默认值 | 描述 |
|---|---|---|---|
Height |
double |
30 |
控件高度 |
Icon |
ImageSource |
null |
下拉框旁显示的图标 |
Hint |
object |
null |
占位提示文字 |
DisplayMemberPath |
string |
string.Empty |
下拉列表中显示的属性名称 |
ItemsSource |
IEnumerable |
null |
要显示的项集合 |
SelectedItem |
object |
null |
当前选中的项(默认为双向绑定) |
Snet.Windows.Controls — PropertyControl
一个卡片包裹的属性网格,支持导入/导出功能。
依赖属性
| 属性 | 类型 | 默认值 | 描述 |
|---|---|---|---|
BasicsData |
object |
null |
要编辑的对象(默认为双向绑定) |
ExpCommand |
ICommand |
null |
导出按钮的命令 |
IncCommand |
ICommand |
null |
导入按钮的命令 |
ButtonVisibility |
Visibility |
Collapsed |
控制导入/导出按钮的可见性 |
方法
| 方法 | 签名 | 描述 |
|---|---|---|
GetBasics |
object GetBasics() |
返回当前数据;用 GetSource<T>() 转换为类型化值 |
SetBasics |
void SetBasics(object value) |
替换当前数据并刷新网格 |
封装了 MaterialDesign Card + 属性网格,并带有两个操作按钮(导入/导出)。使用模型类上的 [Category] 和 [Description] 特性来自动组织网格。
Snet.Windows.Controls — MessageBox
封装了 DialogHost 的静态类,用于模态对话框。
方法
Show() 有五个重载:
| 重载 | 参数 |
|---|---|
Show(string content) |
仅内容文字 |
Show(string content, string title) |
内容 + 标题 |
Show(string content, string title, MessageBoxImage img) |
内容 + 标题 + 图标 |
Show(string content, string title, MessageBoxButton btn) |
内容 + 标题 + 按钮配置 |
Show(string content, string title, MessageBoxButton btn, MessageBoxImage img) |
内容 + 标题 + 按钮 + 图标 |
MessageBoxButton
| 值 | 描述 |
|---|---|
OK |
单个 OK 按钮 |
OKCancel |
OK + 取消按钮 |
Yes |
单个 Yes 按钮 |
YesNo |
Yes + No 按钮 |
MessageBoxImage
10 种图标类型,映射到 SystemIcons:
| 值 | 系统图标 |
|---|---|
Exclamation |
SystemIcons.Exclamation |
Application |
应用程序默认图标 |
Asterisk |
SystemIcons.Asterisk |
Error |
SystemIcons.Error |
Hand |
SystemIcons.Hand |
Information |
SystemIcons.Information |
Question |
SystemIcons.Question |
Shield |
SystemIcons.Shield |
Warning |
SystemIcons.Warning |
WinLogo |
Windows 徽标图标 |
Snet.Windows.Controls.data — Models
| 模型 | 属性 | 适用场景 |
|---|---|---|
ComboBoxModel |
Key(string)、Value(object) |
下拉列表项 |
EditModel |
Name(string)、Description(string)、Color(string?) |
可编辑实体 |
ItemsControlModel |
Key(string)、IsChecked(bool)、IsEnabled(bool,默认 true)、Title(string)、Content(object?) |
可勾选列表项 |
TabControlModel |
Title(string)、Icon(object)、Content(UserControl) |
选项卡界面 |
Snet.Windows.Controls.drag — 布局持久化模型
DragControlsLayout 是可直接 JSON 序列化的画布布局根模型(Version 默认 1,Items 默认空集合)。每个 DragControlsLayoutItem 保存 Type、X、Y、Width、Height、Angle,并可选保存 Text、IsChecked、Fill、SourceName、SN、ExtensionData 与自定义 Extra 字符串字典:
using System.Text.Json;
using Snet.Windows.Controls.drag;
var layout = new DragControlsLayout
{
Items =
[
new DragControlsLayoutItem
{
Type = "Button", X = 40, Y = 24, Width = 120, Height = 36,
Angle = 15, Text = "启动", SourceName = "command-button"
}
]
};
string json = JsonSerializer.Serialize(layout);
var restored = JsonSerializer.Deserialize<DragControlsLayout>(json);
Snet.Windows.Controls.property.wpf — NaturalStringComparer
NaturalStringComparer 实现 IComparer<string?>,按数字片段比较且不受 Int32 上限约束,因此 Item2 会排在 Item10 前:
using Snet.Windows.Controls.property.wpf;
var names = new[] { "Item10", "Item2", "Item1" };
Array.Sort(names, new NaturalStringComparer());
💻 代码示例
完整的 MVVM 窗口
MainWindow.xaml.cs
public partial class MainWindow : WindowBase
{
public MainWindow()
{
InitializeComponent();
DataContext = new MainViewModel();
}
}
MainViewModel.cs
public class MainViewModel : BindNotify
{
public string Name
{
get => GetProperty(() => Name);
set => SetProperty(() => Name, value);
}
public IAsyncRelayCommand Hello => new AsyncRelayCommand(async () =>
{
await MessageBox.Show(
$"你好, {Name}!",
"问候",
MessageBoxButton.OK,
MessageBoxImage.Information);
});
}
MainWindow.xaml
<snet:WindowBase
xmlns:snet="https://snet.cn"
LanguageEnabled="True"
SkinEnabled="True"
LoadAnimationEnabled="True"
AnimationTime="1500">
<StackPanel Margin="20">
<snet:TextBoxControl
Text="{Binding Name}"
Hint="请输入您的名字" />
<snet:ButtonControl
Margin="0,10,0,0"
Content="打招呼"
Command="{Binding Hello}" />
</StackPanel>
</snet:WindowBase>
带导入/导出功能的属性网格
public class AppSettings : BindNotify
{
[Category("常规")]
[Description("应用程序显示名称")]
public string AppName
{
get => GetProperty(() => AppName);
set => SetProperty(() => AppName, value);
}
[Category("常规")]
[Description("最大显示条目数")]
public int MaxItems
{
get => GetProperty(() => MaxItems);
set => SetProperty(() => MaxItems, value);
}
[Category("高级")]
[Description("启用调试日志")]
public bool DebugMode
{
get => GetProperty(() => DebugMode);
set => SetProperty(() => DebugMode, value);
}
}
<snet:PropertyControl
BasicsData="{Binding Settings}"
ExpCommand="{Binding ExportCommand}"
IncCommand="{Binding ImportCommand}" />
从代码切换主题
private void ToggleTheme()
{
var current = SkinHandler.GetSkin();
SkinHandler.SetSkin(
current == SkinType.Dark ? SkinType.Light : SkinType.Dark);
}
使用依赖注入
// 通过 DI 创建主窗口及其 ViewModel
var mainWindow = InjectionWpf.Window<MainWindow, MainViewModel>();
// 等价于 Application.Run
mainWindow.ShowDialog();
❓ 常见问题
1. 如何使用 MVVM 创建新窗口?
使用 InjectionWpf.Window<MyWindow, MyViewModel>()。它会构造 View 和 ViewModel,绑定 DataContext,并返回完全初始化的窗口。
2. 如何以编程方式切换主题?
SkinHandler.SetSkin(SkinType.Light); // 浅色主题
SkinHandler.SetSkin(SkinType.Dark); // 深色主题
选择会持久化到 config/skin.json,并在下次启动时自动恢复。
3. 如何添加新语言?
- 添加一个包含翻译字符串的
Language.xx.resx资源文件 - 将
xx变体添加到LanguageType枚举中(如果尚未存在) - 在启动时调用
LanguageHandler.SetLanguage(LanguageType.xx)
库默认提供 Language.resx(中性/简体中文)和 Language.en.resx(英文)。
4. BindNotify.GetProperty/SetProperty 和 ObservableObject.SetProperty 有什么区别?
BindNotify.GetProperty<T>(() => Property) / SetProperty<T>(() => Property, value) 使用基于表达式的属性容器——值存储在以表达式成员名称为键的内部字典中。您无需声明私有后台字段。
CommunityToolkit.Mvvm 的 ObservableObject.SetProperty<T>(ref field, value) 需要一个显式的后台字段,并通过引用传递它。
| 方法 | 后台字段 | 样板代码 |
|---|---|---|
BindNotify.SetProperty |
不需要 | 较少 |
ObservableObject.SetProperty |
需要(private T _field;) |
较多 |
5. 如何使用属性网格(PropertyGrid)?
将 BasicsData 属性设置为任何使用 System.ComponentModel 中的 [Category] 和 [Description] 特性装饰的对象:
using System.ComponentModel;
public class MyConfig
{
[Category("常规")]
[Description("应用程序标题")]
public string Title { get; set; }
}
PropertyControl 会自动生成带有分类的可编辑网格。使用 GetBasics() 获取当前值(用 GetSource<T>() 进行类型转换),使用 SetBasics(object) 替换它们。
6. 是否支持 .NET 8 和 .NET 10?
是的。这些包同时面向 net8.0-windows 和 net10.0-windows。您可以在任一目标框架上使用相同版本的包。
