【深入浅出】之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 鼠标坐标相对于谁)
- [4.1 `button()` 和 `buttons()` 不要混在一起](#4.1
- [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怎么选)
- [9.1 为什么要判断 `timerId()`](#9.1 为什么要判断
- [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 底层的"事件"。它们每天都在窗口里来来往往,只是以前我没有认真和它们打招呼。
常见的鼠标事件有如下几种:
enterEventAndLeaveEventmousePressEventmouseMoveEventwheelEventkeyPressEventmoveEventAndResizeEventtimerEvent
1. 先认识 Qt 的事件系统
用户在窗口中移动鼠标、按下键盘或者滚动滚轮时,操作系统会产生对应消息。Qt 会把这些消息转换成事件对象,再把事件送到合适的控件手中。

例如:
| 用户操作 | 常见事件对象 | 常见处理函数 |
|---|---|---|
| 鼠标进入或离开 | QEvent |
enterEvent()、leaveEvent() |
| 鼠标点击或移动 | QMouseEvent |
mousePressEvent()、mouseMoveEvent() |
| 滚动滚轮 | QWheelEvent |
wheelEvent() |
| 按下键盘 | QKeyEvent |
keyPressEvent() |
| 移动或缩放窗口 | QMoveEvent、QResizeEvent |
moveEvent()、resizeEvent() |
| 基础定时器到期 | QTimerEvent |
timerEvent() |
这些处理函数大多是 Qt 类中已经定义好的虚函数。我们要做的不是凭空发明一个同名函数,而是在自己的窗口类或控件类中重写它。
cpp
void mousePressEvent(QMouseEvent *event) override;
这里的 override 很有用。它像一个认真负责的小检查员:如果函数签名没有真正覆盖父类虚函数,编译器就会提醒我们。
事件对象是数据的载体,它封装了事件的详细信息,比如拿QEvent这个事件对象来说:
这个类是所有事件对象的基类,它里面有以下重要信息:
- 事件类型 (Type):通过
type()函数获取,告诉你当前事件对象的类型是什么。常见类型有QEvent::MouseButtonPress、QEvent::KeyPress、QEvent::Paint等。
绝大多数你关心的内容都在它的子类中,基类重要存储共有的元数据。
而事件处理函数(Event Handler)是"对此做什么"的响应代码,通过在子类中实现相应的处理逻辑,我们可以做到在不同的窗口中,对同一事件做不同的处理。
2. 事件和信号槽有什么区别
事件与信号槽经常一起出现,但它们并不是同一个机制。
可以先做一个简单区分:
| 对比项 | 事件 | 信号与槽 |
|---|---|---|
| 主要用途 | 描述某件事情已经发生 | 建立对象之间的通知关系 |
| 常见写法 | 重写事件处理函数 | 使用 connect() 建立连接 |
| 常见数据 | QMouseEvent、QKeyEvent 等事件对象 |
信号携带的参数 |
| 适合场景 | 鼠标、键盘、窗口、绘制等底层交互 | 按钮点击、数据变化、业务状态通知 |
例如,鼠标按下时,控件首先收到的是鼠标事件;而 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;
};
下面是enterEvent和leaveEvent事件处理函数的实现:
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 在大多数情况下表现得像"全局的",但严格来说,它是"窗口级别的全局"。**它不依赖焦点,焦点就是当前鼠标在哪个控件上,在键盘事件中,鼠标在哪个事件上,谁就能优先获得按键事件的处理权。
键盘事件排查"三步走"
当你发现"按了键盘没反应"时,可以按这个顺序排查:
- 看焦点 :当前焦点在哪个控件上?(用
QApplication::focusWidget()可以打印出来) - 看政策 :目标控件的
focusPolicy是否允许接收键盘事件? - 看机制 :你的需求是"控件内部的个性化处理",还是"全局的快捷键响应"?
- 如果是前者,重写控件的
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()。否则一个事件触发另一个同类事件,双方都很热情,程序却未必开心。
运行结果:
- 移动窗口:

- 改变窗口尺寸:

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类继承于QLabel,QLabel又间接继承了QWidget,所以可以直接调用。
10.4 在高频事件中执行耗时操作
鼠标移动、窗口缩放等事件可能高频触发。耗时计算、文件读写和网络请求不适合直接塞进这些处理函数。
10.5 什么时候调用父类实现
如果希望保留控件原有行为,可以在自定义处理前后调用父类版本:
cpp
QWidget::mousePressEvent(event);
是否调用要看具体控件和业务目标。完全拦下事件与保留默认行为,会产生不同结果。
11. 还可以继续扩展什么
学完基础事件函数后,还可以继续探索:
- 使用
eventFilter()集中监听多个控件 - 通过
event()观察事件分发入口 - 结合鼠标事件制作简单画板
- 用键盘事件控制控件移动
- 保存并恢复窗口的位置和尺寸
- 比较
QTimer与基础定时器 - 研究事件的接受、忽略和向上传递
14. 总结
这 7 个事件让我从另一条路线重新认识了 Qt 界面交互:按钮点击不只是一个孤零零的信号,鼠标、键盘、窗口和定时器背后,还有一套持续运转的事件系统。
从 enterEvent() 到 timerEvent(),函数名称虽然不同,但使用方式有明显的共同规律:确认事件类型、重写对应函数、读取事件对象、完成自己的处理,并根据需要保留父类原有行为。