🎬 第三章 Qt信号槽机制及图片轮播
📄 一句话概括

信号槽就是一行 connect,但它顺手解决了三件难事:解耦、跨线程、自动断开。 代价是排队投递会积压——投得比处理得快,延迟就一直在涨。

📝 本节和前面几节的关系

前两章用的是 LibVLC 的回调:一个 C 函数指针,加一个 void* 用户数据。 信号槽是同一件事的面向对象版本,但多了一层「跨线程自动排队」。 这一层既是它的价值,也是它的坑。

一、connect 的四要素

一个连接由四样东西组成:谁发、发的是哪个信号、谁收、收到后调哪个函数。 第五样是「怎么投递」,后面单独讲。

要素 在代码里 说明
发送者 信号所属的对象 必须是 QObject 派生
信号 &QMediaPlayer::positionChanged 只能声明、不能实现
接收者 槽所属的对象 也必须是 QObject 派生
槽 &MyPlayer::onPos 可以是普通成员函数

同一个连接有五种写法,差别很大:

写法 检查时机 缺点
SIGNAL() / SLOT() 宏 运行期查元对象表 拼错不报错,只在控制台打警告
函数指针 编译期 重载的信号要写 qOverload
lambda 编译期 捕获的对象可能先死
仿函数 编译期 不绑定接收者,不会自动断开
QMetaMethod 运行期 啰嗦,仅特殊场景用

推荐函数指针:拼错信号名这种事,应该在你按下编译键时就失败,而不是等程序跑到那一步、 在控制台角落里打一行 No such signal。

lambda 那行的坑要说清:函数指针写法会记录接收者,接收者析构时连接自动断开。 lambda 如果没写接收者参数(connect(sender, sig, this, [](){}) 里的 this),就等于「没有接收者」——对象销毁后信号照发,lambda 里访问成员就是野指针。

二、连接方式决定在哪条线程执行

这是信号槽最容易误解的地方:connect 本身不决定线程,决定的只是「投递方式」。

连接方式 什么时候用 执行在谁的线程
AutoConnection 默认,占绝大多数 见下面两条规则
DirectConnection 同线程、要立刻执行 发射者的线程
QueuedConnection 强制跨线程异步 接收者的线程
BlockingQueuedConnection 要等结果 接收者线程,但发射者阻塞
UniqueConnection 防止重复 connect 可与上面按位或

AutoConnection 的两条规则,背下来就够用: 发射信号的线程 就是 接收者所属的线程 → 走直连,当场调用。 发射信号的线程 不是 接收者所属线程 → 走排队,丢进对方的事件队列。

「接收者所属的线程」指的是它被创建时所在的那条线程,不是它被 connect 时所在的线程。 把对象 moveToThread 之后,这条归属会跟着变。

排队投递有一个硬性要求:参数必须能拷贝。因为参数会被拷贝一份放进事件队列, 等接收者线程有空时才真的调用。

所以这几种参数不能进信号: 裸指针、引用——拷的是地址,原来的对象可能早没了。 自定义的 struct——除非它注册过元类型。 QImage 这种大对象——能拷,但每次都要拷一份完整像素。

第三条是性能陷阱:一帧 1080p 的 QImage 是 8 MB,60 fps 下每秒要拷 480 MB, 只为了把画面送到界面线程。这就是后面要讲「只发有新帧了」的原因。

三、排队投递的代价:积压

排队的本质是一条先进先出的队列,处理速度赶不上投递速度,队列就变长。 队列长了,延迟就是它的长度。

算一遍就清楚,一轮事件循环 16.67 毫秒(60 Hz): 每轮能处理几个 = 16.67 ÷ 单个处理耗时。 每轮会来几个 = 16.67 ÷ 发送间隔。 前者大 → 队列一直是空的;后者大 → 队列一路涨。

Demo 里那张四行表就是这个除法:「每帧发一次、槽只要 1 毫秒」时每轮能处理 16 个, 来 1 个处理 16 个,队列永远是空的。

但「解码线程猛发」那一行就反过来了:每 2 毫秒来一张、每张要 8 毫秒, 每轮来 8 张只处理 2 张,队列每轮净增 6 张。 两秒之后队列就有七百多张,用户看到的是「画面比声音慢了一秒半」。

这时候加大队列没用,因为队列不是问题,是症状。 真正的解法是「别把数据放进队列」:信号里不带帧,只带一句「有新帧了」, 帧本身放在一个共享的「最新帧」变量里,界面线程收到信号就去取。

这样队列长度恒为 1,延迟恒为一轮, 而且旧帧会被新帧覆盖、自动丢掉——这恰恰是播放想要的:追不上就丢帧,而不是越拖越晚。

四、对象死了,信号还连着吗

不会。这是信号槽比裸回调强的地方。

QObject 析构时会做两件事:把自己发出去的所有连接断掉,把自己作为接收者的所有连接也断掉。 所以「发送者死了」和「接收者死了」都不会让程序崩。

但有一个例外必须记住:已经排进事件队列的那个调用,不会被取消。

场景是这样的:解码线程发了一个排队信号,事件已经躺在界面线程的队列里, 这时候你把接收者 delete 了——等界面线程处理到那个事件,接收者已经是一块被回收的内存。

所以规矩是:要删一个有连接的对象,用 deleteLater()。它把「删除」本身也排进事件队列, 排在已有的投递事件后面,自然就不会撞上。

还有一条相关的:关闭窗口时先 player->stop() 再关, 否则解码线程还在往界面线程投递,而界面已经没了。 这类崩溃在调试器里往往看不出堆栈,因为它发生在事件派发里。

五、动手 Demo:排队投递会不会积压,算一遍就知道

这段不算图像,只算队列:每 2 毫秒投一张纸条、每轮处理 2 张,看队列长度怎么长起来。 最后那一列「折合延迟」是关键:队列长度乘以投递间隔,就是画面比声音晚多少。

frame_ms = 1000.0 / 60.0      # 一轮事件循环
q = 0                          # 队列长度
nxt = 0.0                      # 下一张纸条什么时候来
t = 0.0
for r in range(1, 121):
    t += frame_ms
    while nxt <= t:            # 这一轮来了几张
        q += 1
        nxt += 2.0
    q -= min(q, 2)             # 这一轮只处理 2 张
    if r in (1, 10, 30, 60, 120):
        print(r, q, "%.0f 毫秒" % (q * 2.0))

实跑输出:

--------------------------------------------------------------------
排队投递会不会积压,算一遍就知道
--------------------------------------------------------------------
**Qt 的排队连接把「信号」变成「队列里的一张纸条」,界面线程每轮回调时把纸条处理掉。**
**所以积压与否,只取决于三件事:多久发一张、处理一张多久、每轮给多少时间。**

  屏幕 60 Hz,一轮事件循环 16.67 毫秒。

  场景                   发送间隔       处理耗时     每轮能处理     结论
  ----------------------------------------------------------------------------------
  状态变化才发           500.0 毫秒     1.0 毫秒     16 个          不积压
  每帧发一次             16.7 毫秒      1.0 毫秒     16 个          不积压
  每帧发一次,槽很重     16.7 毫秒      8.0 毫秒     2 个           不积压
  解码线程猛发           2.0 毫秒       8.0 毫秒     2 个           **越积越多**

**第二行与第四行的差别最值得看**:发送间隔一样,但一个是「每轮只来一张」、一个是「每轮来十几张」。
  第二种情况下,队列长度会一路涨,用户看到的是「延迟越来越大」。

**真跑一次队列演化**(每 2 毫秒入队一个、每轮处理 2 个):

  第几轮     队列长度     折合延迟 毫秒
  -----------------------------------------
  1          7            14
  10         64           128
  30         191          382
  60         380          760
  120        761          1522

**队列长度在涨,而且涨得稳定——这就是「积压」。**
**折合延迟是它的直接后果**:队列里堆十个,画面就晚二十毫秒。

**解法不是「加大队列」,是「别把数据放进队列」**:
**只发「有新帧了」,让界面自己去取最新那帧**——队列长度永远不超过 1。

队列长度是 7、64、191、380、761——每十轮翻一倍多,而且完全没有回头的意思。

折合延迟那列更直观:第 120 轮时队列里堆了 761 张,按每 2 毫秒一张算,就是 1522 毫秒。 用户按下暂停,画面要一秒半之后才停。

这里的算术很简单:每轮来 8.3 张、走 2 张,净增 6.3 张。120 轮之后应该是 756 张,实测 761, 差的那几张是取整与起始相位造成的。

注意这张表里没有任何「上限」:队列会一直涨到内存耗尽。 现实中的程序不会崩,是因为发送方自己会慢下来(解码线程发现内存不够、或者被操作系统限流), 但那已经是另一个故障了。

30 秒实验

  1. 把 min(q, 2) 改成 min(q, 9),队列还会涨吗?(每轮来 8.3 张、走 9 张,答案是不涨。)
  2. 算一下:每秒 60 个信号,每个信号带一帧 8 MB 的 QImage,光拷贝要占多少内存带宽?
  3. 判断一下:你的「进度更新」信号,该带百分比数字,还是带整个播放状态结构体?

常见误区

  1. 用字符串宏写 connect。 SIGNAL(positionChanged(qint64)) 少一个 qint64都不报错, 只在运行时打一行警告,槽根本不执行。改用函数指针写法。
  2. 跨线程信号里传 const char*。 指针被拷贝、指向的内存没被拷贝,接收方拿到时已经悬空。 要传 QString。
  3. 在槽里 delete 发送者。 队列里可能还有它的事件,删完再投递就是野指针。用 deleteLater()。
  4. 以为 connect 之后槽就在自己线程里跑。 跨线程时它跑在接收者的线程, 你的 this 成员变量在另一条线程被改,没加锁就是数据竞争。
自测三问(点开对答案) 1. `AutoConnection` 什么时候变成排队投递? —— 发射信号的线程与接收者所属的线程不是同一条的时候。 注意看的是「接收者被创建/迁到哪条线程」,不是 connect 发生在哪。 2. 为什么排队投递的参数必须可拷贝? —— 因为参数会被拷贝一份塞进事件队列,等接收者线程有空才执行。 指针和引用拷的只是地址,原对象可能已经不在了。 3. `deleteLater()` 与 `delete` 差在哪? —— `delete` 立刻回收内存; `deleteLater` 把删除排进事件队列,等当前这轮事件派发完再删, 避免删掉「正在被派发的对象」或「还有事件排在队里的对象」。

信号槽这一层通了,下一个问题很具体:解码线程给了你一块像素,怎么把它放到窗口上。 下一节只有两个类:QImage 和 QPixmap。

相关笔记