1. 为什么自动化测试需要SendInput?从“玩具”到“工具”的蜕变

如果你做过自动化测试,尤其是UI自动化,肯定遇到过这样的场景:脚本运行得好好的,突然某个输入框没反应了,或者组合键没按出来,测试就卡在那里了。我之前用C#做Windows桌面应用自动化时,也踩过不少坑。最早用的是SendKeys,写起来简单,但稳定性真是一言难尽,稍微复杂点的窗口焦点切换就可能失效。后来试了keybd_event和mouse_event,功能强了点,但微软官方文档已经明确说这些方法过时了,未来可能不保证兼容性。

直到我深入研究了SendInput这个Windows API,才发现这才是自动化测试的“终极武器”。简单来说,SendInput是Windows系统底层用来处理用户输入的核心函数之一。当你按下一个键、移动一下鼠标,操作系统最终都会调用类似SendInput的机制,把输入事件分发给各个应用程序。所以,用SendInput来模拟输入,本质上是在“扮演”真实的键盘和鼠标,系统几乎无法区分这是真人操作还是程序模拟。

这带来的好处太明显了:极高的兼容性和稳定性。无论是WinForms、WPF、控制台程序,甚至是某些游戏窗口,只要它能接收正常用户输入,SendInput就能模拟。我在实际项目中用它测试过财务软件、工业控制界面甚至一些老旧的MFC程序,成功率接近100%。更重要的是,SendInput可以一次性发送多个输入事件,还能精确控制每个事件的时间戳,这对于模拟复杂的用户操作序列(比如拖拽、连续快捷键)至关重要。

不过我得提醒你,SendInput也不是万能的。它工作在系统级别,所以如果你的测试程序运行在普通权限,而目标程序需要管理员权限,可能会失败。另外,一些安全软件或者游戏的反作弊系统可能会拦截SendInput调用。但在绝大多数企业级应用测试场景中,这些问题都可以通过合理的测试环境配置来规避。

2. 5分钟上手:你的第一个SendInput键盘模拟程序

理论说再多不如动手试一次。咱们先来写个最简单的例子:用SendInput模拟按下并释放A键。别担心,代码虽然涉及一些Windows API调用,但我会一步步拆解,保证你能看懂。

首先,创建一个新的C#控制台项目。然后,我们需要定义几个关键的结构体和API声明。这些代码看起来有点长,但大部分都是固定模板,复制过去就能用。

using System;
using System.Runtime.InteropServices;

namespace SendInputDemo
{
    class Program
    {
        // 定义输入类型常量
        private const int INPUT_KEYBOARD = 1;
        private const int KEYEVENTF_KEYDOWN = 0x0000;
        private const int KEYEVENTF_KEYUP = 0x0002;
        
        // 定义INPUT结构体 - 这是SendInput的核心数据结构
        [StructLayout(LayoutKind.Sequential)]
        public struct INPUT
        {
            public int type;
            public InputUnion u;
        }
        
        // 这是一个联合体(union),可以存放键盘、鼠标或硬件输入
        [StructLayout(LayoutKind.Explicit)]
        public struct InputUnion
        {
            [FieldOffset(0)]
            public MOUSEINPUT mi;
            [FieldOffset(0)]
            public KEYBDINPUT ki;
            [FieldOffset(0)]
            public HARDWAREINPUT hi;
        }
        
        // 键盘输入结构体
        [StructLayout(LayoutKind.Sequential)]
        public struct KEYBDINPUT
        {
            public ushort wVk;          // 虚拟键码
            public ushort wScan;        // 硬件扫描码
            public uint dwFlags;        // 标志位(按下/释放等)
            public uint time;           // 时间戳
            public IntPtr dwExtraInfo;  // 额外信息
        }
        
        // 鼠标输入结构体(这里先定义,后面会用到)
        [StructLayout(LayoutKind.Sequential)]
        public struct MOUSEINPUT
        {
            public int dx;
            public int dy;
            public uint mouseData;
            public uint dwFlags;
            public uint time;
            public IntPtr dwExtraInfo;
        }
        
        // 硬件输入结构体(较少使用)
        [StructLayout(LayoutKind.Sequential)]
        public struct HARDWAREINPUT
        {
            public uint uMsg;
            public ushort wParamL;
            public ushort wParamH;
        }
        
        // 导入SendInput API
        [DllImport("user32.dll", SetLastError = true)]
        static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
        
        static void Main(string[] args)
        {
            Console.WriteLine("准备模拟按下A键...");
            Console.WriteLine("请确保记事本或其他文本编辑器处于活动状态,然后按Enter键继续");
            Console.ReadLine();
            
            // 模拟按下A键
            SimulateKeyPress(0x41); // 0x41是'A'键的虚拟键码
            
            Console.WriteLine("A键已模拟按下!");
        }
        
        static void SimulateKeyPress(ushort keyCode)
        {
            // 创建两个输入事件:按下和释放
            INPUT[] inputs = new INPUT[2];
            
            // 按下事件
            inputs[0] = new INPUT();
            inputs[0].type = INPUT_KEYBOARD;
            inputs[0].u.ki.wVk = keyCode;
            inputs[0].u.ki.dwFlags = KEYEVENTF_KEYDOWN;
            inputs[0].u.ki.time = 0;
            inputs[0].u.ki.dwExtraInfo = IntPtr.Zero;
            
            // 释放事件
            inputs[1] = new INPUT();
            inputs[1].type = INPUT_KEYBOARD;
            inputs[1].u.ki.wVk = keyCode;
            inputs[1].u.ki.dwFlags = KEYEVENTF_KEYUP;
            inputs[1].u.ki.time = 0;
            inputs[1].u.ki.dwExtraInfo = IntPtr.Zero;
            
            // 发送输入
            uint result = SendInput(2, inputs, Marshal.SizeOf(typeof(INPUT)));
            
            if (result != 2)
            {
                Console.WriteLine($"发送输入失败!错误代码: {Marshal.GetLastWin32Error()}");
            }
        }
    }
}

运行这个程序前,记得先打开一个文本编辑器(比如记事本),把光标放在里面,然后运行程序并按Enter键。你会看到文本编辑器里出现了一个字母"A"。这就是SendInput在起作用!

这里有几个关键点需要解释一下。INPUT结构体是SendInput函数的核心参数,它用type字段告诉系统这是键盘输入、鼠标输入还是硬件输入。对于键盘输入,我们主要关注KEYBDINPUT结构体中的wVk(虚拟键码)和dwFlags(标志位)。虚拟键码是Windows定义的每个按键的唯一标识,比如A键是0x41,回车键是0x0D。

SendInput函数的第一个参数是要发送的输入事件数量,第二个是INPUT数组,第三个是每个INPUT结构体的大小。这里用Marshal.SizeOf(typeof(INPUT))自动计算大小,避免手动计算出错。

你可能注意到了,我用了ushort类型的虚拟键码,而不是原始文章中的byte。这是因为Windows API中虚拟键码实际上是WORD类型(16位无符号整数),用ushort更准确。不过在实际使用中,大部分常用键的虚拟键码都在0-255范围内,用byte也没问题。

3. 虚拟键码大全:从A到Z,从F1到Win键

在实际的自动化测试中,我们不可能只模拟A键。你需要模拟回车、Tab、功能键、组合键等等。这时候就需要一个完整的虚拟键码表。原始文章里给出了一个很长的VKCODE类,包含了几乎所有按键的定义。我这里整理了一个更实用的版本,按功能分类,方便查找:

public static class VirtualKeyCodes
{
    // 字母键 A-Z
    public const byte VK_A = 0x41;
    public const byte VK_B = 0x42;
    public const byte VK_C = 0x43;
    // ... 其他字母键
    public const byte VK_Z = 0x5A;
    
    // 数字键 0-9
    public const byte VK_0 = 0x30;
    public const byte VK_1 = 0x31;
    // ... 其他数字键
    public const byte VK_9 = 0x39;
    
    // 功能键 F1-F24
    public const byte VK_F1 = 0x70;
    public const byte VK_F2 = 0x71;
    // ... 其他功能键
    public const byte VK_F24 = 0x87;
    
    // 控制键
    public const byte VK_SHIFT = 0x10;
    public const byte VK_CONTROL = 0x11;  // Ctrl键
    public const byte VK_MENU = 0x12;     // Alt键
    public const byte VK_LWIN = 0x5B;     // 左Win键
    public const byte VK_RWIN = 0x5C;     // 右Win键
    
    // 导航键
    public const byte VK_LEFT = 0x25;     // 左箭头
    public const byte VK_UP = 0x26;       // 上箭头
    public const byte VK_RIGHT = 0x27;    // 右箭头
    public const byte VK_DOWN = 0x28;     // 下箭头
    public const byte VK_HOME = 0x24;
    public const byte VK_END = 0x23;
    public const byte VK_PAGEUP = 0x21;   // Page Up
    public const byte VK_PAGEDOWN = 0x22; // Page Down
    
    // 编辑键
    public const byte VK_BACK = 0x08;     // Backspace
    public const byte VK_TAB = 0x09;
    public const byte VK_RETURN = 0x0D;   // Enter键
    public const byte VK_ESCAPE = 0x1B;   // Esc键
    public const byte VK_SPACE = 0x20;
    public const byte VK_INSERT = 0x2D;
    public const byte VK_DELETE = 0x2E;
    
    // 锁定键
    public const byte VK_CAPITAL = 0x14;  // Caps Lock
    public const byte VK_NUMLOCK = 0x90;
    public const byte VK_SCROLL = 0x91;   // Scroll Lock
    
    // 特殊符号键(这些键码可能因键盘布局而异)
    public const byte VK_OEM_1 = 0xBA;    // 分号; 冒号:
    public const byte VK_OEM_PLUS = 0xBB; // 加号+
    public const byte VK_OEM_COMMA = 0xBC; // 逗号,
    public const byte VK_OEM_MINUS = 0xBD; // 减号-
    public const byte VK_OEM_PERIOD = 0xBE; // 句点.
    public const byte VK_OEM_2 = 0xBF;    // 问号? 斜杠/
    public const byte VK_OEM_3 = 0xC0;    // 波浪号~ 反引号`
}

有了这个键码表,模拟任何按键都变得很简单。比如要模拟按下F5刷新网页:

SimulateKeyPress(VirtualKeyCodes.VK_F5);

但这里有个细节需要注意:有些键是“切换键”(Toggle Key),比如Caps Lock、Num Lock、Scroll Lock。这些键的状态是持久性的,按一次开启,再按一次关闭。在自动化测试中,如果你需要确保这些键处于特定状态,最好先检查当前状态。不过说实话,在大多数测试场景中,我建议避免依赖这些切换键的状态,因为用户可能在测试前无意中改变了它们。

另一个实用技巧是模拟数字小键盘的输入。数字小键盘的键码和主键盘区的数字键是不同的!比如数字小键盘的"0"是VK_NUMPAD0(0x60),而主键盘的"0"是VK_0(0x30)。如果你的测试涉及财务软件或者需要大量数字输入,一定要区分清楚。

4. 组合键模拟:Ctrl+C、Alt+Tab的完美实现

单个按键的模拟只是基础,真正的自动化测试经常需要模拟组合键。比如Ctrl+C复制、Ctrl+V粘贴、Alt+Tab切换窗口等。模拟组合键的关键在于顺序和时机。

让我先给你看一个经典的错误示例,这是我早期踩过的坑:

// 错误示例:这样模拟Ctrl+C可能会失败
SimulateKeyPress(VirtualKeyCodes.VK_CONTROL);
SimulateKeyPress(VirtualKeyCodes.VK_C);  // 问题在这里!
SimulateKeyRelease(VirtualKeyCodes.VK_C);
SimulateKeyRelease(VirtualKeyCodes.VK_CONTROL);

这段代码的问题在于,SimulateKeyPress函数内部已经包含了按下和释放两个事件。所以实际上执行的是:按下Ctrl → 按下C → 释放C → 释放Ctrl。但系统可能还没反应过来Ctrl键已经按下,C键的按下事件就发送了。

正确的做法应该是把Ctrl键的按下、C键的按下、C键的释放、Ctrl键的释放这四个事件作为一个整体一次性发送:

static void SimulateCtrlC()
{
    INPUT[] inputs = new INPUT[4];
    int inputSize = Marshal.SizeOf(typeof(INPUT));
    
    // 1. 按下Ctrl键
    inputs[0] = CreateKeyboardInput(VirtualKeyCodes.VK_CONTROL, KEYEVENTF_KEYDOWN);
    
    // 2. 按下C键
    inputs[1] = CreateKeyboardInput(VirtualKeyCodes.VK_C, KEYEVENTF_KEYDOWN);
    
    // 3. 释放C键
    inputs[2] = CreateKeyboardInput(VirtualKeyCodes.VK_C, KEYEVENTF_KEYUP);
    
    // 4. 释放Ctrl键
    inputs[3] = CreateKeyboardInput(VirtualKeyCodes.VK_CONTROL, KEYEVENTF_KEYUP);
    
    // 一次性发送所有事件
    uint result = SendInput(4, inputs, inputSize);
    
    if (result != 4)
    {
        Console.WriteLine($"发送组合键失败!错误代码: {Marshal.GetLastWin32Error()}");
    }
}

static INPUT CreateKeyboardInput(ushort keyCode, uint flags)
{
    INPUT input = new INPUT();
    input.type = INPUT_KEYBOARD;
    input.u.ki.wVk = keyCode;
    input.u.ki.dwFlags = flags;
    input.u.ki.time = 0;
    input.u.ki.dwExtraInfo = IntPtr.Zero;
    return input;
}

看到区别了吗?我们把四个输入事件放在一个数组里,一次性调用SendInput发送。这样系统会按顺序处理这些事件,确保Ctrl键在C键按下时确实是按下的状态。

对于更复杂的组合键,比如Ctrl+Shift+Esc(打开任务管理器),原理是一样的,只是事件更多:

static void SimulateCtrlShiftEsc()
{
    INPUT[] inputs = new INPUT[6];
    
    // 按下Ctrl
    inputs[0] = CreateKeyboardInput(VirtualKeyCodes.VK_CONTROL, KEYEVENTF_KEYDOWN);
    // 按下Shift
    inputs[1] = CreateKeyboardInput(VirtualKeyCodes.VK_SHIFT, KEYEVENTF_KEYDOWN);
    // 按下Esc
    inputs[2] = CreateKeyboardInput(VirtualKeyCodes.VK_ESCAPE, KEYEVENTF_KEYDOWN);
    // 释放Esc
    inputs[3] = CreateKeyboardInput(VirtualKeyCodes.VK_ESCAPE, KEYEVENTF_KEYUP);
    // 释放Shift
    inputs[4] = CreateKeyboardInput(VirtualKeyCodes.VK_SHIFT, KEYEVENTF_KEYUP);
    // 释放Ctrl
    inputs[5] = CreateKeyboardInput(VirtualKeyCodes.VK_CONTROL, KEYEVENTF_KEYUP);
    
    SendInput(6, inputs, Marshal.SizeOf(typeof(INPUT)));
}

这里有个重要的细节:按键释放的顺序应该和按下的顺序相反。就像真人操作一样,你先按下Ctrl和Shift,再按Esc,然后先释放Esc,再释放Shift,最后释放Ctrl。虽然有些情况下不严格遵守这个顺序也能工作,但为了模拟的真实性和兼容性,我建议遵循这个规则。

5. 鼠标模拟实战:点击、移动、滚轮全掌握

键盘模拟搞定了,现在来看看鼠标。鼠标模拟比键盘稍微复杂一点,因为涉及坐标系统。不过别担心,我会用最直白的方式讲清楚。

首先,我们需要定义鼠标事件相关的常量:

// 鼠标事件标志
private const int MOUSEEVENTF_MOVE = 0x0001;        // 移动鼠标
private const int MOUSEEVENTF_LEFTDOWN = 0x0002;    // 按下左键
private const int MOUSEEVENTF_LEFTUP = 0x0004;      // 释放左键
private const int MOUSEEVENTF_RIGHTDOWN = 0x0008;   // 按下右键
private const int MOUSEEVENTF_RIGHTUP = 0x0010;     // 释放右键
private const int MOUSEEVENTF_MIDDLEDOWN = 0x0020;  // 按下中键
private const int MOUSEEVENTF_MIDDLEUP = 0x0040;    // 释放中键
private const int MOUSEEVENTF_WHEEL = 0x0800;       // 滚轮滚动
private const int MOUSEEVENTF_ABSOLUTE = 0x8000;    // 使用绝对坐标

private const int INPUT_MOUSE = 0;  // 输入类型:鼠标

5.1 模拟鼠标点击

最简单的鼠标操作就是点击。我们先实现一个左键单击:

static void SimulateLeftClick()
{
    INPUT[] inputs = new INPUT[2];
    int inputSize = Marshal.SizeOf(typeof(INPUT));
    
    // 按下左键
    inputs[0] = new INPUT();
    inputs[0].type = INPUT_MOUSE;
    inputs[0].u.mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
    inputs[0].u.mi.time = 0;
    inputs[0].u.mi.dwExtraInfo = IntPtr.Zero;
    
    // 释放左键
    inputs[1] = new INPUT();
    inputs[1].type = INPUT_MOUSE;
    inputs[1].u.mi.dwFlags = MOUSEEVENTF_LEFTUP;
    inputs[1].u.mi.time = 0;
    inputs[1].u.mi.dwExtraInfo = IntPtr.Zero;
    
    SendInput(2, inputs, inputSize);
}

右键单击和中键单击的代码类似,只是把LEFTDOWN和LEFTUP换成RIGHTDOWN/RIGHTUP或MIDDLEDOWN/MIDDLEUP。

双击操作就是两个快速单击的组合,但中间需要有一点延迟。不过在实际测试中,我建议直接调用两次SimulateLeftClick(),然后在中间加一个短暂的Thread.Sleep,比如10-50毫秒。Windows系统对双击的判定有时间阈值,通常是500毫秒内两次点击算作双击,所以我们的延迟要小于这个值。

5.2 模拟鼠标移动

鼠标移动有两种模式:相对移动和绝对移动。相对移动是指“从当前位置移动多少距离”,绝对移动是指“移动到屏幕的某个具体坐标”。

先看相对移动,这适合模拟拖拽操作:

static void SimulateMouseMoveRelative(int deltaX, int deltaY)
{
    INPUT input = new INPUT();
    input.type = INPUT_MOUSE;
    input.u.mi.dx = deltaX;  // X方向移动量
    input.u.mi.dy = deltaY;  // Y方向移动量
    input.u.mi.dwFlags = MOUSEEVENTF_MOVE;
    input.u.mi.time = 0;
    input.u.mi.dwExtraInfo = IntPtr.Zero;
    
    INPUT[] inputs = { input };
    SendInput(1, inputs, Marshal.SizeOf(typeof(INPUT)));
}

绝对移动稍微复杂点,因为需要把屏幕坐标转换成SendInput需要的坐标系统。SendInput的绝对坐标范围是0到65535,对应整个屏幕的宽度和高度。所以我们需要做一个转换:

static void SimulateMouseMoveAbsolute(int screenX, int screenY)
{
    // 获取屏幕分辨率
    int screenWidth = GetSystemMetrics(0);  // SM_CXSCREEN
    int screenHeight = GetSystemMetrics(1); // SM_CYSCREEN
    
    // 转换坐标
    int absoluteX = screenX * 65535 / screenWidth;
    int absoluteY = screenY * 65535 / screenHeight;
    
    INPUT input = new INPUT();
    input.type = INPUT_MOUSE;
    input.u.mi.dx = absoluteX;
    input.u.mi.dy = absoluteY;
    input.u.mi.dwFlags = MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE;
    input.u.mi.time = 0;
    input.u.mi.dwExtraInfo = IntPtr.Zero;
    
    INPUT[] inputs = { input };
    SendInput(1, inputs, Marshal.SizeOf(typeof(INPUT)));
}

[DllImport("user32.dll")]
static extern int GetSystemMetrics(int nIndex);

这里用到了GetSystemMetrics这个API来获取屏幕尺寸。注意MOUSEEVENTF_ABSOLUTE标志,它告诉系统我们使用的是绝对坐标。

5.3 模拟鼠标滚轮

滚轮模拟也很简单,关键是mouseData参数。正值表示向上滚动(远离用户),负值表示向下滚动(朝向用户)。Windows定义滚轮的一个“刻度”是120个单位:

static void SimulateMouseWheel(int delta)
{
    INPUT input = new INPUT();
    input.type = INPUT_MOUSE;
    input.u.mi.mouseData = delta;  // 正值向上,负值向下
    input.u.mi.dwFlags = MOUSEEVENTF_WHEEL;
    input.u.mi.time = 0;
    input.u.mi.dwExtraInfo = IntPtr.Zero;
    
    INPUT[] inputs = { input };
    SendInput(1, inputs, Marshal.SizeOf(typeof(INPUT)));
}

// 使用示例:向上滚动3个刻度
SimulateMouseWheel(120 * 3);
// 向下滚动2个刻度
SimulateMouseWheel(-120 * 2);

6. 自动化测试中的实战技巧:避开那些年我踩过的坑

有了键盘和鼠标模拟的基础,现在我们可以聊聊在真实自动化测试项目中如何应用这些技术。我总结了一些实战经验和常见问题的解决方案。

6.1 焦点管理:确保输入发送到正确窗口

这是SendInput最常见的问题之一:你模拟了按键,但什么也没发生。90%的情况是因为目标窗口没有获得焦点。SendInput发送的输入事件会进入系统的全局输入队列,然后由系统分发给当前获得焦点的窗口。

解决方法有几个。最简单的是在发送输入前,先激活目标窗口:

[DllImport("user32.dll")]
static extern bool SetForegroundWindow(IntPtr hWnd);

[DllImport("user32.dll", SetLastError = true)]
static extern IntPtr FindWindow(string lpClassName, string lpWindowName);

// 激活记事本窗口
IntPtr notepadHandle = FindWindow("Notepad", null);
if (notepadHandle != IntPtr.Zero)
{
    SetForegroundWindow(notepadHandle);
    Thread.Sleep(100);  // 给窗口一点时间获得焦点
    // 现在可以安全地发送输入了
    SimulateKeyPress(VirtualKeyCodes.VK_A);
}

FindWindow可以根据窗口类名或标题查找窗口句柄。如果知道窗口标题,用标题查找更可靠。SetForegroundWindow将指定窗口带到前台并给予焦点。那个Thread.Sleep(100)很重要,因为窗口激活需要时间,立即发送输入可能会丢失。

6.2 输入延迟:模拟真实用户操作节奏

真人操作键盘鼠标是有节奏的,不会像机器一样瞬间完成所有操作。在自动化测试中,适当的延迟能提高成功率:

static void TypeTextWithDelay(string text, int delayBetweenKeys = 50)
{
    foreach (char c in text)
    {
        // 这里需要将字符转换为虚拟键码
        // 简单起见,假设都是字母
        if (c >= 'a' && c <= 'z')
        {
            ushort keyCode = (ushort)(c - 'a' + VirtualKeyCodes.VK_A);
            SimulateKeyPress(keyCode);
        }
        else if (c >= 'A' && c <= 'Z')
        {
            // 大写字母需要配合Shift键
            // 先按Shift,再按字母,然后释放
            SimulateKeyCombination(VirtualKeyCodes.VK_SHIFT, (ushort)(c - 'A' + VirtualKeyCodes.VK_A));
        }
        
        Thread.Sleep(delayBetweenKeys);
    }
}

对于鼠标操作,特别是拖拽,更需要模拟真实的人类操作速度:

static void SimulateDragWithTrajectory(int startX, int startY, int endX, int endY, int steps = 20, int stepDelay = 20)
{
    // 先移动到起点
    SimulateMouseMoveAbsolute(startX, startY);
    Thread.Sleep(100);
    
    // 按下鼠标左键
    SimulateMouseButton(MOUSEEVENTF_LEFTDOWN);
    Thread.Sleep(50);
    
    // 分步移动到终点
    for (int i = 1; i <= steps; i++)
    {
        int currentX = startX + (endX - startX) * i / steps;
        int currentY = startY + (endY - startY) * i / steps;
        
        SimulateMouseMoveAbsolute(currentX, currentY);
        Thread.Sleep(stepDelay);
    }
    
    // 释放鼠标左键
    SimulateMouseButton(MOUSEEVENTF_LEFTUP);
}

6.3 处理输入法干扰

这是一个容易被忽视但很重要的问题。当系统输入法处于中文或其他非英文状态时,SendInput模拟的键盘输入可能会被输入法拦截并转换。比如你模拟输入"hello",可能变成"你好"。

解决方案是在发送输入前,确保输入法是英文状态。可以通过发送切换输入法的快捷键:

static void EnsureEnglishInputMethod()
{
    // 发送Ctrl+Space切换中英文输入法(这是常见的中文输入法切换方式)
    // 注意:不同输入法的切换快捷键可能不同
    SimulateKeyCombination(VirtualKeyCodes.VK_CONTROL, VirtualKeyCodes.VK_SPACE);
    Thread.Sleep(100);
    
    // 或者发送Shift键切换中英文
    SimulateKeyPress(VirtualKeyCodes.VK_SHIFT);
    Thread.Sleep(50);
    SimulateKeyRelease(VirtualKeyCodes.VK_SHIFT);
    Thread.Sleep(100);
}

更可靠的方法是在测试开始前,通过系统API设置输入法。但这涉及更复杂的COM编程,对于大多数测试场景,发送切换快捷键已经足够。

6.4 错误处理和重试机制

自动化测试脚本需要健壮性。SendInput可能因为各种原因失败,我们需要有错误处理和重试机制:

static bool SendInputWithRetry(INPUT[] inputs, int maxRetries = 3)
{
    int retryCount = 0;
    int inputSize = Marshal.SizeOf(typeof(INPUT));
    
    while (retryCount < maxRetries)
    {
        uint result = SendInput((uint)inputs.Length, inputs, inputSize);
        
        if (result == inputs.Length)
        {
            return true;  // 成功
        }
        
        retryCount++;
        Console.WriteLine($"SendInput失败,第{retryCount}次重试...");
        Thread.Sleep(100 * retryCount);  // 指数退避延迟
    }
    
    Console.WriteLine($"SendInput失败,已重试{maxRetries}次");
    return false;
}

对于关键操作,还可以添加验证步骤。比如模拟点击按钮后,检查预期的结果是否出现:

static bool ClickButtonAndVerify(IntPtr buttonHandle, string expectedText)
{
    // 获取按钮位置并点击
    RECT buttonRect = GetControlRect(buttonHandle);
    int centerX = (buttonRect.Left + buttonRect.Right) / 2;
    int centerY = (buttonRect.Top + buttonRect.Bottom) / 2;
    
    SimulateMouseMoveAbsolute(centerX, centerY);
    Thread.Sleep(50);
    SimulateLeftClick();
    
    // 等待并验证
    for (int i = 0; i < 10; i++)
    {
        Thread.Sleep(200);
        if (CheckExpectedResult(expectedText))
        {
            return true;
        }
    }
    
    return false;
}

7. 性能优化:让自动化测试飞起来

当你的测试用例从几十个增加到几百个时,性能就变得重要了。SendInput本身很快,但不当的使用方式会拖慢整个测试套件。

7.1 批量发送输入事件

SendInput支持一次性发送多个输入事件,这比多次调用SendInput要高效得多。前面模拟组合键时我们已经用到了这个特性。对于复杂的操作序列,更应该批量发送:

static void SimulateLoginSequence(string username, string password)
{
    // 计算需要多少事件:Tab到用户名字段,输入用户名,Tab到密码字段,输入密码,按Enter
    int eventCount = 2 + username.Length * 2 + 2 + password.Length * 2 + 2;
    INPUT[] inputs = new INPUT[eventCount];
    int index = 0;
    
    // Tab到用户名字段
    inputs[index++] = CreateKeyboardInput(VirtualKeyCodes.VK_TAB, KEYEVENTF_KEYDOWN | KEYEVENTF_KEYUP);
    
    // 输入用户名(每个字符需要按下和释放两个事件)
    foreach (char c in username)
    {
        ushort keyCode = GetKeyCodeForChar(c);
        inputs[index++] = CreateKeyboardInput(keyCode, KEYEVENTF_KEYDOWN);
        inputs[index++] = CreateKeyboardInput(keyCode, KEYEVENTF_KEYUP);
    }
    
    // Tab到密码字段
    inputs[index++] = CreateKeyboardInput(VirtualKeyCodes.VK_TAB, KEYEVENTF_KEYDOWN | KEYEVENTF_KEYUP);
    
    // 输入密码
    foreach (char c in password)
    {
        ushort keyCode = GetKeyCodeForChar(c);
        inputs[index++] = CreateKeyboardInput(keyCode, KEYEVENTF_KEYDOWN);
        inputs[index++] = CreateKeyboardInput(keyCode, KEYEVENTF_KEYUP);
    }
    
    // 按Enter登录
    inputs[index++] = CreateKeyboardInput(VirtualKeyCodes.VK_RETURN, KEYEVENTF_KEYDOWN);
    inputs[index++] = CreateKeyboardInput(VirtualKeyCodes.VK_RETURN, KEYEVENTF_KEYUP);
    
    // 一次性发送所有事件
    SendInput((uint)eventCount, inputs, Marshal.SizeOf(typeof(INPUT)));
}

这种方法大大减少了API调用次数,对于长文本输入特别有效。

7.2 减少不必要的等待

很多自动化脚本包含了过多的Thread.Sleep调用,这是性能杀手。应该用更智能的等待方式:

static void WaitForCondition(Func<bool> condition, int timeoutMs = 5000, int checkIntervalMs = 100)
{
    Stopwatch sw = Stopwatch.StartNew();
    
    while (sw.ElapsedMilliseconds < timeoutMs)
    {
        if (condition())
        {
            return;
        }
        Thread.Sleep(checkIntervalMs);
    }
    
    throw new TimeoutException("等待条件超时");
}

// 使用示例:等待某个窗口出现
WaitForCondition(() => FindWindow("Notepad", null) != IntPtr.Zero);

7.3 使用扫描码提高兼容性

前面我们一直用虚拟键码(wVk),但SendInput还支持扫描码(wScan)。扫描码是键盘硬件的原始编码,不依赖键盘布局。对于国际化测试场景,使用扫描码可能更可靠:

static void SimulateKeyPressByScanCode(ushort scanCode)
{
    INPUT[] inputs = new INPUT[2];
    
    // 按下
    inputs[0] = new INPUT();
    inputs[0].type = INPUT_KEYBOARD;
    inputs[0].u.ki.wScan = scanCode;
    inputs[0].u.ki.dwFlags = KEYEVENTF_SCANCODE;  // 使用扫描码标志
    inputs[0].u.ki.time = 0;
    inputs[0].u.ki.dwExtraInfo = IntPtr.Zero;
    
    // 释放
    inputs[1] = new INPUT();
    inputs[1].type = INPUT_KEYBOARD;
    inputs[1].u.ki.wScan = scanCode;
    inputs[1].u.ki.dwFlags = KEYEVENTF_SCANCODE | KEYEVENTF_KEYUP;
    inputs[1].u.ki.time = 0;
    inputs[1].u.ki.dwExtraInfo = IntPtr.Zero;
    
    SendInput(2, inputs, Marshal.SizeOf(typeof(INPUT)));
}

扫描码的缺点是代码可读性差,你需要查表才知道0x1E对应的是'A'键。但在某些特殊场景下,比如测试多语言软件时,扫描码能确保输入的一致性。

8. 与UI自动化框架集成:强强联合

虽然SendInput很强大,但在现代自动化测试中,我们通常不会单独使用它,而是与UI自动化框架结合。比如Windows自带的UI Automation框架,或者第三方框架如Selenium(用于Web)、Appium(用于移动和桌面)。

8.1 与UI Automation结合

UI Automation能获取控件信息,SendInput能模拟操作,两者结合就是完美的自动化测试方案:

using System.Windows.Automation;

static void TestLoginForm()
{
    // 使用UI Automation找到登录窗口
    AutomationElement desktop = AutomationElement.RootElement;
    
    // 按名称查找窗口
    Condition condition = new PropertyCondition(
        AutomationElement.NameProperty, "登录");
    AutomationElement loginWindow = desktop.FindFirst(
        TreeScope.Children, condition);
    
    if (loginWindow != null)
    {
        // 找到用户名输入框
        Condition usernameCondition = new PropertyCondition(
            AutomationElement.AutomationIdProperty, "usernameTextBox");
        AutomationElement usernameBox = loginWindow.FindFirst(
            TreeScope.Descendants, usernameCondition);
        
        if (usernameBox != null)
        {
            // 将焦点设置到输入框
            usernameBox.SetFocus();
            Thread.Sleep(100);
            
            // 使用SendInput输入用户名
            TypeText("testuser");
            
            // 找到密码框
            Condition passwordCondition = new PropertyCondition(
                AutomationElement.AutomationIdProperty, "passwordBox");
            AutomationElement passwordBox = loginWindow.FindFirst(
                TreeScope.Descendants, passwordCondition);
            
            if (passwordBox != null)
            {
                // Tab到密码框(比用鼠标点击更可靠)
                SimulateKeyPress(VirtualKeyCodes.VK_TAB);
                Thread.Sleep(50);
                
                // 输入密码
                TypeText("testpass123");
                
                // 找到并点击登录按钮
                Condition loginButtonCondition = new PropertyCondition(
                    AutomationElement.NameProperty, "登录");
                AutomationElement loginButton = loginWindow.FindFirst(
                    TreeScope.Descendants, loginButtonCondition);
                
                if (loginButton != null)
                {
                    // 获取按钮位置并点击
                    Rect buttonRect = loginButton.Current.BoundingRectangle;
                    int centerX = (int)((buttonRect.Left + buttonRect.Right) / 2);
                    int centerY = (int)((buttonRect.Top + buttonRect.Bottom) / 2);
                    
                    SimulateMouseMoveAbsolute(centerX, centerY);
                    Thread.Sleep(50);
                    SimulateLeftClick();
                }
            }
        }
    }
}

这种组合方式的优势很明显:UI Automation负责“找控件”,SendInput负责“操作控件”。UI Automation能获取丰富的控件信息(位置、状态、属性),而SendInput能模拟最接近真实用户的操作。

8.2 封装成可重用的测试工具

在实际项目中,我会把SendInput的相关功能封装成专门的工具类,方便团队其他成员使用:

public class InputSimulator
{
    // 键盘操作
    public void KeyPress(ushort keyCode) { /* 实现 */ }
    public void KeyDown(ushort keyCode) { /* 实现 */ }
    public void KeyUp(ushort keyCode) { /* 实现 */ }
    public void TypeText(string text) { /* 实现 */ }
    public void TypeText(string text, int delayBetweenKeys) { /* 实现 */ }
    
    // 组合键
    public void KeyCombination(params ushort[] keyCodes) { /* 实现 */ }
    public void CtrlC() { KeyCombination(VirtualKeyCodes.VK_CONTROL, VirtualKeyCodes.VK_C); }
    public void CtrlV() { KeyCombination(VirtualKeyCodes.VK_CONTROL, VirtualKeyCodes.VK_V); }
    public void AltTab() { KeyCombination(VirtualKeyCodes.VK_MENU, VirtualKeyCodes.VK_TAB); }
    
    // 鼠标操作
    public void MoveMouseTo(int x, int y) { /* 实现 */ }
    public void MoveMouseRelative(int deltaX, int deltaY) { /* 实现 */ }
    public void LeftClick() { /* 实现 */ }
    public void RightClick() { /* 实现 */ }
    public void DoubleClick() { /* 实现 */ }
    public void Drag(int startX, int startY, int endX, int endY) { /* 实现 */ }
    public void Scroll(int delta) { /* 实现 */ }
    
    // 高级功能
    public void Wait(int milliseconds) { Thread.Sleep(milliseconds); }
    public void WaitFor(Func<bool> condition, int timeoutMs = 5000) { /* 实现 */ }
    
    // 焦点管理
    public bool ActivateWindow(string windowTitle) { /* 实现 */ }
    public bool ActivateWindow(IntPtr handle) { /* 实现 */ }
}

这样的封装让测试代码更简洁:

// 使用封装的InputSimulator
var input = new InputSimulator();

// 激活记事本并输入内容
input.ActivateWindow("无标题 - 记事本");
input.Wait(200);
input.TypeText("Hello, SendInput!");
input.KeyCombination(VirtualKeyCodes.VK_CONTROL, VirtualKeyCodes.VK_S);
input.Wait(500);
input.TypeText("test.txt");
input.KeyPress(VirtualKeyCodes.VK_RETURN);

8.3 处理异常和边缘情况

在实际测试中,各种奇怪的情况都可能发生。好的自动化框架需要能处理这些异常:

public class RobustInputSimulator : InputSimulator
{
    private int _retryCount = 3;
    private int _defaultDelay = 100;
    
    public void RobustTypeText(string text)
    {
        foreach (char c in text)
        {
            int retry = 0;
            bool success = false;
            
            while (!success && retry < _retryCount)
            {
                try
                {
                    // 尝试输入字符
                    TypeChar(c);
                    success = true;
                }
                catch (Exception ex)
                {
                    retry++;
                    Console.WriteLine($"输入字符'{c}'失败,第{retry}次重试。错误: {ex.Message}");
                    
                    if (retry >= _retryCount)
                    {
                        // 最后一次尝试:先发送Esc取消可能的状态,再重试
                        KeyPress(VirtualKeyCodes.VK_ESCAPE);
                        Wait(_defaultDelay * 2);
                        TypeChar(c);
                    }
                    else
                    {
                        Wait(_defaultDelay * retry);
                    }
                }
            }
            
            Wait(_defaultDelay);
        }
    }
    
    public void SafeClick(int x, int y)
    {
        // 先保存当前鼠标位置
        Point originalPos = GetCursorPos();
        
        try
        {
            // 移动到目标位置并点击
            MoveMouseTo(x, y);
            Wait(50);
            LeftClick();
            
            // 验证点击是否生效(比如检查窗口状态变化)
            if (!VerifyClickResult())
            {
                // 点击可能没生效,尝试再次点击
                Console.WriteLine("第一次点击可能失败,尝试第二次...");
                LeftClick();
            }
        }
        finally
        {
            // 恢复鼠标位置(可选)
            // MoveMouseTo(originalPos.X, originalPos.Y);
        }
    }
    
    private bool VerifyClickResult()
    {
        // 这里实现点击后的验证逻辑
        // 比如检查某个控件状态是否变化
        // 或者等待某个窗口出现
        return true; // 简化实现
    }
}

这种“防御性编程”的思路在自动化测试中特别重要。测试环境往往不如开发环境稳定,可能有其他程序弹出对话框、系统通知干扰、网络延迟等问题。健壮的测试脚本应该能处理这些干扰,继续执行或至少给出清晰的错误信息。

9. 高级应用场景:超越基础自动化

掌握了SendInput的基础用法后,我们可以探索一些更高级的应用场景。这些场景可能不会在每天的测试中出现,但一旦需要,它们能解决大问题。

9.1 模拟游戏操作

游戏自动化是SendInput的一个重要应用领域。但游戏通常有更严格的输入检测,有些甚至故意阻止自动化工具。这时候需要一些技巧:

public class GameInputSimulator
{
    // 一些游戏需要更精确的时间控制
    public void SimulateComboInFightingGame()
    {
        // 格斗游戏连招:↓↘→ + A
        // 这种操作需要非常精确的时序
        
        // ↓
        KeyDown(VirtualKeyCodes.VK_DOWN);
        Wait(50);
        
        // ↘ (↓ + →)
        KeyDown(VirtualKeyCodes.VK_RIGHT);
        Wait(30);
        KeyUp(VirtualKeyCodes.VK_DOWN);
        Wait(20);
        
        // →
        Wait(30);
        KeyUp(VirtualKeyCodes.VK_RIGHT);
        
        // A键攻击
        Wait(20);
        KeyPress(VirtualKeyCodes.VK_A);
    }
    
    // 模拟鼠标宏(比如FPS游戏的压枪)
    public void SimulateRecoilCompensation(int shots, int recoilPatternX, int recoilPatternY)
    {
        for (int i = 0; i < shots; i++)
        {
            // 按下鼠标左键开火
            MouseButtonDown(MOUSEEVENTF_LEFTDOWN);
            
            // 根据后坐力模式移动鼠标
            int moveX = CalculateRecoilX(i, recoilPatternX);
            int moveY = CalculateRecoilY(i, recoilPatternY);
            MoveMouseRelative(moveX, moveY);
            
            // 保持开枪状态一段时间
            Wait(100);
            
            // 释放左键
            MouseButtonUp(MOUSEEVENTF_LEFTUP);
            
            // 射击间隔
            Wait(50);
        }
    }
}

需要注意的是,很多在线游戏将这种自动化视为作弊。所以这些技术只适用于单机游戏的测试,或者你自己开发的游戏。

9.2 无障碍辅助工具

SendInput可以用来开发辅助技术工具,帮助有障碍的用户操作电脑:

public class AccessibilityInputHelper
{
    // 为行动不便的用户提供点击辅助
    public void AssistClick(Point target, Point current, int movementTimeMs = 1000)
    {
        // 缓慢移动鼠标,让用户能看到移动轨迹
        int steps = movementTimeMs / 20; // 每20毫秒移动一步
        for (int i = 0; i <= steps; i++)
        {
            int x = current.X + (target.X - current.X) * i / steps;
            int y = current.Y + (target.Y - current.Y) * i / steps;
            
            MoveMouseTo(x, y);
            Wait(20);
        }
        
        // 点击前提示(比如播放声音)
        PlayClickSound();
        Wait(200);
        
        // 执行点击
        LeftClick();
    }
    
    // 语音输入转键盘输入
    public void TypeFromSpeech(string spokenText)
    {
        // 简单的语音命令映射
        var commandMap = new Dictionary<string, Action>
        {
            ["打开开始菜单"] = () => KeyPress(VirtualKeyCodes.VK_LWIN),
            ["复制"] = () => KeyCombination(VirtualKeyCodes.VK_CONTROL, VirtualKeyCodes.VK_C),
            ["粘贴"] = () => KeyCombination(VirtualKeyCodes.VK_CONTROL, VirtualKeyCodes.VK_V),
            ["保存"] = () => KeyCombination(VirtualKeyCodes.VK_CONTROL, VirtualKeyCodes.VK_S),
            // ... 更多命令
        };
        
        if (commandMap.ContainsKey(spokenText))
        {
            commandMap[spokenText]();
        }
        else
        {
            // 不是命令,当作普通文本输入
            TypeText(spokenText);
        }
    }
}

9.3 录制和回放用户操作

结合SetWindowsHookExAPI,可以录制用户的真实操作,然后用SendInput回放:

public class InputRecorder
{
    private List<InputEvent> _recordedEvents = new List<InputEvent>();
    private bool _isRecording = false;
    
    public void StartRecording()
    {
        _recordedEvents.Clear();
        _isRecording = true;
        
        // 安装键盘钩子
        InstallKeyboardHook();
        // 安装鼠标钩子
        InstallMouseHook();
    }
    
    public void StopRecording()
    {
        _isRecording = false;
        UninstallHooks();
    }
    
    public void Playback()
    {
        DateTime startTime = DateTime.Now;
        
        foreach (var evt in _recordedEvents)
        {
            // 等待到正确的时间点
            TimeSpan delay = evt.Timestamp - startTime;
            if (delay.TotalMilliseconds > 0)
            {
                Thread.Sleep(delay);
            }
            
            // 回放事件
            PlaybackEvent(evt);
        }
    }
    
    private void OnKeyboardEvent(KeyboardEventData data)
    {
        if (_isRecording)
        {
            _recordedEvents.Add(new InputEvent
            {
                Type = InputType.Keyboard,
                Timestamp = DateTime.Now,
                Data = data
            });
        }
    }
    
    private void OnMouseEvent(MouseEventData data)
    {
        if (_isRecording)
        {
            _recordedEvents.Add(new InputEvent
            {
                Type = InputType.Mouse,
                Timestamp = DateTime.Now,
                Data = data
            });
        }
    }
}

这种录制回放功能对于创建演示、自动化重复性工作、或者测试用户操作流程非常有用。

10. 调试技巧和常见问题排查

即使有了完善的代码,在实际使用中还是会遇到各种问题。这里分享一些我积累的调试技巧。

10.1 输入没有生效?先检查这些

当SendInput没有按预期工作时,按这个检查列表排查:

  1. 权限问题:如果你的程序运行在普通用户权限,而目标程序需要管理员权限,SendInput可能失败。尝试以管理员身份运行你的程序。

  2. 焦点问题:确保目标窗口确实获得了焦点。可以在发送输入前添加一个SetForegroundWindow调用,并等待足够时间。

  3. UAC弹窗干扰:用户账户控制弹窗会窃取焦点。在测试期间可以暂时关闭UAC,或者确保测试流程不会触发UAC。

  4. 杀毒软件/安全软件拦截:有些安全软件会阻止程序模拟输入。尝试将你的程序添加到白名单,或者暂时禁用安全软件测试。

  5. 输入法状态:特别是输入中文时,确保输入法处于英文状态。

  6. 系统缩放设置:在高DPI显示器上,鼠标坐标可能需要根据缩放比例调整。

10.2 添加调试日志

在关键位置添加日志,记录发送的输入和结果:

public class DebuggableInputSimulator : InputSimulator
{
    private StreamWriter _logWriter;
    private bool _enableLogging = true;
    
    public DebuggableInputSimulator(string logFilePath = "input_log.txt")
    {
        _logWriter = new StreamWriter(logFilePath, true);
    }
    
    public override void KeyPress(ushort keyCode)
    {
        if (_enableLogging)
        {
            _logWriter.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] 按键: {keyCode:X2}");
            _logWriter.Flush();
        }
        
        try
        {
            base.KeyPress(keyCode);
            
            if (_enableLogging)
            {
                _logWriter.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] 按键成功");
                _logWriter.Flush();
            }
        }
        catch (Exception ex)
        {
            if (_enableLogging)
            {
                _logWriter.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] 按键失败: {ex.Message}");
                _logWriter.Flush();
            }
            throw;
        }
    }
    
    // 类似地重写其他方法...
}

10.3 使用Spy++等工具验证

Visual Studio自带的Spy++(或者类似的WinSpy++、AutoIt Window Info)是调试Windows程序的利器。你可以:

  1. 查看目标窗口的句柄、类名、标题
  2. 监控发送到窗口的消息
  3. 查看窗口的属性和样式

当SendInput不工作时,用Spy++看看目标窗口是否收到了预期的WM_KEYDOWN、WM_KEYUP、WM_MOUSEMOVE等消息。如果没有收到,说明问题在发送端;如果收到了但窗口没反应,说明问题在接收端。

10.4 创建最小可复现示例

当遇到奇怪的问题时,创建一个最简单的测试程序:

// 最小测试程序
static void Main()
{
    Console.WriteLine("最小化测试 - 将在5秒后向记事本发送输入");
    Console.WriteLine("请确保记事本已打开并处于活动状态");
    
    Thread.Sleep(5000);
    
    // 测试基本按键
    var input = new InputSimulator();
    input.TypeText("Hello World");
    input.KeyPress(VirtualKeyCodes.VK_RETURN);
    input.TypeText("This is a test");
    
    Console.WriteLine("测试完成,检查记事本中是否有文本");
    Console.ReadLine();
}

如果这个最小测试能工作,但你的完整程序不能,说明问题在别的地方(可能是时机、焦点管理或其他逻辑)。如果最小测试也不能工作,说明是环境或基础代码问题。

10.5 处理64位/32位兼容性问题

如果你的程序是AnyCPU或x64,但调用了32位DLL(或者反过来),可能会遇到问题。确保P/Invoke声明正确:

// 显式指定调用约定和字符集
[DllImport("user32.dll", SetLastError = true, CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Auto)]
static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);

对于需要处理指针大小的情况,使用IntPtr而不是int:

[StructLayout(LayoutKind.Sequential)]
public struct KEYBDINPUT
{
    public ushort wVk;
    public ushort wScan;
    public uint dwFlags;
    public uint time;
    public IntPtr dwExtraInfo;  // 使用IntPtr而不是int
}

我在实际项目中遇到过这样的问题:在32位系统上运行正常的代码,在64位系统上崩溃。最后发现是结构体对齐问题。[StructLayout(LayoutKind.Sequential)]确保了字段顺序,但不同位数的系统可能有不同的对齐方式。如果遇到奇怪的内存访问错误,可以尝试[StructLayout(LayoutKind.Sequential, Pack = 1)]来强制1字节对齐。

这些调试技巧看起来琐碎,但能节省大量排查时间。特别是当测试在CI/CD服务器上失败,但在开发机器上成功时,这些系统性的排查方法就特别有用。

更多推荐