跳到正文

Qt 6 事件循环与事件处理

环境:Windows 11 x64,Qt 6.9.3 for MinGW(D:/Qt/6.9.3/mingw_64),GCC 13.1.0(D:/Qt/Tools/mingw1310_64/bin/g++.exe;PATH 上的 g++ 是 Strawberry Perl 4.9.2 32 位,禁用),CMake 3.30.5 + Ninja,-O2 -DNDEBUG,GUI 一律 QT_QPA_PLATFORM=offscreen 无头跑,不截图。耗时为进程内多轮中位数并给区间;本机同期有另外 3 个 agent 在抢核,绝对值偏大、比值更可靠。时钟分辨率一项额外用全新进程逐项单跑(否则前面实验抬高的分辨率会污染后面的数据),编译期结论用 g++ -fsyntax-only 逐例取证。

一、五级派发:一条轨迹看清先后

给 QPushButton 同时挂上应用级过滤器、两个对象级过滤器、覆写 event()、覆写 mousePressEvent(),再 sendEvent 一条 QEvent::User:

  notify() 进入: type=1000 layer=QPushBtn
    2a appFilter      type=1000 obj=Btn
    2b objFilter-B    type=1000          <- 后装的 B 先被调用
    2b objFilter-A    type=1000
    3  Btn::event()   type=1000
  sendEvent(User) 后: notify() 调用次数增量=1
层挂法能看到什么能做什么
1覆写 QApplication::notify()全部事件(含发给别的线程对象的)最后一道闸
2aapp.installEventFilter()全进程所有对象的所有事件返回 true = 事件当场死亡
2bobj.installEventFilter()只有该 obj 自己的事件同上;多个过滤器按安装逆序执行
3覆写 QWidget::event()该控件收到的全部事件返回 true 就不再有第 4 层
4覆写 mousePressEvent() 等单类事件ignore() 让父控件接手(仅部分事件)
  过滤器装在父 Win 上、事件发给子按钮 Btn:appFilter type=2 obj=Btn(看见),Win 的 objFilter 一条没打印(看不见)
  过滤器 return true      :mousePressEvent 增量=0  clicked 信号增量=0
  Btn::event() return true:mousePressEvent 增量=0  clicked 信号增量=0
  正常放行               :mousePressEvent 增量=1  clicked 增量=0(只按下没抬起,本来就不该有 clicked)
  一次按下顺带派发:WindowActivate(68) → MouseButtonPress(2) → 四级链 → UpdateRequest(77)(重绘请求入队)

父控件的事件过滤器看不到子控件的事件——“全局拦截”只能挂在 QCoreApplication 上。而 clicked() 是信号不是事件,click() 这个槽根本不走事件系统:click(): pressed=1 released=1 clicked=1 toggled=0 mousePressEvent增量=0。三个信号全响、mousePressEvent() 一次没被调用,所以用 click() 永远验证不到事件过滤逻辑,要模拟就 sendEvent(&btn, &QMouseEvent(...))。

二、send 与 post:谁能插队,各多少钱

  post 2 条后再 sendEvent 1 条 —— sendEvent 那条立刻送达,两条 post 等到 processEvents 才进来
  postEvent 优先级:post(User+10,prio=0) post(User+11,prio=200) post(User+12,prio=100)
    到达: User+11 / User+12 / User+10        ← 优先级大的先出队,同优先级 FIFO

sendEvent 同步(返回时目标已处理完,notify() 恰好 +1),它不进队列,所以能插到已入队事件前面。价格(20 万次/轮 × 5 轮中位):

路径中位说明
栈上 QEvent + sendEvent(含 3 层过滤器)49.4 – 55.1 ns一次虚派发
postEvent(堆分配 + 加锁 + 入队)156.1 ns还没派发
sendPostedEvents 排空134.0 ns/条派发那一段
裸 QEvent 构造+析构6.8 ns下限参照

post 一条再派发的总成本 ≈290ns,是同一次 send 的 5.9 倍,多出来的是 new QEvent、投递锁和队列;跨线程还得叠加唤醒对端线程的开销(上一节量到跨线程 emit 是同线程的 15–21 倍)。

  sizeof(QEvent)=16  QMouseEvent=80  QPaintEvent=48  QResizeEvent=32
  Timer=1 MouseButtonPress=2 FocusIn=8 Paint=12 Move=13 Resize=14 Show=17 Hide=18 Close=19
  KeyPress=6 DeferredDelete=52 Polish=75 LayoutRequest=76 UpdateRequest=77 User=1000 MaxUser=65535

自定义事件只能占 User..MaxUser。注意 QEvent 不是 QObject:e.setProperty("k",1) 直接编译错误,要带数据必须自己派生子类。spontaneous() 区分”平台送进来的”和”Qt 自己造的”,show() + 60ms 循环里只看这一个控件:spontaneous=3 非自发=13——首帧那批事件里只有 3 个来自平台,其余全是 Qt 内部投递/发送的,这正是 offscreen 下整套 UI 逻辑照样能跑的原因。

三、Windows 的 15.6ms tick 困不住 Qt 定时器

基线(全新进程,什么都没干):Sleep(1)=14.94 Sleep(10)=15.10 Sleep(16)=30.59(ms)——tick ≈15.6ms,所有等待向上取整,与本系列前面量到的 Sleep(1) 14.7–15.9ms 一致。再看 Qt(同一个进程,第一件事就是起 10ms PreciseTimer):

  10 ms 定时器跑 300ms -> 触发 30 次 avg=9.961  min=2.868 p95=11.223 max=17.657
  紧接着 2 ms 定时器跑 300ms -> 触发 150 次 avg=1.997  min=0.280 p95=2.666
  singleShot(1ms) 6 次: median=1.875 min=0.601 max=2.623 ms
  singleShot(0ms) 6 次: median=0.003 ms      ← 0ms 真的当轮就兑现

一次没漏(30/300ms)。原因不是 Qt 会算,而是它把进程的系统时钟分辨率抬上去了:

状态Sleep(1) 实测
全新进程(基线)14.94 – 15.42 ms
有活动 QTimer 时,在 tick 回调里测1.63 – 1.79 ms
显式 timeBeginPeriod(1)1.81 – 1.87 ms
显式 timeEndPeriod(1) 之后13.79 – 15.38 ms

Qt 的 Win32 派发器在有活动定时器期间等价于 timeBeginPeriod(1),stop() 之后就还回去(实测回到 15.38ms)。所以”Windows 定时器只有 15.6ms 精度”对 Sleep() 成立、对 QTimer 不成立;反过来也提醒自己:GUI 里挂一个 1ms 定时器会把全进程的时钟精度拉高。

设定offscreen/core avg(3 轮区间)windows 平台 avg备注
1 ms1.046(1.046–1.095)1.053(1.053–1.088)min 0.19ms,真能到 1ms 级
10 ms9.999(9.984–9.999)10.022(9.993–10.022)
15 ms14.98615.009这里才开始和 tick 撞上
16 ms15.99716.007一帧 16ms 不会变成 30ms
50 ms49.74650.045

换后端不改变精度,只改变派发器名字:offscreen/QCoreApplication 是 QEventDispatcherWin32,windows/QApplication 是 QWindowsGuiEventDispatcher。

四、三种 TimerType:差别全在”允许偏差”

Qt::TimerType 只剩三个值(Qt::DefaultTimer 在 6.9 已不存在,写上去就是编译错误),而 QTimer 默认给的是 1:QTimer 默认 timerType()=1 枚举值 Precise=0 Coarse=1 VeryCoarse=2。同一 interval、8 秒窗口、独立进程实测(avg = 相邻 tick 间隔均值):

intervalPreciseTimerCoarseTimerVeryCoarseTimer
1 ms1.046 – 1.1251.187400ms 窗口内 0 次
50 ms49.918(60 次)62.633(47 次,+25%)8s 内 2 次
300 ms300.001(26 次)312.553(25 次,+4.2%)1000.864(7 次,=3.3×)

CoarseTimer 文档说容差 5%:300ms 上 +4.2% 符合,但 50ms 上实测 +25%、3 秒少触发 13 次,短 interval 段偏差被放大,不能把 5% 当保证。VeryCoarseTimer 是”对齐到整秒”——300ms 直接变 1000.9ms,它是给倒计时类 UI 省 CPU 的,不是精度工具。动画/采样一律显式 setTimerType(Qt::PreciseTimer)。

事件循环被卡住时,错过的 tick 不补。 10ms Precise + 忙等 200ms(错过约 20 个 tick):

  正常 100ms 里 tick=10(理论 10)
  忙等后第一次 processEvents(AllEvents,1ms) 只送达 1 个 timeout;再跑 60ms 累计 7(理论 6)
  => 错过的 tick 被合并成 1 个事件,不补帧

这就是”卡完跳一帧”的物理原因:Qt 派发时只问”该醒几次”,答案永远是一次。要保证节拍得在回调里用 QElapsedTimer 自己补算,而不是数 timeout 次数。

五、processEvents():三宗罪,全部可复现

罪一:maxtime 拦不住已经排队的风暴,也不肯等未来的事件。

  队列 5000 条,给 1ms 时间片:实际送达 5000 条,调用耗时 353 us
  队列已空,给 300ms:送达 0 条,耗时 1 us(不会白等,立刻返回)
  120ms 后才有 1 条事件,给 300ms:送达 0 条,耗时 514 us —— maxtime 不会让它等
  同一批,改用 WaitForMoreEvents:送达 0 条,耗时 1 us(exec() 之外这个标志也不生效)

maxtime 只限时间、不限条数:5000 条已在队列里时,1ms 时间片照样一口气派发完。想给 UI 留喘息只能少投递。

罪二:重入是吃栈的。 在事件处理器里调 processEvents(),队列里 2000 条事件(护栏:最深 60 层):

  派发 2000 条,最大重入深度 60,耗时 0 ms
  栈跨度:最深帧与第 1 帧相差 786352 字节 => 每层嵌套约 13328 字节栈
  对照(处理器里不调 processEvents):本次派发 0 条,深度 0

每层重入约 13.3KB 栈,默认 1MB 线程栈大约 75 层就爆,而且没有任何 Qt 告警。这就是”用户连点两次、第二次落在第一次的槽里 → 同一函数跑两遍 → 状态被写坏”的物理形态。护栏要么 if (busy) return;,要么判 QEventLoop::isRunning();QCoreApplication::processEvents() 返回 void,问不到”处理了几条”,唯一线索是 QEventLoop::processEvents() 的 bool。

罪三:应用级过滤器一旦吞掉 Timer 事件,循环就再也出不来。 装一个对所有 QEvent::Timer 返回 true 的过滤器(靠不依赖定时器的逃生事件才测出来):

  [对照:未装过滤器      ] l.exec() 实际阻塞   50 ms(Timer 被吞 0 条 / 逃生事件 1 条)
  [装了吞 Timer 的过滤器 ] l.exec() 实际阻塞  411 ms(Timer 被吞 8 条 / 逃生事件 1 条)

本应 50ms 退出变成 411ms——QTimer::singleShot 内部也是定时器事件,被同一个过滤器吃掉了。quit()/exec() 的退出路径和所有业务定时器共用一条通道,全局过滤要按 type 精确放行,别一把梭 return true。嵌套 exec() 的另一面是内层把队列整个吃掉:

  100 条 User 事件:第 1 条里嵌套 inner.exec();inner 返回时已派发 100 条
  => 内层把外层的 99 条提前消费掉了(outer 自己一条都没分到)

QDialog::exec()、QMessageBox、进度条里手写的 QEventLoop 都是这个结构。再叠加上一节的实测——processEvents() 不兑现 DeferredDelete——就是”槽里 deleteLater() + 子过程里弹了个对话框”的翻车现场。

六、合并与不合并:Qt 压什么、不压什么

操作次数产生的事件结论
widget->update()1000Paint 1(本轮共 2)压缩比 500:1
update() ×3 + processEvents(ExcludeUserInputEvents)3Paint 1合并与标志位无关
layout->addWidget()500LayoutRequest 2(add 期间 0 次)布局失效是”标脏+合并”,不同步派发
widget->resize()500Resize 499,耗时 1 ms不压缩,每条同步派发
postEvent(QEvent::User)1000全部送达 1000 条自定义事件永不合并

最该记住的一对:update() 一千次也只有一个 Paint,resize() 五百次就是五百次同步派发。改尺寸、改字体、setVisible、往布局里加控件都是”立刻算一遍”的贵操作,重绘是”记账、空闲时算一遍”的便宜操作。批量改 UI 的正确姿势是先 setUpdatesEnabled(false) 或先隐藏,改完再打开。

七、Qt 6.9 里这些旧写法已经不成立(逐条编译验证)

旧写法结果
bool ok = QCoreApplication::postEvent(...)error: void value not ignored as it ought to be——postEvent 现在返回 void,投递失败不告诉你
bool r = QCoreApplication::processEvents()同上,返回 void
QCoreApplication::hasPendingEvents()error: 'hasPendingEvents' is not a member——符号整个没了,探测队列只能自己数事件
QCoreApplication::wakeUp()error: 'wakeUp' is not a member——只有 QAbstractEventDispatcher::wakeUp()
QApplication::instance()->eventLoop()error: has no member named 'eventLoop'(不再公开)
QEvent::NumberOfTypes / QEvent::GraphicsSetuperror: ... is not a member of 'QEvent'——6.9 里 217 号是 PlatformSurface,最大内置类型是 227 SafeAreaMarginsChange
Qt::TimerType tt = Qt::DefaultTimererror: 'DefaultTimer' is not a member of 'Qt'
QEvent e(QEvent::User + 7)error: invalid conversion from 'int' to 'QEvent::Type'——构造函数是 explicit,必须 static_cast
e.setProperty("k", 1)error: 'class QEvent' has no member named 'setProperty'
QEvent::registerEventType(QStringLiteral("Foo"))error: cannot convert 'QString' to 'int'——签名变成 registerEventType(int hint = -1)
d->wakingEnabled()error: has no member(不再公开)
qInstallMsgHandler(...)error: ... did you mean 'qInstallMessageHandler'?
QCoreApplication::sendPostedEvents(&o, QEvent::User)能编译(形参是 int event_type,枚举自动提升)

八、小结

  • 派发链固定五级:notify() → 应用过滤器 → 对象过滤器(逆安装序)→ event() → 专用处理器。父控件的过滤器看不到子控件的事件;2a/2b 或 event() 返回 true 会让后面的层和 clicked() 一起消失。click() 不走事件系统(mousePressEvent 增量 0)。
  • 成本:sendEvent 49.4–55.1ns,postEvent 156.1ns + 排空 134.0ns ≈ 5.9 倍。sendEvent 能插到已入队事件前面;postEvent 按优先级出队(200→100→0),同优先级 FIFO。show() 首轮只有 3 个自发事件 / 13 个内部事件。
  • Windows 15.6ms tick 只卡 Sleep(Sleep(1)=14.94ms、Sleep(16)=30.59ms),不卡 QTimer:QTimer(10ms) 实测 30 次/300ms、avg 9.96–10.02ms,1ms 定时器 avg 1.05ms;机制是有活动定时器期间进程时钟分辨率被抬到 1ms(tick 里 Sleep(1)=1.63–1.79ms,timeBeginPeriod(1) 手工验证等价)。offscreen 与 windows 两档精度一致,只是派发器换了名字。
  • TimerType:默认 CoarseTimer(1)。VeryCoarse 把 300ms 变成 1000.9ms(3.3×);Coarse 的 50ms 实测 62.6ms(+25%,3 秒少 13 次),“5% 容差”不能当承诺。
  • 阻塞后错过的 tick 合并成 1 个事件,不补帧。maxtime 不限条数(1ms 派发完 5000 条)、不等未来事件、WaitForMoreEvents 在 exec() 外不生效。
  • processEvents() 每层重入 13.3KB 栈(约 75 层耗尽 1MB);嵌套 exec() 让内层吃掉外层排队的事件(实测 99/100);应用级过滤器吞掉 Timer 会让本应 50ms 退出的循环变成 411ms。
  • 合并表:update() 1000→1 Paint,addWidget() 500→2 LayoutRequest,resize() 500→499 个同步 Resize 事件;批量改 UI 前先关更新。
  • 13 条 Qt 6.9 API 删改全部编译验证:postEvent/processEvents 改返回 void,hasPendingEvents/Qt::DefaultTimer/QEvent::NumberOfTypes 已删,QEvent::User+N 必须显式转型。
  • 本节未在本机实测:真实显示器下的合成/垂直同步节奏、qdirect2d 后端下的 Paint 时序、Wayland/X11 侧的事件打包差异(本机只有 Windows 一套 Qt)。

下一节进入控件与布局:sizeHint 从哪来、伸缩因子怎么分配空白、setMinimumWidth 为什么会把整行顶宽,以及 Tab 焦点链的真实排序规则。