【键盘测试】05-WPF 可视化键盘布局配置 + 全局钩子按键检测实战

【键盘测试】05-WPF 可视化键盘布局配置 + 全局钩子按键检测实战

系列:《WPF 产线功能测试实战》第五篇

关键词:WPF 键盘测试、全局键盘钩子、ScanCode、Windows消息机制、组合键检测、产线测试


第一章:前言与应用场景

在笔记本电脑产线测试中,键盘测试是必不可少的一环。由于不同尺寸机型的按键数量差异显著(14寸通用键盘 vs 16寸带数字小键盘),同一套测试程序必须支持灵活配置------你无法为每款机型都写死一套按键列表。

更棘手的是,有些按键根本无法被标准系统 API 捕获:FN 功能键不上报给操作系统、ScreenXpert 等厂商定制键只走私有 Windows 消息通道、媒体热键只有 ScanCode 可靠......

本文介绍一套完整的 WPF 键盘测试方案:通过可视化配置界面,支持14/16寸预设模板、普通按键监听添加、ScanCode/Windows消息/组合键三种特殊键检测方式、可视化拖放排列,配置完成后一键进入测试,所有按键按下抬起即自动判定通过。


第二章:基础概念科普

在深入代码之前,先把几个容易混淆的概念说清楚。

2.1 三种按键捕获方式,各有适用场景

VirtualKey(虚拟键码) :Windows 定义的逻辑按键编号,WPF 的 Key 枚举就是对它的封装。大多数按键(字母、数字、方向键)都有对应的 VirtualKey,通过 KeyDown 事件即可获取。局限在于:FN 键、某些媒体键、厂商定制键没有 VirtualKey 定义。

ScanCode(扫描码) :硬件键盘发出的原始信号,在 VirtualKey 映射之前就存在。每个物理键位都有唯一的 ScanCode,即使 VirtualKey 相同(如小键盘的 1 和主键盘的 1),ScanCode 也不同。通过底层的 WH_KEYBOARD_LL 全局钩子可以拿到 ScanCode。

Windows 消息(WM_xxx) :某些特殊功能键(如 ScreenXpert 键)不走标准键盘通道,而是通过 SendMessage/PostMessage 发送私有 Windows 消息到特定窗口。检测这类按键需要重写窗口的 WndProc,监听特定的 msg + wParam 组合。

组合键(ComboKey):本质上是多个普通键同时按下的判断。比如 FN+F5 这样的组合,系统不上报 FN,但可以通过维护"当前已按下键集合",检测到目标键集合全部在其中时触发。

2.2 全局钩子 vs 窗口事件

WPF 的 PreviewKeyDown 只能捕获焦点在当前窗口时的事件。全局键盘钩子(WH_KEYBOARD_LL)通过 P/Invoke 调用 SetWindowsHookEx,能捕获系统范围内的所有键盘事件,无论焦点在哪------这在产线测试场景下尤为重要(测试窗口不一定始终获焦)。


第三章:方案设计与架构

3.1 整体流程

3.2 核心数据模型

复制代码
KeyInfoPro(按键模型)
├── DisplayName     显示名称(如 "ESC"、"SXpert")
├── Key             WPF Key 枚举(特殊键为 Key.None)
├── KeyType         Normal / Special / Empty(占位格)
├── DetectMethod    ScanCode / WindowsMessage / ComboKey
├── ScanCode        硬件扫描码(十六进制)
├── WindowsMessageCode + MessageWParam   Windows 消息参数
├── ComboKeys       List<Key>,组合键按键列表
└── Row/Column/RowSpan/ColumnSpan  在 Grid 中的位置

3.3 MVVM 分工

职责
KeyInfoPro(Model) 按键数据 + 测试状态(IsKeyDownDetected、IsPassed 等)
MainVM(ViewModel) 启动测试、处理钩子事件、判定全部通过
KeyboardConfigWindow(View) 可视化配置界面,保存 JSON
KeyboardHookPro(Utils) 封装 Win32 全局钩子,对外暴露 KeyEvent 事件

第四章:核心代码详解

4.1 全局键盘钩子(KeyboardHookPro)

这是整个方案的感知层,同时用于配置阶段捕获按键测试阶段检测按键

csharp 复制代码
public class KeyboardHookPro : IDisposable
{
    private const int WH_KEYBOARD_LL = 13;    // 低级键盘钩子类型
    private const int WM_KEYDOWN    = 0x0100;
    private const int WM_KEYUP      = 0x0101;
    private const int WM_SYSKEYDOWN = 0x0104; // Alt 组合键走这个消息
    private const int WM_SYSKEYUP   = 0x0105;

    [StructLayout(LayoutKind.Sequential)]
    public class KBDLLHOOKSTRUCT
    {
        public int vkCode;    // 虚拟键码
        public int scanCode;  // 硬件扫描码 ← 特殊键检测的关键
        public int flags;
        public int time;
        public int dwExtraInfo;
    }

    private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
    {
        if (nCode >= 0)
        {
            bool isKeyDown = (wParam.ToInt32() == WM_KEYDOWN || wParam.ToInt32() == WM_SYSKEYDOWN);
            bool isKeyUp   = (wParam.ToInt32() == WM_KEYUP   || wParam.ToInt32() == WM_SYSKEYUP);

            if (isKeyDown || isKeyUp)
            {
                var hookStruct = (KBDLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(KBDLLHOOKSTRUCT));
                Key key = KeyInterop.KeyFromVirtualKey(hookStruct.vkCode);
                KeyEvent?.Invoke(this, new KeyboardHookEventArgs
                {
                    Key      = key,
                    VkCode   = hookStruct.vkCode,
                    ScanCode = hookStruct.scanCode,  // 直接透传给上层
                    IsKeyUp  = isKeyUp
                });
            }
        }
        return (IntPtr)1; // 返回非零值,阻止按键消息继续传播
    }
}

关键点 :钩子回调里将 scanCode 原始透传给上层,上层再与配置的 ScanCode 比对------这就是为什么能检测到那些没有 VirtualKey 的按键。

4.2 配置界面:三种特殊键检测方式

配置窗口 KeyboardConfigWindow 是本文的核心亮点,支持三种特殊键检测方式,通过下拉框切换不同的输入面板:

csharp 复制代码
private void CmbDetectMethod_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
    var selected = (CmbDetectMethod.SelectedItem as ComboBoxItem)?.Content?.ToString();
    // 根据选择动态显示对应的输入区域
    PanelScanCode.Visibility = selected == "ScanCode"       ? Visibility.Visible : Visibility.Collapsed;
    PanelWinMsg.Visibility   = selected == "WindowsMessage" ? Visibility.Visible : Visibility.Collapsed;
    PanelComboKey.Visibility = selected == "ComboKey"       ? Visibility.Visible : Visibility.Collapsed;
}

添加特殊键的核心逻辑(三分支处理):

csharp 复制代码
private void BtnAddSpecialKey_Click(object sender, RoutedEventArgs e)
{
    var keyInfo = new KeyInfoPro { DisplayName = TxtSpecialName.Text.Trim(), Key = Key.None, KeyType = KeyType.Special };

    var selectedMethod = (CmbDetectMethod.SelectedItem as ComboBoxItem)?.Content?.ToString();

    if (selectedMethod == "ScanCode")
    {
        // 分支1:ScanCode 检测 ------ 输入十六进制扫描码,如 "64" 代表 F5
        keyInfo.DetectMethod = SpecialKeyDetectMethod.ScanCode;
        if (int.TryParse(TxtScanCode.Text.Trim(), NumberStyles.HexNumber,
            CultureInfo.InvariantCulture, out int scanCode))
            keyInfo.ScanCode = scanCode;
    }
    else if (selectedMethod == "WindowsMessage")
    {
        // 分支2:Windows 消息检测 ------ 输入 NotifyCode(wParam),如 "5F" 代表 ScreenXpert 键
        keyInfo.DetectMethod = SpecialKeyDetectMethod.WindowsMessage;
        if (int.TryParse(TxtWParam.Text.Trim(), NumberStyles.HexNumber,
            CultureInfo.InvariantCulture, out int wParam))
            keyInfo.MessageWParam = wParam;
    }
    else if (selectedMethod == "ComboKey")
    {
        // 分支3:组合键 ------ 先点「捕获」按住组合键,松开后自动识别
        if (_capturedComboKeys == null || _capturedComboKeys.Count < 2)
        { MessageBox.Show("请先捕获至少2个按键的组合键"); return; }
        keyInfo.DetectMethod = SpecialKeyDetectMethod.ComboKey;
        keyInfo.ComboKeys    = _capturedComboKeys.ToList();
    }

    ConfiguredKeys.Add(keyInfo);
}

组合键捕获:点击「捕获组合键」按钮后,系统进入捕获模式,实时收集所有 KeyDown 事件,直到第一个按键抬起时结束捕获:

csharp 复制代码
private void OnHookKeyEvent(object sender, KeyboardHookEventArgs e)
{
    if (_isCapturingCombo)
    {
        this.Dispatcher.BeginInvoke(new Action(() =>
        {
            if (!e.IsKeyUp)
            {
                if (!_capturedComboKeys.Contains(e.Key)) _capturedComboKeys.Add(e.Key);
                // 实时更新显示:"捕获中: L_Ctrl + F5"
                TxtComboDisplay.Text = "捕获中: " + string.Join(" + ",
                    _capturedComboKeys.Select(k => GetFriendlyKeyName(k)));
            }
            else
            {
                _isCapturingCombo = false;  // 松键即完成
                TxtComboDisplay.Text = "已捕获: " + string.Join(" + ",
                    _capturedComboKeys.Select(k => GetFriendlyKeyName(k)));
                TxtComboDisplay.Foreground = new SolidColorBrush(Colors.LimeGreen);
            }
        }));
        return;
    }
    // ... 普通键监听逻辑
}

4.3 按键位置管理:移动与删除

配置界面使用 WPF Grid 的 6×20 网格展示按键,空白格用 KeyType.Empty 占位。左键点击实现"选中→点击空白格→移动",右键弹确认对话框删除:

csharp 复制代码
private void KeyPreview_LeftClick(object sender, MouseButtonEventArgs e)
{
    var clickedKey = (fe.DataContext as KeyInfoPro);

    if (clickedKey.KeyType == KeyType.Empty)
    {
        // 点击空白格 → 将已选中的按键移动过来
        if (_selectedKeyForMove != null)
            MoveSpecialKeyToEmptyCell(_selectedKeyForMove, clickedKey);
        return;
    }

    if (!clickedKey.IsFixed)
    {
        // 非固定键(特殊键、手动添加的键)可以被选中
        _selectedKeyForMove = clickedKey;
        clickedKey.IsSelectedForMove = true;  // UI 高亮显示蓝色边框
    }
}

private void MoveSpecialKeyToEmptyCell(KeyInfoPro key, KeyInfoPro emptyCell)
{
    int targetRow = emptyCell.Row, targetCol = emptyCell.Column;
    int oldRow = key.Row,         oldCol    = key.Column;

    ConfiguredKeys.Remove(emptyCell);
    ConfiguredKeys.Remove(key);

    key.Row = targetRow; key.Column = targetCol;

    // 原位置变成新的空白格
    ConfiguredKeys.Add(new KeyInfoPro { KeyType = KeyType.Empty, Row = oldRow, Column = oldCol });
    ConfiguredKeys.Add(key);
}

4.4 测试阶段:三路检测分发

测试开始后,所有按键事件统一进入 OnKeyEvent,按优先级依次匹配:

csharp 复制代码
private void OnKeyEvent(object sender, KeyboardHookEventArgs e)
{
    Application.Current.Dispatcher.Invoke(() =>
    {
        // ① ScanCode 优先匹配(不受输入法影响,最可靠)
        var scanCodeMatch = KeyboardKeys.FirstOrDefault(k =>
            k.KeyType == KeyType.Special &&
            k.DetectMethod == SpecialKeyDetectMethod.ScanCode &&
            k.ScanCode == e.ScanCode && !k.IsSpecialDetected);

        if (scanCodeMatch != null) { scanCodeMatch.IsSpecialDetected = true; CheckAllKeysPassed(); return; }

        // ② 组合键检测(维护当前按下键集合)
        if (!e.IsKeyUp)
        {
            _currentlyPressedKeys.Add(e.Key);
            foreach (var comboKey in KeyboardKeys.Where(k =>
                k.KeyType == KeyType.Special &&
                k.DetectMethod == SpecialKeyDetectMethod.ComboKey &&
                !k.IsSpecialDetected))
            {
                if (comboKey.ComboKeys.All(ck => _currentlyPressedKeys.Contains(ck)))
                {
                    comboKey.IsSpecialDetected = true;
                    // 回滚组合键中已单独检测的普通键 KeyDown 状态
                    foreach (var ck in comboKey.ComboKeys) _comboConsumedKeys.Add(ck);
                    CheckAllKeysPassed(); return;
                }
            }
        }

        // ③ 普通键:需要同时检测 KeyDown + KeyUp 才算通过
        var normalKey = KeyboardKeys.FirstOrDefault(k => k.KeyType == KeyType.Normal && k.Key == e.Key);
        if (normalKey != null)
        {
            if (!e.IsKeyUp && !normalKey.IsKeyDownDetected) normalKey.IsKeyDownDetected = true;
            else if (e.IsKeyUp && normalKey.IsKeyDownDetected) normalKey.IsKeyUpDetected = true;
            CheckAllKeysPassed();
        }
    });
}

4.5 FN 键的特殊处理

FN 键不上报给操作系统,全局钩子永远捕获不到它。解决思路:检测到 F1~F12 任意一个功能键被按下时,自动将 FN 标记为已通过:

csharp 复制代码
private bool IsFunctionKey(Key key) => key >= Key.F1 && key <= Key.F12;

private void MarkFnKeyAsPressed()
{
    var fnKey = KeyboardKeys.FirstOrDefault(k =>
        k.Key == Key.None && k.DisplayName == "FN" && k.KeyType == KeyType.Normal);
    if (fnKey != null && !fnKey.IsKeyDownDetected)
    {
        fnKey.IsKeyDownDetected = true;
        fnKey.IsKeyUpDetected   = true; // FN 无 KeyUp,直接标记通过
    }
}

4.6 按键颜色实时反馈

KeyInfoPro.UpdateBackground() 根据状态自动更新背景色,绑定到 UI:

状态 颜色
未按下 深灰 #2D2D2D
仅 KeyDown 橙色 Orange
KeyDown + KeyUp 绿色 LimeGreen
特殊键已检测 绿色 LimeGreen

第五章:完整可运行 Demo

本 Demo 是上述方案的完整迷你实现,含配置界面 (监听按键、保存 JSON)和测试界面(加载 JSON、检测按下/抬起),两个页面通过 TabControl 切换------这正是实际产测工具的缩小版。

使用步骤

  1. 在「配置」页开启监听,直接按 C、D、E 等按键添加,右键可删除,点「保存配置」
  2. 切换到「测试」页,点「加载配置并开始测试」,依次按下并抬起所有按键
  3. 全部变绿 → 自动弹出通过

Demo 结构

复制代码
KeyboardTestDemo/
├── App.xaml + App.xaml.cs
├── MainWindow.xaml          ← 主窗口(TabControl:⚙配置 / ▶测试)
├── MainWindow.xaml.cs       ← 配置/测试全部逻辑
├── Models/
│   └── KeyInfo.cs           ← 按键数据模型(含颜色状态)
└── Utils/
    └── KeyboardHook.cs      ← 全局钩子(P/Invoke WH_KEYBOARD_LL)

依赖 :NuGet 安装 Newtonsoft.Json,目标框架 .NET Framework 4.8 + WPF


Models/KeyInfo.cs ------ 按键数据模型

csharp 复制代码
using Newtonsoft.Json;
using System.ComponentModel;
using System.Windows.Input;
using System.Windows.Media;

namespace KeyboardTestDemo.Models
{
    // 仅普通键,Demo 不含 Special/ComboKey 配置(那是第四章的进阶功能)
    public class KeyInfo : INotifyPropertyChanged
    {
        // ── 持久化字段(写入 JSON)──
        public string DisplayName { get; set; }
        public Key Key { get; set; }

        // ── 运行时状态(不写入 JSON)──
        private bool _keyDown;
        [JsonIgnore]
        public bool IsKeyDownDetected
        {
            get => _keyDown;
            set { _keyDown = value; Notify(nameof(IsKeyDownDetected)); RefreshBg(); }
        }

        private bool _keyUp;
        [JsonIgnore]
        public bool IsKeyUpDetected
        {
            get => _keyUp;
            set { _keyUp = value; Notify(nameof(IsKeyUpDetected)); RefreshBg(); }
        }

        private SolidColorBrush _bg = new SolidColorBrush(Color.FromRgb(0x2D, 0x2D, 0x2D));
        [JsonIgnore]
        public SolidColorBrush Background
        {
            get => _bg;
            private set { _bg = value; Notify(nameof(Background)); }
        }

        [JsonIgnore]
        public bool IsPassed => IsKeyDownDetected && IsKeyUpDetected;

        public void Reset() { IsKeyDownDetected = false; IsKeyUpDetected = false; }

        private void RefreshBg()
        {
            if (IsKeyDownDetected && IsKeyUpDetected)
                Background = new SolidColorBrush(Colors.LimeGreen);   // 通过:绿色
            else if (IsKeyDownDetected)
                Background = new SolidColorBrush(Colors.Orange);       // 仅按下:橙色
            else
                Background = new SolidColorBrush(Color.FromRgb(0x2D, 0x2D, 0x2D)); // 未测试:深灰
        }

        public event PropertyChangedEventHandler PropertyChanged;
        private void Notify(string p) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(p));
    }
}

Utils/KeyboardHook.cs ------ 全局键盘钩子

csharp 复制代码
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Windows.Input;

namespace KeyboardTestDemo.Utils
{
    public class KeyHookEventArgs : EventArgs
    {
        public Key Key { get; set; }
        public int ScanCode { get; set; }
        public bool IsKeyUp { get; set; }
    }

    public class KeyboardHook : IDisposable
    {
        private const int WH_KEYBOARD_LL = 13;
        private const int WM_KEYDOWN = 0x0100, WM_KEYUP = 0x0101;
        private const int WM_SYSKEYDOWN = 0x0104, WM_SYSKEYUP = 0x0105;

        public event EventHandler<KeyHookEventArgs> KeyEvent;
        private IntPtr _hookId = IntPtr.Zero;
        private LowLevelKeyboardProc _proc;

        [StructLayout(LayoutKind.Sequential)]
        private class KBDLLHOOKSTRUCT { public int vkCode, scanCode, flags, time, dwExtraInfo; }
        private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);

        public KeyboardHook() { _proc = HookCallback; }

        public void Start()
        {
            using (var p = Process.GetCurrentProcess())
            using (var m = p.MainModule)
                _hookId = SetWindowsHookEx(WH_KEYBOARD_LL, _proc, GetModuleHandle(m.ModuleName), 0);
        }

        public void Stop()
        {
            if (_hookId != IntPtr.Zero) { UnhookWindowsHookEx(_hookId); _hookId = IntPtr.Zero; }
        }

        private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
        {
            if (nCode >= 0)
            {
                int msg = wParam.ToInt32();
                bool down = (msg == WM_KEYDOWN || msg == WM_SYSKEYDOWN);
                bool up   = (msg == WM_KEYUP   || msg == WM_SYSKEYUP);
                if (down || up)
                {
                    var s = (KBDLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(KBDLLHOOKSTRUCT));
                    KeyEvent?.Invoke(this, new KeyHookEventArgs
                    {
                        Key      = KeyInterop.KeyFromVirtualKey(s.vkCode),
                        ScanCode = s.scanCode,
                        IsKeyUp  = up
                    });
                }
            }
            return (IntPtr)1;
        }

        [DllImport("user32.dll")] static extern IntPtr SetWindowsHookEx(int id, LowLevelKeyboardProc fn, IntPtr hMod, uint threadId);
        [DllImport("user32.dll")] static extern bool UnhookWindowsHookEx(IntPtr hhk);
        [DllImport("kernel32.dll")] static extern IntPtr GetModuleHandle(string name);
        public void Dispose() => Stop();
    }
}

MainWindow.xaml ------ 双 Tab 主窗口

xml 复制代码
<Window x:Class="KeyboardTestDemo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="键盘测试 Demo" Height="520" Width="780"
        Background="#FF1E2540" Closed="Window_Closed">
    <TabControl Margin="10" Background="#FF1E2540" BorderBrush="#FF3A4A6B"
                SelectionChanged="TabControl_SelectionChanged">

        <!-- ===== Tab 1:配置 ===== -->
        <TabItem Header="⚙  配置键盘布局" Foreground="White" Background="#FF252B47">
            <Grid Margin="12">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="*"/>
                    <RowDefinition Height="Auto"/>
                </Grid.RowDefinitions>

                <!-- 工具栏 -->
                <StackPanel Orientation="Horizontal" Margin="0,0,0,8">
                    <ToggleButton x:Name="TglListen" Content="开启按键监听"
                                  Checked="TglListen_Checked" Unchecked="TglListen_Unchecked"
                                  Padding="12,6" Background="#FF4CAF50" Foreground="White" Margin="0,0,10,0"/>
                    <TextBlock x:Name="TxtListenStatus" Text="(未监听)"
                               Foreground="#FF8899BB" VerticalAlignment="Center" Margin="0,0,20,0"/>
                    <TextBlock Text="开启后直接按键添加 | 右键点击按键可删除"
                               Foreground="#FF64B5F6" VerticalAlignment="Center" FontStyle="Italic"/>
                </StackPanel>

                <!-- 按键预览区 -->
                <Border Grid.Row="1" Background="#FF252B47" CornerRadius="8" Padding="10">
                    <ScrollViewer VerticalScrollBarVisibility="Auto">
                        <ItemsControl x:Name="ConfigKeyList">
                            <ItemsControl.ItemsPanel>
                                <ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
                            </ItemsControl.ItemsPanel>
                            <ItemsControl.ItemTemplate>
                                <DataTemplate>
                                    <Border Margin="4" Padding="12,6" CornerRadius="5" Cursor="Hand"
                                            BorderBrush="#CCFFFFFF" BorderThickness="0.5"
                                            MouseRightButtonDown="ConfigKey_RightClick">
                                        <Border.Style>
                                            <Style TargetType="Border">
                                                <Setter Property="Background" Value="#FF2D3152"/>
                                                <Style.Triggers>
                                                    <Trigger Property="IsMouseOver" Value="True">
                                                        <Setter Property="Background" Value="#FFFF4444"/>
                                                    </Trigger>
                                                </Style.Triggers>
                                            </Style>
                                        </Border.Style>
                                        <TextBlock Text="{Binding DisplayName}" Foreground="White" FontSize="14"/>
                                    </Border>
                                </DataTemplate>
                            </ItemsControl.ItemTemplate>
                        </ItemsControl>
                    </ScrollViewer>
                </Border>

                <!-- 底部按钮 -->
                <StackPanel Grid.Row="2" Orientation="Horizontal"
                            HorizontalAlignment="Right" Margin="0,10,0,0">
                    <TextBlock x:Name="TxtSavePath" Foreground="#FF8899BB"
                               VerticalAlignment="Center" Margin="0,0,15,0" FontSize="11"/>
                    <Button Content="清空所有" Click="BtnClear_Click"
                            Padding="12,6" Background="#FFF44336" Foreground="White" Margin="0,0,10,0"/>
                    <Button Content="保存配置" Click="BtnSave_Click"
                            Padding="12,6" Background="#FF2196F3" Foreground="White"/>
                </StackPanel>
            </Grid>
        </TabItem>

        <!-- ===== Tab 2:测试 ===== -->
        <TabItem Header="▶  执行测试" Foreground="White" Background="#FF252B47">
            <Grid Margin="12">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="*"/>
                    <RowDefinition Height="Auto"/>
                </Grid.RowDefinitions>

                <!-- 状态提示 -->
                <TextBlock x:Name="TxtTestStatus" Grid.Row="0" FontSize="16" Foreground="DodgerBlue"
                           Text="点击下方按钮加载配置,然后依次按下所有按键" Margin="0,0,0,10"/>

                <!-- 启动按钮 -->
                <Button Grid.Row="1" x:Name="BtnStartTest" Content="加载配置并开始测试"
                        Click="BtnStartTest_Click" HorizontalAlignment="Left"
                        Padding="15,8" Background="#FF9C27B0" Foreground="White" Margin="0,0,0,10"/>

                <!-- 按键色块区 -->
                <Border Grid.Row="2" Background="#FF252B47" CornerRadius="8" Padding="10">
                    <ScrollViewer VerticalScrollBarVisibility="Auto">
                        <ItemsControl x:Name="TestKeyList">
                            <ItemsControl.ItemsPanel>
                                <ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
                            </ItemsControl.ItemsPanel>
                            <ItemsControl.ItemTemplate>
                                <DataTemplate>
                                    <Border Margin="4" Padding="14,8" CornerRadius="5" MinWidth="55"
                                            Background="{Binding Background}">
                                        <TextBlock Text="{Binding DisplayName}" Foreground="White"
                                                   FontSize="14" HorizontalAlignment="Center"/>
                                    </Border>
                                </DataTemplate>
                            </ItemsControl.ItemTemplate>
                        </ItemsControl>
                    </ScrollViewer>
                </Border>

                <!-- 手动按钮 -->
                <StackPanel Grid.Row="3" Orientation="Horizontal"
                            HorizontalAlignment="Right" Margin="0,10,0,0">
                    <Button Content="手动通过" Click="BtnManualPass_Click"
                            Padding="12,6" Background="#FF4CAF50" Foreground="White" Margin="0,0,10,0"/>
                    <Button Content="手动失败" Click="BtnManualFail_Click"
                            Padding="12,6" Background="#FFF44336" Foreground="White"/>
                </StackPanel>
            </Grid>
        </TabItem>
    </TabControl>
</Window>

MainWindow.xaml.cs ------ 配置 + 测试全部逻辑

csharp 复制代码
using KeyboardTestDemo.Models;
using KeyboardTestDemo.Utils;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;

namespace KeyboardTestDemo
{
    public partial class MainWindow : Window
    {
        // 配置页:已配置按键
        private readonly ObservableCollection<KeyInfo> _configKeys = new ObservableCollection<KeyInfo>();
        // 测试页:测试中的按键(从配置加载后重置状态)
        private readonly ObservableCollection<KeyInfo> _testKeys = new ObservableCollection<KeyInfo>();

        private KeyboardHook _hook;
        private bool _isTestRunning;

        // JSON 保存在 exe 同目录
        private static readonly string ConfigPath =
            Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "keyboard_config.json");

        // 用于 JSON 序列化/反序列化的简单 DTO
        private class SavedKey { public string DisplayName { get; set; } public string KeyEnum { get; set; } }

        public MainWindow()
        {
            InitializeComponent();
            ConfigKeyList.ItemsSource = _configKeys;
            TestKeyList.ItemsSource   = _testKeys;
            TxtSavePath.Text = $"配置路径:{ConfigPath}";
        }

        // ─────────────────────────────────────────────
        // 配置 Tab
        // ─────────────────────────────────────────────

        private void TglListen_Checked(object sender, RoutedEventArgs e)
        {
            StopHook();
            _hook = new KeyboardHook();
            _hook.KeyEvent += OnConfigKeyEvent;
            _hook.Start();
            TxtListenStatus.Text       = "监听中... 直接按键盘上的按键即可添加!";
            TxtListenStatus.Foreground = new SolidColorBrush(Colors.LimeGreen);
        }

        private void TglListen_Unchecked(object sender, RoutedEventArgs e)
        {
            StopHook();
            TxtListenStatus.Text       = "(监听已关闭)";
            TxtListenStatus.Foreground = new SolidColorBrush(Color.FromRgb(0x88, 0x99, 0xBB));
        }

        private void OnConfigKeyEvent(object sender, KeyHookEventArgs e)
        {
            if (e.IsKeyUp) return;  // 只处理 KeyDown

            Dispatcher.Invoke(() =>
            {
                if (e.Key == Key.None) return;
                // 不重复添加同一个按键
                if (_configKeys.Any(k => k.Key == e.Key)) return;

                _configKeys.Add(new KeyInfo
                {
                    DisplayName = GetFriendlyName(e.Key),
                    Key         = e.Key
                });
            });
        }

        private void ConfigKey_RightClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
        {
            if (sender is FrameworkElement fe && fe.DataContext is KeyInfo key)
            {
                if (MessageBox.Show($"删除按键 [{key.DisplayName}]?", "确认",
                        MessageBoxButton.YesNo) == MessageBoxResult.Yes)
                    _configKeys.Remove(key);
            }
        }

        private void BtnClear_Click(object sender, RoutedEventArgs e) => _configKeys.Clear();

        private void BtnSave_Click(object sender, RoutedEventArgs e)
        {
            if (_configKeys.Count == 0) { MessageBox.Show("至少需要配置一个按键!"); return; }

            var data = _configKeys
                .Select(k => new SavedKey { DisplayName = k.DisplayName, KeyEnum = k.Key.ToString() })
                .ToList();
            File.WriteAllText(ConfigPath, JsonConvert.SerializeObject(data, Formatting.Indented));
            MessageBox.Show($"已保存 {_configKeys.Count} 个按键。\n文件:{ConfigPath}", "保存成功");
        }

        // ─────────────────────────────────────────────
        // 测试 Tab
        // ─────────────────────────────────────────────

        private void BtnStartTest_Click(object sender, RoutedEventArgs e)
        {
            if (!File.Exists(ConfigPath))
            {
                MessageBox.Show("找不到配置文件,请先在「配置」页保存!", "提示");
                return;
            }

            try
            {
                var saved = JsonConvert.DeserializeObject<List<SavedKey>>(File.ReadAllText(ConfigPath));
                _testKeys.Clear();

                foreach (var item in saved)
                {
                    if (Enum.TryParse<Key>(item.KeyEnum, out Key key))
                        _testKeys.Add(new KeyInfo { DisplayName = item.DisplayName, Key = key });
                }

                if (_testKeys.Count == 0) { MessageBox.Show("配置文件中无有效按键!"); return; }
            }
            catch (Exception ex) { MessageBox.Show($"加载失败:{ex.Message}"); return; }

            // 启动测试钩子
            StopHook();
            _isTestRunning = true;
            _hook = new KeyboardHook();
            _hook.KeyEvent += OnTestKeyEvent;
            _hook.Start();

            TxtTestStatus.Text       = $"请依次按下并抬起全部 {_testKeys.Count} 个按键";
            TxtTestStatus.Foreground = new SolidColorBrush(Colors.DodgerBlue);
            BtnStartTest.IsEnabled   = false;
        }

        private void OnTestKeyEvent(object sender, KeyHookEventArgs e)
        {
            if (!_isTestRunning) return;

            Dispatcher.Invoke(() =>
            {
                var key = _testKeys.FirstOrDefault(k => k.Key == e.Key);
                if (key == null) return;

                if (!e.IsKeyUp && !key.IsKeyDownDetected)
                    key.IsKeyDownDetected = true;
                else if (e.IsKeyUp && key.IsKeyDownDetected && !key.IsKeyUpDetected)
                    key.IsKeyUpDetected = true;

                // 检查是否全部通过
                int passed = _testKeys.Count(k => k.IsPassed);
                if (passed == _testKeys.Count)
                {
                    _isTestRunning = false;
                    StopHook();
                    TxtTestStatus.Text       = $"✔ 测试通过!全部 {passed} 个按键验证完成";
                    TxtTestStatus.Foreground = new SolidColorBrush(Colors.LimeGreen);
                    BtnStartTest.IsEnabled   = true;
                    MessageBox.Show("所有按键测试通过!", "通过");
                }
            });
        }

        private void BtnManualPass_Click(object sender, RoutedEventArgs e)
        {
            _isTestRunning = false; StopHook(); BtnStartTest.IsEnabled = true;
            TxtTestStatus.Text = "手动通过"; TxtTestStatus.Foreground = new SolidColorBrush(Colors.LimeGreen);
        }

        private void BtnManualFail_Click(object sender, RoutedEventArgs e)
        {
            _isTestRunning = false; StopHook(); BtnStartTest.IsEnabled = true;
            TxtTestStatus.Text = "手动失败"; TxtTestStatus.Foreground = new SolidColorBrush(Colors.Red);
        }

        // ─────────────────────────────────────────────
        // 公共
        // ─────────────────────────────────────────────

        private void TabControl_SelectionChanged(object sender, SelectionChangedEventArgs e)
        {
            // 切换 Tab 时停止当前钩子,避免两个 Tab 的钩子同时运行
            StopHook();
            _isTestRunning = false;
            if (TglListen != null) TglListen.IsChecked = false;
        }

        private void StopHook() { _hook?.Stop(); _hook?.Dispose(); _hook = null; }

        private void Window_Closed(object sender, EventArgs e) => StopHook();

        private static string GetFriendlyName(Key key)
        {
            if (key >= Key.D0 && key <= Key.D9)       return ((int)key - (int)Key.D0).ToString();
            if (key >= Key.NumPad0 && key <= Key.NumPad9) return "Num" + ((int)key - (int)Key.NumPad0);
            if (key >= Key.F1 && key <= Key.F12)      return key.ToString();
            switch (key)
            {
                case Key.Space:          return "Space";
                case Key.Return:         return "Enter";
                case Key.Tab:            return "Tab";
                case Key.Escape:         return "ESC";
                case Key.Back:           return "←";
                case Key.Delete:         return "DEL";
                case Key.Insert:         return "INS";
                case Key.Home:           return "Home";
                case Key.End:            return "End";
                case Key.PageUp:         return "PgUp";
                case Key.PageDown:       return "PgDn";
                case Key.Left:           return "←";
                case Key.Right:          return "→";
                case Key.Up:             return "↑";
                case Key.Down:           return "↓";
                case Key.CapsLock:       return "CAPS";
                case Key.LeftShift:      return "L-Shift";
                case Key.RightShift:     return "R-Shift";
                case Key.LeftCtrl:       return "L-Ctrl";
                case Key.RightCtrl:      return "R-Ctrl";
                case Key.LeftAlt:        return "L-Alt";
                case Key.RightAlt:       return "R-Alt";
                case Key.LWin:           return "Win";
                case Key.OemTilde:       return "`";
                case Key.OemMinus:       return "-";
                case Key.OemPlus:        return "=";
                case Key.OemOpenBrackets:return "[";
                case Key.OemCloseBrackets:return "]";
                case Key.Oem5:           return "\\";
                case Key.OemSemicolon:   return ";";
                case Key.OemQuotes:      return "'";
                case Key.OemComma:       return ",";
                case Key.OemPeriod:      return ".";
                case Key.OemQuestion:    return "/";
                default:                 return key.ToString();
            }
        }
    }
}

运行效果

场景 颜色
配置页 - 鼠标悬停按键 变红,代表右键可删
测试页 - 未按下 深灰
测试页 - 仅 KeyDown 橙色
测试页 - Down+Up 完成 绿色

第六章:总结与扩展

核心要点

  • 三种特殊键检测方式互补:VirtualKey(普通键)、ScanCode(硬件无 VirtualKey 的键)、WindowsMessage(厂商私有消息键)、ComboKey(多键同按)覆盖了键盘上的所有场景
  • 全局钩子(WH_KEYBOARD_LL) 是核心感知层,配置阶段用来捕获按键添加到列表,测试阶段用来检测按键通过状态
  • Grid 占位格(KeyType.Empty) 实现了可视化的按键位置管理,左键移动、右键删除,交互直观
  • JSON 序列化 将布局配置持久化,测试时反序列化加载,配置与测试完全解耦
  • FN 键通过间接推断(按 F1~F12 任意一个即认为 FN 已按)解决了 FN 无法被系统捕获的问题

可扩展方向

  1. 增加 RowSpan/ColumnSpan 拖放调整:目前特殊键固定占 1×1 格,可扩展为可拉伸的大键(如 Enter、Space 的模拟)

  2. 多语言键名支持 :当前 GetFriendlyKeyName 写死英文,可结合资源字典实现中英文切换


如有问题欢迎评论区交流。觉得有用的话点个赞和关注下,系列会持续更新 😃

相关推荐
bugcome_com1 小时前
WPF 路径动画完全指南:自绘制控件实战
wpf
不会编程的懒洋洋2 天前
WPF 性能优化+异步+渲染
开发语言·笔记·性能优化·c#·wpf·图形渲染·线程
求学中--3 天前
状态管理一文通:@State、@Prop、@Link、@Provide/Consume全解析
人工智能·小程序·uni-app·wpf·harmonyos
雨浓YN4 天前
GKTGD 工业监控系统-00设计文档
wpf
秋の本名5 天前
第一章 鸿蒙生态架构与开发理念
华为·wpf·harmonyos
Bofu-5 天前
【音频测试】03-WPF 实现声道自动验证 + Whisper 语音识别录音检测
c#·whisper·wpf·音视频·音频测试·naudio 声道控制
秋の本名5 天前
DevEco Studio 版本演进揭秘:从3.0到5.0的分布式开发能力飞跃与智能体验革新
wpf·鸿蒙系统