信号就是「我发生了」,它不知道谁在听,也不关心有没有人听。 它是观察者模式,二十行 Python 就能写完——写完你就不再觉得它神秘。
SDL 那条线到这里结束,剩下七节讲 Qt 的信号槽机制。 先讲信号(发出方),再讲槽(接收方),最后讲关联(怎么接上), 这三节连起来就是一套完整的通知机制。 为什么在监控项目里要用它:界面、采集、录像、报警四个模块之间的通信,全靠这套东西解耦。
一、它解决什么问题
假设没有信号,界面怎么知道采集出错了?只能轮询或者直接调用。
| 做法 | 耦合度 | 问题 |
|---|---|---|
| 直接调用 | 高 | 界面要认识采集模块 |
| 轮询标志位 | 中 | 延迟大,浪费 CPU |
| 信号槽 | 低 | 谁都不用认识谁 |
第三行是全部价值:采集模块只管 emit,
它不需要知道有没有界面、界面长什么样、甚至有没有界面。
这就是「解耦」两个字的具体含义——编译期不依赖,所以可以单独测试、单独替换。
二、三个特性,三个坑
D34 验出来的三条,每条都对应一个常见误解:
| 特性 | 含义 | 误解 |
|---|---|---|
| 只发不管 | 没人听就静默丢弃 | 以为没连上=没发出去 |
| 一对多 | 按登记顺序挨个调 | 以为顺序不确定 |
| 返回值被丢 | 拿不到槽的返回值 | 以为能靠返回值传结果 |
第一行最容易让人怀疑人生:写了 emit 界面没反应,先查连接,别查信号。
因为信号发出去是「无条件的」,Qt 不检查有没有接收者,也不返回「发给了几个人」。
第三行解释了一个设计选择:Qt 的槽不返回业务值,要传结果就传引用参数(int &result)。
这是 C++ 的常规做法,不是 Qt 的怪癖。
三、唯一连接:为什么需要去重
D34 里同一个函数连两次,第二次被拦下(返回 False)。
这个行为在 Qt 里是默认的——同一个信号对同一个槽,重复 connect 不会重复触发。
要重复触发得显式用 Qt::UniqueConnection 的反面。
但要注意反过来的情况:两个不同的函数对象(比如两个 lambda)连同一个信号,
Qt 认为是两个槽,会各调一次。
Python 里尤其容易踩:obj.method 每次取都生成新的绑定方法对象,
所以「看起来是同一个」其实不是。
这一点在动态语言里比 C++ 更容易出问题——C++ 的函数指针比较是地址比较,
Python 的绑定方法比较是「同对象同函数」。
四、信号能不能带参数
能,而且必须声明类型(3.4.7 会专门试)。
| 声明 | 能传什么 |
|---|---|
clicked() |
什么都不传 |
progress(int, int) |
两个整数 |
frameReady(QImage) |
一张图(要注册类型) |
第三行的括号里那句话是坑:跨线程排队时,自定义类型必须先 qRegisterMetaType,
否则信号发得出去、槽收不到。
这个错的报错信息是「Cannot queue arguments of type 'QImage'」,看到这句就知道是没注册。
五、动手 Demo:二十行写完一个信号,把它该有的行为全试一遍
用一个回调列表实现 Signal,试三件事:按顺序调用、断开后不再调、重复连接被拦下。 这三件事就是 Qt 信号的全部行为。
class Signal(object):
def __init__(self, unique=True):
self._slots = []
self._unique = unique
def connect(self, fn):
if self._unique and fn in self._slots:
return False
self._slots.append(fn)
return True
def emit(self, *a):
for fn in list(self._slots):
fn(*a)
return len(self._slots)
clicked = Signal()
clicked.connect(lambda: print("A"))
clicked.connect(lambda: print("B"))
clicked.emit() # A 然后 B
实跑输出:
--------------------------------------------------------------------
信号就是「我发生了,谁来听」
--------------------------------------------------------------------
**Qt 的信号没有任何魔法,它就是一个回调列表:有人登记,有人挨个调用。**
**把它写成二十行 Python,Qt 那一套就褪掉神秘感了。**
三个槽登记后发一次信号,**按登记顺序调用,返回调用了 3 个**:
调用顺序:A → B → C
**顺序是「先进先调」,不是「随机」**——这一点在槽之间有依赖时很关键(比如先算后画)。
全部断开后再发,调用 0 个,日志 `空`——**没人听了。**
同一个函数连两次,第二次返回 **False**(被拦下)
实际调用次数:**1**——这就是 Qt 的 `Qt::UniqueConnection`
**信号有三个必须知道的特性:**
特性 含义 用错会怎样
--------------------------------------------------------------------------
只发不管 发出后不知道有没有人接 没连接时静默丢弃,不报错
一对多 一个信号可以连多个槽 顺序在前的先跑,别依赖反过来
返回值被丢 槽的返回值拿不到 要结果就得用回调参数
**第一行最容易让人迷惑**:写了 `emit` 却没反应,
**先怀疑没连上,别怀疑信号没发**——Qt 里发不存在的信号会编译报错,但连不上的槽是运行期静默的。
**第三行解释了一个常见需求**:想让槽「返回一个值」,Qt 的做法是传一个引用参数出去,
而不是用返回值。
三个槽按登记顺序调:A → B → C。 顺序是确定的,这一点在有依赖时很重要(先算后画)。
全部 disconnect() 之后再发,调用 0 个,日志是空的。
这就是「只发不管」——不报错、不抛异常,什么都不发生。
同一个函数连两次,第二次返回 False,实际只调用 1 次。这正是 Qt 的默认行为。
但注意我用的是同一个函数对象 cb——如果写成两个 lambda: ...,就是两个对象,会调两次。
Python 里这个区别很隐蔽。
30 秒实验
- 把
unique改成 False,同一个函数连两次,看是不是调两次。 - 在
emit里加一句print(len(self._slots)),看断开前后这个数怎么变。 - 让某个槽抛异常,看后面的槽还执行不执行——这个行为决定了你要不要在槽里加 try。
常见误区
- 以为信号发了就一定有人收到。 没连接时静默丢弃,不报错、不抛异常、不返回失败。
- 以为
obj.method每次都是同一个槽。 Python 里每次取都生成新的绑定方法,Qt 认成两个槽, 会调两次。 - 以为槽能返回值。 Qt 的槽不返回业务值,要传结果得用引用参数。
自测三问(点开对答案)
1. 信号发出后,Qt 会检查有没有接收者吗? —— 不会。没连接时静默丢弃。所以「写了 emit 没反应」先查 connect。 2. 同一个槽连两次会触发几次? —— 默认一次(Qt 做了去重)。但 Python 里两个不同的 lambda 会被当成两个槽。 3. 为什么槽不能靠返回值传结果? —— 因为一个信号可能连多个槽,返回值该用哪个说不清。Qt 的做法是传引用参数出去。下一节讲槽:信号是谁都能发,槽是「什么都能当」。
- ☐ (本节完,下一节 03.04.02 槽)