【深入浅出】之Qt 事件系统实战:鼠标、键盘、滚轮、窗口与定时器事件

【深入浅出】之Qt 事件系统实战:鼠标、键盘、滚轮、窗口与定时器事件

  • [Qt 事件系统实战:鼠标、键盘、滚轮、窗口与定时器事件](#Qt 事件系统实战:鼠标、键盘、滚轮、窗口与定时器事件)
    • 前言
    • [1. 先认识 Qt 的事件系统](#1. 先认识 Qt 的事件系统)
    • [2. 事件和信号槽有什么区别](#2. 事件和信号槽有什么区别)
    • [3. `enterEvent()` 与 `leaveEvent()`:鼠标来过,也离开过](#3. enterEvent()leaveEvent():鼠标来过,也离开过)
    • [4. 鼠标按下、释放与双击:一次点击其实很忙](#4. 鼠标按下、释放与双击:一次点击其实很忙)
      • [4.1 `button()` 和 `buttons()` 不要混在一起](#4.1 button()buttons() 不要混在一起)
      • [4.2 鼠标坐标相对于谁](#4.2 鼠标坐标相对于谁)
    • [5. `mouseMoveEvent()`:鼠标不点,也能被看见](#5. mouseMoveEvent():鼠标不点,也能被看见)
    • [6. `wheelEvent()`:滚轮滚的不只是页面](#6. wheelEvent():滚轮滚的不只是页面)
    • [7. `keyPressEvent()`:程序也会听快捷键](#7. keyPressEvent():程序也会听快捷键)
      • [7.1 为什么有时按键事件没有进来](#7.1 为什么有时按键事件没有进来)
      • [7.2 长按按键与自动重复](#7.2 长按按键与自动重复)
    • [8. `moveEvent()` 与 `resizeEvent()`:窗口一动,程序就知道](#8. moveEvent()resizeEvent():窗口一动,程序就知道)
    • [9. `timerEvent()`:不用 `QTimer` 也能倒计时](#9. timerEvent():不用 QTimer 也能倒计时)
      • [9.1 为什么要判断 `timerId()`](#9.1 为什么要判断 timerId())
      • [9.2 `timerEvent()` 和 `QTimer` 怎么选](#9.2 timerEvent()QTimer 怎么选)
    • [10. 事件处理中的几个常见问题](#10. 事件处理中的几个常见问题)
      • [10.1 把局部坐标当成全局坐标](#10.1 把局部坐标当成全局坐标)
      • [10.2 忘记鼠标追踪](#10.2 忘记鼠标追踪)
      • [10.3 忽略键盘焦点](#10.3 忽略键盘焦点)
      • [10.4 在高频事件中执行耗时操作](#10.4 在高频事件中执行耗时操作)
      • [10.5 什么时候调用父类实现](#10.5 什么时候调用父类实现)
    • [11. 还可以继续扩展什么](#11. 还可以继续扩展什么)
    • [14. 总结](#14. 总结)

Qt 事件系统实战:鼠标、键盘、滚轮、窗口与定时器事件

前言

前面学习 Qt 时,我经常使用信号与槽处理按钮点击、文本变化和控件联动。写得多了以后,很容易产生一种错觉:仿佛界面上发生的所有事情,都应该先找一个信号再说。

但鼠标进入控件、键盘按键、滚轮滚动、窗口移动和尺寸变化这些操作,本身更接近 Qt 底层的"事件"。它们每天都在窗口里来来往往,只是以前我没有认真和它们打招呼。

常见的鼠标事件有如下几种:

  • enterEventAndLeaveEvent
  • mousePressEvent
  • mouseMoveEvent
  • wheelEvent
  • keyPressEvent
  • moveEventAndResizeEvent
  • timerEvent

1. 先认识 Qt 的事件系统

用户在窗口中移动鼠标、按下键盘或者滚动滚轮时,操作系统会产生对应消息。Qt 会把这些消息转换成事件对象,再把事件送到合适的控件手中。

例如:

用户操作 常见事件对象 常见处理函数
鼠标进入或离开 QEvent enterEvent()leaveEvent()
鼠标点击或移动 QMouseEvent mousePressEvent()mouseMoveEvent()
滚动滚轮 QWheelEvent wheelEvent()
按下键盘 QKeyEvent keyPressEvent()
移动或缩放窗口 QMoveEventQResizeEvent moveEvent()resizeEvent()
基础定时器到期 QTimerEvent timerEvent()

这些处理函数大多是 Qt 类中已经定义好的虚函数。我们要做的不是凭空发明一个同名函数,而是在自己的窗口类或控件类中重写它。

cpp 复制代码
void mousePressEvent(QMouseEvent *event) override;

这里的 override 很有用。它像一个认真负责的小检查员:如果函数签名没有真正覆盖父类虚函数,编译器就会提醒我们。

事件对象是数据的载体,它封装了事件的详细信息,比如拿QEvent这个事件对象来说:

这个类是所有事件对象的基类,它里面有以下重要信息:

  • 事件类型 (Type):通过type()函数获取,告诉你当前事件对象的类型是什么。常见类型有 QEvent::MouseButtonPressQEvent::KeyPressQEvent::Paint 等。

绝大多数你关心的内容都在它的子类中,基类重要存储共有的元数据。

事件处理函数(Event Handler)是"对此做什么"的响应代码,通过在子类中实现相应的处理逻辑,我们可以做到在不同的窗口中,对同一事件做不同的处理。

2. 事件和信号槽有什么区别

事件与信号槽经常一起出现,但它们并不是同一个机制。

可以先做一个简单区分:

对比项 事件 信号与槽
主要用途 描述某件事情已经发生 建立对象之间的通知关系
常见写法 重写事件处理函数 使用 connect() 建立连接
常见数据 QMouseEventQKeyEvent 等事件对象 信号携带的参数
适合场景 鼠标、键盘、窗口、绘制等底层交互 按钮点击、数据变化、业务状态通知

例如,鼠标按下时,控件首先收到的是鼠标事件;而 QPushButton 在处理完相关鼠标事件后,又可能发出 clicked() 信号。一个负责接住原始动作,一个负责把更明确的控件行为通知出去。

所以它们更像搭档,不是谁把谁赶出了项目。硬要所有逻辑都塞进事件函数,界面会变得很忙;硬要所有底层交互都寻找现成信号,也可能找半天只找到空气。

它们是协作的关系,而不是谁替代谁。

3. enterEvent()leaveEvent():鼠标来过,也离开过

第一个工程练习的是鼠标进入和离开控件区域。

为了只监听某个标签,而不是整个窗口,我继承 QLabel 写了自己的 Label 类,并重写两个事件处理函数:

cpp 复制代码
class Label : public QLabel
{
    Q_OBJECT

public:
    explicit Label(QWidget *parent = nullptr);

protected:
    void enterEvent(QEvent *event) override;
    void leaveEvent(QEvent *event) override;
};

下面是enterEventleaveEvent事件处理函数的实现:

c++ 复制代码
void  Label::enterEvent(QEvent* event)
{
    (void)event;
    qDebug() << "enterEvent";
}

void  Label::leaveEvent(QEvent* event)
{
    (void)event;
    qDebug() << "leaveEvent";
}

这里我们并没有写具体的业务逻辑,创建自定义的类Label继承QLabel类,然后使用Label类来创建控件。

这个例子最直观的运行现象是:鼠标进入标签区域时打印一次信息,离开时再打印一次。鼠标只是路过了一下,程序已经认真地做完了出入登记。

除了打印日志,这两个事件还适合实现:

  • 鼠标悬停时改变控件样式
  • 进入图片区域后显示操作按钮
  • 离开输入区域后隐藏辅助提示
  • 自定义悬停高亮效果

运行结果:

当鼠标进入Label控件的区域,打印一次enterEvent;当鼠标离开,打印一次leaveEvent

4. 鼠标按下、释放与双击:一次点击其实很忙

一次看似普通的点击,可以继续拆成按下、释放和双击等不同阶段:

cpp 复制代码
void mousePressEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void mouseDoubleClickEvent(QMouseEvent *event) override;

我在 mousePressEvent 工程中对左键和右键进行了区分:

cpp 复制代码
// 鼠标点击事件
void label::mousePressEvent(QMouseEvent *ev)
{
    if(ev->button() == Qt::LeftButton)
    {
        qDebug() << "按下左键";
    }
    else if(ev->button() == Qt::RightButton)
    {
        qDebug() << "按下右键";
    }

    // 相对于父控件的x和y

    qDebug() << "x:" << ev->x() << "y: " << ev->y();
}

// 鼠标释放事件
void label::mouseReleaseEvent(QMouseEvent *ev)
{
    if(ev->button() == Qt::LeftButton)
    {
        qDebug() << "释放左键";
    }
    else if(ev->button() == Qt::RightButton)
    {
        qDebug() << "释放右键";
    }
}

// 鼠标双击事件
 void label::mouseDoubleClickEvent(QMouseEvent *event)
 {
     if(event->button() == Qt::LeftButton)
     {
         qDebug() << "双击左键";
     }
     else if(event->button() == Qt::RightButton)
     {
         qDebug() << "双击右键";
     }
 }

4.1 button()buttons() 不要混在一起

button() 表示触发当前事件的那个按键,适合判断"刚才按下或释放的是谁";buttons() 表示当前仍处于按下状态的一组鼠标按键,常用于鼠标移动过程中判断是否正在拖动。

cpp 复制代码
if (event->buttons() & Qt::LeftButton) {
    // 鼠标移动时,左键仍然按着
}

一般在移动事件,mouseMoveEvent()使用buttons,而在按下事件中使用button

Qt中的源码是这样的:

c++ 复制代码
inline Qt::MouseButton button() const { return b; }
inline Qt::MouseButtons buttons() const { return mouseState; }
  • button() 返回的是成员变量 b ,它只能存储一个 Qt::MouseButton 值,所以它只记录触发当前事件的那一个按键。
  • buttons() 返回的是成员变量 mouseState ,它的类型是 Qt::MouseButtons(注意末尾的 s),这是一个标志位的组合 (使用按位或 | 操作),可以同时记录多个按键的按下状态。

这很直观的表示了button是单数用来描述触发者,而buttons是复数,用来描述当前的一个状态。

4.2 鼠标坐标相对于谁

在 Qt 5 的 QMouseEvent 中,x()y()pos() 得到的是相对于当前接收事件的控件的局部坐标,不是固定相对于父控件,也不是屏幕全局坐标。

5. mouseMoveEvent():鼠标不点,也能被看见

默认情况下,控件通常需要在鼠标按键按下时才会持续接收移动事件。如果希望鼠标没有按下任何按键时也能触发 mouseMoveEvent(),需要开启鼠标追踪:

cpp 复制代码
setMouseTracking(true);

然后重写移动事件:

cpp 复制代码
void Widget::mouseMoveEvent(QMouseEvent *event)
{
    // 读取并处理当前位置
}

**开启鼠标追踪后,mouseMoveEvent() 里获取到的坐标,默认是相对于"接收事件的那个控件本身"的(即控件的左上角为原点 (0,0))**的相对坐标,当你调用pos函数时,因此可能出现负值。

mouseMoveEvent() 常见于:

  • 实时显示鼠标坐标
  • 拖动窗口或自定义控件
  • 绘图板中的连续画线
  • 图片取点和区域选择
  • 鼠标靠近目标时显示辅助线

需要注意的是,鼠标移动事件触发频率很高。每挪一点就大张旗鼓地执行复杂任务,界面可能会累得喘气。因此实际项目中应尽量让处理逻辑保持轻量。

6. wheelEvent():滚轮滚的不只是页面

滚轮事件由 QWheelEvent 提供。我的练习中把每次滚轮产生的增量累加起来,从而观察向前和向后滚动时数值如何变化。

Qt 5.14 中推荐通过下面的方式获取滚轮角度增量:

cpp 复制代码
void Widget::wheelEvent(QWheelEvent *event)
{
    total += event->delta();
    qDebug() << total;
}

滚轮除了滚动页面,还可以用于:

  • 放大或缩小图片
  • 调节音量
  • 修改数值
  • 缩放时间轴
  • 控制自定义画布比例

运行结果:

可以看到打印结果,当我们往上滑动鼠标滚轮,这个值就会减小;反之增加。

7. keyPressEvent():程序也会听快捷键

键盘事件通过 QKeyEvent 提供。判断普通按键可以使用 key()

cpp 复制代码
if (event->key() == Qt::Key_0) {
    // 按下数字 0
}

组合键还需要检查修饰键,其中event->modifiers() 返回的是键盘修饰键(Modifier Keys)的当前状态 ,也就是用户是否同时按下了 Ctrl、Shift、Alt、Meta(Win/Command) 等辅助键。

cpp 复制代码
 void Widget::keyPressEvent(QKeyEvent *event)
 {
    if(event->key() == Qt::Key_0 && event->modifiers() == Qt::ControlModifier)
    {
        qDebug() << "ctrl+0" ;
    }
 }

7.1 为什么有时按键事件没有进来

键盘事件通常会先交给当前拥有焦点的控件。如果焦点正在 QLineEdit 等输入控件中,按键可能被它先处理。因此排查时可以关注:

  • 当前焦点在哪个控件上
  • 控件的 focusPolicy 是否允许获得焦点
  • 按键是否已经被子控件处理
  • 是否需要快捷键机制 QShortcut,而不是重写整个窗口的按键事件

这和我们的快捷键机制是有区别的,**QShortcut 在大多数情况下表现得像"全局的",但严格来说,它是"窗口级别的全局"。**它不依赖焦点,焦点就是当前鼠标在哪个控件上,在键盘事件中,鼠标在哪个事件上,谁就能优先获得按键事件的处理权。

键盘事件排查"三步走"

当你发现"按了键盘没反应"时,可以按这个顺序排查:

  1. 看焦点 :当前焦点在哪个控件上?(用 QApplication::focusWidget() 可以打印出来)
  2. 看政策 :目标控件的 focusPolicy 是否允许接收键盘事件?
  3. 看机制 :你的需求是"控件内部的个性化处理",还是"全局的快捷键响应"?
    • 如果是前者,重写控件的 keyPressEvent
    • 如果是后者(比如保存、撤销、全选等),优先使用 QShortcut,省心又靠谱。

7.2 长按按键与自动重复

长按一个按键时,系统可能连续产生按键事件。可以通过 isAutoRepeat() 判断当前事件是否来自自动重复。

cpp 复制代码
if (event->isAutoRepeat()) {
    // 根据业务决定处理或忽略
}

8. moveEvent()resizeEvent():窗口一动,程序就知道

窗口位置改变时会触发 moveEvent(),窗口尺寸改变时会触发 resizeEvent()

cpp 复制代码
void Widget::moveEvent(QMoveEvent *event)
{
    qDebug() << event->pos();
}

void Widget::resizeEvent(QResizeEvent *event)
{
    qDebug() << event->size();
}

除了新值,事件对象还能提供变化前的信息:

事件 新状态 旧状态
QMoveEvent pos() oldPos()
QResizeEvent size() oldSize()

这两个事件常用于保存窗口状态、动态调整内容、自定义无边框窗口,以及在尺寸变化后重新计算绘制区域。

需要小心的是,不要在 moveEvent() 中无条件再次 move(),也不要在 resizeEvent() 中无条件再次 resize()。否则一个事件触发另一个同类事件,双方都很热情,程序却未必开心。

运行结果:

  1. 移动窗口:
  1. 改变窗口尺寸:

9. timerEvent():不用 QTimer 也能倒计时

除了创建 QTimer 对象,QObject 还提供了一套基础定时器机制。通过 startTimer() 可以启动定时器,并得到唯一的定时器编号:

cpp 复制代码
timerId = startTimer(1000);

startTimer参数是设置定时器的到期事件。

定时器到期后,Qt 会调用 timerEvent()

cpp 复制代码
void Widget::timerEvent(QTimerEvent* event)
{
    if(event->timerId() != timeId)
    {
        // 不是我们的定时器直接忽略
        return;
    }

    int value = ui->lcdNumber->value();
    if(value <= 0)
    {
        this->killTimer(timeId);
        return;
    }

    value -= 1;
    ui->lcdNumber->display(value);
}

不再需要定时器时,可以使用:

cpp 复制代码
killTimer(timerId);

9.1 为什么要判断 timerId()

因为对于同一个控件,可以设置多个定时器事件,每个事件有不同的ID,但是都进同一个事件函数中处理。

同一个对象可能同时启动多个基础定时器。timerEvent() 是它们共同到达的入口,所以需要根据编号确认"这次是谁来敲门"。不判断也许暂时能跑,但定时器一多,大家都拿着同一把钥匙,场面就会逐渐失去秩序。

当前我们之所以要保存定时器ID,就是为了后面去停掉该定时器,因为这个ID只有创建时能拿到。

9.2 timerEvent()QTimer 怎么选

方式 特点 更适合的场景
startTimer() + timerEvent() 轻量,通过编号区分定时器 简单、直接的底层定时任务
QTimer + 信号槽 API 清楚,可单独启停和配置 大多数日常业务和多个独立定时任务

对初学者来说,两种方式都值得敲一遍。真正写业务时,QTimer 往往更直观;理解 timerEvent() 则能帮助我们看清 Qt 的定时器本质上也在通过事件循环工作。

QTimer中有timeout()信号,当定时器到期后,实例化的QTimer对象就会发射这个信号。

实际上,QTimer底层就是对timerEvent()事件的一个封装,这个类维护了定时器的timeID,当定时器到期后,它内部由于重写了 timerEvent方法,如果id吻合,就会发射一个信号。

10. 事件处理中的几个常见问题

10.1 把局部坐标当成全局坐标

先确认事件由哪个控件接收,再判断 pos() 的参考系。需要屏幕坐标时,应使用对应的全局坐标接口或坐标转换函数。

10.2 忘记鼠标追踪

希望鼠标不按键也能持续触发移动事件时,需要为实际接收事件的控件开启 setMouseTracking(true)

10.3 忽略键盘焦点

键盘事件没有触发时,先找焦点,不要立刻怀疑键盘今天心情不好。

一般的控件,我们可以通过下面方法,来看它是否获得了键盘的焦点:

C++ 复制代码
 hasFocus();

如果没有获得焦点,就返回false

可以手动给当前控件设置焦点:

c++ 复制代码
setFocusPolicy(Qt::StrongFocus)

这个方法会把键盘的焦点给到当前控件,它是QWidget里的方法,而我们的Label类继承于QLabelQLabel又间接继承了QWidget,所以可以直接调用。

10.4 在高频事件中执行耗时操作

鼠标移动、窗口缩放等事件可能高频触发。耗时计算、文件读写和网络请求不适合直接塞进这些处理函数。

10.5 什么时候调用父类实现

如果希望保留控件原有行为,可以在自定义处理前后调用父类版本:

cpp 复制代码
QWidget::mousePressEvent(event);

是否调用要看具体控件和业务目标。完全拦下事件与保留默认行为,会产生不同结果。

11. 还可以继续扩展什么

学完基础事件函数后,还可以继续探索:

  • 使用 eventFilter() 集中监听多个控件
  • 通过 event() 观察事件分发入口
  • 结合鼠标事件制作简单画板
  • 用键盘事件控制控件移动
  • 保存并恢复窗口的位置和尺寸
  • 比较 QTimer 与基础定时器
  • 研究事件的接受、忽略和向上传递

14. 总结

这 7 个事件让我从另一条路线重新认识了 Qt 界面交互:按钮点击不只是一个孤零零的信号,鼠标、键盘、窗口和定时器背后,还有一套持续运转的事件系统。

enterEvent()timerEvent(),函数名称虽然不同,但使用方式有明显的共同规律:确认事件类型、重写对应函数、读取事件对象、完成自己的处理,并根据需要保留父类原有行为。

相关推荐
Sirens.19 分钟前
MySQL数据库:索引
数据库·mysql
2601_9620725021 分钟前
大数据-234 离线数仓 - 异构数据源 DataX 将数据 从 HDFS 到 MySQL
大数据·mysql·hdfs
treacle田1 小时前
达梦数据库-Linux DM主备集群动态增加节点-记录总结
linux·运维·数据库·达梦主备集群动态扩展
K成长日志1 小时前
BLE不可连接状态--广播态
物联网·网络协议·蓝牙·低功耗·iot·ble
一只专注api接口开发的技术猿1 小时前
Open Claw 实战|快速搭建电商商品监控与数据分析能力
大数据·数据库·数据挖掘·数据分析
杨云龙UP1 小时前
Oracle 19c RMAN历史备份清理失败:CONTROL_FILE_RECORD_KEEP_TIME 问题排查与整改实战
linux·运维·网络·数据库·oracle·rac
2601_962073971 小时前
【MySQL】全面学习数据库查询技巧:查询指令深度学习指南
数据库·学习·mysql
新知图书1 小时前
12.3 智能体中Skill的约束与自进化实战
java·前端·数据库
小小龙学IT2 小时前
Qt 数据库编程(Qt SQL 模块)深度解析
数据库·sql·qt