第 3 讲 | 信号与槽:PyQt 的灵魂
本讲目标:搞懂信号槽机制,掌握 connect 的三种写法和 lambda 传参的经典坑,写出一个能真算数的计算器。
配套代码:
cases/c03_signal_slot.py
一、一句话说清信号与槽
信号(Signal)= 某个控件身上"发生了什么事"
槽(Slot)= 一件"要去做的事"
把两者连起来就是信号槽:
按钮.clicked.connect(某个函数)意思是:当按钮被点击时,去执行这个函数。
PyQt 里几乎所有交互都长这样:
| 场景 | 信号 | 槽 |
|---|---|---|
| 点按钮 | btn.clicked | 打开文件 |
| 输入框内容变了 | edit.textChanged | 更新搜索提示 |
| 下拉框换选项 | combo.currentIndexChanged | 重新加载数据 |
| 表格选中行 | table.itemSelectionChanged | 回填表单 |
| 定时器到点 | timer.timeout | 刷新图表 |
为什么要有这套机制?因为它把"控件"和"业务"彻底解耦了。QPushButton根本不知道你点击它之后要干什么,它只负责在点击发生时喊一嗓子"我被人点了"。
谁爱听谁听。这样你换按钮、换逻辑,互不影响。
二、connect 的三种写法
写法 1:连到自定义方法(最常用)
classMyWindow(QWidget):def__init__(self):super().__init__()self.btn=QPushButton("保存",self)self.btn.clicked.connect(self.on_save)# ★ 注意:只写函数名,不带括号defon_save(self):print("保存被点击了")新手第一大坑就在这里:connect(self.on_save)不带括号。
self.btn.clicked.connect(self.on_save)# ✔ 把函数本身交给 connectself.btn.clicked.connect(self.on_save())# ✘ 立刻执行了 on_save,把返回值交给了 connect第二种写法在窗口创建时就执行一次on_save(),然后 connect 到None,点击按钮毫无反应(还会报错)。
记住:connect 的参数是一个函数对象,不是函数调用结果。
写法 2:连到内置槽(省事)
Qt 控件自带很多现成的槽,不用自己写:
self.btn_clear.clicked.connect(self.edit.clear)# 清空输入框self.btn_copy.clicked.connect(self.edit.copy)# 复制self.btn_quit.clicked.connect(self.close)# 关窗self.slider.valueChanged.connect(self.progress.setValue)# 滑块联动进度条滑块联动进度条这一行值得记下来,是这个写法最经典的用法:valueChanged发出一个 int,setValue正好接收一个 int,类型对上了,直接连。
写法 3:lambda 传参(一个槽处理多个控件)
假设有 10 个按钮,点击后都要执行同一个逻辑,只是参数不同:
fortextin["1","2","3","4","5"]:btn=QPushButton(text)btn.clicked.connect(lambda_,t=text:self.on_key(t))# ★ 关键写法clicked信号本身会带一个"是否被选中"的布尔值,所以 lambda 的第一个参数必须留着
(写_表示不用),从第二个参数开始才是你传的值。
而t=text这个默认参数是必须的,不能省。看错误版本错在哪:
# ✘ 错误示范:闭包陷阱fortextin["1","2","3","4","5"]:btn.clicked.connect(lambda:self.on_key(text))区别在于没有把text的值固定下来。lambda 里的text是个自由变量,
它引用的是循环变量本身,等按钮真被点击时循环早就结束了,text永远是最后一个值"5"。
结果就是点哪个按钮都执行on_key("5")。
用t=text把当前值"冻结"成默认参数,每次循环生成一个独立的闭包,问题就解决了。
这个坑语言层面叫闭包晚绑定,不只在 PyQt 里会遇到,但只要记住
“循环里连 lambda,一律写成lambda _, 参数名=变量: ...”就够了。
三、信号可以带参数
信号不只是"通知",还能携带数据。常见的有:
self.edit.textChanged.connect(lambdas:print(f"现在的内容是:{s}"))# 输出:现在的内容是:abcself.combo.currentIndexChanged.connect(lambdai:print(f"选中第{i}项"))self.checkbox.stateChanged.connect(lambdastate:print(f"勾选状态:{bool(state)}"))注意stateChanged传的是整数(0 未选 / 2 选中,Qt6 里也可能是 1)。
想要布尔值就自己转一下。如果只是想判断"有没有勾选",直接用self.checkbox.isChecked()更省事。
当你不知道某个控件会发什么信号、参数是什么,最快的办法是把所有信号都接到打印上,
点一遍看输出——第 4 讲的案例就是这么做的。
四、自定义信号(进阶但很常用)
有时需要在自定义类里往外发通知。比如你的"数据加载器"加载完了想告诉界面:
fromPyQt6.QtCoreimportpyqtSignalclassDataLoader(QObject):# 类属性里声明信号,参数类型写在括号里progress=pyqtSignal(int,str)# 百分比, 描述finished=pyqtSignal(object)# 结果对象failed=pyqtSignal(str)# 错误信息定义好之后就能像内置信号一样用:
self.loader.progress.connect(lambdapct,msg:self.progress_bar.setValue(pct))这三行自定义信号,本课程第 8 讲开始会反反复复出现——所有耗时任务都靠它把进度和结果
从子线程传回界面。现在先混个眼熟就行。
五、断开与临时阻塞
btn.clicked.disconnect(self.on_save)# 断开某个连接btn.clicked.disconnect()# 断开这个信号的所有连接edit.blockSignals(True)# 临时屏蔽这个控件的所有信号edit.setText("程序设置的初始值")# 这时候 textChanged 不会触发edit.blockSignals(False)# 记得打开blockSignals用得很多。典型场景:用代码填充输入框时不想触发校验逻辑。
忘了恢复(blockSignals(False))会造成"输入框好像死了",排查起来很费时间。
六、完整案例:一个能真算数的计算器
结构上就三件事:
① 用 QGridLayout 排九宫格
KEYS=[["7","8","9","/"],["4","5","6","*"],["1","2","3","-"],["0",".","=","+"],]grid=QGridLayout()grid.setSpacing(8)forr,rowinenumerate(KEYS):forc,textinenumerate(row):btn=QPushButton(text)btn.setMinimumSize(60,52)btn.setStyleSheet(self._btn_style(text))btn.clicked.connect(lambda_,t=text:self.on_key(t))# ★ 本讲重点grid.addWidget(btn,r,c)② 所有按钮汇到同一个入口方法
不要让每个按钮连一个独立的函数,那样 16 个按钮就要 16 个函数。统一入口:
defon_key(self,text:str)->None:iftext=="=":self._evaluate()returniftextin"+-*/":ifself._exprandself._expr[-1]in"+-*/":self._expr=self._expr[:-1]# 连按两个运算符,替换掉前一个self._expr+=textelse:self._expr+=text self.display.setText(self._expror"0")self.hint.setText(f"提示:表达式{self._expror'(空)'}")③ 用一个 QLabel 显示内部状态
这是本案例一个刻意的设计:界面上除了显示屏,还有一行灰色的"提示"文字,
实时显示当前累积的表达式。调试信号槽逻辑时,一个能看到内部状态的小标签,
比 print 到控制台有用十倍——因为它是跟着界面刷新的。
运行起来就是这样:上面是显示屏,中间是状态提示,下面是按键区。
(示例代码里用eval()算结果,只为教学演示;真实项目要做表达式解析,绝不能用 eval 处理用户输入。)
七、四个坑
坑 1:connect后面加了括号(前面说过,最高频,必须记住)
坑 2:同一个信号连了两次同一个函数
结果是点一次按钮执行两次。常见于初始化代码被调用了两次(比如你手动调了一次、
又在某个事件里调了一次)。用disconnect()清一下即可。
坑 3:在槽函数里做耗时操作,界面卡死
按钮点下去,槽函数里跑一个 30 秒的循环,整个界面就不响应了——鼠标转圈、窗口拖不动。
这是主线程被占住了,第 8 讲会专门讲怎么解决(把活儿丢给子线程)。
坑 4:循环变量没有冻结(上面那个闭包陷阱)
八、完整代码
# -*- coding: utf-8 -*-"""第 3 讲 案例 03:信号与槽 —— 一个能算的简易计算器 知识点: - signal(信号)与 slot(槽)的机制:谁变了、通知谁、干什么 - button.clicked.connect(...) 的三种连接写法 - 用 lambda 给多个按钮传不同参数(最常用的技巧) - QGridLayout 做九宫格 - 用单一入口方法统一处理所有按键 """importsysfrompathlibimportPath sys.path.insert(0,str(Path(__file__).resolve().parent.parent))fromPyQt6.QtCoreimportQt# noqa: E402fromPyQt6.QtWidgetsimport(QGridLayout,QLabel,QLineEdit,# noqa: E402QPushButton,QVBoxLayout,QWidget)fromutils.envimportrun# noqa: E402classCalculator(QWidget):"""四则运算计算器:演示信号与槽最核心的用法"""# 按键布局:4 行 4 列KEYS=[["7","8","9","/"],["4","5","6","*"],["1","2","3","-"],["0",".","=","+"],]def__init__(self)->None:super().__init__()self.setWindowTitle("简易计算器")self.resize(300,380)# ---- 显示屏:用只读 QLineEdit,用户可以选中复制 ----self.display=QLineEdit("0")self.display.setReadOnly(True)self.display.setAlignment(Qt.AlignmentFlag.AlignRight)self.display.setStyleSheet("QLineEdit { font-size:22px; padding:8px; background:#f5f6f8;"" border:1px solid #dcdfe6; border-radius:4px; }")# ---- 提示条:显示当前内部状态,方便调试 ----self.hint=QLabel("提示:0")self.hint.setStyleSheet("color:#8a8f99; font-size:12px;")# ---- 按钮区 ----grid=QGridLayout()grid.setSpacing(8)forr,rowinenumerate(self.KEYS):forc,textinenumerate(row):btn=QPushButton(text)btn.setMinimumSize(60,52)btn.setStyleSheet(self._btn_style(text))# ★ 核心:把按键字符通过 lambda 传给同一个处理方法# 直接写 self.on_key(text) 会在窗口创建时就执行,所以必须包一层 lambdabtn.clicked.connect(lambda_,t=text:self.on_key(t))grid.addWidget(btn,r,c)# ---- 清空键单独一行 ----clear_btn=QPushButton("清空 (C)")clear_btn.setMinimumHeight(40)clear_btn.setStyleSheet(self._btn_style("C"))clear_btn.clicked.connect(self.on_clear)root=QVBoxLayout(self)root.setContentsMargins(14,14,14,14)root.setSpacing(10)root.addWidget(self.display)root.addWidget(self.hint)root.addLayout(grid)root.addWidget(clear_btn)# 内部运算状态self._expr=""# 累积的表达式self._last=""# 上一个操作数# ---------- 样式:运算符和等号配色不同 ----------@staticmethoddef_btn_style(text:str)->str:base=("QPushButton { border:1px solid #dcdfe6; border-radius:4px;"" font-size:16px; background:white; }""QPushButton:hover { background:#eef2f8; }")iftextin"+-*/":returnbase.replace("background:white","background:#eaf1ff")iftext=="=":return("QPushButton { background:#2d7ff9; color:white; border:none;"" border-radius:4px; font-size:16px; }""QPushButton:hover { background:#1c6be0; }")iftext=="C":return("QPushButton { background:#fff1f0; color:#d4380d;"" border:1px solid #ffccc7; border-radius:4px; font-size:14px; }")returnbase# ---------- 统一的按键入口(所有按钮都汇到这里) ----------defon_key(self,text:str)->None:iftext=="=":self._evaluate()returniftextin"+-*/":# 已经有运算符,先算掉前一步ifself._exprandself._expr[-1]in"+-*/":self._expr=self._expr[:-1]self._expr+=textelse:self._expr+=text self.display.setText(self._expror"0")self.hint.setText(f"提示:表达式{self._expror'(空)'}")defon_clear(self)->None:self._expr=""self.display.setText("0")self.hint.setText("提示:已清空")def_evaluate(self)->None:"""注意:这里用 eval 只是教学演示,真实项目要做表达式解析"""try:result=eval(self._expr)# noqa: S307result=round(result,6)self.hint.setText(f"提示:{self._expr}={result}")self.display.setText(str(result))self._expr=str(result)# 支持连续计算exceptExceptionasexc:# noqa: BLE001self.hint.setText(f"提示:表达式错误({type(exc).__name__})")self._expr=""if__name__=="__main__":sys.exit(run(Calculator,shot="c03_calculator",width=300,height=380))九、小结
- 信号槽 = 事件通知机制,语法是
信号.connect(槽) - 三种写法:自定义方法、内置槽、lambda 传参
- 两个必须记住的细节:connect 不写括号;循环里连 lambda 要
t=var冻结变量 - 自定义信号
pyqtSignal是后面多线程章节的基础
到这里,界面能排整齐(第 2 讲),按钮也能响应(第 3 讲)了。
下一讲:把 PyQt 里最常用的 12 个控件一次过一遍——每个控件我都会接上信号监听,
让你亲眼看到"这个控件到底什么时候发信号、发什么参数",这是学控件最快的方法。
本讲是《PyQt6 客户端编程从入门到实战》专栏的第 3 讲。专栏共 22 讲、22 个可运行案例。