C# 高效模拟输入实战:SendInput API在自动化测试中的应用
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没有按预期工作时,按这个检查列表排查:
-
权限问题:如果你的程序运行在普通用户权限,而目标程序需要管理员权限,
SendInput可能失败。尝试以管理员身份运行你的程序。 -
焦点问题:确保目标窗口确实获得了焦点。可以在发送输入前添加一个
SetForegroundWindow调用,并等待足够时间。 -
UAC弹窗干扰:用户账户控制弹窗会窃取焦点。在测试期间可以暂时关闭UAC,或者确保测试流程不会触发UAC。
-
杀毒软件/安全软件拦截:有些安全软件会阻止程序模拟输入。尝试将你的程序添加到白名单,或者暂时禁用安全软件测试。
-
输入法状态:特别是输入中文时,确保输入法处于英文状态。
-
系统缩放设置:在高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程序的利器。你可以:
- 查看目标窗口的句柄、类名、标题
- 监控发送到窗口的消息
- 查看窗口的属性和样式
当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服务器上失败,但在开发机器上成功时,这些系统性的排查方法就特别有用。
更多推荐

所有评论(0)