先交代一下这个活儿是怎么来的。手头有一块基于 STM32F746 的 RGB 屏开发板,4.3 寸 480×272 分辨率,最初的需求一句话就能说完:做一个模拟相机取景器的界面,屏幕中间显示一个光圈,然后用板子上的方向键去控制这个光圈移动。说白了就是 TouchGFX 简单界面设计里最常见的入门交互——按键控制光圈移动。我当时想的是,不就画个圆然后改坐标吗,裸机随手就糊上了。可真正动手才发现,从画圆到"按键一按、画面平滑无残影地响应",中间隔着一整套 GUI 框架的边界问题。这篇应用笔记就记录一下我踩过的路,适合想用 TouchGFX 做物理按键交互、又对 MVP 框架和显示刷新时序不太熟悉的嵌入式工程师参考。
1. 为什么选 TouchGFX:在"画一个圆"和"砸一套界面框架"之间
1.1 明明是画个圆,为什么裸机反而别扭
先说说我最初想用裸机实现的方案。画圆的算法本身并不难,Bresenham 或者中点画圆法,一页 PPT 就能讲清楚,但难点在"移动"这个动作上。
圆移动以后,旧位置要擦除,新位置要重绘,就引出了脏矩形管理。如果屏幕上只有一个圆,那清理一下整块区域也没问题,可一旦背景上加了半透明渐变、叠加了准星标线,旁边还要实时更新坐标数值,裸机的重绘逻辑就开始失控。更麻烦的是,后续如果想把"光圈"换成带叶片形状的图形,或者给移动过程加缓动动画,裸机代码的每次改动都牵一发动全身。
在这种"看似简单、但要持续演化和扩展"的小需求面前,直接上 GUI 框架反而是省事的选择。嵌入式领域主流的方案无非那几套,我简单对比一下,大家就能明白我为什么最后选 TouchGFX。
| 方案 | 上手成本 | 显示效果 | 内存开销 | 动画与交互机制 | 工具链依赖 |
|---|---|---|---|---|---|
| 裸机直绘 | 低,会画点线就能写 | 取决于代码量,叠加效果难做 | 最低 | 全部手写 | 无 |
| LVGL | 中,API 简单,文档多 | 传统控件风格,现代感需自己调 | 较低,适合窄内存 MCU | 有内置动画,但精细度一般 | 独立于芯片厂商,交叉编译 |
| TouchGFX | 中偏高,绑定 ST 生态 | 现代感强,支持渐变、抗锯齿、局部刷新 | 较高,通常需外部 RAM | 丰富的动画与缓动方程 | 深度集成 STM32CubeMX + Designer |
TouchGFX 有一个别人比不了的优势:它能让你在 PC 上的 WYSIWYG 设计器里先把界面拖出来,马上在模拟器里跑,再生成工程代码。这种"所见即所得"的开发体验,在嵌入式 GUI 里属于降维打击。代价是它对硬件有一定要求,内存和 Flash 开销都不算低,所以它更适合 STM32F7/H7 这类性能较强的平台。我手上的 F746 带 320KB RAM 和外部 SDRAM,跑 480×272 的界面刚好合适。
1.2 这个需求里 TouchGFX 真正解决的问题
回到"按键控制光圈移动"这个小例子,TouchGFX 帮我把最枯燥的三件事接管了:
第一是屏幕刷新管理。TouchGFX 内部有一套脏矩形和帧缓冲机制,界面任何控件变化都会自动合并重绘区域,我不需要关心哪些像素要重画。
第二是界面设计的美观度。Designer 里可以直接放 Circle、Rectangle、TextArea 这些控件,背景还能做渐变,跑出来就是一个现代感很强的取景器界面,而不是那种一眼假的测试程序。
第三是后续扩展的架构基础。TouchGFX 强制 MVC 的变体 MVP 分层,界面逻辑和业务逻辑分开,后面加数据面板、加触摸拖拽、加动画,都是在预设轨道上叠加,不会把代码带进死胡同。
2. 工程搭建与取景器界面的落地
2.1 CubeMX 里把 TouchGFX Generator 跑通
如果你用 ST 官方板和官方 RGB 屏,这部分非常顺滑。我用的 CubeMX 版本是 6.10,TouchGFX Designer 是 4.22,两者配合比较稳定。
在 CubeMX 里操作路径大概是:选中 MCU 之后,先配置好 RCC、SDRAM、LTDC 这些基础外设,然后在中间件列表里勾选 TouchGFX Generator。勾选之后会多出几个配置项,重点看三处:
- 帧缓冲设置(Frame Buffer):我选了 RGB565,480×272 的画面一个缓冲大约 255KB,这个尺寸放在外部 SDRAM 里完全没压力。如果你的屏幕要做透明度混合,可以选 ARGB8888,但那会让单个缓冲翻倍到 510KB 左右。
- Buffer Count:先按默认的 1 或 2 都行。我建议一开始就选 2,原因后面讲"残影"问题时大家会看到。
- 刷新机制:TouchGFX 支持全部帧缓冲和部分帧缓冲两种模式。部分帧缓冲能省内存,但对 MCU 和屏幕驱动时序的要求更高,初期先用全帧缓冲把功能跑通更安全。
CubeMX 生成工程以后,会生成一个.part文件,这个是 TouchGFX Designer 的工程描述文件。我一般用 CubeIDE 打开工程,确认能编译通过,再回到 Designer 打开.part开始做界面。两边会自动同步源码,这个联动是 TouchGFX 生态最舒服的地方。
2.2 Designer 里拖出取景器界面
打开 Designer 后,我新建了一个名为 CameraView 的 Screen。界面的结构很简单,从上到下排了三层:
- 背景层:一个全屏 Rectangle,填充色设为深灰到黑色的垂直渐变。TouchGFX 的 Rectangle 支持颜色渐变填充,这个在属性面板里直接调就行。
- 光圈层:拖入一个 Circle 控件。这是整个界面的核心,我把它命名为 apertureCircle。半径设为 60 像素,线宽设成 4 像素,颜色用白色。再叠加一个半径稍大、透明度 30% 的辅助圆,模拟相机取景器里那种淡色的对焦辅助圈。
- 信息层:两个 TextArea,分别放在左上角和右下角。左上角显示标题 "APERTURE POSITION",右下角显示当前的光圈中心坐标。坐标值后续在代码里动态更新。
Circle 在 Designer 里的坐标属性同时暴露了左上角位置和中心点位置,拖拽时这两套值会联动变化。在代码里我推荐直接使用setCenter(cx, cy)这种方式,语义更明确,也避免被左上角的布局坐标绕晕。
为了让界面更贴近相机取景器,我在光圈中心加了两条细矩形十字线,这样光圈移动时能明显看出位置变化。整个界面搭下来不到十分钟,这一阶段 Designer 的主要价值就是让布局直观可见,比盲写代码试位置高效太多。
2.3 生成的 MVP 代码骨架长什么样
界面保存之后切回 CubeIDE,重新生成代码,会看到 TouchGFX 目录下多出了很多源码。初次接触的人容易被这套结构吓到,其实核心只需要认识这几个文件:
Model:负责业务状态和周期任务,运行时会周期性调用tick()。ModelListener:抽象接口,定义设备层向界面层通知事件的方法。MainPresenter:持有 View 引用,接收 Model 的通知并转发给 View。MainView:真正的界面代码,操作控件、实现交互效果。- 每个 Screen 还有一个
MainViewBase和MainPresenterBase基类,这些是生成代码,下次 Designer 生成时会被覆盖,千万不要在里面手改逻辑。
我在 View 层打开MainView.hpp,确认apertureCircle控件已经被声明成成员变量,这个就是我们后面移动光圈的入口对象。
2.4 在模拟器里先预跑一遍
Designer 自带模拟器,这个功能我强烈建议先用起来。不用烧录、不用接线,直接在 PC 上点击 Run Simulator,界面就起来了。我通常先在模拟器里确认两件事:
一是确认控件坐标更新后画面表现是否符合预期。在模拟器里,如果代码调用setCenter()之后不调用invalidate(),你会看到屏幕纹丝不动,这个现象和真机一致,能提前暴露 API 误用问题。
二是确认布局在不同分辨率下的表现。虽然这次是固定分辨率,但模拟器很方便地支持缩放窗口查看效果,对检查控件是否超边界很有用。
模拟器和真机不是 100% 一致,但用来跑通交互逻辑足够了。真正和显示时序相关的坑,模拟器是看不出来的,那些问题留到第五部分讲。
3. 物理按键接入:从 GPIO 抖动到 GUI 事件
3.1 按键引脚配置与电路
板子上的方向键是四路独立按键,我接在 F746 的四个 GPIO 上,配置为输入上拉,按键按下时引脚被拉低,所以逻辑上是低电平有效。
在 CubeMX 里设置 GPIO 时有几个小细节要注意:
- 把 GPIO 的上下拉配置为 Pull-Up。
- GPIO 速度可以设为 Low 或者 Medium,按键信号用不到高速,设置太高反而徒增噪声。
- 如果这几个引脚同时被其他外设复用,一定要在展开的调试信息里检查冲突。我之前就遇到过按键引脚和触摸屏 INT 引脚共用了一个 USB 口附近的 GPIO,结果插上触摸屏以后按键状态乱跳,排查了很久才发现是 CubeMX 引脚分配打架。
3.2 不用延时也能消抖的状态机写法
按键消抖是最容易被糊弄过去、但也最容易给后续埋坑的部分。网上最常见的写法是检测到低电平后HAL_Delay(20)再读一次,两个低电平都确认才算按下。这种方法在小工程里能用,但有个问题:HAL_Delay会阻塞当前任务,如果此时正好赶上 TouchGFX 渲染的关键时刻,会造成明显的卡顿或者撕裂。
我在这个项目里用的是非阻塞状态机,把它放在一个周期触发的扫描函数里,每次扫描一个按键。
typedef enum { KEY_IDLE = 0, KEY_PRESSED, KEY_CONFIRMED } KeyState; typedef struct { GPIO_TypeDef* port; uint16_t pin; KeyState state; } KeyConfig; void KeyScan(KeyConfig* key, uint8_t* edgeFlag) { uint8_t level = HAL_GPIO_ReadPin(key->port, key->pin); switch (key->state) { case KEY_IDLE: if (level == GPIO_PIN_RESET) { key->state = KEY_PRESSED; } break; case KEY_PRESSED: if (level == GPIO_PIN_RESET) { key->state = KEY_CONFIRMED; *edgeFlag = 1; // 只有从 PRESSED 进入 CONFIRMED 时才产生一次边沿触发 } else { key->state = KEY_IDLE; // 抖动弹回,复位状态 } break; case KEY_CONFIRMED: if (level == GPIO_PIN_SET) { key->state = KEY_IDLE; // 释放后才能进入下一次触发 } break; default: key->state = KEY_IDLE; break; } }这段代码的核心逻辑是:按键必须连续两次扫描都读到低电平,才能进入CONFIRMED状态并产生一次边沿触发;只要中间有一次读到高电平,状态立刻回到IDLE,相当于把抖动过滤掉了。因为扫描函数本身周期很短,这段代码天然实现了防抖,又没有阻塞调用。
四个按键分别用一组KeyConfig结构体管理,再各自配一个edgeFlag标志位。扫描频率我用的是 100Hz,也就是 10ms 一次,这个频率对确认稳态信号足够了。
3.3 两条把按键事件送进界面的通道,我选了哪条
按键扫描出有效事件以后,怎么通知到 GUI 层,TouchGFX 里有两种典型做法,我一开始也没想清楚,干脆两条路都试了一遍。
第一条:走 TouchGFX 的键盘事件机制。TouchGFX 的 HAL 层提供了KeyListener和handleKeyEvent(),你可以把按键编码直接发送到当前 Activity 的 View,由 View 里的按键处理函数响应。这条路的优点是事件精确、实时性好,适合做"按键控制焦点切换""按键触发界面跳转"这类强交互逻辑。缺点是你要在 HAL 层找合适的位置挂发送函数,改动范围比轮询大一些,对不熟悉 TouchGFX 事件流的人来说有点拐弯。
第二条:在 Model 的tick()里轮询标志位。TouchGFX 的 Model 类有一个周期性回调tick(),默认跟随帧率节奏,大约是 60Hz。按键扫描函数把edgeFlag置位以后,tick()里读取这个标志,再通过 ModelListener 接口通知到 View。这个方案改动最小,非常适合本项目这种"按键只影响业务状态、不抢系统焦点"的场景。
我最终选了第二条。原因很直接:光圈移动本质上是一个"业务状态更新"而不是"界面焦点切换",用 Model 层来响应最贴合 MVP 的分层职责。
void Model::tick() { if (g_keyEdgeFlag & KEY_UP_MASK) { modelListener->onApertureMove(DIR_UP); g_keyEdgeFlag &= ~KEY_UP_MASK; } // 其余三个方向同理 }这里注意一件事:tick()消费掉标志位之后要立即清除,否则下一帧会再次触发,表现为"按一次键移动好几格",这就是第五部分要讲的连发问题。
4. 光圈移动逻辑:坐标到底该放在 MVP 的哪一层
4.1 Model 负责"状态感知",不负责"画图"
MVP 分层最大的价值是让"数据"和"显示"解耦。在这个例子里,Model::tick()通过标志位感知到用户按了哪个方向,但它并不直接去碰apertureCircle控件,而是把方向信息包装成一个事件,通过ModelListener接口发出。
class ModelListener { public: virtual void onApertureMove(uint8_t dir) {} };MainPresenter继承ModelListener,在这个接口里接收事件并调用 View 的对应方法。
void MainPresenter::onApertureMove(uint8_t dir) { view.apertureMove(dir); }从 Model 到 Presenter 再到 View,事件层层转交,看起来有点绕,但好处是清晰。后面如果需求变化,比如增加"光圈到达边界时震动提示",Model 层可以直接决定不发事件,View 完全不用改动。
4.2 Presenter 不是简单的传声筒,它能做约束
有些人觉得 Presenter 就是个转发壳子,我一开始也这么想。但实际测试中发现,如果不加约束,用户在界面切换动画期间狂按方向键,会产生一连串的事件堆积,界面还没来得及响应完,队列里已经排了十几个移动请求。
所以我在 Presenter 里加了一个简单的时间闸门:记录上一次事件转发的 tick 计数,如果距离上次不足 4 个 tick(约 66ms),直接丢弃本次移动事件。这样既保证了按键响应的顺手程度,又避免了事件风暴。
void MainPresenter::onApertureMove(uint8_t dir) { uint16_t now = model->getTickCount(); if (now - lastMoveTick < 4) { return; } lastMoveTick = now; view.apertureMove(dir); }这类轻量级约束放在 Presenter 里最合适,因为 Model 不该操心界面交互的节奏,View 又不应该反向决定业务规则。
4.3 View 里两步操作:改坐标 + invalidate
View 层的apertureMove()是真正操作控件的地方。移动逻辑拆开就两步:先算出新坐标,然后让控件和渲染管线知道"我变了"。
void MainView::apertureMove(uint8_t dir) { const int16_t STEP = 10; const int16_t R = 60; int16_t cx = apertureCircle.getCenterX(); int16_t cy = apertureCircle.getCenterY(); if (dir & DIR_UP) cy -= STEP; if (dir & DIR_DOWN) cy += STEP; if (dir & DIR_LEFT) cx -= STEP; if (dir & DIR_RIGHT) cx += STEP; cx = clamp(cx, R, SCREEN_WIDTH - R); cy = clamp(cy, R, SCREEN_HEIGHT - R); apertureCircle.setCenter(cx, cy); apertureCircle.invalidate(); }这里有几个细节:
setCenter()是 Circle 控件提供的圆心设置接口,比直接用setX()/setY()更安全,因为它同时处理了控件内部的几何偏移,不会出现圆心和控件包围盒错位的情况。
invalidate()一定不能漏。这是我这个项目里犯的第一个错误,在模拟器上跑了半天,按下按键后光圈纹丝不动,后来查了 API 文档才意识到,TouchGFX 的控件更新坐标后不会自动触发重绘,必须手动把自己标记为"脏区域"。只有调用了invalidate(),渲染管线才会在下一帧里重新绘制这块区域。
关于clamp函数,TouchGFX 的touchgfx命名空间里其实没有直接提供,我这里写的是自己的工具函数,逻辑就是边界夹取,把值限制在合法范围内。
4.4 边界限制的计算细节
边界限制的数值不是拍脑袋写的。屏幕宽 480,高 272,光圈半径 60。为了让光圈完整地留在屏幕内,光圈圆心能活动的范围是:
- X 方向:60 到 480-60=420
- Y 方向:60 到 272-60=212
所以每次算完新坐标后,要执行clamp(cx, 60, 420)和clamp(cy, 60, 212)。如果没有这一步,光圈移动到边界时会半个圆消失到屏幕外,看起来会非常不专业。
移动步长STEP我选了 10 像素。按一下移动 10 像素,在 480×272 的屏幕上,视觉效果是"明显移动一格"但又不会太跳。如果步长设为 4,看起来就像微调;如果步长设为 20,按三下就到边缘了。这个数值没有标准答案,取决于你对交互手感的要求。
4.5 给光圈移动加一个缓动动画
功能跑通以后,我又想让光圈移动看起来更顺滑。最直接的办法是使用 TouchGFX 的MoveAnimator界面动画组件。
#include <touchgfx/widgets/MoveAnimator.hpp> MoveAnimator<Circle> animator(apertureCircle); animator.startMoveAnimation(newCx, newCy, 12, EasingEquations::cubicEaseInOut);startMoveAnimation接收四个参数:目标 X、目标 Y、动画时长(帧数)、缓动方程。我用的cubicEaseInOut会让光圈先加速再减速,看起来就像有惯性一样,非常接近相机对焦时轻微晃动的感觉。
不过动画加进来以后要注意一个问题:动画期间如果用户再次按键,新的动画会和旧动画产生竞争。我在apertureMove里加了一个判断,如果动画正在播放,先停止再启动新的:
if (animator.isRunning()) { animator.cancelMoveAnimation(); }这个细节让连续按键的响应稳定了很多,不会出现"按了五次键,光圈却慢慢吞吞只滑到第二个点"的诡异现象。
5. 实测中遇到的三个坑与完整排查链路
5.1 坑一:移动后旧位置留下残影
这个坑是第一次烧到真机上遇到的。现象是:光圈从左边移到右边后,原来那个地方留下一个很淡的灰色圆印,过几帧才慢慢消失,非常影响观感。
我先排查的是代码逻辑。回看apertureMove(),确认了setCenter()在invalidate()之前被调用,顺序没有问题。又在模拟器上试了同样的操作,模拟器上完全没有残影,这说明问题出在真机的显示时序上。
接下来把注意力放到帧缓冲配置上。我最初的工程里 Buffer Count 配置为 1,也就是单帧缓冲。TouchGFX 在单缓冲模式下,渲染和屏幕刷新共用同一块内存区域,当渲染管线往这块内存里写新画面时,显示器可能正在读同一片内存,于是屏幕上呈现出"新旧画面混合"的撕裂状态。那团淡灰色圆印,本质上是旧画面的残留数据被显示器读到了。
最终我把 Buffer Count 改为 2,让渲染和刷新分别操作两块独立的显存,显示控制器通过 DMA2D 在帧同步信号控制下切换显示源,撕裂和残影问题彻底消失。代价是 RAM 占用多了约 255KB,对于带外部 SDRAM 的板子来说完全不值一提。
5.2 坑二:按"上"光圈却往右跑
这个问题是代码逻辑完全正确,但现象匪夷所思。按方向键的"上",光圈往右移动;按"右"键,光圈往下跑。四个方向整体旋转了 90 度。
我先怀疑方向定义搞错了,但逐行检查看出DIR_UP对应cy -= STEP,逻辑上没有问题。又怀疑按键扫描的映射反了,于是加了一行串口调试,把四个按键的edgeFlag打印出来,确认按键扫描层的事件方向是正确。
最后发现的问题,出在屏幕的物理装配方向。这块屏是排线从下方伸出,而我在 CubeMX 里配置 LTDC 时,没有注意LTDC_CR寄存器里关于水平扫描方向和垂直扫描方向的配置。默认的坐标原点是屏幕左上角,但屏幕装配后实际视觉上的"上方"对应的是坐标系的左侧,这样就产生了整体旋转 90 度的效果。
排查思路是:先验证按键事件字段正确,再用白点坐标测试法,在屏幕上依次显示 (0,0)、(479,0)、(0,271)、(479,271) 四个角,肉眼确认屏幕坐标原点在哪里。最后在 CubeMX 的 LTDC 配置里调整水平/垂直方向参数,让软件坐标系和屏幕物理方向一致。
这个坑的本质是"软件坐标"和"物理视觉方向"的映射问题,不是 TouchGFX 的问题,但每个用 RGB 屏的人都会遇到一次,建议早做坐标标定测试。
5.3 坑三:单击变成了"连发"
现象是:快速按一下方向键,光圈移动了两到三格。按键确认是单击,物理上也没有多次触发,问题出在事件消费逻辑上。
回看我的按键处理过程。KeyScan()函数在边沿触发时置位g_keyEdgeFlag,但这里有个隐患:按键进入KEY_CONFIRMED状态后,只要按键还没有释放,状态机就一直停在CONFIRMED状态。如果KeyScan()被 100Hz 频率周期调用,而Model::tick()是 60Hz 频率,那么两个节奏之间就可能出现"tick 还没来得及消费标志位,下一轮扫描又路过同一次按键状态"的情况。
但真正导致连发的原因更隐蔽。我在Model::tick()里消费标志位时确实清了标志,可是KeyScan()内部,只有从PRESSED进入CONFIRMED的那一次才置位。理论上不应该重复触发。后来我把代码展开分析,发现问题出在tick()执行时,KeyScan()正好运行在另一个线程且抢先读取了 GPIO,导致同一次按键在软件层面被判定成了两次边沿。
这个问题的完整修复方案有三步:
- 一是把
KeyScan()和Model::tick()的执行频率对齐,都放到同一个时钟节拍的同一个上下文里执行,避免竞态。 - 二是在
CONFIRMED状态下加一个次数阈值,只有释放后重新进入IDLE才能再次触发。 - 三是保留 Presenter 里的时间闸门,从事件消费侧兜底。
我最终把按键扫描函数直接从 TouchGFX 的 Model tick 里调用,彻底消除跨线程的竞争问题,连发现象没有再出现。
5.4 排查思路小结
这三个坑放在一起,其实能总结出嵌入式 GUI 调试的一般方法:
- 先用最低成本的工具确认数据链路:GPIO 读数、串口打印标志位,先保证按键→标志位的链路没问题,再去查界面层。
- 区分模拟器和真机的差异:模拟器验证逻辑,真机验证时序。凡是和帧缓冲、显示刷新、像素残留相关的现象,模拟器一概帮不上忙。
- 每次只改一个变量:比如查残影时,先改 Buffer Count,再做其他调整。同时改好几个参数,出了问题根本无法定位。
6. 从"让光圈跑起来"到继续往深走
6.1 把方向键换成旋转编码器
按键控制移动只是一个起点。做完以后,我立刻想到一个更实际的改造:把方向键换成 EC11 旋转编码器,用来模拟相机镜头上的调节拨轮。
EC11 编码器通常有 A、B 两相输出和一个按键开关,硬件上接定时器的编码器模式,就可以直接读到旋转方向和脉冲数。在业务层,我把编码器的脉冲数累加到一个变量里,每个脉冲对应光圈移动一步,按下编码器中间的开关则切换"调整位置"和"调整光圈大小"两个模式。这个交互逻辑放在 Model 层做增量计算,Presenter 层只转发一次"模式切换"事件,View 层在下一次刷新时根据模式显示不同的光圈形态。
这个改造最核心的价值是:它沿用了一模一样的 MVP 分层,我只改了 Model 层的数据来源和部分业务逻辑,View 层几乎没有动。这就是框架分层带来的维护红利。
6.2 把"移动"升级成"取景器参数面板"
光圈动起来以后,我顺手把左上角的信息区扩成了一个微型参数面板,分三行显示:中心坐标 X/Y、当前光圈值、模拟的快门速度。这些数值全部绑定在 Model 层,Model 每次更新光圈位置时同步计算要显示的内容,然后 View 层的 TextArea 通过setText()更新。
这里有一个我个人的心得体会:TextArea 频繁更新的时候,显示的文本长度最好不要反复跳动。比如显示坐标值,我会用固定位数的格式化字符串,比如"X: %03d Y: %03d",让数字位数恒定,界面看起来就不抖。这个细节不会写进任何文档,但直接影响观感。
6.3 触摸屏场景的改造方向
如果后续换成触摸屏,TouchGFX 对触摸事件的支持更完整。把方向键逻辑替换成手势拖拽,只需要在 View 里重写handleDragEvent(),把触摸坐标直接映射到光圈的中心坐标即可。由于 TouchGFX 的交互体系已经内置了触摸坐标转换,这部分改造的工作量不会比按键方案多多少。
但我还是要提醒一句:触摸拖拽场景下,坐标更新频率会远高于按键事件,事件频率一高,动画和刷新时序的优化就得更仔细。到那一步,光是invalidate()的调用时机就得重新设计,比如考虑只刷新新旧位置的并集区域,而不是整屏刷新。
6.4 最后说点个人的体会
这个"按键控制光圈移动"的小例子,如果从纯粹的代码量看,大概就是一两百行的事。但真正做下来,我发现它把 TouchGFX 的整个技术栈串了一遍:Designer 设计界面、CubeMX 配置外设、MVP 分层组织代码、GPIO 消抖、事件转发、帧缓冲刷新、动画缓动、真机时序问题排查。每一个环节看起来都简单,但放在一起就会出现各种只有真机才能暴露的问题。
我一开始以为这是一个两小时的活,最后实际花了差不多两个整天,其中一半时间花在残影和连发这两个问题上。但也是这两个问题,让我真正理解了 TouchGFX 的显示刷新机制,后面再做复杂界面的时候,思路就清晰多了。如果你也在调类似的功能,建议先把缓冲配置、按键消抖状态机、invalidate 调用习惯这三件事做好,能省下不少真机痛苦的调试时间。