界面编程的九成代码都是「控件 A 变了,通知控件 B」。 一个按钮点三次,两个视图各更新三次——这就是标准信号槽的全部。
上一节讲了实现原理,这一节回到用法,做最小的一个界面。
用 Python 模拟,因为沙箱里没有 Qt;但信号、槽、事件循环三层结构一个不少。
把它套到 Qt 上,只是把 Signal 换成 signals: 声明,逻辑完全一样。
一、三层结构
事件循环、分发器、槽——三层,缺一不可。
| 层 | 做什么 | 卡住会怎样 |
|---|---|---|
| 事件队列 | 收系统消息 | 界面完全不响应 |
| 分发器 | 把消息转成信号 | 点了没反应 |
| 槽 | 处理信号 | 界面假死 |
三行是同一件事的三个位置,排查「界面卡住」就从这里往下切: 队列满 → 系统层面卡;分发器慢 → 点了没反应;槽慢 → 假死。 D39 的按钮和标签之间只隔了一个信号,正好是最小的三层样例。
二、「值没变就不发」这一句
D39 的 setText 里有一句比较,这是标准做法。
| 写法 | 后果 |
|---|---|
| 不比较,每次都 emit | 无谓重绘,可能死循环 |
| 比较后再 emit | 只在真变化时通知 |
第二行能防死循环:如果两个控件互相绑定(A 变通知 B,B 变通知 A),不比较就会无限来回发。
Qt 自己的 setText 内部也做了这个比较,所以你写不写都行;但自定义 setter 一定要写。
三、多视图怎么同步
D39 里两个视图都连了同一个信号,所以都更新。
这就是 MVC 里「模型一变,所有视图都刷新」的最小实现:
| 角色 | 对应什么 |
|---|---|
| 模型 | 那个 Label(持有数据) |
| 视图 | 两个 lambda(只管显示) |
| 控制器 | Button(改数据) |
三个角色之间只有一条信号线,模型不知道有几个视图——这就是解耦。 监控客户端的多画面预览就是这么做的:一路信号,N 个窗口订阅。
四、什么时候不该用信号槽
它也有不适用的时候,别当成万能药。
| 场景 | 该用什么 |
|---|---|
| 同步要结果 | 直接函数调用 |
| 高频调用(每帧) | 直接调用,省掉派发开销 |
| 一对一的强依赖 | 直接调用,读代码更清楚 |
第二行值得解释:30 fps 每帧都走一遍队列,派发开销不大但会打乱时序, 而且调试时堆栈会变得很深。 所以「每帧」的东西通常直接调用,只有「状态变化」才用信号。
五、动手 Demo:按钮点三次,两个视图各更新三次
用 Signal 做一条 Chain:按钮 clicked → 标签 setText → 标签 changed → 两个视图。 中间那一层 setText 带「值变了才发」的判断,这是标准写法。
class Label(QObject):
changed = Signal()
def setText(self, s):
if s != self.text: # 值没变就不发
self.text = s
self.changed.emit(s)
lb.changed.connect(lambda s: views.append("视图1: " + s))
lb.changed.connect(lambda s: views.append("视图2: " + s))
bt.clicked.connect(lambda: lb.setText("已点 %d 次" % n))
bt.clicked.emit()
bt.clicked.emit()
bt.clicked.emit()
实跑输出:
--------------------------------------------------------------------
一个按钮一个标签,标准信号槽怎么搭
--------------------------------------------------------------------
**界面编程的九成代码都是「控件 A 变了,通知控件 B」。**这就是信号槽要解决的全部问题。
点三次按钮,两个视图各收到三次:
视图1: 已点 1 次
视图2: 已点 1 次
视图1: 已点 2 次
视图2: 已点 2 次
视图1: 已点 3 次
视图2: 已点 3 次
**注意「值没变就不发」这个判断**:`setText` 里先比较,相同就不 emit。
**少了这一句会有两个后果**:一是不必要的重绘,二是「A 改成 B、B 又改回 A」时会来回发,
如果两边都这样连,就成死循环。**Qt 的 setText 内部也做了同样的比较。**
**事件循环就是「不停地取消息、发信号」**:
层 做什么 卡住会怎样
--------------------------------------------------
事件队列 收系统消息 界面完全不响应
分发器 把消息转成信号 点了没反应
槽 处理信号 界面假死
**三行是同一件事的三个位置**,排查「界面卡住」就从这里往下切。
点三次,两个视图各收到三次,交替出现。顺序是「视图1 先、视图2 后」——按连接顺序。 这个顺序不是巧合:信号内部就是列表,从头遍历。想让某个视图先更新,就让它先连。
那句「值没变就不发」在三次点击里没起作用(值每次都变),但它防的是另一种情况: 同一句话被设两次。 那种情况下不比较就会多发一次 changed,如果下游有重绘或者写日志,就是无谓开销。
三层结构在这段代码里很清楚:bt.clicked.emit() 是消息,lambda 是分发,setText 是槽。
Qt 里一模一样,只是多了个事件循环在背后转。
30 秒实验
- 把
setText里的比较去掉,再点三次,看调用次数变不变——值每次都不一样,所以不变。 - 改成连续两次设同一个值,看有没有比较的差别。
- 把两个视图的连接顺序调换,看输出顺序变不变。
常见误区
- 以为一个信号只能连一个槽。 能连任意多个,按连接顺序依次调用。
- 以为 setText 里不用比较。 Qt 自带的确实做了比较,但你写的自定义 setter 没有, 会引发无谓重绘甚至死循环。
- 以为所有通信都该用信号槽。 每帧的高频调用、需要同步取结果的,直接调用更清楚也更快。
自测三问(点开对答案)
1. 事件循环的三层是什么?哪一层卡住会假死? —— 事件队列、分发器、槽。槽卡住会假死,队列卡住是系统层面不响应。 2. 为什么 setText 里要判断「值变没变」? —— 防止两个控件互相绑定时无限来回发信号,也避免无谓重绘。 3. 多个视图订阅同一个信号,模型需要知道有几个视图吗? —— 不需要。模型只管 emit,谁订阅它不知道——这就是解耦。下一节做自定义信号:内置的不够用时,自己的信号怎么声明、参数怎么带。
- ☐ (本节完,下一节 03.04.07 案例:自定义信号槽)