3、事件驱动基础:事件驱动编程模型、回调函数机制、TouchGFX中的事件类型

好,咱们进入第三章。这一章是整个课程的地基。你想想看,TouchGFX 本质上就是个事件驱动的框架。如果你不理解事件是怎么流转的,后面写再多的界面代码,也容易出各种奇怪的问题。

我个人习惯,在讲具体 API 之前,先把这个“事件驱动”的思维模型讲透。说白了,就是搞清楚一件事:你的代码什么时候被执行?

3.1 事件驱动编程模型

传统的单片机编程,我们经常写一个超级循环:

while(1) {
    if(按键按下) { 处理按键 }
    if(定时器超时) { 更新屏幕 }
    if(串口收到数据) { 处理数据 }
}

这叫“轮询模型”。CPU 像个保安,不停地问每个窗口:“有事吗?有事吗?”

事件驱动模型不一样。它更像一个客服中心:你不需要主动去问,系统会主动通知你。当用户触摸屏幕时,TouchGFX 的底层驱动检测到中断,把触摸坐标打包成一个事件,然后调用你注册好的处理函数。

我在项目中遇到过一位同事,他习惯在 while 循环里用 delay 做延时,结果界面卡得不行。为什么?因为 delay 阻塞了整个事件循环,TouchGFX 没法及时处理触摸事件。这就是没理解事件驱动模型的核心——不要阻塞主循环。

事件驱动模型的核心三要素:

  • 事件源:谁产生了事件?(触摸屏、定时器、传感器等)
  • 事件队列:事件怎么排队等待处理?
  • 事件处理器:谁来响应这个事件?(你的回调函数)

下面这张图,是我自己画的事件驱动流程。你看一眼,心里就有谱了。

TouchGFX 事件驱动流程 事件源 触摸屏 / 定时器 / 外部中断 事件队列 FIFO 排队 事件分发器 查找对应的 Handler 回调函数(你的代码) handleClickEvent / handleDragEvent 💡 关键理解: 你的代码不是主动运行的,而是被事件“唤醒”的。TouchGFX 的 main 循环负责从队列里取事件,然后调用你注册的回调。 所以,不要在回调里做耗时操作,否则会阻塞后续事件的处理。

3.2 回调函数机制

回调函数,是事件驱动模型的“灵魂”。说白了,就是你写一个函数,然后把它的地址告诉系统:“嘿,当这个事件发生时,你帮我调用它。”

在 TouchGFX 里,回调机制用得非常频繁。比如你给一个按钮绑定点击事件:

// 在视图类中声明回调
Callback<MyView, const AbstractButton&> buttonClickedCallback;

// 在构造函数或 setupScreen 中绑定
buttonClickedCallback = Callback<MyView, const AbstractButton&>(this, &MyView::onButtonClicked);

// 将回调注册到按钮
button1.setAction(buttonClickedCallback);

// 实现回调函数
void MyView::onButtonClicked(const AbstractButton& source) {
    if(&source == &button1) {
        // 处理按钮1的点击
        textArea1.setTypedText(TypedText(T_MY_TEXT));
        textArea1.invalidate();
    }
}

这段代码,我刚开始看的时候也觉得有点绕。为什么不能直接调用?因为 TouchGFX 不知道你什么时候会点按钮,它只能通过回调这种“钩子”机制,在事件发生时通知你。

我的经验:回调函数里尽量只做“轻量级”操作。比如更新文本、改变颜色、触发一个标志位。千万别在里面做延时循环、文件读写、或者复杂的数学运算。我曾经在一个回调里做了个浮点运算,结果界面掉帧掉得厉害,查了半天才发现是这里拖慢了事件循环。

3.3 TouchGFX 中的事件类型

TouchGFX 把用户交互抽象成三种核心事件:ClickEvent、DragEvent 和 GestureEvent。咱们一个一个说。

3.3.1 ClickEvent(点击事件)

这是最基础的事件。当用户触摸屏幕并抬起时,就会产生 ClickEvent。它包含两个关键信息:

  • 事件类型:是按下(PRESSED)还是抬起(RELEASED)?
  • 坐标:触摸点在屏幕上的 (x, y) 位置。

你可以在自定义控件里重写 handleClickEvent 方法来捕获它:

class MyButton : public Button {
public:
    MyButton() {}
    
    virtual void handleClickEvent(const ClickEvent& event) {
        // 判断是按下还是抬起
        if(event.getType() == ClickEvent::PRESSED) {
            // 按钮被按下,改变外观
            setTouchable(false);  // 防止重复触发
            // 可以在这里播放音效或震动
        } else if(event.getType() == ClickEvent::RELEASED) {
            // 按钮被抬起,执行动作
            setTouchable(true);
            // 触发回调
            executeAction();
        }
        
        // 记得调用父类方法,否则按钮的默认行为会丢失
        Button::handleClickEvent(event);
    }
};

注意:我见过有人重写 handleClickEvent 时忘了调用父类方法,结果按钮按下去没反应。父类方法里做了很多重要的事情,比如更新状态、触发回调链。所以,除非你确定要完全替换默认行为,否则一定要加上 Button::handleClickEvent(event)。

3.3.2 DragEvent(拖拽事件)

当用户触摸屏幕并滑动手指时,就会产生 DragEvent。它比 ClickEvent 复杂一点,因为涉及连续的坐标变化。

DragEvent 包含以下信息:

属性 说明
getOldX() / getOldY() 上一次触摸点的坐标
getNewX() / getNewY() 当前触摸点的坐标
getDeltaX() / getDeltaY() 本次移动的偏移量(新坐标 - 旧坐标)

这个事件在实现滑动列表、拖动滑块、手势翻页时非常有用。举个例子,实现一个可拖动的圆形:

void MyDraggableCircle::handleDragEvent(const DragEvent& event) {
    // 计算新的位置
    int16_t newX = getX() + event.getDeltaX();
    int16_t newY = getY() + event.getDeltaY();
    
    // 边界限制,防止拖出屏幕
    if(newX < 0) newX = 0;
    if(newY < 0) newY = 0;
    if(newX + getWidth() > screenWidth) newX = screenWidth - getWidth();
    if(newY + getHeight() > screenHeight) newY = screenHeight - getHeight();
    
    // 移动控件
    moveTo(newX, newY);
}

这里有个坑:getDeltaX() 返回的是相对偏移量,不是绝对坐标。你直接用 getX() + getDeltaX() 就能得到新位置,不需要自己算差值。

3.3.3 GestureEvent(手势事件)

GestureEvent 是 TouchGFX 对复杂手势的封装。它把一系列连续的触摸动作,抽象成一个有语义的事件。比如:

  • SWIPE_HORIZONTAL:水平滑动(左滑/右滑)
  • SWIPE_VERTICAL:垂直滑动(上滑/下滑)
  • ZOOM:双指缩放(这个在 TouchGFX 4.x 中支持)

使用手势事件,可以让你不用自己计算滑动方向和速度。框架已经帮你算好了:

void MySwipeContainer::handleGestureEvent(const GestureEvent& event) {
    if(event.getType() == GestureEvent::SWIPE_HORIZONTAL) {
        int16_t velocity = event.getVelocity();  // 滑动速度
        if(velocity > 0) {
            // 向右滑动,切换到上一页
            swipeRight();
        } else {
            // 向左滑动,切换到下一页
            swipeLeft();
        }
    }
}

三种事件的关系:

其实,GestureEvent 是基于 ClickEvent 和 DragEvent 组合出来的。TouchGFX 内部会分析一连串的 DragEvent,如果发现用户的手指移动速度超过某个阈值,就把它识别为一个手势,然后产生 GestureEvent。所以,你可以在同一个控件里同时处理这三种事件,它们并不冲突。

3.4 事件处理的优先级与顺序

在实际项目中,一个控件可能同时需要处理点击、拖拽和手势。那它们被调用的顺序是怎样的?

我总结了一个表格,方便你对照:

顺序 事件 触发条件
1 handleClickEvent (PRESSED) 手指刚接触屏幕
2 handleDragEvent 手指移动(可能多次触发)
3 handleGestureEvent 手指抬起时,如果满足手势条件
4 handleClickEvent (RELEASED) 手指抬起

你看,ClickEvent 的 RELEASED 是在最后才触发的。这意味着,如果你在 handleDragEvent 里改变了控件的状态,那么在 handleClickEvent(RELEASED) 里,你拿到的坐标可能已经不是按下时的坐标了。这个细节,我在做画图板应用时踩过坑——用户画了一条线,抬起手指时我想在终点画个点,结果坐标不对。后来才发现,应该用 getNewX() 而不是 getOldX()。

一个小技巧:如果你只想处理“点击”而不想处理“拖拽”,可以在 handleClickEvent(PRESSED) 里记录按下位置,然后在 handleClickEvent(RELEASED) 里判断手指移动的距离是否小于某个阈值(比如 5 像素)。如果小于,就认为是点击;否则,就当作拖拽处理。这样,一个控件就能同时支持点击和拖拽,而且互不干扰。

3.5 本章小结

事件驱动模型,说白了就是“别主动问,等通知”。TouchGFX 帮你把底层的触摸中断、坐标转换、事件排队都处理好了,你只需要写好回调函数,然后注册到对应的控件上。

三种事件类型——ClickEvent、DragEvent、GestureEvent——覆盖了绝大多数交互场景。记住它们的触发顺序和各自的特点,写代码时心里就有底了。