浅谈Qt事件子系统——以可拖动的通用Widget为例子
浅谈Qt事件子系统——以可拖动的通用Widget为例子
这一篇文章是一个通过实现可拖动的通用Widget为引子简单介绍一下我们的事件对象子系统的事情
代码和所有的文档
1:Qt侧的API介绍和说明
这个是每一个小项目的惯例,我会介绍大部分Qt程序中使用到的细节,比如说,本项目当中就是eventFilter和事件处理队列的Qt编程技术。这个也是我们编程Qt的一个重点。
本项目打算介绍的是——Qt的事件处理机制,以及对象事件监听机制。如果您很熟悉了,可以考虑直接跳过本篇。
所以,Qt的事件处理机制
我喜欢写一个东西的时候,直接说明我要写什么。很简单。
- Qt是如何实现事件处理的?技术的要点有哪些?
- 我们作为开发人员,重点关心的接口有哪些?
- 如何监听,甚至是拦截其他对象的事件处理呢【这个是本项目的实现要点】
Qt是如何实现事件处理的
毫无疑问,事件驱动处理是GUI的一个命根子,我们的GUI接受事件,展示对应的变化;同时我们的用户跟GUI交互,将用户的意图传递给我们的后台。这就是GUI的一个最大的要点。
所以,我们关心事件驱动的对象有哪些呢?
我们的事件队列的处理主要依赖一个重要的概念,叫“事件循环”(Event Loop)。事件循环是一个持续运行的循环,它不断检测、分发并处理各种事件,包括用户输入(如键盘、鼠标事件)、系统消息以及自定义事件。主要过程大致如下:
- 事件产生:当用户操作或系统状态变化时,Qt会生成一个对应的事件对象(QEvent的子类实例)。
- 事件队列:事件对象被放入事件队列中等待处理。
- 事件分发:事件循环依次从队列中取出事件,分发给相应的对象处理。
- 事件响应:目标对象在其事件处理函数中对事件作出响应,更新界面或执行其他逻辑。
我们分析事件,也是主要抓手这四个部分进行学习。
我们事件队列处理的开始,在QApplication::exec上,调用这个,我们的全应用程序的事件队列就开始工作了。下面,我们来看看一些API函数:
对于框架层次,你需要知道这员工的一些函数:
QCoreApplication::notify
Qt的事件分发机制主要依赖于QCoreApplication类中的notify()
方法。每当一个事件需要传递给某个QObject对象时,都会经过该方法。其主要职责是:
- 统一调度:集中管理所有事件的发送和转发。
- 异常处理:对事件处理过程中可能出现的异常进行捕获和处理,保证整个事件循环的稳定性。
- 事件过滤:在正式分发事件前,提供预处理的机会(见下文的事件过滤机制)。
我们一般不会跑去重写notify(至少笔者没见过特殊到要重写notify的)
事件队列与异步处理
Qt支持将事件异步投递到目标对象中,通过QCoreApplication::postEvent()
方法将事件放入事件队列,等待事件循环处理。这种方式使得事件发送和处理解耦,避免在调用过程中产生阻塞,提升了系统响应能力。
与之对应的同步事件发送方式为QCoreApplication::sendEvent()
,该方法直接调用目标对象的事件处理函数,在调用者线程中立刻执行。这种方式适用于对时序和结果有严格要求的情况,但需注意同步调用可能会引发递归调用或死锁问题。
事件循环(Event Loop)
每个Qt应用程序通常都有一个主事件循环,通过调用QCoreApplication::exec()
启动。事件循环在不断地检测、分发和处理事件的同时,也会处理定时器、信号等异步任务。
- 阻塞与非阻塞:事件循环既能阻塞等待事件,也能在无事件时进入休眠状态,保证资源利用率。
- 嵌套事件循环:在某些对话框或模态窗口中,Qt会启动嵌套事件循环,保证界面依然响应用户操作。需要注意的是,嵌套循环可能会带来事件处理顺序和状态管理方面的复杂性。
讨论事件的类型
事件事件,啥事件呢?这就是事件的类型。Qt中的所有事件都以QEvent为基类,其派生类涵盖了丰富的事件类型,如:
- 用户输入事件:QMouseEvent、QKeyEvent、QWheelEvent等。
- 窗口系统事件:QResizeEvent、QCloseEvent等。
- 自定义事件:开发人员可以继承QEvent,定义属于自己的事件类型,实现特定业务逻辑的事件传递。
这些事件呢,就在我们后面的开发接口上埋下了伏笔,所以,让我们马上进入第二个部分
开发人员关心的关键接口
我们的一个大头中的大头,是QObject的一个重要的函数,或者说,QT元对象系统的一个重要的特化于事件处理的核心,就是我们的一个虚函数event(QEvent *event)
,这是所有事件最终处理的入口函数。每个QObject子类都可以重写这个函数,根据事件类型作出不同的响应。
我们需要注意的是——event()函数并不直接处理事件,而是将这些事件对象按照它们不同的类型,**分发给不同的事件处理器(event handler)。**重写一个event事件,我们往往可能是要特化一部分操作。当然,往往我们的功能是——需要在原先拥有事件处理的基础上,进一步扩展通用事件处理的能力,比如说要做薄记,比如说统一的处理,这个时候重写event就是一个很明智的选择了!
例如,在自定义控件中,可以重写event()
函数,对特定事件(如鼠标点击、键盘输入)进行处理,从而实现自定义行为。当然!这只是一个例子,实际上没人这样写!我们会有专门的函数来处理,这是我们下面会提到的议题!
bool MyWidget::event(QEvent *event) {
if (event->type() == QEvent::MouseButtonPress) {
my_process_of_mouseEvent(event);
return true;
}
// 调用基类的事件处理,保证其他事件正常分发
return QWidget::event(event);
}
你需要注意的是——请看,这里函数返回的是一个Bool值,这个bool值的含义是什么呢?答案是——当你返回了true的时候,就说明你的事件已经处理结束,Qt 将会检查这个函数的返回值,如果是true,说明这个事件已经被处理完成,会转而取事件队列的下一个进行预取,如果返回的是false,那么会继续把这个事件传递给其他的组件让他们接着处理
专用事件处理函数
为了简化事件处理,Qt为常见的事件提供了专用的虚函数,举个例子看看:
- mousePressEvent(QMouseEvent *event):处理鼠标按下事件。
- keyPressEvent(QKeyEvent *event):处理键盘按下事件。
- resizeEvent(QResizeEvent *event):处理窗口尺寸变化事件。
这些函数通常在对应的控件类中重写,目的是对特定事件进行精细控制。需要注意的是,如果同时重写了event()函数和专用事件函数,则通常应保证事件在其中一个函数中得到完整处理,避免重复调用。这些在源码中的表先就是:判断事件的Type,然后依据事件的类型转发给对应的回调函数,就是这样简单!
事件发送接口
QCoreApplication::sendEvent()
同步事件发送接口sendEvent()
直接调用目标对象的事件处理函数,并返回处理结果。这种方式适用于需要立即获得事件处理结果的情况。但由于它是在当前线程中执行的,因此要注意防止在事件处理过程中产生阻塞或递归调用。
QCoreApplication::postEvent()
异步事件投递接口postEvent()
将事件放入目标对象所在线程的事件队列中,由事件循环在合适的时机进行分发。常见的应用场景包括跨线程通信、延迟处理等。由于postEvent()并不会立即调用事件处理函数,开发人员在设计逻辑时应考虑事件延时带来的影响。
自定义事件
在许多场景中,内置的事件类型无法满足特定需求,开发者可以通过继承QEvent来定义自定义事件。常见步骤如下:
- 定义新的事件类型(通常选用Qt::User 类型及之后的值)。
- 创建自定义事件类,包含特定数据和处理逻辑。
- 通过postEvent()或sendEvent()将自定义事件投递到目标对象中。
- 在目标对象的event()函数中进行识别和处理。
这种方式提供了极大的扩展性,使得复杂的应用逻辑可以通过事件机制进行模块化解耦。
事件过滤器
Qt还提供了事件过滤器机制,使得开发人员可以在事件传递前拦截、监控或修改事件。关键接口是QObject的installEventFilter(QObject *filterObj)
和eventFilter(QObject *watched, QEvent *event)
函数。通过在某个对象上安装事件过滤器,过滤器对象可以提前捕获并处理目标对象的事件。
例如,在全局日志记录、调试或临时修改事件响应逻辑时,事件过滤器是一种非常有效的手段。下列代码展示了如何为一个窗口安装事件过滤器:
// 在构造函数中安装过滤器
myWidget->installEventFilter(this);
// 重写eventFilter函数
bool MyClass::eventFilter(QObject *watched, QEvent *event) {
if (watched == myWidget && event->type() == QEvent::KeyPress) {
// 对键盘事件进行特殊处理
qDebug() << "捕获到键盘事件";
return true; // 返回true表示事件已经被处理,不再传递
}
// 调用基类实现,确保其他事件可以正常传递
return QObject::eventFilter(watched, event);
}
通过上述接口,开发者可以在不改动原有对象代码的前提下,实现对事件的监听和拦截。
如何监听和拦截其他对象的事件(本次文档的重点)
在实际开发中,经常需要对已有控件或对象的事件进行监听、修改甚至拦截。Qt提供了非常方便的事件过滤机制,使得这一需求得以高效实现。
事件过滤器的核心在于:每个QObject对象都有一个内部列表,用于存储安装到该对象上的过滤器。当事件到达目标对象前,系统会先依次调用每个过滤器对象的eventFilter()
方法。
- 如果某个过滤器返回
true
,表示该事件已经被处理,后续的过滤器和目标对象本身将不再接收到此事件。 - 如果所有过滤器都返回
false
,事件则继续传递给目标对象进行正常处理。
这种机制使得开发人员可以在不侵入原对象逻辑的情况下,对事件进行预处理,甚至阻断事件传递。
安装和使用事件过滤器
要实现对其他对象事件的监听和拦截,主要步骤如下:
- 编写过滤器类
通常通过继承QObject并重写eventFilter()
方法,编写自定义过滤器类。在该方法中,根据watched参数判断当前捕获的事件属于哪个对象,并根据事件类型进行处理。 - 安装过滤器
在需要监控的对象上调用installEventFilter()
方法,将自定义过滤器对象注册到该对象上。一个对象可以安装多个过滤器,调用顺序与安装顺序有关。 - 事件拦截与传递控制
在eventFilter()中,当检测到感兴趣的事件后,可以选择返回true(表示事件已处理,不继续传递),也可以返回false(让目标对象继续处理)。
例如,假设我们需要拦截某个QLineEdit控件中的鼠标事件,可以这样实现:
class MyEventFilter : public QObject {
Q_OBJECT
protected:
bool eventFilter(QObject *watched, QEvent *event) override {
if (watched->inherits("QLineEdit")) {
if (event->type() == QEvent::MouseButtonDblClick) {
// 拦截双击事件
qDebug() << "QLineEdit双击事件被拦截";
return true; // 阻止事件继续传递
}
}
// 其他情况继续传递事件
return QObject::eventFilter(watched, event);
}
};
在程序初始化时,为目标对象安装过滤器:
QLineEdit *edit = new QLineEdit(this);
MyEventFilter *filter = new MyEventFilter();
edit->installEventFilter(filter);
这样,当用户对该QLineEdit进行双击操作时,MyEventFilter将捕获并拦截该事件,而QLineEdit本身不会收到双击事件。
动态监听与跨对象事件监控
有时,我们不仅需要拦截单个对象的事件,还需要在全局范围内对多个对象进行统一监控。例如,在大型应用中调试或记录日志时,可以为整个应用安装一个全局事件过滤器。通常的做法是将过滤器安装在QCoreApplication对象上,这样所有事件都会先经过该过滤器的检测。
class GlobalEventFilter : public QObject {
Q_OBJECT
protected:
bool eventFilter(QObject *watched, QEvent *event) override {
// 可以对所有对象和事件进行日志记录或特定处理
qDebug() << "全局过滤器捕获到事件:" << event->type() << "来自对象:" << watched;
// 根据需求选择是否拦截或继续传递
return QObject::eventFilter(watched, event);
}
};
// 在main()函数中安装全局过滤器
int main(int argc, char *argv[]) {
QApplication app(argc, argv);
GlobalEventFilter *globalFilter = new GlobalEventFilter();
app.installEventFilter(globalFilter);
// 后续创建的所有对象的事件均会经过globalFilter的检测
// …
return app.exec();
}
这种全局过滤器的使用,尤其适用于调试阶段,对复杂交互过程中的事件进行全面记录和分析,或在某些特殊情况下统一拦截某类事件。
注意事项与最佳实践
当然这里说一些重点的事情。在使用事件过滤器时,还需要注意以下几点:
- 性能问题:全局事件过滤器会处理所有事件,因此在实现中要避免执行过于耗时的操作,防止影响界面响应。
- 返回值控制:返回
true
表示事件被完全拦截,可能导致目标对象无法得到响应;返回false
则允许事件继续传递。开发人员需要仔细判断实际需求。 - 层次关系:如果一个对象安装了多个事件过滤器,事件会按照安装顺序依次经过各过滤器,过滤器之间可能存在相互影响,因此在设计时要考虑好先后次序。
- 安全释放:当过滤器对象不再需要时,必须及时调用
removeEventFilter()
方法,或者在对象销毁时自动移除,避免悬挂指针问题。
本项目的实现的重要文档思路
注意,这个文档可能不会跟我们的源码有一定保证的同步,只是提供一种参考!
如何让Widgets跟随鼠标移动呢
一种办法,是让我们创建一个SubWidget,这个SubWidget负责一对一的维护一个目标控件。比如说一个按钮,或者是任何一个其他的控件,当我们的的目标事件传递到这个控件的时候,会优先的投射到我们的这个widgets上来。通过调用控件的 installEventFilter()
方法,将当前对象(this)作为过滤器安装到 holding_widget
上。安装事件过滤器后,该控件产生的所有事件都会首先传递到当前对象的 eventFilter()
方法中进行预处理。如果在 eventFilter()
中返回了 true
,那么该事件就不会继续传递到控件自身的事件处理函数中;如果返回 false
,则事件会继续传递。
这样,我们就可以写自己的一个eventFilter来控制目标widget的行为。而不需要重载我们的对象添加一个Movable或者是其他任何的属性,这样看就会非常的方便。
下面我们要做的就是准备处理我们的move行为
bool CCMovableWidget::eventFilter(QObject *watched, QEvent *event) {
if (!holding_widget || watched != holding_widget) {
return false;
}
QMouseEvent *mouseEvent = dynamic_cast<QMouseEvent *>(event);
if (!mouseEvent) {
return false;
}
// here we handle the mouse events
// this will promise the future extensions
switch (event->type()) {
case QEvent::MouseButtonPress:
handling_mousePressEvent(mouseEvent);
break;
case QEvent::MouseButtonRelease:
handling_mouseReleaseEvent(mouseEvent);
break;
case QEvent::MouseMove:
handling_mouseMoveEvent(mouseEvent);
break;
default:
break;
}
// back the default behavior
return QObject::eventFilter(watched, event);
}
这是笔者的处理方式,依次对这个事件的MouseButtonPress,MouseButtonRelease和MouseMove进行了传递。这也就意味着这里它的事件就传递进来了进行了处理,当然处理结束后,我们还希望让它做进一步的处理,所以我们让他进一步维护其默认的实现。不要更改控件原来的行为。
剩下的内容
剩下的内容就没什么新鲜的了,这里就让AI帮我代劳吧!
// widget is pressed by the mouse, so this means we shell start our moving
void CCMovableWidget::handling_mousePressEvent(QMouseEvent *event) {
qDebug() << "Mouse pressed";
if (!holding_widget)
return; // no widget to hold, reject process
if (accept_buttons.size() > 0 && !accept_buttons.contains(event->button()))
return; // the button is not acceptable, reject process
widget_state.lastPoint = event->pos(); // memorize the last point
widget_state.pressed = true;
}
handling_mousePressEvent(QMouseEvent *event)
是用户按下鼠标时触发的事件处理函数,是整个拖动行为的起点。当鼠标点击到控件上时,首先通过日志输出来表明事件已经被捕获。接着,程序判断 holding_widget
是否存在,如果为空,则说明当前没有设置任何需要被移动的目标控件,因此直接返回,放弃此次操作。随后,如果开发者为这个类设定了一个特定可接受的鼠标按钮列表 accept_buttons
,而当前触发事件的按钮不在该列表中,也同样视为无效事件,拒绝处理。只有当这些条件都满足后,事件才被视为有效操作。此时程序记录当前鼠标点击的位置,保存在 widget_state.lastPoint
中,用于后续计算移动偏移量,并将 widget_state.pressed
标志设为 true
,表明控件已被点击按住,准备进行拖动。
void CCMovableWidget::handling_mouseReleaseEvent(QMouseEvent *event) {
qDebug() << "Mouse released";
if (!holding_widget)
return; // no widget to hold, reject process
widget_state.pressed = false;
}
handling_mouseReleaseEvent(QMouseEvent *event)
则是用户释放鼠标按钮时调用的函数,它的作用相对简单。同样以日志开始,表示捕获了释放事件。随后依旧先检查是否存在 holding_widget
,如果当前并未绑定任何控件,则此次释放事件无需处理。若控件存在,则将 widget_state.pressed
设为 false
,这一行为本质上是标记当前已结束拖动操作,后续的鼠标移动将不再引起控件的位置变化。
void CCMovableWidget::handling_mouseMoveEvent(QMouseEvent *event) {
qDebug() << "Mouse moved";
if (!holding_widget)
return; // no widget to hold, reject process
if (!widget_state.pressed)
return; // the widget is not pressed, reject process
// calculate the offset
int offsetX = event->pos().x() - widget_state.lastPoint.x();
int offsetY = event->pos().y() - widget_state.lastPoint.y();
// calculate the new position
int x = holding_widget->x() + offsetX;
int y = holding_widget->y() + offsetY;
// check if the widget should be in the parent
if (widget_state.inParent) {
QWidget *w = dynamic_cast<QWidget *>(holding_widget->parent());
if (w && (sizeIsOutlier(QPoint(x, y), w) || positionIsOutlier(QPoint(x, y)))) {
return;
}
// move the widget
holding_widget->move(x, y);
}
}
handling_mouseMoveEvent(QMouseEvent *event)
是核心函数,它在用户拖动鼠标时不断被调用,从而持续地更新控件位置,完成“随鼠标移动”的视觉效果。函数首先打印出“鼠标移动”的日志,确认事件的发生。紧接着,它做出两个防御性检查。第一,是否存在 holding_widget
,否则自然不该响应移动。第二,判断是否存在 widget_state.pressed
为真的状态,这是防止控件在未被按住的情况下跟随鼠标移动,确保只有在“鼠标按下后并且未释放”的情形下才进入后续逻辑。接下来,程序通过当前位置与上次记录的鼠标按下点 lastPoint
计算出一个偏移量 offsetX
与 offsetY
,这是拖动过程中控件应该移动的距离。然后,根据当前控件的原始位置加上偏移量,计算出控件新的坐标 x
和 y
。
但并非所有位置更新都是合理的,因此函数中还加入了一道逻辑判断,即如果当前设置了 widget_state.inParent
为真(意味着控件应保持在其父组件内),就需要判断新位置是否越界。这里调用了 sizeIsOutlier(QPoint(x, y), w)
与 positionIsOutlier(QPoint(x, y))
两个函数,前者大概是判断控件在给定位置上是否尺寸越界,后者则可能是判断位置是否超出允许的边界。这一检查使得控件不能被拖出其父容器或显示区域之外。如果这两个函数判定位置无效,则不执行移动操作,函数直接返回。
最后,如果所有条件都满足,程序调用 holding_widget->move(x, y)
将控件平滑地移动到新位置上。这一行为便是“拖动”体验的实现者,控件就随着鼠标游走而流畅移动。