最近在做一个工业触摸屏项目界面里要输入温度、转速、长度这类纯数值参数。当时第一反应是直接用系统软键盘结果被现实狠狠教育了一顿嵌入式 Linux 的镜像里根本没有可用的输入法装了虚拟键盘又小又难看按键戴着手套根本点不中。最后只能自己动手用 Qt 从头写了一个纯数字虚拟键盘。这篇分享打算把整个来龙去脉讲清楚包括为什么不用现成方案、为什么选 Widgets 而不是 QML、键盘本体怎么搭、按键信号怎么准确送到输入框以及嵌入式触摸屏环境下一堆实测踩坑记录。内容偏向可复现的工程细节适合正在做 HMI、工控上位机或任何需要自定义数字键盘的 Qt 开发者。如果你也卡在“系统软键盘不可控”这个点上重点看第五、六节能少走不少弯路。1. 为什么自研纯数字键盘系统软键盘在工控场景的短板1.1 先明确“纯数字键盘”到底指什么很多朋友一听“软键盘”第一反应是调系统输入法或者搜一个虚拟键盘库装进去。但工控触摸屏里说的“纯数字键盘”其实是一套按实际需求裁剪过的输入面板只保留 0-9 数字键、小数点、删除、清空、确认这些键位没有字母没有符号切换甚至连正负号都可能不需要单独做逻辑。在我那个项目里操作员需要输入的数值有几个非常鲜明的特点只允许数字和小数点部分字段还有小数位数限制操作员普遍戴手套按键热区必须比手机键盘大很多键盘需要在主窗口内随时切入切出不能让系统唤出一个和整体 UI 风格完全割裂的小面板。把这几条翻译成需求后系统软键盘一个都满足不了。1.2 系统软键盘在嵌入式 Linux 上的三个短板第一界面不可控。触摸屏上如果真有系统软键盘多数是 X11 环境下的老式虚拟键盘或者应用容器自带的简易面板。按键大小、配色、键位布局都是别人定的没办法为戴手套的场景把按键调大也没办法把“小数点”和“00”这种在工业场景里高频使用的键单独突出。第二信号拦截难。系统软键盘往往通过全局事件注入来模拟按键QLineEdit 会收到一堆键盘事件但开发者很难判断哪些是真实物理按键、哪些来自软键盘。一旦输入法接管了 Ctrl、Shift 这类修饰键业务层状态很容易被带偏测试时真机行为和模拟器行为还经常不一致。第三平台没得选。我实际交叉编译时发现很多 ARM 板子的 Linux 镜像根本没有安装输入法框架连最基本的软键盘程序都不存在。项目不可能为了输入几个小数先去给整个系统补一个输入法框架维护成本直接失控。1.3 为什么不靠现成第三方控件库市面上不是没有第三方数字键盘控件但引入一个外部库意味着要评估它的依赖树、授权协议和更新维护情况。为了一个数字输入场景背这些额外成本在工程上非常不划算。自己做一个键盘控件核心代码范围可控出了问题随时能断点调试跨平台编译也不会有惊喜。2. 方案选择与整体结构Widgets QSS 搭出轻量键盘2.1 Widgets 还是 QML我为什么没有用 QML技术选型时QML 的确是很多人推荐的方案做动画方便、组件风格现代。但数字键盘的交互本质就是“按一下发一个信号”没有转场动画没有复杂的异步视图没有需要绑定实时数据的动态列表。为此把 QML 运行时和资源管理逻辑引入工程对目标 ARM 板子来说有点得不偿失。我对两个方案做了对比对比维度Widgets QSSQML运行时依赖轻只用 Qt Widgets 模块需要 QML 运行时体积明显增加键盘动态绘制QSS 足够改颜色即时生效能力更强但用不上焦点管理用 QApplication::focusWidget() 很直接需要管理 QML 内部焦点和 Qt 焦点两层结构维护成本纯 C跨版本迁移方便新增 QML 文件和 JS 逻辑排查链路变长最终我选了 Widgets工程接起来最省事行为也最好预测。嵌入式项目里稳定和可控永远优先于表达形式的花哨。2.2 做成独立顶层窗口还是普通 Widget确定 Widgets 之后下一个问题是键盘的表现形态。这里两条路线很典型一条是把键盘做成独立顶层窗口show 后浮在主窗口之上另一条是把键盘做成普通 QWidget嵌入主界面的布局管理器里。独立窗口的优势是可以在任意时刻置顶尤其适合那种键盘必须贴着输入框弹出的场景。但这套方案在嵌入式系统上有一个致命弱点很多 ARM 板子的合成器对多层窗口的刷新支持不理想置顶窗口频繁 show/hide 时会出现闪烁甚至露白。而且独立窗口一旦焦点处理不当点击键盘按钮会先把焦点从输入框抢走输入框立刻失焦又触发隐藏键盘的逻辑出现“点一下键盘就消失”的诡异现象。我最后选择的是普通 Widget 嵌入方案。键盘作为主窗口底部的一部分平时隐藏需要输入时通过布局项 show 出来。这样不存在窗口层级问题焦点切换也简单得多。从使用者视角看键盘稳稳待在屏幕底部不悬空、不遮挡其它导航控件视觉上更像“设备自带的一个输入面板”。2.3 整体代码模块划分为了让这套逻辑不至于全塞在一个类里我按职责拆成了三个部分NumKeyboard键盘面板负责生成按键、处理按压状态、发出统一的按键信号。它不关心当前输入框是谁。NumKeyManager控制器负责监听焦点变化记录当前输入目标接收 NumKeyboard 的信号并执行实际的内容插入。它是整个键盘的中枢。KeyOptions输入约束一个轻量数据类记录整数位数、小数位数、是否允许小数点等信息绑定到目标输入框上。类之间的关系非常直接界面上的输入框触发 FocusInNumKeyManager 通过事件过滤器感知到于是让 NumKeyboard 显示操作员按任意键NumKeyboard 发出 keyPressed(KeyType, QString)NumKeyManager 拿到信号后处理插入、删除、清空等动作。整个链路里键盘本身不持有任何业务数据天然解耦。3. 键盘控件实现布局、按键状态与 QSS 细节3.1 用 QGridLayout 做自适应性布局数字键盘的按键很多但基本都是等宽等高的小矩形。这时候如果用绝对坐标摆按钮换分辨率就得重新算一遍非常痛苦。QGridLayout 的 rowStretch 和 columnStretch 能让我用“比例”而不是“像素”来约束布局。我的布局方案是 4 列 x 5 行键位如下列1列2列3列4123Del456Clear789Enter.000Enter(跨行)实际编码时用 QGridLayout 把按钮塞进格子然后统一设定间距和拉伸系数QGridLayout* grid new QGridLayout(this); grid-setSpacing(6); for (int i 0; i 5; i) grid-setRowStretch(i, 1); for (int j 0; j 4; j) grid-setColumnStretch(j, 1); // 添加按钮时直接指定行列和跨行跨列 grid-addWidget(delBtn, 0, 3); grid-addWidget(clearBtn, 1, 3); grid-addWidget(enterBtn, 2, 3, 3, 1);这样做的最大好处是键盘在 800x480 和 1920x1080 两种分辨率下不用改任何代码按钮永远按比例铺满面板。同时布局管理器天然支持窗口缩放不用手动处理 resizeEvent。3.2 键位设计里的工业细节数字键盘的核心键位用脚趾头都能想出来但有几个键是纯粹从实际使用里逼出来的。一个是“00”。设备参数里经常输入像 1000、2000 这种整百数值如果没有 00 键操作员要按三次 0效率太低。加了 00 键后按一个键就输两个 0对戴手套操作的人来说体感差别非常大。另一个是小数点。很多数显设备的默认温度、液位值是带小数的小数点如果藏在符号切换菜单里操作员会崩溃。所以我一律把小数点直接放在主面板上。删除和清空必须分开。删除是逐字符回退清空是整栏重置。一开始偷懒只做了删除键后来发现操作员输错时更希望直接清掉重来于是把清空键加了回去。这个键位取舍其实反映了一个问题虚拟键盘不是手机键盘的移植而是目标用户操作习惯的映射设计之前一定要把现场输入流程想清楚。3.3 按下反馈触摸屏上不能只靠 QSS :pressed刚写第一版时我给按钮 QSS 加了 :pressed 伪状态按下变亮松开恢复。实机测试发现触摸屏上会有迟滞尤其在低性能板子上反馈总感觉慢半拍。原因是触摸事件到样式刷新存在延迟有时候手指已经抬起来了高亮状态还没切回。后面改成了用动态属性 主动样式刷新来做按压反馈。每个按钮响应按下事件时设置一个 pressed 属性为 true然后调用 style()-unpolish 和 style()-polish 强制刷新松开时再属性改回 false 并刷新一次。处理逻辑放在键盘面板里统一做而不是每个按钮各自重写bool NumKeyboard::eventFilter(QObject* obj, QEvent* ev) { if (auto* btn qobject_castQPushButton*(obj)) { if (ev-type() QEvent::MouseButtonPress) { btn-setProperty(pressed, true); btn-style()-unpolish(btn); btn-style()-polish(btn); } else if (ev-type() QEvent::MouseButtonRelease) { btn-setProperty(pressed, false); btn-style()-unpolish(btn); btn-style()-polish(btn); } } return QWidget::eventFilter(obj, ev); }这样反馈是即时且确定的不受 QSS 状态伪类刷新机制的限制。体感上按键非常跟手。3.4 QSS 细节用动态属性区分键位样式数字键盘里按键类型至少有数字键、小数点键、删除键、清空键、确认键五种。如果在代码里逐个 setStyleSheet一方面冗余另一方面后期调整配色要改一大堆。我在按钮创建时直接设置一个 keyType 属性QSS 里用属性选择器选择样式。QPushButton[keyTypedigit] { background-color: #3A3F47; color: #FFFFFF; border: none; border-radius: 6px; font-size: 24px; } QPushButton[keyTypefunction] { background-color: #F0A030; color: #FFFFFF; border: none; border-radius: 6px; font-size: 20px; } QPushButton[keyTypedigit][pressedtrue] { background-color: #5A6070; } QPushButton[keyTypefunction][pressedtrue] { background-color: #C57E1A; }这里有个非常容易忽略的点即使 setProperty 改了值如果 QSS 样式表里使用的选择器没有被重新计算界面不会立即更新。所以前面我在 eventFilter 里手动调 unpolish/polish就是为了强行触发样式系统重新匹配属性。纯代码里 setStyleSheet 常见但动态属性配合 QSS 后必须主动刷样式这是我实测后印象很深的一条经验。4. 信号链路与焦点管理按键怎么准确进入输入框4.1 输入目标不靠“把信号连到某个输入框”最容易犯的设计错误是键盘显示时把 keyPressed 信号用 lambda 连到当前输入框上键盘隐藏前再断开。这种做法在两个输入框之间来回切换时很容易漏连或错连QObject::connect 解耦也麻烦时间长了代码变得一团浆糊。我的做法是控制器统一处理。NumKeyManager 自己维护一个 currentEdit 指针键盘发信号时直接向这个指针操作void NumKeyManager::onKeyPressed(KeyType type, const QString text) { if (!currentEdit) return; switch (type) { case KeyType::Digit: insertValidText(currentEdit, text); break; case KeyType::Dot: insertDotIfAllowed(currentEdit); break; case KeyType::Delete: currentEdit-backspace(); break; case KeyType::Clear: currentEdit-clear(); break; case KeyType::Enter: emit enterPressed(currentEdit); hideKeyboard(); break; } }currentEdit 的来源是焦点监听。在 eventFilter 里检测到任意 QLineEdit 的 FocusIn就把它设为 currentEdit检测到 FocusOut 并且新焦点不是当前键盘上的按钮就把键盘隐藏。这个机制保证了无论界面有多少输入框都不需要业务代码手动告诉键盘“你现在服务谁”。4.2 合法键与校验策略不能只靠 QLineEdit validator数字输入的校验藏着一个坑如果只给 QLineEdit 挂 QDoubleValidator非法输入确实会被挡住但用户完全不知道为什么不生效删除键还会遇到光标卡死的问题。在屏幕上直接操作时用户需要的是“这个键不生效”之前系统先给出判断。我的做法是先拼出候选文本再用自定义规则决定是否调用 insertvoid NumKeyManager::insertValidText(QLineEdit* edit, const QString text) { QString candidate edit-text() text; if (!isCandidateValid(currentOptions, candidate)) return; edit-insert(text); }候选合法性判断逻辑也很直白整数位累计不能超过设置的上限小数点只能出现一次最多允许的小数位数要限制如果当前选中的是一整段内容直接替换而不是追加。这套方式比 validator 实用很多因为键盘上的按键是“自定义的信号”我们完全有机会在进入输入框之前就把不合法的内容拦下来。操作员按了没反应时我们还可以通过闪灯或者简短的状态提示告诉用户原因这是原生 validator 很难做到的事情。4.3 焦点切换与键盘驻留时序虚拟键盘一个绕不开的麻烦点键盘上的按钮一旦被点击焦点就会从 QLineEdit 跳到按钮上。如果控制器在焦点变化时将“当前焦点不是输入框”当成输入完成的信号键盘会立刻隐藏按键事件还没发出去交互就中断了。网上很多方案是给按钮 setFocusPolicy(Qt::NoFocus)强制按钮不抢焦点。这是必须做的基础操作但还不够。因为触摸点击时即使按钮不抢焦点某些平台仍然可能触发 FocusOut。我后来在控制器里做了延迟隐藏收到 FocusOut 后启动一个 150ms 的定时器如果期间收到新的 FocusIn按下“取消隐藏”如果没有其它焦点进来才真正隐藏键盘。这个 150ms 的窗口非常关键它把“焦点短暂脱离输入框”和“用户不再需要键盘”这两个状态区分开了从一个看似矛盾的问题里抽出了正确答案。4.4 多个输入框不同规则用一个登记表管理同一个界面里有的输入框最多 3 位整数和 1 位小数有的只允许整数有的甚至允许负号。这些规则如果不区分键盘就退化成无脑把字符串塞进去约束形同虚设。我用了一个 QHash 把输入框指针映射到对应的输入选项上struct KeyOptions { int maxIntDigits 6; int maxDecDigits 0; bool allowDot true; bool allowMinus false; }; QHashQLineEdit*, KeyOptions m_options; void NumKeyManager::registerEdit(QLineEdit* edit, const KeyOptions opt) { m_options.insert(edit, opt); edit-installEventFilter(this); }界面初始化的时候依次注册即可。控制器取 currentEdit 的同时从哈希表里取得对应规则。这个表还顺带解决了“哪些输入框应该弹出数字键盘”的问题只要注册过的输入框才显示键盘没注册的保持系统原样。业务隔离得很舒服。5. 嵌入式触摸场景的特殊处理5.1 长按删除键的自动重复逻辑数字键盘上删除键的使用频率很高一个数可能删掉七八个字符如果每次都要点一下删一位效率太低。我一开始给删除键开了 QPushButton 的 autoRepeat实测发现默认行为不理想首次重复的延迟太长按住大约一秒后才开始连续删之后又太快稍不注意就把整数位删光了。处理方法是手动控制重复节奏。在 eventFilter 中识别按键按下时第一次立即发一次删除然后启动 QTimer间隔 500ms 后开始连续触发触发间隔保持 200ms。松开后立刻停掉定时器void NumKeyboard::startDeleteRepeat() { emit keyPressed(KeyType::Delete, QString()); m_deleteTimer.stop(); m_deleteTimer.start(200, this); } void NumKeyboard::stopDeleteRepeat() { m_deleteTimer.stop(); } void NumKeyboard::timerEvent(QTimerEvent* event) { if (event-timerId() m_deleteTimer.timerId()) emit keyPressed(KeyType::Delete, QString()); QWidget::timerEvent(event); }这样操作员按住删除键时前 500ms 只删掉第一位之后稳定地每 200ms 删一位直观且不容易误删。数字键我反而明确关闭了 autoRepeat防止按住 1 时连续输出一大堆 11111在多数输入场景里这是不希望的。5.2 DPI 与最小点击区域工业触摸屏的分辨率跨度极大老的 800x480 电阻屏和新一点的 1920x1080 电容屏并存。如果代码里写死按钮最小高度 48px在小屏上可能占掉大半界面在大屏上又显得小气。我直接用物理 DPI 作为参考基准qreal dpiRatio qApp-primaryScreen()-logicalDotsPerInch() / 96.0; int btnMinHeight qRound(48 * dpiRatio);基准值取的是 48 像素实际给了 60 像素作为热区目标。原因是操作员戴手套时指尖接触面积大48 是手机触控的最小值工控上至少要 60 才稳。键盘整体宽度跟随屏幕宽度铺满按钮间距保持 6px视觉上紧凑但不拥挤。5.3 主窗口避让与键盘遮挡键盘嵌入主窗口底部后输入框如果正好被键盘盖住操作员就看不见自己输的内容了。为了保证最高的输入可见性我在放键盘的主窗口里使用了一个占位布局主内容区使用 QVBoxLayout键盘作为布局最后一个元素键盘显示时自动把主内容区剩余空间压缩到键盘以上的区域键盘隐藏时主内容区恢复正常高度。这个做法避免了两个问题一是键盘不会盖住内容因为布局自动避让二是不用监听键盘高度去手动移动窗口省掉了一堆边界处理。如果某些页面的输入框实在太多必须靠着屏幕底部我就在那个页面单独把键盘放进一个底部容器做局部调整。整体思路一致都是靠布局管理器参与避让而不是野蛮地把整个窗口上移。5.4 绘制性能低端 ARM 上的涂黑与闪烁刚开始做键盘时我给面板加了半透明背景和圆角阴影想在视觉上精致一点。实机一跑发现每按一个键都有肉眼可见的闪烁尤其按删除键连续触发时整个键盘区域的重绘延迟非常明显。后来把三个拖后腿的配置都去掉了取消 WA_TranslucentBackground阴影用纯色替换键盘背景直接不透明。同时给键盘面板设置了 WA_OpaquePaintEvent告诉 Qt 这块区域背景不透明不用做复杂的混合处理。性能问题立刻缓解。这是一个很容易被忽视的优化但嵌入式平台对透明合成的开销极其敏感能不开透明就尽量不开。6. 实测遇到的坑与排查记录6.1 坑一键盘 show 了却看不见被主窗口盖住第一版我确实用独立顶层窗口做的。调试时遇到一个诡异现象调用 show() 之后键盘在任务栏或者窗口切换列表里能看到但在主窗口上显示不出来。排查过程分了三步先怀疑是父对象设置问题。把键盘的 parent 改为 nullptr确认它是顶层窗口没用再怀疑 Z 序问题。随手试了 Qt::WindowStaysOnTopHint显示成功了但在触摸屏上出现闪烁而且遮挡其它弹窗的时候非常难看最后回到问题本质嵌入式屏幕不需要一个单独的窗口来承载键盘我为什么要造一个窗口层级问题出来于是改成普通 Widget 嵌入主窗口布局方案问题彻底消失。这条经验的意义在于很多问题不是“怎么调”而是“这个设计就不该存在”。独立置顶窗口在桌面应用里很常见但到嵌入式触摸屏上优先做嵌入式方案。6.2 坑二系统输入法面板跟我的键盘一起弹出来接入项目时发现点击一个 QLineEdit我的键盘弹出来了同时系统输入法面板也弹出来了。两个键盘叠在一起界面瞬间乱掉。排查时先看输入法的触发条件。Qt 对 QLineEdit 默认启用输入法支持输入框获焦时系统输入法框架会收到请求并弹出面板。要阻止它最简单的是禁用输入法属性edit-setAttribute(Qt::WA_InputMethodEnabled, false);如果不想全局改输入框也可以在键盘控制器里对输入框事件拦截只在注册键盘服务的输入框上禁用。另外我在 main 函数里关掉了输入法上下文qApp-setAttribute(Qt::AA_DisableShaderDiskCache); // 这条与本问题无关只是顺带其实不需要启动系统输入法框架只要禁用了 WA_InputMethodEnabled点击已经注册的输入框就不会再唤起系统面板两个键盘打架的问题立刻消失。6.3 坑三点击键盘按钮键盘刚弹出就隐藏这个坑非常典型刚接入触摸屏时差点把我逼疯。现象是点击输入框键盘正常弹出点击数字键“5”键盘立刻隐藏。调试发现键盘隐藏的触发点是 QLineEdit 的 FocusOut。链条是这样的点击“5”按钮时按钮获得了焦点焦点从输入框离开控制器的焦点监听认为“输入结束”于是隐藏键盘键盘隐藏后按钮消失焦点又回到输入框但键盘已经关掉操作员什么都没输进去。解决动作分两层按钮本身 setFocusPolicy(Qt::NoFocus)保证点击后焦点不转移控制器里再做一次 150ms 的延迟隐藏双保险。这个问题几乎是所有自定义虚拟键盘的必修课建议一开始就把 NoFocus 写进所有键盘按钮的创建流程里不要等测试来打脸。6.4 坑四触摸屏上长按触发系统上下文菜单有一阵子客户反馈“按删除键偶尔会弹出一个奇怪小菜单”排查后确认是触摸屏的长按行为被系统识别为鼠标右键进而触发了 QPushButton 的 contextMenuEvent。Qt 在触摸长按时会自动合成右键事件这在桌面平台上无所谓在触控工控屏上就是误弹菜单。修复方式很直接键盘上的所有按钮都设置 setContextMenuPolicy(Qt::NoContextMenu)长按只会走我们自己的逻辑不再触发系统菜单。如果还想适配某些设备可以在 QApplication 层面调整触摸长按时间阈值但是只对键盘按钮做策略设置已经足够范围更可控。6.5 坑五小数点被重复插入测试时常出现“12.5.8”这种非法数据。一开始以为是 insert 逻辑写错后来才发现问题在于两个路径输入框自带输入法联想点击输入框后弹出系统面板输小数用户手动又按一次键盘的小数点。两个来源混在了一起。修复办法是收敛输入来源既然已经强制禁用输入法那就只保留键盘这一条输入通道。同时在控制器里判断如果文本已经包含小数点直接忽略再次点击小数点键的操作不让非法字符进入候选方案。到此键盘的输入行为才算真正闭环。单靠 validator 拦数据其实是在数据进入后再收拾残局在源头把按键拦截掉才是跟手又不吵闹的做法。6.6 坑六换触摸屏型号后按键样式全部失效RD 里换了一块新的触摸屏模组屏幕分辨率一样但按键的按压高亮效果在我更新的代码里莫名失效。刚开始怀疑是 QSS 加载问题排查后定位到原因我通过 setProperty 动态修改了 pressed 属性却没有在属性变化后强制刷新样式。旧板子上执行顺序碰巧让样式还在换了时序不同的平台后就原形毕露。修复方式是把 pressed 状态变化的关键代码统一收拢在事件过滤器里调用 unpolish/polish 强制刷新并将这块逻辑整理成一个公共函数防止其它地方绕过。以后再调按键反馈只改一个函数就行。7. 最后复盘如果再给我一次机会我会这么搭这套数字键盘从最初的原型到最终稳定版本中间折腾了不少版本。如果让我重新做我会把几件事一开始就定下来第一键盘一律做成普通 Widget绝不碰独立置顶窗口第二所有键盘按钮默认 setFocusPolicy(Qt::NoFocus)不用等出 bug 再加第三控制器和键盘界面必须拆成两个类键盘只管发信号控制器管输入目标和规则这个解耦早晚都要做早做早省心。还有个听起来很小但实际很痛的经验凡是改动按钮的 pressed 状态一定要立刻 unpolish/polish别指望样式系统自己反应过来。触控设备上反馈延迟是灾难级别的体验问题宁可多写一行刷新也不要等系统慢慢悠悠地更新。数字键盘这个需求本身不复杂但它牵扯出来的焦点管理、事件过滤、触摸适配、嵌入式性能这些点个个都值得仔细打磨。项目做完之后这套代码已经成了后续所有 HMI 项目的标配控件再遇到不同输入规则只需注册表格里加一行完全不用动键盘本体。如果各位在类似项目里也踩过欢迎一起交流我相信这类小工具在不同场景下能演化出更多有意思的细节。