3、输入事件类型:触摸事件、按键事件、编码器事件、定时器事件

好,咱们接着往下走。上一章我们把事件采集的总体架构理清了,说白了就是「谁触发了、怎么采集、怎么上报」。这一章,我们来聊聊具体有哪些事件类型。

我个人习惯把嵌入式GUI里的输入事件分成四大类:触摸、按键、编码器、定时器。你想想看,一个嵌入式设备,不管它是智能家电还是工业控制面板,交互方式基本就这几种。我做过一个项目,客户非要加手势识别,折腾了三个月,最后发现用户最常用的还是触摸和按键——嗯,有时候简单就是王道。

3.1 触摸事件:按下、释放、滑动

触摸事件是现在最主流的交互方式。从电阻屏到电容屏,从单点到多点,底层原理虽然不同,但抽象出来的事件模型其实很统一。

我一般把触摸事件拆成三个基本动作:

  • 按下(Press/Touch Down):手指接触屏幕的瞬间。这是事件的起点。
  • 释放(Release/Touch Up):手指离开屏幕的瞬间。这是事件的终点。
  • 滑动(Move/Touch Drag):手指在屏幕上移动的过程。注意,这里不是指抬起后的惯性滑动,而是手指未离开时的连续位置变化。

在实际项目中,触摸事件的处理有个关键点:按下和释放必须成对出现。我曾经遇到过一个bug,触摸屏驱动在快速点击时偶尔丢失释放事件,结果界面上的按钮一直处于「按下」状态,用户怎么点都没反应。排查了两天才发现是中断处理函数里有个临界区没处理好。

核心原则:触摸事件采集必须保证按下-释放的配对完整性。丢失任何一个,UI状态机就会跑偏。

下面是一个典型的触摸事件结构体定义,我在多个项目里都用过这个模板:

typedef struct {
    uint8_t  type;       // 0:按下 1:释放 2:滑动
    uint16_t x;          // 当前X坐标
    uint16_t y;          // 当前Y坐标
    uint16_t pressure;   // 压力值(可选,电阻屏常用)
    uint32_t timestamp;  // 时间戳,单位ms
} touch_event_t;

这里我特别想提一下时间戳。很多初学者会忽略这个字段,觉得坐标就够了。但你在做滑动判定、长按检测、双击识别的时候,没有时间戳根本没法算。我习惯在触摸中断里直接打上系统滴答计数器的值,精度至少要到毫秒级。

3.2 按键事件

按键虽然看起来简单,但坑其实不少。按键事件通常分为物理按键和虚拟按键两种。物理按键就是板子上的那些按钮,虚拟按键则是触摸屏上模拟的按键区域。

按键事件的核心状态机是这样的:

  • 按下(Key Down):检测到电平变化,消抖后确认按下。
  • 保持(Key Hold):按键持续被按住,一般用于连续触发场景。
  • 释放(Key Up):检测到电平恢复,消抖后确认释放。

这里有个常见的误区:很多人只采集按下和释放,忽略了保持状态。但你想一下,音量加减、亮度调节这种需要连续增减的操作,如果没有保持事件,用户就得一下一下地按,体验很差。

我的经验:按键消抖时间一般设在10-20ms。太短容易误触发,太长手感会变「肉」。我习惯用15ms,大部分机械按键和薄膜按键都能覆盖。

按键事件的代码实现,我通常会这样设计:

typedef enum {
    KEY_STATE_IDLE,
    KEY_STATE_PRESSED,
    KEY_STATE_HOLD,
    KEY_STATE_RELEASED
} key_state_t;

typedef struct {
    uint8_t      key_id;    // 按键编号
    key_state_t  state;     // 当前状态
    uint32_t     press_time; // 按下时刻的时间戳
    uint32_t     hold_threshold; // 进入保持状态的阈值,单位ms
} key_event_t;

我曾经在一个工业平板项目里,因为按键消抖没做好,导致设备在振动环境下频繁误触发。后来加了一个「连续采样三次一致才确认」的机制,问题才解决。嗯,嵌入式开发就是这样,很多问题不到现场根本想不到。

3.3 编码器事件

编码器,尤其是旋转编码器,在嵌入式GUI里用得越来越多。调音量、选菜单、设参数,一个旋钮比触摸屏盲操作方便多了。

编码器事件主要分两种:

  • 旋转(Rotate):顺时针或逆时针旋转,每次步进产生一个事件。
  • 按下(Button Press):编码器本身带有的按键功能,按下时触发。

编码器的核心是正交解码。A相和B相两个信号,相位差90度。通过判断哪个信号先变化,就能知道旋转方向。

我整理了一个编码器状态表,方便你对照:

A相 B相 旋转方向 事件类型
上升沿 低电平 顺时针 ROTATE_CW
上升沿 高电平 逆时针 ROTATE_CCW
下降沿 低电平 逆时针 ROTATE_CCW
下降沿 高电平 顺时针 ROTATE_CW

注意:编码器有机械抖动,尤其是廉价编码器。我建议在中断里只做状态记录,把旋转判定放到定时器轮询或者任务里做,避免中断频繁触发导致系统卡死。

编码器事件的数据结构相对简单:

typedef struct {
    uint8_t  direction;  // 0:顺时针 1:逆时针
    int16_t  delta;      // 步进值,通常为1或-1
    uint8_t  button;     // 编码器按键状态
    uint32_t timestamp;
} encoder_event_t;

这里有个小技巧:delta字段我习惯用有符号整数,这样上层UI可以直接累加,不用再判断方向。比如音量调节,直接volume += event.delta就行,简洁明了。

3.4 定时器事件

定时器事件比较特殊,它不是由用户直接触发的,而是由系统内部产生的。但它在GUI事件采集里同样重要,甚至可以说,没有定时器事件,很多交互效果都做不出来。

定时器事件的应用场景:

  • 长按检测:触摸按下后启动定时器,超过阈值触发长按事件。
  • 双击判定:两次点击的时间间隔,靠定时器来测量。
  • 界面刷新:比如动画、进度条更新,需要周期性触发。
  • 超时处理:用户长时间无操作,自动返回主界面或息屏。

我习惯把定时器事件设计成两种模式:

模式 说明 典型用途
单次触发 定时到达后触发一次,自动停止 超时返回、延迟执行
周期触发 每隔固定时间触发一次,直到手动停止 动画帧更新、心跳检测

定时器事件的结构体,我一般这样定义:

typedef struct {
    uint8_t  timer_id;   // 定时器编号
    uint8_t  mode;       // 0:单次 1:周期
    uint32_t interval;   // 定时周期,单位ms
    uint32_t elapsed;    // 已运行时间
    void    (*callback)(void); // 回调函数指针
} timer_event_t;

这里我想强调一点:定时器回调里不要做耗时操作。我见过有人直接在定时器中断里刷新整个屏幕,结果界面卡成PPT。正确的做法是,定时器只负责设置一个标志位或者发送一个事件,真正的处理放到主循环或者任务里去做。

避坑指南:我曾经在一个项目里,把定时器周期设成了1ms,结果系统70%的CPU时间都花在定时器中断进出上。后来改成10ms,功能完全不受影响,CPU占用率直接降到5%。定时器精度够用就行,别盲目追求高频率。

3.5 事件类型关系图

为了让你更直观地理解这四类事件的关系,我画了一张图:

嵌入式GUI事件类型关系图 事件采集引擎 事件队列 → 分发 触摸事件 按下 / 释放 / 滑动 按键事件 按下 / 保持 / 释放 编码器事件 旋转 / 按键 定时器事件 单次 / 周期 说明 • 触摸/按键/编码器: 用户主动触发 • 定时器:系统内部触发

从这张图你可以看到,触摸、按键、编码器属于用户主动触发的事件,而定时器属于系统内部触发。它们最终都汇聚到事件采集引擎,经过队列缓冲后分发给上层UI处理。

好了,这一章的内容就到这里。四种事件类型,每种都有自己的特点和坑。你在实际项目中,可以根据硬件条件和应用场景灵活选择。记住一点:事件采集的最终目的,是让上层UI拿到准确、完整、及时的输入信息。其他的,都是手段。