4. 触摸响应性能指标

触摸响应这件事,说白了就是「事件怎么从手指传到界面」。很多工程师把精力都花在画控件上,结果一跑起来,点按钮没反应、滑动卡顿、双击变单击……问题全出在事件处理这一层。

我个人习惯,做嵌入式GUI的第一步不是选控件库,而是先把事件框架搭稳。今天咱们就聊聊事件队列、优先级调度和去抖这三个核心话题。

4.1 事件队列设计:别让触摸事件「堵车」

想象一下,你用手指快速滑动列表,每帧可能产生几十个触摸事件。如果每个事件都直接触发回调,CPU根本忙不过来。这时候就需要一个队列——把事件先存起来,再按顺序处理。

我见过不少项目,事件队列设计得过于简单,结果就是高优先级事件被低优先级事件堵住,界面响应慢得像蜗牛。

4.1.1 环形缓冲区 vs 链表队列

嵌入式系统里,最常用的是环形缓冲区。为什么?因为它没有动态内存分配,不会产生碎片。

特性 环形缓冲区 链表队列
内存分配 静态分配,无碎片 动态分配,有碎片风险
入队/出队速度 O(1),固定时间 O(1),但malloc耗时
最大长度 固定,溢出需处理 受堆大小限制
适用场景 RTOS、裸机系统 Linux用户态

我建议在MCU上优先用环形缓冲区。代码简单,性能稳定。下面是一个典型实现:

#define EVENT_QUEUE_SIZE 32

typedef struct {
    uint8_t type;      // 事件类型
    uint16_t x, y;     // 坐标
    uint32_t timestamp; // 时间戳
} touch_event_t;

typedef struct {
    touch_event_t buffer[EVENT_QUEUE_SIZE];
    volatile uint8_t head;
    volatile uint8_t tail;
} event_queue_t;

bool event_queue_push(event_queue_t *q, touch_event_t *ev) {
    uint8_t next = (q->head + 1) % EVENT_QUEUE_SIZE;
    if (next == q->tail) {
        return false; // 队列满,丢弃
    }
    q->buffer[q->head] = *ev;
    q->head = next;
    return true;
}

bool event_queue_pop(event_queue_t *q, touch_event_t *ev) {
    if (q->tail == q->head) {
        return false; // 队列空
    }
    *ev = q->buffer[q->tail];
    q->tail = (q->tail + 1) % EVENT_QUEUE_SIZE;
    return true;
}
小技巧:队列长度建议设置为2的幂次,这样取模运算可以用位运算替代(比如 head & (SIZE-1)),速度更快。

4.2 优先级调度:让关键事件「插队」

队列设计好了,但所有事件都排在一个队里,还是会有问题。比如用户正在拖动滑块,突然来了一个系统告警事件——你肯定希望告警先被处理,而不是等拖动结束。

这就引出了优先级调度。我习惯用多级队列,每个优先级一个队列。

4.2.1 三级优先级模型

在实际项目中,我把事件分为三级:

  • 高优先级:系统事件(如低电量告警、触摸校准请求)——必须立即响应
  • 中优先级:用户交互事件(如点击、滑动、长按)——正常响应
  • 低优先级:后台事件(如界面刷新、动画更新)——可以延迟

调度逻辑很简单:每次从最高优先级的非空队列取事件。如果高优先级队列一直有事件,低优先级就永远得不到处理——这叫「饥饿」。所以我会加一个老化机制:低优先级事件等待超过一定时间后,临时提升优先级。

typedef struct {
    event_queue_t queues[3]; // 0:高, 1:中, 2:低
    uint32_t low_priority_wait_ticks;
} priority_scheduler_t;

touch_event_t* scheduler_get_event(priority_scheduler_t *sched) {
    for (int prio = 0; prio < 3; prio++) {
        touch_event_t *ev = queue_peek(&sched->queues[prio]);
        if (ev) return ev;
    }
    // 老化处理:低优先级等待超过100ms,提升到中优先级
    if (sched->low_priority_wait_ticks > 100) {
        touch_event_t *ev = queue_pop(&sched->queues[2]);
        if (ev) {
            queue_push(&sched->queues[1], ev);
            sched->low_priority_wait_ticks = 0;
        }
    }
    return NULL;
}
注意:优先级反转是个坑。我曾经在一个项目里,低优先级的触摸事件因为持有某个互斥锁,导致高优先级事件被阻塞。解决办法是使用优先级继承协议,或者在事件处理中避免使用锁。

4.3 去抖算法:别让「抖动」毁了用户体验

触摸屏的物理特性决定了:你按一次,硬件可能上报几十次「按下-释放」的抖动信号。如果不做去抖,一个按钮点击会触发多次回调。

去抖分两种:硬件去抖和软件去抖。我个人的经验是:能用硬件解决的,别用软件。但很多时候硬件已经固定了,只能靠软件补救。

4.3.1 硬件去抖

硬件去抖通常是在触摸屏的触控IC内部完成的。比如FT5x06系列,它内部有一个去抖滤波器,可以配置去抖时间。我建议在初始化时就把这个参数设好:

// FT5x06 去抖配置示例
void ft5x06_set_debounce(uint8_t ms) {
    uint8_t reg = 0x06; // 去抖时间寄存器
    i2c_write(FT5x06_ADDR, reg, ms);
}

硬件去抖的好处是不占CPU,但缺点是灵活性差——一旦设死了,所有场景都用同一个参数。

4.3.2 软件去抖

软件去抖的核心思路是:在时间窗口内,只认第一次或最后一次有效状态。常用的有两种算法:

  • 状态机去抖:维护一个状态机,检测到按下后启动定时器,定时器到期后才确认按下。适合按键类场景。
  • 采样去抖:连续采样N次,如果N次中有M次是同一状态,就认为状态稳定。适合触摸滑动场景。

我比较喜欢用采样去抖,因为它对噪声的容忍度更高。下面是一个实现:

#define DEBOUNCE_SAMPLES 5
#define DEBOUNCE_THRESHOLD 3

typedef struct {
    uint16_t x_samples[DEBOUNCE_SAMPLES];
    uint16_t y_samples[DEBOUNCE_SAMPLES];
    uint8_t index;
    uint8_t count;
} debounce_filter_t;

bool debounce_filter(debounce_filter_t *f, uint16_t x, uint16_t y, uint16_t *out_x, uint16_t *out_y) {
    f->x_samples[f->index] = x;
    f->y_samples[f->index] = y;
    f->index = (f->index + 1) % DEBOUNCE_SAMPLES;
    if (f->count < DEBOUNCE_SAMPLES) {
        f->count++;
    }

    // 统计中位数(简单实现:排序后取中间值)
    uint16_t xs[DEBOUNCE_SAMPLES], ys[DEBOUNCE_SAMPLES];
    memcpy(xs, f->x_samples, sizeof(xs));
    memcpy(ys, f->y_samples, sizeof(ys));
    sort(xs, DEBOUNCE_SAMPLES);
    sort(ys, DEBOUNCE_SAMPLES);

    *out_x = xs[DEBOUNCE_SAMPLES / 2];
    *out_y = ys[DEBOUNCE_SAMPLES / 2];
    return f->count >= DEBOUNCE_THRESHOLD;
}
核心观点:去抖不是「消除所有抖动」,而是「在抖动中找到真实意图」。采样去抖用中位数而不是平均值,就是因为中位数对异常值不敏感——你想想看,如果某次采样因为干扰跳到了屏幕边缘,平均值会被拉偏,但中位数不会。

4.4 综合架构:事件处理的完整链路

好了,我们把上面三个东西串起来。一个完整的事件处理链路应该是这样的:

  1. 硬件中断:触摸屏产生中断,ISR中快速读取坐标,压入环形缓冲区
  2. 软件去抖:在任务或主循环中,从缓冲区取出原始数据,经过去抖滤波器
  3. 优先级分类:根据事件类型,放入对应优先级的队列
  4. 调度执行:调度器按优先级取出事件,分发给对应的控件

下面这张图可以帮你理清思路:

事件处理完整链路 触摸硬件中断 环形缓冲区(ISR入队) 软件去抖(采样滤波) 优先级分类器 高优先级队列 中优先级队列 低优先级队列 说明: 1. ISR中只做入队,不做复杂处理 2. 去抖在任务/主循环中执行 3. 调度器按优先级取事件

4.5 避坑指南:我踩过的几个坑

做事件处理优化,有些坑是新手必踩的。我分享几个亲身经历:

  • 坑一:ISR里做去抖——我曾经在中断服务函数里直接做软件去抖,结果中断耗时太长,导致其他中断丢失。记住:ISR里只做最轻量的操作,比如把数据压入队列。
  • 坑二:队列长度设太小——有个项目,用户快速滑动时事件丢失严重。查了半天,发现队列只有8个位置,一帧就满了。后来改成64个,问题解决。
  • 坑三:去抖参数一刀切——点击和滑动需要的去抖参数完全不同。点击需要20-50ms的去抖窗口,滑动则需要更短的窗口(5-10ms)来保证流畅度。我后来改成了动态参数,根据事件类型自动切换。
调试技巧:在事件队列里加一个统计计数器,记录入队总数、出队总数、丢弃总数。这样你就能直观地看到队列是否溢出、处理速度是否跟得上。我习惯在调试串口上打印这些统计,每10秒一次。

嗯,事件处理优化就聊到这儿。记住一个原则:让紧急的事件先走,让抖动的事件稳定,让队列不堵车。做到这三点,你的触摸响应基本就稳了。