概览 - Snet Docs

🖥️ WPF 概览

Snet.Windows 是一个面向 .NET 8/10 Windows 桌面应用的现代化 WPF UI 库。它提供了干净、可直接用于生产环境的基础设施,帮助您以最少的样板代码构建丰富的 WPF 应用程序。

📦 包

包名 版本 描述
Snet.Windows.Core 基础库 — MVVM、主题、本地化、依赖注入、DPI 感知
Snet.Windows.Controls 控件库 — 自定义控件、属性网格、消息框、数据模型

目标框架

  • net8.0-windows
  • net10.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 资源
自定义控件 ButtonControlTextBoxControlComboBoxControlPropertyControl
属性网格 基于特性的属性编辑器,支持导入/导出
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.CoreWPF-UIMaterialDesignThemesCommunityToolkit.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

对于需要 ObservableObjectAsyncRelayCommandObservableValidator 的场景,该库与 CommunityToolkit.Mvvm 完全互操作。BindNotify 本身继承自 ObservableObject

public class MyViewModel : BindNotify
{
    public IAsyncRelayCommand SaveCommand => new AsyncRelayCommand(async () =>
    {
        // 异步逻辑
    });
}

2. 深色/浅色主题

SkinHandler 管理 MaterialDesignThemesWPF-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.OnSkinEventSkinHandler.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 继承 WindowBaseMainWindowModel 继承 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.xamlWindowBase 根窗口 + 导航 + 系统托盘;本地化通过 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.csBindNotify 视图模型:使用 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.CorePluginHandlerCore 中:它封装 Snet.Core.pluginPluginOperate,通过泛型扩展方法按接口创建采集(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.xamlMq.xamlOpcUaNodeBrowsing.xaml 等)展示了库控件的典型用法——TextBoxControlButtonControlComboBoxControlPropertyControl 配合 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 属性的路由事件(LoadedSelectionChanged)桥接给视图模型命令:

<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.csBindNotify 视图模型:表达式属性 + 图表控件属性(后台字段风格)+ 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 保存 TypeXYWidthHeightAngle,并可选保存 TextIsCheckedFillSourceNameSNExtensionData 与自定义 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. 如何添加新语言?

  1. 添加一个包含翻译字符串的 Language.xx.resx 资源文件
  2. xx 变体添加到 LanguageType 枚举中(如果尚未存在)
  3. 在启动时调用 LanguageHandler.SetLanguage(LanguageType.xx)

库默认提供 Language.resx(中性/简体中文)和 Language.en.resx(英文)。

4. BindNotify.GetProperty/SetPropertyObservableObject.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-windowsnet10.0-windows。您可以在任一目标框架上使用相同版本的包。