☰
TouchGFX自定义屏幕键盘:从重复代码到可复用组件实践
2026/10/27 18:55:57 网站建设 项目流程

做嵌入式的都懂,凡是要让用户填点东西的界面,迟早得面对屏幕键盘。我之前做一台带网口配置的仪表,客户要求能在触摸屏上输入IP、掩码、网关和SSID。第一版图省事,直接在配置界面里拖了整整一排按钮,用的时候还挺顺利。等第二个界面需要输入上传服务器地址时,我把那排按钮复制粘贴了一份,改改回调,也能用。但到第三个界面要输入用户名密码时,我开始觉得不对劲:键盘逻辑、大小写切换、退格、长按重复,这些代码散落在三个View里,想统一改一次键位,得开三个文件来回翻。后来痛定思痛,用TouchGFX的Custom Container把键盘做成了一个独立组件,一个文件改逻辑,所有界面跟着升级。这篇就分享整个设计过程,包括UI骨架怎么搭、对外接口怎么定、按键逻辑怎么封装,以及工程里那些不踩不知道的坑。

1. 为什么固定键盘控件救不了自己的项目

1.1 内置Keyboard的边界

TouchGFX 自带的 Keyboard 控件其实不算差,开箱就能弹出一个标准键盘,也能直接对接 TextArea,很多 Demo 工程里都这么用。但它最大的问题是“够用但不好改”。内置键盘的布局、按键数量、字符集、特殊键行为,都绑在一套相对固定的配置里。你想把 Shift 键改成切换数字页,想把回车键放在右侧竖排,想加一个清除全部文本的按键,都会发现要绕不少弯。更深一层的限制是,内置键盘把“按键渲染”和“文本输入逻辑”耦合在了一起,你在工程里很难把它当成一个独立可替换的组件。

我做那块仪表时,输入IP地址的界面需要纯数字键盘,输入SSID的界面需要带字母和符号的完整键盘,稍一深入就发现内置 Keyboard 的方案有点拧巴。与其花时间反向适配它的模式,不如直接用 Custom Container 做自己的键盘,把每一个按键、每一段逻辑都握在自己手里。

1.2 自定义容器在TouchGFX中的定位

Custom Container 在TouchGFX里是一个很“实在”的机制。它的本质就是一个touchgfx::Container子类,你可以像搭积木一样把多个 Button、TextArea、Box 放进去,Designer 会自动生成对应的 Base 类和初始化代码。这个容器一旦建好,内部所有控件和逻辑都被封装起来,外面只看到一个整体——可以放进任意 Screen,可以设置坐标、缩放、透明度,也可以整体显示/隐藏。

这不是结构上的巧合,而是TouchGFX刻意支持的复用方式。Container 本身参与事件分发、重绘和动画系统,你在里面放多少个按钮,对外不增加任何负担。真正让它“可变复用”的关键,是把对外接口设计清楚:键盘需要告诉外部“我按了哪个键”,但不需要知道外部是更新 TextArea、存到变量,还是做别的动作。这个边界必须从第一天就画清楚,否则做出来的“容器”还是换了个名字的重复代码。

2. 搭骨架:在TouchGFX Designer里画出可复用键盘

2.1 新建Custom Container与UI分层

打开TouchGFX Designer,在左侧组件列表里找到 Custom Container,拖到画布上。先别着急放按键,第一步把尺寸和分层想好。以一块800x480的屏为例,键盘通常放在屏幕下半部分,我习惯把容器宽度设成800,高度设成240,底部和屏幕对齐。这样键盘弹出来时不至于挡住上半部分的输入框和说明文字。

容器内部的分层建议是这样的:底层放一个 Box 做键盘面板背景,颜色最好和主界面有明显区分,否则按键边界看不清;背景之上放一行特殊键位区(退格、Shift、符号切换、回车);再往下是字母和数字按键区。用 Box 而不是图片做背景,可以省掉一份图片资源和解码时间,视觉上也干净。

新建容器后,Designer会自动生成两个类:CustomKeyboardBase和CustomKeyboard。Base类里是所有的控件初始化和布局代码,非必要别去改,否则Designer下次重新生成会直接覆盖你的改动。自己的业务逻辑全部写在上层类里。这是整个复用体系的第一个原则:生成代码和手写代码隔离。

2.2 按键布局与统一事件入口

按键布局我推荐用标准的 QWERTY 变体,但要根据产品实际输入场景裁剪。比如我做的那台仪表,用户只需要输入IP、SSID、密码和服务器地址,所以留了26个英文字母、0-9数字、退格、Shift、回车和一个“清空”键就够用。没有必要放F1到F12,也没有必要放主键盘区右侧那一堆编辑键。

布局坐标可以用表格规划,以800x240的容器为例,我用了下面这组参数:

行内容按键宽度按键高度与上一行间距
1数字0-9+退格7040顶边距8
2字母QWERTYUIOP62408
3字母ASDFGHJKL+回车62408
4Shift+字母ZXCVBNM+清空62408

按键一律用 ButtonWithLabel,或者用 Button 叠加 TextArea。我比较推荐 ButtonWithLabel,因为Designer直接支持按键的按下/松开图片或填充色,文本也自带居中,省掉不少对齐的功夫。给每个按键取一个有意义的名字,比如keyA、keyShift、keyBackspace,生成代码里就会对应同名变量,后面写逻辑时一眼能认出是哪个键。

事件绑定用统一入口:在 Base 类里,所有按键的setAction都会指到同一个buttonCallback,回调最终进入buttonClickedHandler(const AbstractButton& src)。不要每个键单独挂一个回调,那样处理函数会膨胀到没法维护。统一回调后,所有按键命中都走同一段代码,你只需要在回调里判断&src指向的是哪个按钮就行。

2.3 大小写/符号页的控件布局策略

键盘做成自定义容器,一个绕不开的问题是:大小写、符号、数字页怎么呈现。最简单的方案是每个页面做一套独立的容器,根据状态切换可见性。但这样会浪费RAM,而且页面之间切换时有明显的瞬间跳变。

更实用的做法是:只画一份按键,切换页面时改的是按键上显示的文字和按下后发送的字符。这样容器内部控件数量固定,状态切换只是改ButtonWithLabel的文本和映射表索引,动画也平滑。对于产品里固定使用的键盘,这种“单套控件+状态切换”的方案长期来看最好维护。

但有一点要注意:符号页的键位不一定和字母页一一对应,比如我放了一个“清空”键,在字母页和符号页都需要它,但在数字页可能想换成小数点。处理方式是在状态切换函数里,根据目标页面改变某些键的文本和映射。这个逻辑集中在switchPage(KeyboardPage page)函数里,不要散落在各个按键回调里。

3. 定契约:键盘容器与外部世界的通信方式

3.1 只发事件,不碰业务

键盘容器做出来之后,第一件要定的事就是它怎么把“用户按了什么”告诉外部。我强烈建议用事件回调,而不是让容器直接持有目标 TextArea 的指针去改文本。理由很简单:如果容器只发事件,它就是一个纯输入组件,可以非常轻易地被替换、扩展和测试;如果容器直接操作某个具体 TextArea,那么它就和那个界面绑死了。

我在代码里定义了一个精简的事件结构:

struct KeyEvent { enum Type { CHAR, // 普通字符 BACKSPACE, // 退格 ENTER, // 回车/确认 CLEAR, // 清空当前输入 SWITCH_PAGE // 切换键盘页面,仅内部使用 }; Type type; uint16_t ch; // 当type=CHAR时有效 KeyEvent() : type(CHAR), ch(0) {} KeyEvent(Type t, uint16_t c = 0) : type(t), ch(c) {} };

容器内部持有一个touchgfx::GenericCallback<KeyEvent>* listener,每次按键命中,就执行listener->execute(event)。宿主View在初始化时,把自己的处理函数传进来:

keyboard.setListener(touchgfx::GenericCallback<KeyEvent>& cb);

这样,键盘容器完全不知道外部是更新一个 TextArea,还是把字符追加到某个数组里。它只知道“我发出了什么事件”。这就是复用的核心:容器的职责边界清晰,外部接口稳定,内部怎么改都不影响调用方。

3.2 直接写TextArea的简化方案

当然,不是所有项目都值得做这么干净的事件层。如果你的产品只有一个输入界面,或者只想快速验证效果,那让容器直接持有 TextArea 指针也完全可以。做法是在容器里加一个setTargetTextArea(TextArea& ta)接口,按字符时直接操作ta.setWildcard(buffer),退格时自己维护buffer的长度。

这个方案的坏处是一旦界面里有多个输入框,你要反复切换容器持有的目标指针,稍不留神就会指错。而且验证逻辑、输入长度限制、字符过滤都堆在容器里,时间一长容器会变得很臃肿。我个人的经验是:如果超过两个界面需要键盘,就老老实实走事件回调。前期的接口设计成本,会在后面的每一个新增输入界面里连本带利还回来。

3.3 特殊键语义与事件编码

事件编码也要提前约定。字母和数字可以直接用对应的Unicode码,但退格、回车、清空这些控制键不能被误当成字符。所以上面用了KeyEvent::Type来区分。宿主View收到事件后,只需要关心几种情况:

  • CHAR:把event.ch追加到输入缓冲
  • BACKSPACE:从缓冲尾部删除一个字符
  • ENTER:执行“确认输入”逻辑,比如跳转界面或保存配置
  • CLEAR:清空缓冲并把显示区域刷新为空白

在这个协议里,Shift 键和符号切换键其实不需要对外发事件。它们属于键盘自身的状态管理,用户按一下 Shift,容器内部把页面从“小写”切到“大写”,然后更新按键上的标签,后续按字母键时发出大写字符。外部完全感知不到这个过程。把内部状态和外部事件分开,界面层才能保持简单。

4. 填逻辑:按键映射、状态机和文本输入链路

4.1 从按钮指针到KeyEvent的映射

Designer 生成的 Base 类里,每个按键都是一个成员变量。在派生类里,我把它们整理成一个查找表,避免在buttonClickedHandler里写几十行 if-else。

touchgfx::Button* keyWidgets[KEY_COUNT] = { &keyA, &keyB, /* ... */ }; const uint16_t keyLower[KEY_COUNT] = { 'a', 'b', /* ... */ }; const uint16_t keyUpper[KEY_COUNT] = { 'A', 'B', /* ... */ }; const uint16_t keySymbol[KEY_COUNT] = { '1', '2', /* ... 根据布局自己定义 */ };

buttonClickedHandler收到按键指针后,先查这个序号,再根据当前页面选择对应的字符数组:

for (uint8_t i = 0; i < KEY_COUNT; i++) { if (&src == keyWidgets[i]) { uint16_t ch; switch (page) { case KB_UPPER: ch = keyUpper[i]; break; case KB_SYMBOL: ch = keySymbol[i]; break; default: ch = keyLower[i]; break; } sendEvent(KeyEvent(KeyEvent::CHAR, ch)); return; } }

控制键单独判断,比如keyShift、keyBackspace、keyEnter。把普通字符键和控制键分开处理,逻辑清晰,以后新增一个按键也不需要动回调结构,只要往表里加一行。

4.2 大小写切换与页面切换的状态机

页面切换用一个简单的状态枚举就够了:

enum KeyboardPage { KB_LOWER, KB_UPPER, KB_SYMBOL };

Shift 键按下的行为要提前定义好。我建议:按一次 Shift 只让下一个字母大写,再按一次恢复小写;双击 Shift 则锁定大写。这个交互习惯和手机键盘一致,用户基本不需要学习成本。实现上,Shift 按下时先判断当前是不是大写,如果是大写则回到小写,否则切到大写并启动一个“一次性大写”标记。

切换页面后,需要调用一个刷新按键文本的函数,比如updateKeyLabels()。它会遍历按键数组,根据当前页面把ButtonWithLabel的文本改成对应的字符。注意不要只改发送字符不改显示文本,否则用户会看到键帽上写的是 a,按下去却输出了 A,这种体验非常扣分。

符号页的切换键不只是 Shift,还要有一个专门的“符号/数字”切换键。它可以和 Shift 分开,也可以复用 Shift 的键位。我更喜欢单独放一个#+=键,因为符号页里通常还有数字,它和字母大小写是完全独立的两个维度。

4.3 TextArea字符缓冲的维护与刷新

键盘发出事件后,真正的文本输入逻辑在 View 里完成。TouchGFX 的 TextArea 要显示动态文本,通常走 wildcard 机制,也就是TextArea通过setWildcard(Unicode::UnicodeChar* buf)指向一个缓冲区,显示内容就是缓冲区的字符串。

我在 View 里用一个固定长度数组做输入缓冲:

static const uint8_t BUF_LEN = 32; Unicode::UnicodeChar inputBuf[BUF_LEN]; uint8_t inputLen = 0;

收到 CHAR 事件时,先检查inputLen < BUF_LEN - 1,防止越界。然后把event.ch放进inputBuf[inputLen++],在inputBuf[inputLen]补\0,最后调用textArea.setWildcard(inputBuf)和textArea.invalidate()。这里一定要先补结束符再刷新,否则 TextArea 会继续读旧缓冲里的残留内容。

收到 BACKSPACE 时,if (inputLen > 0) inputLen--;,同样清结束符并刷新。收到 CLEAR 时,把inputLen归零并清空第一个字符。这段逻辑虽然简单,但很容易在边界条件上出问题,尤其是“缓冲区已经满了还要输入”和“空缓冲还要退格”这两种情况,必须写清楚退出条件。

5. 实战排坑:刷新、字体、触摸和内存

5.1 别让一次按键触发整屏重绘

TouchGFX 的重绘是按区域来的,理论上每次按键只需要刷新被修改的那个 TextArea 附近区域。但如果回调里误调用了screen.invalidate()或者container.invalidate(),整块键盘都会被重新绘制,在低主频的 STM32 上能明显感觉到卡顿。实测下来,一次完整的键盘重绘在 STM32F429 上可能吃掉十几毫秒,如果用户快速连续点按,UI 线程就扛不住了。

正确做法是只失效真正变化的部分:改的是 TextArea 的显示,就调用textArea.invalidate();改的是 ButtonWithLabel 的文本,就调用按钮的invalidate()。只有当整个键盘从隐藏切到显示,或者显示切到隐藏时,才需要刷新整个容器区域。

如果你的界面用了双缓冲或多缓冲,还要留意帧缓冲切换时的带宽。TouchGFX 会对比前后帧的差异来决定无效区域,只要你刷新的区域足够小,这个机制基本不会成为瓶颈。真正拖慢速度的往往是按键背景图太大、每张图都是全屏尺寸,这种习惯要趁早改掉。

5.2 字体裁剪与多语言输入的现实方案

屏幕上键盘不是字词库,它每个键只显示一个字符,理论上字体资源不需要太大。但如果你的工程要支持中文输入,或者符号页放了很多生僻符号,情况就复杂了。TouchGFX 的字体生成工具是按文本资源自动裁剪的,键盘上显示的字符必须在字体文件里存在,否则键帽上会显示成方框。

我碰到的实际问题是:默认字体只包含ASCII,符号页里的±°μ这些单位符号全部缺失。排查了半天才发现是字体没有把这些字符包含进来,后来在字体生成配置里把常用符号手动加进字符集才解决。所以做键盘前,先把你需要输入的字符范围列成清单,一次性配置到字体的Unicode块里,避免做到一半再回头补字体。

对于中文这种大字符集的输入,我建议在产品里换一种策略:不在设备上做全拼输入,而是提供常见短语和数字键盘,或者让用户通过上位机配置。STM32 的资源放不下完整的汉字输入法和庞大的字库,勉强做出来,体验也不会好。这是很多项目倒在流程末端的坑,提前和产品经理谈清楚。

5.3 触摸热区与模态遮挡

屏幕键盘的按键通常比普通按钮更密集,在7寸以下的小屏上尤其明显。按键热区如果小于40x40像素,用户就很容易点偏。官方推荐的最小触摸区域至少是40x40,我自己的底线是不要低于36x36。按键之间的间隙不要紧挨着,留2到4像素,否则误触概率很高。

另一类问题是模态遮挡。如果你在键盘上方加了一层半透明 Box 做遮罩,这个 Box 默认是 touchable 的,它会吃掉所有触摸事件,导致下面的按键全部失灵。解决方法是把遮罩 Box 的touchable属性关掉,只保留绘制功能,或者把 Box 放到容器后面,确保按键始终在所有遮罩控件之上。

5.4 多屏幕实例化的内存开销

Custom Container 复制到多个 Screen 使用时,每个 Screen 的 View 里都会实例化一份完整的按钮对象。一次键盘实例大约增加几十KB RAM,具体取决于按键数量和文本缓冲。如果项目里有四五个 Screen 都要输入文本,一份键盘放五个地方,RAM 就很吃紧了。

更合理的做法是把键盘放在一个专门的 Screen 上,输入界面通过ScreenTransition跳转过去,或者把键盘容器放在一个全局可见的顶层容器里,其他界面只是切换它的显示内容。这样 RAM 只花一份,而且键盘状态(比如当前在大写页)也能保留。缺点是多了一次界面切换的动画成本,但对于大多数配置类交互场景,这种切换反而是符合用户预期的。

如果你确实必须在多个 Screen 里各放一个键盘实例,建议把键盘内部的大缓冲区统一放到共享内存或者静态区,别让每个实例都分配一份。这个优化在总量上很可观。

6. 经验小结与下一步扩展

这套自定义键盘方案做下来,我最大的体会是:组件复用能不能成功,不在于控件本身被拖了多少个地方,而在于外部接口是否稳定、内部状态是否自治。按键布局、页面切换、字符发送都收在容器内部,界面只处理“收到事件后更新哪个输入框”,改造和增删都变得很轻。

最后分享一个我自己实践出来的小技巧:键盘容器设置一个“初始页面参数”接口,比如setInitialPage(KB_UPPER),这样在密码输入界面可以直接以小写字母页出现,但不消耗额外状态代码。另一个可以扩展的方向是把按键音反馈、按键长按重复也做进容器内部,这样连界面层都不用感知。这个思路不止适用于键盘,任何需要反复出现在多个界面里的复杂控件——比如日期选择器、下拉列表、数字拨盘——都可以用同样的“容器+回调+状态机”套路去实现。TouchGFX 的 Custom Container 本质就是专门为这种需求准备的,把它用好,很多界面逻辑会变得清爽很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询