Bongo Paw Clicker 开发记录

本文是Bongo Paw Clicker的开发记录,对我在开发鼠标连点器过程中遇到的问题进行了记录

cn-preview.webp

传送门

UI设计

WPF使用可扩展应用程序标记语言 (XAML)来生成UI,VS中的XAML设计器提供了一个可视界面来帮助我们进行UI设计。XAML在使用上和CSS比较像,上手基本没有难度,难的是设计。吐槽:程序员自己做UI设计是真的心累,我设计UI的时间比写代码的时间都长。

窗口布局

我在UI中使用了Grid和StackPanel进行UI布局,Grid用于划分横向功能区,StackPanel用于在Grid的每行中横向排布组件。更多布局方式见微软文档

如果直接在同一个Grid中加入多个组件的话,这些组件会堆叠到一起,而不是按照顺序排列。如果要让组件均匀排列,需要为每个组件指定Margin,很麻烦,所以我才在Grid中嵌套了StackPanel来排布组件。

Grid

顾名思义,Grid就是将面板划分为不同的网格

Grid示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<Grid>
<Grid.RowDefinitions> <!--定义行-->
<RowDefinition Height="50" /> <!--定义首行参数,此处设置首行行高为50-->
<RowDefinition Height="60" /> <!--第二行参数-->
</Grid.RowDefinitions>

<Grid Grid.Row="0"> <!--第一行-->
<Grid.ColumnDefinitions> <!--定义列-->
<ColumnDefinition Width="130" /> <!--定义首列参数,此处设置首列列宽为130-->
<ColumnDefinition Width="*" /> <!--第二列参数,此处*代表将列宽设为此行剩余的所有宽度,如果有多个列的宽度设置为*,则代表这些列均分剩余宽度-->
</Grid.ColumnDefinitions>

<Grid Grid.Column="0"> <!--第一列的内容-->
<!-- Some Content -->
</Grid>

<Grid Grid.Column="1"> <!--第二列的内容-->
<!-- Some Content -->
</Grid>
</Grid>

<Grid Grid.Row="1"> <!--第二行-->
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*" /> <!-- 这里2*标识占据所有宽度的七分之二-->
<ColumnDefinition Width="5*" /> <!-- 这里2*标识占据所有宽度的七分之五-->
</Grid.ColumnDefinitions>
</Grid>
</Grid>

StackPanel

StackPanel可以实现在单个行中水平或者垂直排列组件,它不会自动换行,超出宽度的内容会不可视。

StackPanel示例

1
2
3
4
5
6
7
8
9
10
11
12
<StackPanel
Height="60" <!--设置高度-->
HorizontalAlignment="Left" <!--设置水平对齐方式-->
VerticalAlignment="Center" <!--设置垂直对齐方式-->
Orientation="Horizontal"> <!--设置排列方向-->
<Label VerticalAlignment="Center">
<AccessText Text="1" />
</Label>
<Label VerticalAlignment="Center">
<AccessText Text="2" />
</Label>
</StackPanel>

Material Design

起初我是想自己写一个UI样式,写着写着就发现自己很天真,圆角的半径、按钮的动画什么的真的让我很头大。于是决定摆烂,直接在程序中套用UI框架,这样做的好处是省事且美观,缺点是程序打包后的文件体积会变大。挑选了半天,我最后选择了Material Design In XAML

使用方法

App.xaml中添加一个新的命名空间引用:xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes",然后再添加几个ResourceDictionary

App.xaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<Application
x:Class="[PackageName].App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:[PackageName]"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
StartupUri="MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Light.xaml" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignColors;component/Themes/Recommended/Primary/MaterialDesignColor.DeepPurple.xaml" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignColors;component/Themes/Recommended/Accent/MaterialDesignColor.Lime.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

然后在每个窗口对应的xaml文件的Window Tag中添加命名空间引用和相关属性(可选)。

Window Tag

1
2
3
4
5
6
7
8
9
10
<Window
//已经存在的内容
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
TextElement.Foreground="{DynamicResource MaterialDesignBody}"
Background="{DynamicResource MaterialDesignPaper}"
TextElement.FontWeight="Medium"
TextElement.FontSize="14"
FontFamily="{materialDesign:MaterialDesignFont}"
//其他内容>
</Window>

窗口样式

因为我想实现无边框的窗口(又菜又爱玩),就在Window Tag中将WindowStyle设置成None了。这么做的好处是很自由,能够随心所欲的设计窗口样式,缺点就是太自由了,关闭、最小化等功能按钮都需要自己重新实现,Windows原生的动画效果也全部没有了。

功能按钮

首先在MainWindow.xaml文件中重绘顶栏,左侧是应用标题,右侧是功能按钮,功能按钮共有三个,分别为设置、最小化以及关闭应用。然后在按钮中绑定Click事件。

关闭按钮示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!--MainWindow.xaml 关闭按钮示例-->
<Button
x:Name="CloseButton"
Width="30"
Height="30"
Margin="10"
VerticalAlignment="Center"
Click="CloseButton_Click" //绑定关闭事件
Style="{StaticResource MaterialDesignFloatingActionMiniButton}"
WindowChrome.IsHitTestVisibleInChrome="True">
<materialDesign:PackIcon
Width="25"
Height="25"
Kind="Close" />
</Button>

最后在MainWindow.xaml.cs中重新实现按钮的功能。

  • 关闭窗口:Close()
  • 最小化:this.WindowState = WindowState.Minimized
  • 拖拽窗口:DragMove();

关闭窗口示例

1
2
3
4
5
//MainWindow.xaml.cs 关闭窗口示例
private void CloseButton_Click(object sender, RoutedEventArgs e)
{
Close(); //关闭窗口
}

窗口阴影

实现阴影效果需要允许窗口透明,并将窗口背景设置为空值

MainWindow.xaml相关Tag示例

1
2
3
4
5
6
<Window
//其他内容
AllowsTransparency="True" //允许透明
Background="{x:Null}" //背景设置为空值
WindowStyle="None" //不使用Windows默认窗口样式
//其他内容>

下面是两个错误示例,左边的图片没有设置允许窗口透明,窗口的边框会显示黑色。右边的图片将背景设置为了红色。

bpc_shadow2.png
bpc_shadow1.png

阴影效果我使用了Grid中的DropShadowEffect实现。

DropShadowEffect示例

1
2
3
4
5
6
7
<Grid.Effect>
<DropShadowEffect
BlurRadius="15" //表示阴影模糊效果半径的值默认值为5
Opacity="0.8" //数值越大阴影越明显
ShadowDepth="0" //设置阴影与窗口的距离阴影会随着数值增大向窗口右下方移动
Color="#666666" />
</Grid.Effect>

此时是看不到窗口的阴影的,因为阴影是环绕在Grid的外围的,而当前的Grid已经占用了全部的窗口空间,阴影自然就不可见了。所以需要通过Margin属性为Grid设置外边距,Margin具体的数值要根据阴影的宽度设置。

窗口圆角

圆角效果有多种实现方式,可以使用Card中的UniformCornerRadius="半径"统一设置4个圆角的半径,也可以使用Border中的CornerRadius="左上半径,右上半径,右下半径,左下半径"分别设置每个圆角的半径。我使用了Card,并将圆角半径设置为15。

窗口圆角示例

1
<materialDesign:Card Background="{DynamicResource CardBackgroundColor}" UniformCornerRadius="15"/>

窗口动画

需要重新实现的动画有窗口启动、窗口关闭、最小化、从最小化恢复窗口四个。

  • 窗口启动:模仿Windows原生动画
  • 窗口关闭:模仿Windows原生动画
  • 最小化:窗口向下滑动,同时透明度逐渐降低
  • 恢复窗口:窗口从底部滑动出现,同时透明度逐渐增加

创建动画

Windows原生启动动画的效果是窗口透明度逐渐增加,窗口从0.7-0.8倍尺寸向四周逐渐扩大至正常尺寸,窗口关闭动画则相反,我这里以加载窗口的动画为例。为实现近似的效果,需要创建一个Storyboard,并向其中添加三个DoubleAnimation,分别是透明度变化、横向拉伸和竖向拉伸。

透明度

透明度变化的动画很简单,下面的代码实现的是窗口启动时透明度由0到1的变化。注意其中的FillBehavior属性,它的默认值是HoldEnd,意味着动画中To的值会覆写程序原本的值,这在某些情况下会造成问题,解决方法是将它设置为Stop

透明度动画

1
2
3
4
5
6
<DoubleAnimation
FillBehavior="Stop"
Storyboard.TargetProperty="Opacity"
From="0" //初始透明度
To="1" //最终透明度
Duration="0:0:0.2" //动画播放时间0.2秒 />
窗口拉伸

首先在主Grid中设置x:NameRenderTransformOrigin属性。x:Name用于拉伸动画寻找对应的Grid,RenderTransformOrigin用于设置动画的起点,取值范围是0-1,设置为0,0时动画的起点是窗口的左上角,设置为1,1时动画的起点是窗口的右下角。我这里设置为0.5,0.5,即窗口的中心。

然后向Grid中添加RenderTransform属性,RenderTransform是一个用于控制元素呈现时的变换的属性。它允许你对元素应用平移、缩放、旋转和倾斜等变换效果,以改变元素的呈现位置、大小和方向。这里我为了方便后续添加其他效果使用了TransformGroup类,它是 WPF 中的一个用于组合多个变换效果的容器类,它允许你将多个平移、缩放、旋转、倾斜等变换组合在一起,从而可以同时应用这些变换效果到一个元素上。

下一步是在上面创建的Storyboard中添加横向拉伸和竖向拉伸的动画DoubleAnimation,使用Storyboard.TargetName指定拉伸对象,使用Storyboard.TargetProperty`指定变形类型。

窗口拉伸动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<Window.Resources>
<ResourceDictionary>
<!--加载窗口动画的Storyboard-->
<Storyboard x:Key="ShowWindow">
<!--透明度动画-->
<DoubleAnimation
FillBehavior="Stop"
Storyboard.TargetProperty="Opacity"
From="0"
To="1"
Duration="0:0:0.2" />
<!--横向拉伸-->
<DoubleAnimation
FillBehavior="Stop"
Storyboard.TargetName="MainGrid"
Storyboard.TargetProperty="(UIElement.RenderTransform).(TransformGroup.Children)[0].(ScaleX)"
From="0.8"
To="1"
Duration="0:0:0.2" />
<!--纵向拉伸-->
<DoubleAnimation
FillBehavior="Stop"
Storyboard.TargetName="MainGrid"
Storyboard.TargetProperty="(UIElement.RenderTransform).(TransformGroup.Children)[0].(ScaleY)"
From="0.8"
To="1"
Duration="0:0:0.2" />
</Storyboard>
</ResourceDictionary>
</Window.Resources>

<!--目标网格-->
<Grid
x:Name="MainGrid"
Margin="10"
RenderTransformOrigin="0.5,0.5"> <!--动画的起点-->

<Grid.RenderTransform>
<TransformGroup> <!--容器类-->
<ScaleTransform ScaleX="1" ScaleY="1" /> <!--缩放-->
<SkewTransform /> <!--扭曲-->
<RotateTransform /> <!--旋转-->
<TranslateTransform /> <!--平移-->
</TransformGroup>
</Grid.RenderTransform>

<!--其他内容-->
</Grid>

触发动画

窗口加载动画的触发很简单,在xaml文件中添加一个Window.Triggers即可。

Window.Triggers示例

1
2
3
4
5
<Window.Triggers>
<EventTrigger RoutedEvent="Loaded"> <!--窗口加载时触发-->
<BeginStoryboard Storyboard="{StaticResource ShowWindow}" />
</EventTrigger>
</Window.Triggers>

但是最小化窗口等动画不太适合直接使用Window.Triggers,因为有一些特殊情况需要判定是否播放动画,所以我还是在代码中触发这些动画。下面是用于触发窗口从最小化恢复的动画的代码,需要先在xaml文件的Window Tag中添加一行StateChanged="Window_StateChanged"来绑定窗口状态改变事件。

窗口状态改变代码

1
2
3
4
5
6
7
8
9
10
11
private void Window_StateChanged(object sender, EventArgs e)
{
//当窗口透明度为0且尺寸恢复正常时,执行从最小化恢复的动画
//因为我的最小化动画最终会将窗口的透明度设置为0,也方便了这里进行判定
//如果不判定窗口的透明度,窗口在失去焦点但并没有最小化的情况下重新获得焦点时,动画也会被触发
if (WindowState == WindowState.Normal && this.Opacity==0)
{
var story = (Storyboard)this.Resources["MaximizeWindow"];
story.Begin(this);
}
}

图像与音频

我在根目录下新建了一个Asset文件夹来存放图像、音频等媒体资源。

应用图标

我的图标设计的很简单粗暴,一只Bongo Cat抱着一个鼠标光标,PS里几步操作就完成了。添加图标的步骤为打开项目属性,在应用程序菜单的资源选项中点选图标和清单,然后单击浏览按钮并找到自己的图标即可。图标文件仅支持ico格式
bpc_icon.png

Bongo Cat

我通过切换4张不同状态下的Bongo Cat的图片来实现Bongo Cat的爪子随着鼠标点击同步拍桌子的效果。

  • 默认状态:两只猫爪都是抬起来的
  • 鼠标左键单击:左侧猫爪拍下
  • 鼠标左键双击:两只猫爪同时拍下
  • 鼠标右键单击:右侧猫爪拍下
nonePaw.png
leftPaw.png
rightPaw.png
bothPaw.png

首先提前在PS中处理四张图片,对齐Bongo Cat的身体轮廓,导出为相同大小的文件。然后放置在同一个Canvas下并设置相同的尺寸与对齐方式,除默认状态下的图片,其他三张图片的Visibility属性都设置为"Collapsed"

Visibility属性

以下内容由ChatGPT生成

Visibility 属性用于控制一个元素在界面上的可见性。在 WPF 中,Visibility 属性有三个可能的值:VisibleCollapsedHidden

  1. Visible:元素是可见的,并且占据布局空间。它会显示在界面上,并响应用户交互。
  2. Collapsed:元素是不可见的,并且不占据布局空间。它不会显示在界面上,也不会占用任何空间。与 Visible 不同,使用 Collapsed 后该元素不会留下空白,周围的布局会紧凑起来。
  3. Hidden:元素是不可见的,但它仍然占据布局空间。与 Visible 相似,只是元素不会显示出来,但它会占用空间,导致周围的布局不会紧凑。

一般来说,如果你想要完全隐藏一个元素,并且希望周围的布局重新排列以填补它的位置,你可以使用 Collapsed。如果你希望元素不显示,但仍然占用空间,你可以使用 Hidden。。

然后在代码中进行处理,以左键单击为例,当模拟鼠标左键单击事件时,将左边爪子拍下的图片设置为可见,并折叠默认状态下的图片,等待100毫秒后恢复原样。

左键单击示例

1
2
3
4
5
6
7
8
9
10
11
12
if (clickType == 0)//左键单击
{
Dispatcher.InvokeAsync((Action)(async () =>
{
LeftPaw.Visibility = Visibility.Visible;
NonePaw.Visibility = Visibility.Collapsed;
await Task.Delay(100);
LeftPaw.Visibility = Visibility.Collapsed;
NonePaw.Visibility = Visibility.Visible;
}));
User32API.ClickOnScreen(0, xCoord, yCoord);//模拟点击
}

用户中断点击事件的执行时,图片的切换也会被中断,当前显示的图片可能不是默认状态下的图片,所以在模拟点击命令中断或结束后还需要将图片恢复默认状态。

播放音频

我在添加音频时遇到了一个很奇怪的问题,如果直接在xaml中使用MediaPlayer,就必须在代码中使用绝对路径调用音频文件,使用相对路径就会提示找不到资源,最后我是把音频文件加入到了Resources.resx中,然后通过Properties.Resources.[FileName]调用成功的。

播放音频代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public partial class MainWindow : Window
{
//音频播放器
System.Media.SoundPlayer meowAudio;

//窗口加载时载入音频
private void Window_Loaded(object sender, RoutedEventArgs e)
{
meowAudio = new System.Media.SoundPlayer(Properties.Resources.meow);
}

//执行模拟点击完成后播放音频
private void ClickLuncher()
{
Dispatcher.InvokeAsync((Action)(() =>
{
meowAudio.Play();
}));
}
}

这里还有一个比较坑的地方,Resources.resx中默认显示的资源类型是字符串,需要通过左上角的下拉菜单切换到音频,而且只有点击到一个非常小非常不明显的小箭头才能打开下拉菜单,如下图所示。

bpc_audio.png

UI线程与Dispatcher

部分内容来自ChatGPT

你可能已经注意到了,在代码中切换猫爪图片需要使用到Dispatcher,因为这里涉及到了UI线程的问题。

UI线程

UI线程是负责处理用户界面交互和更新的线程。在WPF应用程序中,UI线程负责处理用户界面元素(如窗口、按钮、文本框等)的创建、显示、交互和更新操作。UI线程负责绘制界面、响应用户输入以及处理各种UI事件。

UI线程的重要性在于它的线程上下文。大多数UI框架,包括WPF,要求所有与UI相关的操作都在同一个线程上进行,以确保线程安全。这就意味着你不能在非UI线程上直接更新UI元素,否则可能会引发跨线程问题。

UI线程通常有以下特点:

  • 单线程模型: 在大多数UI框架中,包括WPF,UI线程是单线程模型。这意味着所有UI元素的创建、修改和交互都必须在同一个UI线程上进行。
  • 用户界面操作: UI线程负责处理用户界面的各种操作,包括响应用户输入、处理鼠标和键盘事件、处理窗口状态变化(最大化、最小化等)等。
  • 渲染绘制: UI线程负责渲染和绘制用户界面元素,确保它们正确地显示在屏幕上。
  • 响应性: 为了保持应用程序的响应性,UI线程应该保持轻量级,以便能够及时响应用户的操作。
  • 线程安全: UI线程的操作必须是线程安全的,以避免并发和同步问题。

当进行长时间运算或涉及耗时操作时,应该将这些操作分离到非UI线程,以保持用户界面的流畅性。但在更新UI元素时,你仍然需要使用Dispatcher将操作发送到UI线程,以确保线程安全。

Dispatcher

Dispatcher 是WPF中的一个机制,允许你在非UI线程上将操作调度到UI线程上执行。通过使用Dispatcher,你可以确保UI元素的更新和操作都发生在正确的线程上,避免了线程安全问题。

例如,如果你在一个后台线程上计算某些值,并希望将计算结果显示在UI界面上,你需要通过Dispatcher将更新操作发送到UI线程。这样做可以保证UI元素的更新不会导致跨线程问题。

Dispatcher中含有三个常用方法,即InvokeBeginInvokeInvokeAsync

  • Invoke是同步方法,意味着调用线程将会被阻塞,直到操作在UI线程上完成为止。
  • BeginInvokeInvokeAsync都是异步方法,它会将操作加入到UI线程的消息队列中,然后立即返回,不会阻塞调用线程,可以继续执行后续代码。InvokeAsync是在.NET Framework 4.5中新引入的,两者的主要区别是对异常的处理方式,建议首选InvokeAsync,特别是需要使用await方法的时候。

设置面板

我在程序中单独创建了设置面板的窗口,设置面板的功能有:

  • 切换程序的明/暗主题
  • 设置程序是否始终置顶
  • 设置热键
  • 设置点击完成后的提醒

创建窗口

首先在主窗口的xaml文件中添加一个按钮并绑定点击事件Click="SettingButton_Click",然后在项目中添加一个新的窗口并命名为SettingPanel。随后打开主窗口的代码文件,找到并编辑自动生成的方法代码。
bpc_sp1.png

创建设置窗口

1
2
3
4
5
6
7
private void SettingButton_Click(object sender, RoutedEventArgs e)
{
SettingPanel settingPanel = new SettingPanel(); //创建设置窗口
settingPanel.WindowStartupLocation = WindowStartupLocation.CenterOwner;//设置窗口启动时的位置,这里设置为在主窗口的中心
settingPanel.Owner = this; //将主窗口设为设置窗口的拥有者
settingPanel.ShowDialog(); //显示设置窗口
}

ShowDialog与Show

以下内容来自ChatGPT

ShowDialog与Show

在WPF中,Show和ShowDialog是两种不同的方式来显示窗口

Show

  • 使用Show方法显示的窗口是非模态的,即它不会阻止用户与应用程序的其他部分进行交互。
  • 用户可以在打开的窗口之间自由切换,甚至可以在主窗口和其他打开的窗口之间进行交互。
  • Show方法会立即返回,而不会等待打开的窗口关闭。

ShowDialog

  • 使用ShowDialog方法显示的窗口是模态的,即它会阻止用户与应用程序的其他部分进行交互,直到该窗口被关闭。
  • 用户必须先关闭模态窗口,才能回到主窗口或其他已打开的窗口。
  • ShowDialog方法会在打开的窗口关闭后才返回,因此它可以用于实现需要用户响应的对话框。

主题切换

在深夜测试的时候感觉默认的亮色主题有些刺眼,于是决定增加一个暗色的主题,并在设置窗口中添加一个按钮用于主题的切换。

Material Design已经内置了亮色和暗色主题,也内置了几套Primary Color配色方案,而Primary Color又分为Light、Normal和Dark三种颜色,TextBox、ToggleButton等组件使用的是Light配色,Button使用的是Dark配色。我对它的Light配色不太满意,就通过资源字典增加了一套配色,在程序的背景、Toggle按钮等组件上使用自己的颜色方案,在普通按钮上沿用Material Design的配色。

首先在App.xaml中添加两个SolidColorBrush属性,其中CardBackgroundColor是窗口的背景颜色,IndicatorColor是各个控件的主题颜色,这里默认是亮色主题。

SolidColorBrush示例

1
2
3
4
5
6
7
<Application.Resources>
<ResourceDictionary>
<SolidColorBrush x:Key="CardBackgroundColor">#f8f8ff</SolidColorBrush>
<SolidColorBrush x:Key="IndicatorColor">#b39ddb</SolidColorBrush>
<!--其他资源-->
</ResourceDictionary>
</Application.Resources>

然后在SettingPanel.xaml中添加一个开关按钮,使用CheckedUnchecked绑定打开和关闭开关的两个事件。

开关按钮示例

1
2
3
4
5
6
7
8
<ToggleButton
x:Name="DarkModeToggleButton"
Margin="10,0,0,0"
Background="{DynamicResource IndicatorColor}"
Checked="DarkModeToggleButton_Checked"
Effect="{StaticResource MaterialDesignElevationShadow2}"
Style="{StaticResource MaterialDesignSwitchLightToggleButton}"
Unchecked="DarkModeToggleButton_Unchecked" />

然后编辑SettingPanel.xaml.cs中对应的方法。起初我把切换主题的所有代码都放在了设置窗口中,但是我在点击开关时发现,只有设置窗口自己的主题颜色改变了,主窗口只是切换成了Material Design的内置暗色主题,我自定义的颜色并没有改变,后来发现需要在主窗口中强制刷新Dynamic Resources才能更改自定义的颜色,索性直接把切换主题的代码放到了主窗口里。

设置窗口的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//SettingPanel.xaml.cs
//仅强制当前窗口刷新Dynamic Resources

//切换暗色主题
private void DarkModeToggleButton_Checked(object sender, RoutedEventArgs e)
{
if (Owner is MainWindow mainWindow)
{
mainWindow.SwitchToDarkMode();
}
//强制当前窗口刷新Dynamic Resources
Resources["CardBackgroundColor"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#3d3d3d"));
Resources["IndicatorColor"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#ffb330"));
}

//切换亮色主题
private void DarkModeToggleButton_Unchecked(object sender, RoutedEventArgs e)
{
if (Owner is MainWindow mainWindow)
{
mainWindow.SwitchToLightMode();
}
//强制当前窗口刷新Dynamic Resources
Resources["CardBackgroundColor"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#f8f8ff"));
Resources["IndicatorColor"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#b39ddb"));
}

主窗口中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
//MainWindow.xaml.cs
//切换主题的主要代码

//切换至深色模式
public void SwitchToDarkMode()
{
var paletteHelper = new PaletteHelper();
//获取主题自带配色琥珀色
PrimaryColor primary = PrimaryColor.Amber;
Color primaryColor = SwatchHelper.Lookup[(MaterialDesignColor)primary];

//切换自定义配色,背景颜色为深灰色,组件颜色为金黄色
Resources["CardBackgroundColor"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#3d3d3d"));
Resources["IndicatorColor"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#ffb330"));

//切换至Material Design自带的深色主题
ITheme theme = paletteHelper.GetTheme();
theme.SetBaseTheme(Theme.Dark);
theme.SetPrimaryColor(primaryColor);
paletteHelper.SetTheme(theme);
}

public void SwitchToLightMode()
{
var paletteHelper = new PaletteHelper();
//获取主题自带配色深紫色
PrimaryColor primary = PrimaryColor.DeepPurple;
Color primaryColor = SwatchHelper.Lookup[(MaterialDesignColor)primary];

//切换自定义配色,背景颜色为烟灰色,组件颜色为淡紫色
Resources["CardBackgroundColor"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#f8f8ff"));
Resources["IndicatorColor"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#b39ddb"));

//切换至Material Design自带的亮色主题
ITheme theme = paletteHelper.GetTheme();
theme.SetBaseTheme(Theme.Light);
theme.SetPrimaryColor(primaryColor);
paletteHelper.SetTheme(theme);
}

最后给需要应用自定义配色的组件设置Background="{DynamicResource IndicatorColor}"或者BorderBrush="{DynamicResource IndicatorColor}"属性,不再使用Material Design的Primary Color

模拟点击

模拟鼠标点击需要调用user32.dlluser32.dll是 Windows 操作系统的一个核心 DLL,包含了很多用户界面相关的函数,如创建窗口、显示消息框、处理输入等等。user32.dll需要通过P/Invoke(Platform Invocation Services)在.NET程序中进行调用,代码为[DllImport("user32.dll")],相应托管方法的声明中需要添加extern关键字来说明这是一个外部方法。

我新建了一个名为User32API.cs的类来存放与user32.dll相关的代码。具体的点击分为两步,第一步是通过SetCursorPos函数将光标移动到用户设定的屏幕坐标点,第二步是通过mouse_event函数根据用户输入的点击类型进行点击。

SetCursorPos

SetCursorPos 函数用于将鼠标光标移动到指定的屏幕坐标位置。函数原型如下:

1
BOOL SetCursorPos(int X, int Y);

参数说明:

  • X:目标屏幕像素坐标的 X 值,即水平位置。
  • Y:目标屏幕像素坐标的 Y 值,即垂直位置。

该函数调用后会将鼠标光标移动到指定的坐标位置,无论此时鼠标是否在活动窗口内。另外这个函数不会显示移动的过程。

如果提供的 X 或 Y 值超出了屏幕的边界,系统会将鼠标位置限制在屏幕的可见区域内。例如,如果你将 X 设置为一个负数,系统会将其限制在左边界上,而如果你将 X 设置为一个大于屏幕宽度的值,系统会将其限制在右边界上。同样的逻辑也适用于 Y 值。

SetCursorPos引用示例

1
2
[DllImport("user32.dll")]
private static extern int SetCursorPos(int x, int y);

mouse_event

mouse_event 函数用于模拟鼠标操作,如鼠标的点击、移动、滚动等。函数原型如下:

1
2
3
4
5
6
7
VOID mouse_event(
DWORD dwFlags,
DWORD dx,
DWORD dy,
DWORD dwData,
ULONG_PTR dwExtraInfo
);

参数解释:

  • dwFlags是表示鼠标操作的标志位,指定了操作类型(如点击、移动、滚动等)
    • 常用操作类型

      类型数值说明
      MOUSEEVENTF_ABSOLUTE0x8000绝对坐标标志
      MOUSEEVENTF_LEFTDOWN0x0002按下左键
      MOUSEEVENTF_LEFTUP0x0004抬起左键
      MOUSEEVENTF_RIGHTDOWN0x0008按下右键
      MOUSEEVENTF_RIGHTUP0x0010抬起右键
  • dx和dy有两种用法
    • 如果使用 MOUSEEVENTF_ABSOLUTE 标志,那么 dxdy 就被解释为绝对的屏幕坐标。这时候,它们的取值范围通常是从 0 到 65535。这是因为屏幕坐标系统通常是一个0到65535的范围,其中 (0, 0) 表示屏幕的左上角,(65535, 65535) 表示屏幕的右下角。如果你想要模拟鼠标在屏幕上的绝对移动,需要使用绝对坐标值。
      • 示例:将鼠标移动到屏幕中心

        1
        2
        3
        4
        5
        int screenWidth = 1920;
        int screenHeight = 1080;
        int centerX = (screenWidth * 65535) / 2;
        int centerY = (screenHeight * 65535) / 2;
        mouse_event(MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE, centerX, centerY, 0, 0);
    • 如果不使用 MOUSEEVENTF_ABSOLUTE 标志,dxdy 就被解释为相对移动的像素数。这时候,它们的取值范围通常是一个有符号的16位整数,负值表示向左或向上移动,正值表示向右或向下移动。
  • dwData用于标识滚轮滚动距离,大于零表示向上移动,小于零表示向下移动。如果dwFlags中不包含鼠标滚动事件,则设置为0
  • dwExtraInfo表示额外的信息,通常设置为0

模拟点击示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private const int MOUSEEVENT_LEFTDOWN = 0x0002;
private const int MOUSEEVENTF_LEFTUP = 0x0004;
private const int MOUSEEVENTF_RIGHTDOWN = 0x0008;
private const int MOUSEEVENTF_RIGHTUP = 0x0010;
private const int MOUSEEVENTF_ABSOLUTE = 0x8000;

//右键单击
mouse_event(MOUSEEVENTF_RIGHTDOWN | MOUSEEVENTF_RIGHTUP, 0, 0, 0, 0);

//左键单击
mouse_event(MOUSEEVENT_LEFTDOWN | MOUSEEVENTF_LEFTUP, 0, 0, 0, 0);

//左键双击(也就是两次左键单击)
mouse_event(MOUSEEVENT_LEFTDOWN | MOUSEEVENTF_LEFTUP, 0, 0, 0, 0);
mouse_event(MOUSEEVENT_LEFTDOWN | MOUSEEVENTF_LEFTUP, 0, 0, 0, 0);

捕获鼠标坐标

让用户直接输入要点击的坐标并不现实,因为屏幕上并没有像素标尺告诉用户光标目前在哪里,所以需要设计一个能够实时显示光标坐标,并且在用户按下鼠标左键后捕获当前光标像素坐标的窗口。另外因为我在程序中增加了区域内随机点击的支持,所以这个窗口也需要支持通过框选获像素坐标。

创建窗口

  1. 首先新建一个窗口
  2. 将窗口状态设置为最大化并置顶窗口,且不使用默认的窗口样式
  3. 允许窗口透明并将透明度设置为0.5,用户可以透过半透明的窗口看到原本显示的内容。
  4. 绑定键盘输入事件KeyDown,实现当用户按下Esc键时关闭窗口的功能
  5. 绑定鼠标移动事件MouseMove,实现实时更新光标的像素坐标
  6. 绑定鼠标左键的按下和抬起事件,实现点选获取坐标,框选获取坐标范围
  7. 添加两个Label,随鼠标移动更新像素坐标
  8. 添加一个Rectangle,用于框选范围预览

捕获鼠标坐标窗口Window Tag示例

1
2
3
4
5
6
7
8
9
10
11
<Window
AllowsTransparency="True"
Background="Gray"
KeyDown="PositionSelector_KeyDown"
MouseLeftButtonDown="PositionSelectorCanvas_MouseLeftButtonDown"
MouseLeftButtonUp="PositionSelectorCanvas_MouseLeftButtonUp"
MouseMove="PositionSelectorCanvas_MouseMove"
Opacity="0.5"
Topmost="True"
WindowState="Maximized"
WindowStyle="None">

获取光标坐标

我们可以使用e.GetPosition(this)来获取坐标,GetPosition是在 WPF 中用于获取鼠标或者触摸操作相对于一个指定元素的本地坐标的方法。它返回的是Point对象,Point 是一个表示二维坐标点的结构。它具有两个属性:XY,分别表示点的水平和垂直坐标,Point.XPoint.Y都是double类型。

注意这里获取到的是窗口内地本地坐标,并不是屏幕的绝对像素坐标,需要通过PointToScreen方法将它转化为屏幕坐标。PointToScreen方法接受一个 Point 对象,这个 Point 对象代表了相对于调用这个方法的元素的坐标。方法会返回一个 Point 对象,表示该坐标在屏幕上的绝对位置。

框选范围预览与捕获

预览框需要随着光标的移动而实时变化,每次触发鼠标移动事件MouseMove时都要重新设定矩形的长宽。由于预览框是在窗口内绘制的,所以这里直接使用窗口本地坐标。另外还需要考虑到用户反向框选的可能性,即从右下角向左上角框选,所以矩形的长度和宽度需要取两个点位坐标差的绝对值。

如果用户开启了在随机范围内点击,那么程序需要捕获两个屏幕坐标:框选的起始点与结束点,也就是鼠标左键按下和抬起的点位。我使用了变量w表示矩形的宽度,变量h表示矩形的高度,w与h的值就是两个点位的水平和垂直坐标的差的绝对值。

框选相关代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
//屏幕像素坐标
private Point mouseDownPoint;
private Point mouseUpPoint;
private Point currentPoint;

private bool randomAreaSelection = false;//开启随机点击
private bool isDragging = false;

//框选范围参数
private int x = 0;
private int y = 0;
private int w = 0;
private int h = 0;

//本地窗口坐标
private Point previewRectangleStartPoint;
private Point previewRectangleEndPoint;

//鼠标左键按下
private void PositionSelectorCanvas_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
//框选的起始点
mouseDownPoint = PointToScreen(e.GetPosition(this));
x = (int)mouseDownPoint.X;
y = (int)mouseDownPoint.Y;

//如果开启范围内随机点击
if (randomAreaSelection)
{
//预览框的起始点
previewRectangleStartPoint = e.GetPosition(this);
isDragging = true;
//显示预览框
SelectedAreaPreview.Visibility = Visibility.Visible;
}
}

//鼠标左键抬起
private void PositionSelectorCanvas_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
//如果开启范围内随机点击
if (randomAreaSelection)
{
//框选的结束点
mouseUpPoint = PointToScreen(e.GetPosition(this));
w = (int)mouseUpPoint.X - x;
h = (int)mouseUpPoint.Y - y;

//如果用户反向框选,则转化相关参数
if (w < 0)
{
x += w;
w *= -1;
}
if (h < 0)
{
y += h;
h *= -1;
}
isDragging = false;
//隐藏预览框
SelectedAreaPreview.Visibility = Visibility.Hidden;
}
}

//鼠标移动
private void PositionSelectorCanvas_MouseMove(object sender, MouseEventArgs e)
{
//获取光标的实时屏幕坐标并更新Label中的内容
currentPoint = PointToScreen(e.GetPosition(this));
XCoordsPreview.Content = (int)currentPoint.X;
YCoordsPreview.Content = (int)currentPoint.Y;

//预览框拖拽点
previewRectangleEndPoint = e.GetPosition(this);
if (randomAreaSelection && isDragging)
{
//更新代表预览框的矩形
SelectedAreaPreview.Margin = new Thickness(
Math.Min((int)previewRectangleStartPoint.X, (int)previewRectangleEndPoint.X),
Math.Min((int)previewRectangleStartPoint.Y, (int)previewRectangleEndPoint.Y),
0, 0);
SelectedAreaPreview.Width = Math.Abs((int)previewRectangleEndPoint.X - (int)previewRectangleStartPoint.X);
SelectedAreaPreview.Height = Math.Abs((int)previewRectangleEndPoint.Y - (int)previewRectangleStartPoint.Y);
}
}

存储用户配置

存储用户的自定义配置,当程序启动时自动加载并恢复用户配置,当程序关闭时刷新并保存用户配置。

首先打开./Properties/Settings.settings,添加需要保存的属性,设置它们的初始值与类型。然后在主窗口的Window Tag中绑定窗口加载事件Loaded="Window_Loaded"。最后在代码中使用Properties.Settings.Default.[属性名称]来读取相关属性。

在窗口关闭事件Close()前使用Properties.Settings.Default.Save();来保存相关属性。

热键设置

如果用户将点击的间隔设置的比较小,将无法通过点击停止按钮来提前结束模拟点击,因为程序会一直将光标移动到预设的点位,所以必须实现使用热键来停止点击的功能。另外由于本程序的热键可能与用户使用的其他程序的热键产生冲突,所以需要实现允许用户自定义热键的功能。

录入热键

UI

我在设置窗口中添加了一个文本框来记录用户按下的键位,然后添加了一个完成按钮来触发后续操作。

文本框需要设置为只读,并绑定PreviewKeyDown事件。与普通的KeyDown事件不同,PreviewKeyDown事件是一个隧道事件,它从父元素沿着视觉树向下传递,然后再从目标元素冒泡回来。这个事件的作用是允许在按下键之后,但还没有释放键之前,对键盘事件进行预处理或拦截。

文本框示例

1
2
3
4
5
6
7
<TextBox
x:Name="HotKeyTextBox"
VerticalContentAlignment="Center"
IsReadOnly="true"
PreviewKeyDown="HotKeyTextBox_PreviewKeyDown"
TextWrapping="NoWrap"
VerticalScrollBarVisibility="Hidden" />

键盘事件预处理

PreviewKeyDown事件被触发后,程序会判断用户按下的键位是否合法,因为有一些特殊的键位不能用作热键,需要排除掉它们。处理完成后再将用户按下的键位以字符串的形式返回到文本框中。

用户按下的键位分为两类,一类是Key,另一类是ModifierKeys,它们都是用于处理键盘输入的枚举类型。

  • Key:Key定义了所有可能的键盘按键,包括字母、数字、功能键、控制键等。通过这个枚举可以判断用户按下了哪个键。以下是一些 Key 枚举的示例:
    • Key.A: 表示字母 A 键
    • Key.Escape: 表示 Esc 键
    • Key.Enter: 表示 Enter 键
    • Key.Space: 表示空格键
    • 完整的枚举详见微软官方文档
  • ModifierKeys:ModifierKeys定义了一组修饰键,这些键通常与其他键一起使用,以执行某些特定操作。这些修饰键包括 Shift、Ctrl、Alt 和 Windows 键。以下是 ModifierKeys 枚举的一些示例:
    • ModifierKeys.None: 无修饰键被按下
    • ModifierKeys.Shift: Shift 键被按下
    • ModifierKeys.Control: Ctrl 键被按下
    • ModifierKeys.Alt: Alt 键被按下

由于Key枚举中部分键位与ModifierKeys中的修饰键是同一个键位,所以要将这些键位排除掉。

键盘事件预处理代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
private Key hotKey;
private ModifierKeys hotKeyModifiers;

//键盘预处理事件
private void HotKeyTextBox_PreviewKeyDown(object sender, KeyEventArgs e)
{
try
{
//判断Key是否合法
if (IsForbiddenKey(e.Key))
{
// 如果按键非法则阻止按键事件继续传播
e.Handled = true;
return;
}
//将用户输入的键位赋值到变量中
hotKey = e.Key;
hotKeyModifiers = Keyboard.Modifiers;
UpdateHotKeyText();
}
catch (Exception ex)
{
MessageBox.Show(ex.ToString(), "Error!", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
}

//更新文本框
private void UpdateHotKeyText()
{
HotKeyTextBox.Text = $"{hotKeyModifiers} + {hotKey}";
}

private bool IsForbiddenKey(Key key)
{
return Enum.IsDefined(typeof(ForbiddenKeys), key.ToString());
}

//非法Key枚举
public enum ForbiddenKeys
{
Back,
Capital, CapsLock,
Delete, Down,
End, Enter, Escape,
Home,
Insert,
Left, LeftAlt, LeftCtrl, LeftShift, LWin,
Next, NumLock,
PageDown, PageUp, Pause, PrintScreen,
Return, Right, RightAlt, RightCtrl, RightShift, RWin,
System,
Tab,
Up,
VolumeDown, VolumeMute, VolumeUp
};

注册/注销全局热键

全局热键的注册与注销也会使用到user32.dll,注册使用的函数是RegisterHotKey。注销使用的函数是UnregisterHotKey。建议在程序启动时注册全局热键,并在退出时注销,以免与其他程序的热键产生冲突。

RegisterHotKey 函数

用途:在操作系统中注册一个全局热键,该热键可以由用户定义,并且在应用程序处于后台运行或没有焦点时也可以触发。

1
2
3
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
static extern bool RegisterHotKey(IntPtr hWnd, int id, ModifierKeys fsModifiers, uint vk);

参数:

  • hWnd: 要接收热键通知的窗口的句柄,通常是主窗口的句柄。
  • id: 热键的标识符。用于标识不同的热键。
  • fsModifiers: 热键修饰符,如 Ctrl、Alt、Shift 等。
  • vk: 要与热键一起使用的虚拟键码,可通过Key转化。

默认情况下,如果成功注册热键,则返回非零值;如果失败,则返回零。但是为了后续处理方便,这里使用了MarshalAs将返回值转换为布尔类型。

UnregisterHotKey 函数:

用途:取消先前注册的全局热键。

1
2
[DllImport("user32.dll")]
static extern bool UnregisterHotKey(IntPtr hWnd, int id);

参数:

  • hWnd: 先前注册热键时提供的窗口句柄。
  • id: 先前注册热键时提供的热键标识符。

绑定热键功能

User32API.cs创建一个新的方法Regist用来绑定热键的功能。当程序启动时,主窗口会从用户配置中读取并传入Key与ModifierKeys,然后通过回调函数传入需要绑定热键的命令。

主窗口代码

1
2
3
4
5
6
7
8
9
10
11
12
//重写窗口初始化函数
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);

//向User32API.cs中的Regist方法传递相关参数
Regist(this, hotKeyModifiers, hotKey, () =>
{
if (StopButton.IsEnabled) { StopButton.RaiseEvent(new RoutedEventArgs(System.Windows.Controls.Primitives.ButtonBase.ClickEvent)); }
else { StartButton.RaiseEvent(new RoutedEventArgs(System.Windows.Controls.Primitives.ButtonBase.ClickEvent)); }
});
}

此外我还在这里定义了一个委托类型HotKeyCallBackHanlder,它是一个指向函数的指针,用于定义在全局热键被触发时要执行的操作。

委托 delegate 是存有对某个方法的引用的一种引用类型变量,它允许我们定义方法的签名,然后可以将这个签名用作方法参数,或者将其赋值给其他方法变量,以便在运行时动态调用方法。

注册热键代码及说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public delegate void HotKeyCallBackHanlder();
public static HotKeyCallBackHanlder hotKeyCallBackHanlder = null;

public static void Regist(Window window, ModifierKeys fsModifiers, Key key, HotKeyCallBackHanlder callBack)
{
//获取窗口的句柄
var hwnd = new WindowInteropHelper(window).Handle;
//使用窗口句柄创建一个 HwndSource 对象
var _hwndSource = HwndSource.FromHwnd(hwnd);
//将消息处理委托 WndProc 添加到窗口的消息处理链中,窗口收到消息后会先传递给 WndProc 方法进行处理
_hwndSource.AddHook(WndProc);

//将传入的Key转化为虚拟键码vk
var vk = KeyInterop.VirtualKeyFromKey(key);

//如果注册失败则弹窗报错
if (!RegisterHotKey(hwnd, HOTKEY_ID, fsModifiers, (uint)vk))
MessageBox.Show("Failed to register global hotkey!");

//将回调函数赋值给了 hotKeyCallBackHanlder 变量。这个回调函数就是在注册时由主窗口传入的 callBack 参数
hotKeyCallBackHanlder = callBack;
}

处理热键消息

在 Windows 中,WndProc 是用于处理窗口消息的函数。当注册的热键被触发时,系统会发送一个窗口消息给指定的窗口句柄,然后我们就可以在 WndProc 函数中处理这个消息。

处理窗口消息 WndProc

1
2
3
4
5
6
7
8
9
static IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
//当收到 WM_HOTKEY 消息并且热键 ID 匹配时,会调用 hotKeyCallBackHanlder(),即执行了预先设置的回调函数。
if (msg == WM_HOTKEY && wParam.ToInt32() == HOTKEY_ID)
{
hotKeyCallBackHanlder();
}
return IntPtr.Zero;
}

更改热键

User32API.cs中创建一个新的方法ModifyGlobalHotKey。当用户在设置窗口中录入热键并点击完成按钮后,程序会更新存储热键的变量值,并将更新后热键的值传递给User32API.cs

ModifyGlobalHotKey会先注销先前的热键,然后尝试注册新的热键,如果注册失败则弹窗报错。

更改热键 ModifyGlobalHotKey

1
2
3
4
5
6
7
8
public static void ModifyGlobalHotKey(WindowInteropHelper windowInteropHelper, Key key, ModifierKeys modifiers)
{
//注销先前的热键
UnregisterHotKey(windowInteropHelper.Handle, HOTKEY_ID);
//尝试注册新的热键
if (!RegisterHotKey(windowInteropHelper.Handle, HOTKEY_ID, modifiers, (uint)KeyInterop.VirtualKeyFromKey(key)))
MessageBox.Show("Failed to register global hotkey!");
}

读取和保存热键

由于组成热键的Key与ModifierKeys都是枚举类型,而./Properties/Settings.settings并不支持存储枚举类型,所以保存时要使用ToString()先将它们转化为字符串类型。

保存热键代码

1
2
3
4
5
6
7
8
9
10
private Key hotKey;
private ModifierKeys hotKeyModifiers;

private void CloseButton_Click(object sender, RoutedEventArgs e)
{
Properties.Settings.Default.HotKey = hotKey.ToString();
Properties.Settings.Default.Modifiers = hotKeyModifiers.ToString();
Properties.Settings.Default.Save();
//Close();
}

在程序启动时再通过Enum.Parse()来将字符串转换为枚举类型。这里需要将相关代码放在窗口初始化的代码中,如果放在窗口加载的代码中这段代码好像不会被执行,很迷惑。

读取热键代码

1
2
3
4
5
6
7
8
9
10
11
private Key hotKey;
private ModifierKeys hotKeyModifiers;

public MainWindow()
{
InitializeComponent();

//读取热键并转换类型
hotKey = (Key)Enum.Parse(typeof(Key), Properties.Settings.Default.HotKey);
hotKeyModifiers = (ModifierKeys)Enum.Parse(typeof(ModifierKeys), Properties.Settings.Default.Modifiers);
}

本地化

WPF程序的本地化有多种实现方法,例如使用资源字典,使用NuGet包,使用.resx资源文件等,我用的是比较方便的.resx资源文件。

创建资源文件

  1. 在VS的解决方案资源管理器中打开./Properties/Resources.resx,这里的结构类似于c#中的Dictionary<string, string>,名称相当于Key,值就是Value
  2. 填写默认语言资源键值
  3. 访问修饰符Internal更改为Public
  4. 添加一个新的资源文件,并命名为Resources.[LCID].resx,这里的[LCID]是Windows语言代码标识符,例如简体中文的资源文件应该被命名为Resources.zh-CN.resx,完整的语言代码标识符详见MS-LCID
    bpc_resx1.png
    bpc_resx2.png
  5. Resources.resx中的内容复制到新建的资源文件中,然后根据语言修改对应的值
    bpc_resx3.png

引用

XAML

在xaml文件中添加新的命名空间引用,将PackageName替换为你的项目名称

1
xmlns:lang="clr-namespace:[PackageName].Properties"

在文本的内容中使用x:Static来引用资源,下面的示例中KeyName就是语言资源文件中的键值Name

1
Text="{x:Static lang:Resources.KeyName}"

Code

代码中可以直接引用

1
var example = Properties.Resources.KeyName;

运行

我实现的功能是程序启动时,根据当前系统的语言标识自动切换至对应的语言,目前仅支持简体中文与英文。在代码中可以使用CultureInfo.CurrentUICulture.Name来获取当前系统的语言标识。实现此功能需要修改App.xaml.cs,完整代码如下

1
2
3
4
5
6
7
8
9
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
if (CultureInfo.CurrentUICulture.Name == "zh-CN")//如果当前系统语言标识为`zh-CN`简体中文
{
Thread.CurrentThread.CurrentUICulture = new CultureInfo("zh-CN");//将程序语言设置为简体中文,使用./Properties/Resources.zh-CN.resx
}
//否则使用./Properties/Resources.resx中设置的默认语言
}

打包程序

VS生成的解决方案文件夹中有很多文件,包括图片、音频等资源文件以及所需的DLL运行库,这些都是程序运行所需的文件,也被称为依赖项。如果尝试单独将其中的exe文件拷贝到其他的目录,它是无法运行的,因为缺少了依赖项。由此带来的问题是:用户需要下载包含依赖项的整个文件夹才能使用这个软件,这非常的不方便。所以这里需要解决的问题就是如何将所需要的文件全部打包到一个单一的exe中,让用户能够开箱即用。解决方案是安装两个NuGet包:Costura.FodyResource.Embedder

Costura.Fody

Costura.Fody 是Fody的一个开源附加程序,它的主要功能是将程序集中的依赖项嵌入到主程序集中,以减少发布和部署时的外部依赖。它通过在编译时修改 IL(Intermediate Language)代码来实现这一点,因此我们无需担心在运行时缺少外部依赖的问题。

安装与使用

在VS的NuGet管理器中搜索并找到Costura.Fody就能够安装。由于它是Fody的附加程序,在安装的时候会同时安装Fody与很多的依赖项,大概有40个(两眼一黑.jpg)

安装完成后,新建一个名为FodyWeavers.xml的文件,并添加到解决方案资源管理器的根目录中。然后将下面的内容粘贴到文件中并保存就可以了。

1
2
3
4
<?xml version="1.0" encoding="utf-8"?>
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<Costura />
</Weavers>

此时在VS中重新生成解决方案,可以看到生成的exe文件的体积比原来大了很多,将它单独拷贝到其他的目录也能够正常使用。

新的问题

我在为程序添加本地化支持的时候遇到了新的问题,新创建的.resx语言文件没有被Costura.Fody正确的添加到exe中,导致程序运行时始终只能显示英语。如果只是在VS中调试,程序是能够以新添加的语言显示的,但是将exe文件单独拷贝出来后,就变回了默认语言。

此时./bin/Debug目录中的文件结构为:

1
2
3
4
5
6
7
8
./Debug

├──ProjectName.exe

└──./zh-CN

└──xxxxx.dll

根据查阅到的资料,我推断这是因为本地化相关的资源属于附属程序集(Satellite Assembly),附属程序集的资源在特定区域中是可用的,只有当用户选择了相应的区域设置时,相关资源才会被加载。而当Costura.Fody运行时,因为新的语言文件并没有被选择并加载,所以只有默认的语言文件被嵌入,在打包完成后新的语言文件才会被加载到解决方案的目录中。

相关的问题在GitHub中已有讨论:Costura doesn’t embed resource satellite assemblies,并且有人给出了解决方案:安装Resource.Embedder,与Costura.Fody搭配使用。

附属程序集

附属程序集

以下是来自ChatGPT的说明

在 WPF中,主程序集和附属程序集是两种不同的程序集概念,它们在应用程序的组织、部署和访问资源方面有一些不同之处。

主程序集(Primary Assembly)

主程序集是应用程序的核心程序集,它通常包含应用程序的启动代码、主要逻辑和主窗口等。主程序集可以包含各种资源,如 XAML 文件、图像、样式、模板等。这些资源在应用程序的整个生命周期内都可以被访问和使用。主程序集一般具有以下特点:

  • 包含启动点: 主程序集包含应用程序的入口点,这是应用程序启动时执行的代码。
  • 全局资源: 主程序集中的资源可以被整个应用程序访问,无需特定的引用。
  • 常驻内存: 主程序集中的资源在应用程序运行期间一直保持在内存中,除非显式释放。

附属程序集(Satellite Assembly)

附属程序集是用于本地化和资源管理的辅助程序集。它们包含应用程序的本地化文本、图像和其他资源的翻译或本地化版本。附属程序集使应用程序能够根据用户的语言和地区选择正确的资源。附属程序集一般具有以下特点:

  • 本地化资源: 附属程序集包含本地化或不同语言版本的资源,用于适应不同的语言和地区。
  • 随需加载: 附属程序集在需要时才会被加载到内存中,以减少内存占用。
  • 命名规则: 附属程序集的命名通常基于语言和区域设置,如AssemblyName.resources.dll。
  • 特定于区域: 附属程序集的资源在特定区域中是可用的,只有当用户选择了相应的区域设置时,相关资源才会被加载。

总结

主程序集包含应用程序的主要逻辑和资源,附属程序集用于本地化和资源管理。主程序集包含应用程序的核心代码和全局资源,而附属程序集包含特定语言和地区的本地化资源。通过将资源分为主程序集和附属程序集,WPF 应用程序可以实现多语言支持、资源管理和更好的内存利用。

注意事项

在使用Costura.Fody重新生成解决方案后,VS中xaml文件的预览会报错,这是因为Costura.Fody将资源全部嵌入了exe文件,导致VS的设计环境无法根据原有的相对路径找到资源,从而产生预览报错。在设计与预览时将FodyWeavers.xml中的<Costura />暂时注释掉就可以解决。

Resource.Embedder

Resource.Embedder 是一个用于将资源文件嵌入到 .NET 程序集中的工具。它的工作原理是通过创建一个虚拟的文件系统,将资源文件嵌入到其中,然后由 Costura.Fody 将这个虚拟文件系统嵌入到主程序集中,从而实现了附属程序集的嵌入。

安装方法同上,直接在VS的NuGet管理器中搜索并安装,安装完成后不需要进行任何的设置。此时在VS中重新生成解决方案,此时exe文件已经能够以新的语言显示并单独运行。

.Net Framework

.NET Framework 是微软开发的一种用于在 Windows 操作系统上构建和运行应用程序的开发框架。程序使用了.Net Framework 4.8 作为目标框架。

Windows系统从 Windows 10 May 2019 Update(版本号 1903)后开始预装.Net Framework 4.8,所以如果在更早版本的Windows上运行此程序需要额外安装.Net Framework 4.8

.NET Framework 版本与 Windows 版本的关系

  • .NET Framework 1.0 和 1.1: 首次发布时与 Windows XP 兼容。
  • .NET Framework 2.0: 兼容 Windows XP、Windows Server 2003 和 Windows Vista。
  • .NET Framework 3.0 和 3.5: 内置于 Windows Vista 和 Windows Server 2008 中,也可以通过更新包安装在 Windows XP 和 Windows Server 2003 上。
  • .NET Framework 4.0: 兼容 Windows XP、Windows Server 2003、Windows Vista、Windows 7、Windows Server 2008 和 Windows Server 2008 R2。
  • .NET Framework 4.5 到 4.8: 兼容 Windows 7、Windows 8、Windows 8.1、Windows 10,以及相应的 Windows Server 版本。

.Net Core 与 .Net Framework

.NET Core 和 .NET Framework 都是由 Microsoft 开发的跨平台开发框架,这里简单记录下.Net Core 与 .Net Framework的区别。

  1. 跨平台支持
    • .NET Core:设计初衷就是跨平台的,可以在 Windows、Linux 和 macOS 上运行。
    • .NET Framework 主要面向 Windows 平台,不能直接在其他操作系统上运行。
  2. 开源性质
    • .NET Core:是完全开源的,其源代码托管在 GitHub 上。
    • .NET Framework:虽然有一些开源的组件和工具,但整体不是完全开源的。
  3. 依赖关系和部署
    • .NET Core:采用 NuGet 管理依赖,应用程序可以自包含地部署所需的运行时库。
    • .NET Framework:应用程序依赖于 GAC(全局程序集缓存),开发人员需要确保目标计算机上存在所需的 .NET Framework 版本。
  4. 版本和更新
    • .NET Core:发布周期相对较短,更新频繁,每次更新都有新的功能和改进。
    • .NET Framework:发布周期较长,更新相对较慢,以 Service Pack 或更大的版本形式发布。
  5. API 支持
    • .NET Core:提供了更现代和精简的 API 集,一些 Windows 特定的功能可能在 .NET Core 中不支持。
    • .NET Framework:提供了丰富的 Windows API 支持,可以访问广泛的 Windows 功能。
  6. WPF 和 WinForms
    • .NET Core:目前不支持原生的 WPF 和 WinForms 应用程序开发,但可以通过一些工具和库实现。
    • .NET Framework:提供完整的 WPF 和 WinForms 支持。
  7. 性能
    • .NET Core:由于设计轻量,跨平台特性,通常比 .NET Framework 在性能方面更优。
    • .NET Framework:针对 Windows 平台进行了优化,但在跨平台和性能方面可能相对较弱。

吐槽:怎么写文档比做UI还累。。。。。。