简介:这是一份面向Windows平台开发者的屏幕软键盘(虚拟键盘)源码工程,适合需要实现无实体键盘输入、触摸屏交互或安全密码输入场景的开发者参考。资源以VC++工程形式组织,包含对话框界面、系统托盘、资源脚本与图标等模块,可直接编译运行并集成到其他程序中,帮助理解按键事件处理、字符编码转换与应用程序间输入注入的实现思路。压缩包共20个文件,约32KB,以h头文件与cpp源文件为核心,辅以dsw/dsp工程文件、rc/rc2资源脚本、ico图标及txt使用说明,结构紧凑、便于快速上手。目前已有163人学习下载。通过该工程,读者可掌握屏幕键盘的界面布局设计、点击事件响应、字符发送机制以及系统托盘集成等关键环节,并在此基础上扩展自定义布局、辅助输入或安全输入方案,是学习GUI编程与输入法交互的实用参考案例。
1. 屏幕软键盘到底解决什么问题:从物理键盘失灵到跨进程注入
屏幕软键盘这个词,很多人第一反应是手机上的输入法面板,或者 Windows 辅助功能里那个能用鼠标点的虚拟键盘。但标题里这串关键词指向的是另一类东西:一个能脱离物理键盘、把字符送进其他程序输入框的软件键盘。注意后半句——“可通过软键盘向其他程序中输入字符”,这才是核心。它要解决的不是“我自己有个界面能打字”,而是“我打出来的字,怎么进到记事本、浏览器、工控组态软件、收银系统这些我改不了源码的程序里去”。
我最早接触这个需求,是在一台触屏一体机上跑一套老旧的检测软件。那软件只认物理键盘的按键消息,触摸屏自带输入法弹不出来,现场又没有键盘。当时试过系统自带屏幕键盘,结果在部分程序里点了没反应——字符发出去了,目标窗口收不到。后来才明白,屏幕软键盘的难点从来不是画一个键盘界面,而是跨进程的字符投递。这篇文章就围绕这个方向,把选型、实现、参数和踩坑讲清楚,适合做上位机、工控 HMI、自助终端、无障碍辅助工具的开发者。
2. 三种字符投递路线:SendInput、PostMessage 与剪贴板粘贴怎么选
要让软键盘的字符进到别的程序,主流就三条路。选错路线,后面全是玄学问题。这一章先把原理和选型理由讲透,再给可跑的代码。
2.1 路线一:SendInput 模拟真实按键
SendInput 是 Windows 提供的、把输入事件注入到系统输入流里的 API。它的特点是:事件进入系统队列后,由系统分发给当前前台窗口。也就是说,它模拟的是“真的有人在敲键盘”。
// SendInput 发送一个字符(以 'A' 为例,实际需处理大小写与 Shift) #include <windows.h> void SendCharA() { INPUT inputs[2] = {0}; // 按下 Shift(因为 'A' 是大写) inputs[0].type = INPUT_KEYBOARD; inputs[0].ki.wVk = VK_SHIFT; // 按下 A 键 inputs[1].type = INPUT_KEYBOARD; inputs[1].ki.wVk = 'A'; SendInput(2, inputs, sizeof(INPUT)); // 释放(顺序反过来) INPUT ups[2] = {0}; ups[0].type = INPUT_KEYBOARD; ups[0].ki.wVk = 'A'; ups[0].ki.dwFlags = KEYEVENTF_KEYUP; ups[1].type = INPUT_KEYBOARD; ups[1].ki.wVk = VK_SHIFT; ups[1].ki.dwFlags = KEYEVENTF_KEYUP; SendInput(2, ups, sizeof(INPUT)); }逻辑说明:SendInput 的第二个参数是 INPUT 数组,一次调用可以塞多个事件,系统按顺序处理。参数上最关键的是ki.wVk(虚拟键码)和ki.dwFlags(是否 KEYEVENTF_KEYUP)。字符和虚拟键码不是一一对应,比如'a'和'A'都是 VK 'A',靠 Shift 状态区分;中文、特殊符号则要走 Unicode 路线,用KEYEVENTF_UNICODE配合ki.wScan填字符编码,这时wVk必须为 0。
SendInput 的优点是兼容性最好,绝大多数程序都认。缺点是它只发给前台窗口,你的软键盘一弹出来抢了焦点,字符就发给自己了。所以用这条路,通常要配合“不抢焦点”的窗口样式,或者发送前先把焦点还给目标窗口。
2.2 路线二:PostMessage 直接投递到目标窗口
PostMessage 是往指定窗口的消息队列里塞一条消息,不经过系统输入流。常见做法是发WM_CHAR或WM_KEYDOWN/WM_KEYUP。
// 向指定窗口句柄发送一个字符 #include <windows.h> void PostCharToWindow(HWND hTarget, wchar_t ch) { // WM_CHAR 的 wParam 就是字符编码 PostMessageW(hTarget, WM_CHAR, (WPARAM)ch, 0); }逻辑说明:hTarget是目标输入框所在窗口的句柄,一般用FindWindow或WindowFromPoint拿到。WM_CHAR的wParam直接放字符,lParam可以填 1 表示这是正常按键。这条路的好处是不需要目标窗口在前台,你的软键盘可以一直显示,字符照样进得去。
但它的坑也最集中:很多现代程序(尤其是用了自绘控件、浏览器内核、Qt/Electron 的程序)根本不处理WM_CHAR,消息发过去石沉大海。所以 PostMessage 只适合目标程序是标准 Win32 编辑框的情况,通用性远不如 SendInput。
2.3 路线三:剪贴板 + 粘贴
这条路最“土”但最稳:把字符写进剪贴板,然后模拟 Ctrl+V。
// 写剪贴板并触发粘贴(简化版,未含剪贴板打开失败重试) #include <windows.h> #include <string> void PasteText(const std::wstring& text) { if (!OpenClipboard(nullptr)) return; EmptyClipboard(); size_t bytes = (text.size() + 1) * sizeof(wchar_t); HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, bytes); if (hMem) { memcpy(GlobalLock(hMem), text.c_str(), bytes); GlobalUnlock(hMem); SetClipboardData(CF_UNICODETEXT, hMem); // 成功后内存归系统管 } CloseClipboard(); // 模拟 Ctrl+V keybd_event(VK_CONTROL, 0, 0, 0); keybd_event('V', 0, 0, 0); keybd_event('V', 0, KEYEVENTF_KEYUP, 0); keybd_event(VK_CONTROL, 0, KEYEVENTF_KEYUP, 0); }逻辑说明:OpenClipboard可能因为别的程序占用而失败,生产代码要加重试。SetClipboardData成功后,hMem的所有权交给系统,不能再自己GlobalFree。粘贴用keybd_event或 SendInput 都行,本质还是模拟按键,所以同样受前台焦点限制。
三条路线的对比:
| 路线 | 兼容性 | 是否需前台 | 典型适用 | 主要风险 |
|---|---|---|---|---|
| SendInput | 高 | 是 | 通用输入 | 抢焦点、UAC 提权窗口拦截 |
| PostMessage | 低 | 否 | 标准 Win32 控件 | 自绘控件不响应 |
| 剪贴板粘贴 | 高 | 是 | 长文本、特殊符号 | 污染用户剪贴板、焦点问题 |
我的常规做法是:默认走 SendInput,遇到不响应的目标再降级到 PostMessage 试探,长文本或含大量特殊符号时用剪贴板。三条路做成可切换的策略,比死磕一条稳得多。
3. 用 Win32 写一个最小可用的屏幕软键盘
这一章给一个能跑起来的最小实现。目标:一个不抢焦点的悬浮键盘窗口,点按钮后字符进到记事本。
3.1 窗口样式:WS_EX_NOACTIVATE 是灵魂
软键盘最大的矛盾是:它要接收鼠标点击,但又不能抢走目标窗口的键盘焦点。解决办法是给窗口加WS_EX_NOACTIVATE扩展样式。
// 创建软键盘主窗口时的关键样式 HWND hKbd = CreateWindowExW( WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW, // 关键扩展样式 L"SoftKbdClass", L"屏幕软键盘", WS_POPUP, // 无边框弹出窗 100, 100, 480, 200, nullptr, nullptr, hInstance, nullptr); // 点击按钮时,不要用 SetFocus,直接处理鼠标消息逻辑说明:WS_EX_NOACTIVATE让窗口被点击时不激活、不抢焦点,目标程序的前台状态得以保留。WS_EX_TOPMOST保证键盘浮在最上层。WS_EX_TOOLWINDOW让它不出现在 Alt+Tab 列表里。注意:加了 NOACTIVATE 后,窗口收不到正常的键盘输入,但鼠标点击照常,这正是我们要的——用鼠标点按钮,字符发给别人。
3.2 按钮点击到字符投递的完整链路
按钮用自绘或标准 BUTTON 都行,核心是在点击处理里调用投递函数。
// 按钮点击处理:把字符发给当前前台窗口 void OnKeyButtonClick(wchar_t ch) { // 记录点击前的目标窗口(软键盘自己不激活,前台就是目标) HWND hTarget = GetForegroundWindow(); if (!hTarget || hTarget == g_hKbd) return; // 优先 SendInput(Unicode 方式,兼容中英文) INPUT in[2] = {0}; in[0].type = INPUT_KEYBOARD; in[0].ki.wVk = 0; in[0].ki.wScan = ch; in[0].ki.dwFlags = KEYEVENTF_UNICODE; in[1] = in[0]; in[1].ki.dwFlags = KEYEVENTF_UNICODE | KEYEVENTF_KEYUP; SendInput(2, in, sizeof(INPUT)); }逻辑说明:用KEYEVENTF_UNICODE时,wVk必须为 0,字符放在wScan。这样中文、emoji 之外的常见字符都能发。GetForegroundWindow拿到的就是目标窗口,因为软键盘不激活,前台始终是用户正在操作的程序。如果发现某些程序收不到,再补一条 PostMessage 的降级分支。
3.3 参数怎么调:重复延迟、大小写与修饰键
软键盘要处理 Shift、Ctrl、CapsLock 这些状态,否则打不出大写和符号。
// 维护修饰键状态 bool g_shift = false; bool g_caps = false; wchar_t ResolveChar(wchar_t base, wchar_t shifted) { bool upper = g_shift ^ g_caps; // 异或:Shift 和 Caps 同时开等于没开 return upper ? shifted : base; } // 发送修饰键本身(如用户点了 Shift 按钮) void ToggleShift() { g_shift = !g_shift; }逻辑说明:Shift 和 CapsLock 对字母的作用是异或关系,两个都开反而变小写,这是很多人写软键盘时翻车的地方。符号键则只受 Shift 影响,不受 Caps 影响,要分开处理。重复延迟方面,如果做长按连续输入,建议首次延迟 400ms、重复间隔 50ms,和系统默认接近,手感不会突兀。
4. 避坑与排查:字符发不出去时先看这五条
这一章全是血泪经验。软键盘“点了没反应”是最常见的报障,按下面顺序排查,能省掉大量瞎试的时间。
4.1 现象:记事本能输入,浏览器和某工控软件没反应
原因:这些程序用的是自绘控件或非标准输入模型,不处理WM_CHAR,而如果你当时走的正是 PostMessage 路线,自然没反应。SendInput 一般能救,但如果程序内部自己做了输入过滤,也可能拦掉。
解决:先切到 SendInput 的 Unicode 模式试;仍不行,用剪贴板粘贴兜底。判断目标是不是自绘控件,可以用 Spy++ 这类工具看窗口类名,类名不是Edit、RichEdit之类的基本就是自绘。
4.2 现象:字符发到了软键盘自己身上
原因:软键盘窗口被激活,抢了前台焦点,SendInput 就把字符发给自己了。
解决:确认窗口带WS_EX_NOACTIVATE;检查按钮点击处理里有没有误调SetFocus;如果用了模态对话框,模态本身会抢焦点,软键盘不要用模态。
4.3 现象:目标程序以管理员权限运行时完全无效
原因:Windows 的 UIPI(用户界面特权隔离)机制,低完整性级别的进程不能向高完整性级别的进程发送输入。你的软键盘是普通权限,目标程序是管理员权限,SendInput 会被系统静默丢弃。
解决:软键盘自身也以管理员权限运行。注意这会让它无法向普通权限程序发送——所以要么统一权限级别,要么做两个版本。这是权限模型决定的,没有绕过办法。
4.4 现象:中文输入时字符乱码或丢失
原因:用WM_CHAR发中文时,wParam是 UTF-16 编码,但有些老程序按 ANSI 处理,或者一次只收一个字节。另外 SendInput 的 Unicode 模式对某些输入法框架不友好。
解决:中文优先走剪贴板粘贴,最稳。如果必须用 SendInput,确认wScan填的是 UTF-16 码元,且wVk为 0。遇到代理对(如部分生僻字)要分两次发高低代理。
4.5 现象:连续快速点击时丢字符
原因:SendInput 的事件进入系统队列后需要时间处理,点太快时后一个事件可能在前一个还没被消费时就发出,目标程序来不及处理。PostMessage 同理,消息队列积压会乱序。
解决:在每次投递后加一个短延时,一般 10~30ms 就够。不要用Sleep阻塞 UI 线程,放到独立发送线程里做队列。这个延时值可以做成可配置,不同目标程序容忍度不一样。
5. 进阶:把软键盘做成可配置的输入策略引擎
走到这里,一个能用的软键盘已经成型了。但真正让它从“能用”到“好用”的,是把投递策略做成可配置、可探测、可回退的引擎。我现在的习惯是:软键盘启动时先对目标窗口做一次能力探测,根据结果自动选路线,而不是写死一条。
探测思路很简单:向目标窗口发一条无害的探测消息,看它是否响应。更实用的做法是维护一张“程序-策略”映射表,第一次遇到某个程序时人工确认一次,之后记住。表可以存在本地配置文件里,字段包括进程名、窗口类名、推荐策略、发送延时。
# 策略配置示例(伪代码,实际用 JSON 或 ini 存) STRATEGY_TABLE = { "notepad.exe": {"mode": "sendinput", "delay_ms": 10}, "legacy_hmi.exe":{"mode": "postmessage", "delay_ms": 30}, "browser.exe": {"mode": "clipboard", "delay_ms": 50}, }参数上,delay_ms是最需要按程序调的。工控类程序普遍反应慢,30ms 起步;浏览器和现代应用 10~20ms 即可。mode的回退顺序建议是 sendinput → postmessage → clipboard,逐级降级。
验证方法:写一个简单的测试窗口,把收到的每条WM_CHAR和按键消息打日志,然后用软键盘对着它发一串包含大小写、数字、符号、中文的字符串,比对日志和预期。这个自测窗口我每次改投递逻辑都会跑一遍,比在真实程序里试快得多。
最后说个我踩过的坑:早期我图省事,把发送逻辑直接写在按钮的点击回调里,结果长按连续输入时 UI 卡死。后来改成生产者-消费者队列,按钮只负责入队,独立线程负责按延时出队发送,问题消失。这个结构改动不大,但稳定性提升明显。做这类工具,把“界面”和“投递”彻底解耦是我最想强调的一条习惯。希望帮到你。
本文还有配套的精品资源,点击获取