📹 第三章 FFmpeg二次开发采集并预览本地摄像头
📄 一句话概括

信号槽的坑不多,但每一个都「不报错」——不崩、不警告,只是行为不对。 四个坑:对象销毁、重复连接、槽里做重活、跨线程改界面。

📝 本节和前面几节的关系

前六节把机制讲全了,这一节把坑集中踩一遍。 四个坑我都用 Python 复现了,虽然 Python 和 C++ 的表现不同, 但根因是同一个——这一点比现象更重要。 内存管理那一套在 34 课第八章,那一章量过多线程队列的问题。

一、坑一:对象销毁了,连接还在

这是 Qt 里最经典的崩法,也是最难查的。

语言 表现 为什么
C++ 崩溃(野指针调用) 函数指针还指着已释放内存
Python 内存泄漏(对象删不掉) 绑定方法抓着对象引用

两种表现完全相反,但根因一样:连接是「单向引用」,信号不知道自己连的对象还在不在。 C++ 是忘太早,Python 是忘不掉——都说明这个连接没人管理。

Qt 的解法是 connect 时传 context 对象:connect(sender, &S::sig, context, &R::slot), context 销毁时连接自动断。这是 Qt5 最重要的改进之一。

二、坑二:同一个槽连了两次

D37 实测:连两次,发一次信号,槽执行两次。

场景 后果
按钮点一次跳两页 用户以为程序抽风
日志写两条 对不上账
计数器加两次 数据全错

这个错的可怕之处是「偶发」:如果 connect 写在某个「每次打开页面都会执行」的函数里, 第一次正常,第二次重复。 防法有三条:连之前判断、用 Qt::UniqueConnection、或者把 connect 放在只执行一次的构造里。 我建议第三条——从结构上避免,比运行时去重可靠。

三、坑三:槽里做重活

D37 实测:直连时槽里跑 20 毫秒,发送方就卡 20 毫秒。

这就是「界面卡住」的完整解释:

步骤 发生什么
用户点按钮 系统消息进事件队列
分发器处理 直连调用槽
槽里跑 20 毫秒 主线程被占住
这 20 毫秒里 重绘、点击、拖动全部积压

第三行是关键:主线程被占住时,系统消息还在进队列,但没人取——于是界面「假死」。 超过 200 毫秒用户就能感觉到,超过 5 秒操作系统会标记「未响应」。 解法就一句:槽里只发一个「我要干活」的消息,真活交给工作线程,干完再用排队信号送回来。

四、坑四:跨线程改界面

这一条在 3.4.3 提过,这里要把它讲成「可检查的规则」。

对象 能跨线程吗
QWidget 及所有控件 不能,只能主线程
QPixmap 不能
QImage 能
QByteArray、QString 能(隐式共享,只读时安全)

第一行违反了「可能」崩,而且概率和机器负载相关,测试机上常常一次都不复现。 所以只能靠「写的时候就别这么写」,不能靠测试发现。

一个可执行的检查方法:在所有 setText、setPixmap、 update 的调用点上问一句「这行跑在哪个线程」——答不上来就是隐患。

五、动手 Demo:四个坑,四个都能真踩一遍

对象销毁、重复连接、槽里跑重活、跨线程改界面——前三个能用 Python 复现出同样的现象, 第四个只能靠规则。 注意 Python 和 C++ 的表现不同,但根因一致。

# 坑一:删了对象,连接还在
a = Widget("A")
sig.connect(a.handle)
del a
gc.collect()
sig.emit("1")        # Python 里 A 还能收到,因为方法抓着它

# 坑二:同一个槽连两次
s2 = Signal(unique=False)
fn = lambda x: out2.append(x)
s2.connect(fn)
s2.connect(fn)
s2.emit("hit")       # 执行两次

# 坑三:槽里跑重活,发送方要等
t = time.perf_counter()
s3.emit(1)           # 槽里 while 20 毫秒
print((time.perf_counter() - t) * 1000)

实跑输出:

--------------------------------------------------------------------
四个坑,四个都能真踩一遍
--------------------------------------------------------------------
**信号槽的坑不多,但每一个都很安静——不崩溃、不报错,只是行为不对。**

**坑一:对象销毁了,连接还在。**

  A 被删掉后发一次信号,实际收到的是:A 收到 1、B 收到 1
  **Python 这边没事**,因为 `connect` 抓的是绑定方法,对象被方法引用着,删不掉。
  **C++ 那边就是崩溃**:对象析构了,函数指针还指着那块内存,**一发信号就是野指针调用。**
  Qt 的解法是 `connect(sig, obj, &Obj::slot)` 三参数形式——**对象销毁时连接自动断开**。

**坑二:同一个槽连了两次,执行两次。**

  只发一次,槽执行了 **2 次**——重复连接不是错误,Qt 允许。
  **表现是「按钮点一次跳两页」**,而且很难想到是 connect 写了两次。
  **防法**:连之前判断,或者用 `Qt::UniqueConnection`。

**坑三:信号里做重活,把发送方拖死。**

  直连时,发送方要等槽跑完:**20.0 毫秒**。
  **这就是「界面卡住」的典型成因**:槽里做了耗时操作,而直连是在发送方线程里同步执行的。
  **解法**:槽里只发一个「要去干活」的消息,真活交给工作线程。

**坑四:跨线程直接改界面。**

  这不是「信号槽的坑」而是「不按信号槽做」的坑:工作线程直接调 `setText()`,
  Qt 会打印一行警告然后**可能**崩——「可能」是因为它看运气。
  **规则只有一条:界面对象只在主线程碰,跨线程一律用排队信号。**

**四个坑的共同点:都不报错。**
  所以排查时不要问「哪里写错了」,要问「**谁调了谁、在哪个线程调的**」。

坑一在 Python 里是「A 还能收到 1」——因为绑定方法抓着对象,del 只是删了名字。 换 C++ 就是崩溃。 这个反差特别值得记:同一个设计缺陷,两种语言两种死法。

坑二实测执行 2 次,Signal(unique=False) 就是模拟 Qt 的默认行为。 注意 Qt 默认是去重的,这里为了演示才关掉。

坑三实测 20.0 毫秒,和槽里的耗时一分不差——直连就是同步调用,没有任何优化空间。 想让发送方不等,只有排队一条路。

30 秒实验

  1. 把坑一的 del a 换成 a = None,看行为变不变——体会「删名字」和「删对象」的区别。
  2. 在坑三的槽里加 time.sleep(0.001) 循环 1000 次,看发送方等多久——sleep 和忙等的区别在这里。
  3. 给坑二的信号加上 disconnect 再连,看是不是就只调一次了。

常见误区

  1. 以为对象删了连接会自动断。 C++ 里不会,会崩;Python 里对象删不掉(引用还在),变成泄漏。 都要靠 context 对象。
  2. 以为重复连接 Qt 会自动去重。 Qt 默认确实去重(同一个函数), 但两个不同的 lambda 就是两个槽。Python 里尤其容易踩。
  3. 以为槽里慢一点没关系。 直连时发送方同步等待,槽里跑 20 毫秒界面就卡 20 毫秒, 超过 5 秒系统标「未响应」。
自测三问(点开对答案) 1. 对象销毁后连接还在,C++ 和 Python 各是什么表现? —— C++ 崩溃(野指针调用);Python 内存泄漏(绑定方法抓着对象)。根因都是「连接没人管理」。 2. 为什么槽里做耗时操作会让界面卡住? —— 直连时槽在发送方线程(主线程)同步执行,主线程被占住,系统消息进队列但没人取,界面就假死了。 3. 怎么判断一行代码有没有跨线程改界面的风险? —— 在每个 setText/setPixmap/update 的调用点问「这行跑在哪个线程」。答不上来就是隐患。

下一节讲 moc:Qt 为什么需要一个「编译前先跑一遍」的工具。

相关笔记