PySide6与PyQt6入门
选型与环境
PySide6 和 PyQt6 不是两个 GUI 库,而是同一个 Qt 6 的两套 Python 绑定:控件类、信号槽、事件循环、样式表全都来自同一份 C++ Qt,只是包装层(shiboken6 与 sip)不同。所以本篇的代码在两个库里几乎可以互换,差异集中在枚举写法、信号定义名、版本常量这三处,实测数据见「两个绑定到底差在哪」一节。
先说许可,这是选型的第一道门槛
| 绑定 | 许可证 | 商用闭源能不能用 | 维护方 |
|---|---|---|---|
| PySide6 | LGPLv3(Qt 本体也是 LGPL) | 能,动态链接即可,不要求开源 | Qt Company(官方) |
| PyQt6 | GPL v3 或商业授权二选一 | 要么开源,要么向 Riverbank 买授权 | Riverbank Computing(第三方) |
- 结论很直接:公司项目要闭源发布就选 PySide6,这是它相对 PyQt6 最实际的优势;个人开源项目两者都行。
- Qt 本体的 LGPL 条款要求「用户能替换 Qt 库」,所以 PyInstaller 打包时别用
--onefile把 Qt 的 DLL 塞进单个 exe(严格讲不满足可替换),要发就发--onedir。这条在「打包」一节有实测数据。
安装
1 | pip install PySide6 |
1 | PySide6 6.12.0 依赖 shiboken6-6.12.0、PySide6_Essentials、PySide6_Addons、PySide6_WebEngine、PySide6_Pdf |
-
两个包的版本号都跟着 Qt 走(6.12.0 就是 Qt 6.12),装完
import的名字分别是PySide6和PyQt6。 -
PySide6 装完是 650 MB,PyQt6 是 214 MB(实测
site-packages目录体积)。差距不是 Qt 本体大小不同,而是 PySide6 默认把 Addons 全家桶一起拖进来了,按发行包拆开实测:1
2
3
4
5
6
7
8PySide6(元包) 4.8 MB / 76 个文件
PySide6_Essentials 211.5 MB / 2546 个文件 # QtWidgets/QtGui/QtCore 在这里
PySide6_Addons 100.2 MB / 1254 个文件
PySide6_WebEngine 332.6 MB / 135 个文件 # 最大的一块,Chromium
PySide6_Pdf 5.6 MB / 27 个文件
shiboken6 3.0 MB / 77 个文件
PyQt6 25.6 MB / 932 个文件
PyQt6-Qt6 189.6 MB / 2658 个文件- 也就是说光 WebEngine 就占了一半,不用内嵌浏览器的话可以只装
PySide6-Essentials(211 MB,和 PyQt6 一个量级)。
- 也就是说光 WebEngine 就占了一半,不用内嵌浏览器的话可以只装
-
装完自带一堆命令行工具(
pyside6-designer.exe、pyside6-uic.exe、pyside6-lupdate.exe等 20 个),实测装在 venv 的Scripts目录里:1
2
3
4pyside6-assistant / pyside6-balsam / pyside6-deploy / pyside6-designer / pyside6-genpyi
pyside6-linguist / pyside6-lrelease / pyside6-lupdate / pyside6-metaobjectdump / pyside6-project
pyside6-qml / pyside6-qmlformat / pyside6-qmllint / pyside6-qmltyperegistrar / pyside6-qsb
pyside6-qtpy2cpp / pyside6-rcc / pyside6-svgtoqml / pyside6-uic
Python 版本:3.14 上装得上、跑不起来
这是本篇最重要的一条实测结论,别按「最新版 Python」直接上。
1 | 在 Python 3.14.10(Anaconda base)里: |
-
报错原文是「找不到指定的程序」(
ERROR_PROCEDURE_NOT_FOUND),意思是 DLL 找到了、但里面缺它要的导出函数,属于版本对不上,不是「没装」也不是「路径没找到」。 -
我按最常见的三种猜测逐一排除过,实测都无效:
1
2
31) 把 PATH 里所有 conda 目录剔掉,起新进程再 import -> 仍然失败
2) 抢先 ctypes.WinDLL(r"C:\Windows\System32\vcruntime140.dll") 再 import -> 仍然失败
3) 把另一个 conda 环境目录塞到 PATH 最前面(制造更脏的环境) -> 仍然失败- 顺带量到一件事:进程一启动就加载了
D:\ProgramData\anaconda3\envs\shop\vcruntime140.dll(别的 conda 环境里的旧版运行库),但把它换成系统那份也不能救,所以这不是根因。
- 顺带量到一件事:进程一启动就加载了
-
结论与做法:Qt 绑定请用 Python 3.12/3.13。本篇所有 Qt 实测都是在
E:\Desktop\Test\kivy-env(conda 建的 Python 3.12.16)里跑的。 -
另外提醒一句:Qt for Python 的 wheel 是
cp310-abi3(稳定 ABI),pip 会认为它在任何 ≥3.10 的版本上都「兼容」而照装不误,所以装包成功不代表能用,一定要跑一次import才算数。
版本与运行环境怎么读
1 | from PySide6 import QtCore, QtWidgets |
1 | 6.12.0 |
-
PySide6 没有
QtCore.QT_VERSION_STR(实测hasattr为False),要 Qt 版本只能用QtCore.qVersion();PyQt6 两个都有:1
PyQt6: PYQT_VERSION_STR = 6.12.0, QT_VERSION_STR = 6.12.0, QT_VERSION = 396288, qVersion() = 6.12.0
-
platformName()回windows说明用的是平台插件(原生外观);风格列表实测是['windows11', 'windowsvista', 'Windows', 'Fusion'],windows11是 Qt 6.5+ 为 Win11 圆角/新滚动条加的风格。 -
devicePixelRatio()回 1.25 就是系统 125% 缩放,Qt 会自己按这个比例画,不需要像 Tkinter 那样手动换算坐标。
第一个窗口
最小可跑的一段
1 | import sys |
-
四步和 Tkinter 一样对得上:建 QApplication → 建控件(指定父)→
show()→ 进事件循环。差别在于 Qt 必须有且只有一个QApplication,它管着剪贴板、字体、拖放这些全局设施。 -
实测数据(
E:\Desktop\Test\kivy-env,Python 3.12.16):1
2
3
4QApplication 建好耗时 = 0.077 秒
show 之后 isVisible = True size = 420 x 300
平台插件 = windows 风格 = windows11
屏幕 = 2048 x 1152 DPR = 1.25
事件循环:exec、exec_ 和 processEvents
1 | app = QtWidgets.QApplication(sys.argv) |
-
exec()会一直阻塞,这就是为什么「在exec()后面 print」永远看不到输出——新手最常见的「代码不执行」都是这个原因。 -
实测两个绑定的方法名不一样:
1
2PySide6: hasattr(QApplication, "exec") = True, hasattr(QApplication, "exec_") = True
PyQt6: hasattr(QApplication, "exec") = True, hasattr(QApplication, "exec_") = False- 老教程里大量写
app.exec_()(当年exec是 Python 关键字),在 PyQt6 上会AttributeError。统一写app.exec(),两边都能跑。
- 老教程里大量写
-
不阻塞的两种场景要用
processEvents():一边跑循环一边刷新界面(比如自动化脚本里自己点按钮),或者在槽函数里做长任务时手动让界面重绘。本篇所有实测都是show()+processEvents()+ 定时器退出,这样能无人值守地把功能跑完:1
2QtCore.QTimer.singleShot(600, app.quit) # 0.6 秒后自己退出事件循环
app.exec()
窗口相关的常用调用
1 | win.setWindowTitle("标题") |
1 | 实测:resize(640,480) 之后 width()/height() = 640 x 480,isVisible() = True |
-
resize()设的是客户区,不含标题栏和边框,所以x()/y()与屏幕坐标会有边框宽度的偏差(Tkinter 那篇里winfo_rootx也一样)。 -
想「窗口居中」别自己算,用屏幕几何:
1
2screen = app.primaryScreen().geometry()
win.move((screen.width() - win.width()) // 2, (screen.height() - win.height()) // 2)
两个绑定到底差在哪
这一节全部是逐个试出来的结果,同一台机器、同一个 Qt 6.12.0,分别用 PySide6 6.12.0 与 PyQt6 6.12.0 跑。
枚举:PyQt6 只认作用域写法
Qt 6 的枚举在 C++ 里是强类型(enum class),两套绑定都支持「带类名」的作用域写法,但只有 PySide6 还保留了旧的简写:
| 写法 | PySide6 6.12 | PyQt6 6.12 |
|---|---|---|
Qt.AlignmentFlag.AlignLeft |
通过 | 通过 |
Qt.AlignLeft |
通过 | AttributeError |
Qt.Key.Key_N |
通过 | 通过 |
Qt.Key_N |
通过 | AttributeError |
Qt.KeyboardModifier.ControlModifier |
通过 | 通过 |
Qt.ControlModifier |
通过 | AttributeError |
Qt.ItemDataRole.DisplayRole |
通过 | 通过 |
Qt.DisplayRole |
通过 | AttributeError |
Qt.CheckState.Checked |
通过 | 通过 |
Qt.Checked |
通过 | AttributeError |
QMessageBox.StandardButton.Ok |
通过 | 通过 |
QMessageBox.Ok |
通过 | AttributeError |
QDialogButtonBox.ButtonRole.ActionRole |
通过 | 通过 |
QDialogButtonBox.ActionRole |
通过 | AttributeError |
-
所以规则很简单:想两边都能跑,就一律写全
类型.成员;Qt.AlignLeft这种老写法只在 PySide6 里侥幸可用,而且 PySide6 也在逐步弃用它。 -
快捷键字符串两边都支持,写起来最省事,也最不容易出错:
1
act = QtGui.QAction("保存", self, shortcut="Ctrl+Shift+S") # 不用碰枚举
-
枚举的数值转换有个坑:
int()两边都不行,要用.value:1
2
3
4
5
6combo = Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier
combo -> KeyboardModifier.ShiftModifier|ControlModifier
type(combo) -> KeyboardModifier
combo.value -> 100663296
int(combo) -> TypeError: int() argument must be ... not 'KeyboardModifier'
Qt.Key.Key_N + 1 -> 79 (单个 Key 枚举能做整数运算,组合出来的 flags 不行)
信号定义名与版本常量
1 | PySide6: from PySide6.QtCore import Signal, Slot (没有 pyqtSignal) |
-
两边写自定义信号的语法形状一样,只是名字不同:
1
2
3
4
5
6class Panel(QtCore.QObject):
valueChanged = QtCore.Signal(int, str) # PyQt6 里改成 pyqtSignal(int, str)
# PyQt6 里改成 @pyqtSlot(int, str)
def on_value(self, n, s):
print(n, s) -
版本常量差异(实测):
1
2PySide6: QtCore.__version__ = 6.12.0, qVersion() = 6.12.0, 有 QT_VERSION_STR 吗 = False
PyQt6: PYQT_VERSION_STR = 6.12.0, QT_VERSION_STR = 6.12.0, QT_VERSION = 396288, qVersion() = 6.12.0
QAction 在 QtGui,不在 QtWidgets
这是 Qt 5 迁移到 Qt 6 时最容易踩的一刀,两套绑定都一样:
1 | hasattr(QtGui, "QAction") = True |
-
老代码里的
QtWidgets.QAction会直接AttributeError,改成QtGui.QAction即可。 -
顺带两个「这个方法在 6.12 里其实没有」的实测:
1
2
3
4
5
6QStatusBar.permanentWidgets() -> AttributeError(只能自己遍历 children())
QDialogButtonBox.ButtonRole.CloseRole -> 没有这个成员
可用 Role = AcceptRole, ActionRole, ApplyRole, DestructiveRole, HelpRole,
InvalidRole, NoRole, RejectRole, ResetRole, YesRole
想放「关闭」按钮要用标准按钮:btns.addButton(QDialogButtonBox.StandardButton.Close)
QObject.receivers(信号对象) -> TypeError,签名要求字符串:p.receivers("valueChanged(int,QString)")
同一份代码改几行就能换绑定
我把「记事本」小工具的 PySide6 版机械地替换成 PyQt6 版,两份都是 230 行,只有 8 处不同(16 行 diff):
1 | -from PySide6 import QtCore, QtGui, QtWidgets |
-
除了 import 名字、程序标题和「取自身版本号」的常量名,控件、布局、信号槽、样式表、对话框一行都不用动。
-
两边跑出来的自检结果完全一致(同一台机器,同一份逻辑):
1
2PySide6: QApplication 耗时 0.076 秒,总耗时 0.44 秒,保存/撤销/替换/自动存盘全部 True
PyQt6: QApplication 耗时 0.086 秒,总耗时 0.39 秒,同样全部通过 -
所以选型判断不该建立在「API 好不好搬」上——搬起来几乎零成本;要权衡的是许可证、包体积(650 MB vs 214 MB)和工具链(PySide6 自带 Qt Designer 等 20 个命令行工具,PyQt6 只有
pyuic6/pylupdate6两个)。
完整小工具:记事本
- 需求固定成六件事,三个框架各写一遍(另两篇见《Kivy入门》与 Tkinter 篇):新建 / 打开 / 保存 / 另存为、查找替换、撤销重做、状态栏字数、脏标记、关闭时自动存盘。
- 完整源码在
gui_note_pyside.py(230 行),下面按功能块贴。所有实测都用「程序自己点自己」的方式跑:action.trigger()触发菜单、dialog的按钮直接调方法,不需要人操作。
主窗口与菜单
1 | import sys |
-
结构是 Qt 主窗口的标准四件套:中心控件(
setCentralWidget)+ 菜单栏 + 工具栏 + 状态栏,QMainWindow帮你排好了,不用自己算位置。 -
实测出来的结构数字:
1
2
3
4窗口尺寸 = 640 x 480 可见 = True
菜单数 = 2 文件菜单项数 = 7(含 2 条分隔线)
工具栏按钮数 = 2
状态栏里的 QLabel 数 = 1,permanentWidgets() 存在吗 = False
把信号接到功能上
1 | self.act_new.triggered.connect(self.new_file) |
-
textChanged是QTextEdit的信号,不需要参数也能接(它其实带一个text参数,槽签名少写就行,实测可行)。 -
实测的两个「脏标记」脾气,跟直觉不一样:
1
2setPlainText("四行诗") 之后:isModified = False,但标题已经是 '*PySide6 记事本'
save_file() 之后:isModified = FalseQTextEdit.setPlainText()会重置文档的修改标志(Qt 认为这是「装载内容」而不是「用户编辑」),所以用setPlainText灌进去的内容不会触发「未保存」提示。- 而标题里的
*来自更早一次textChanged时的判断,两者不是同一时刻读的值——别拿isModified()当「文本变过」用,要「变过」就用textChanged自己记标志位。
打开与保存
1 | def open_file(self, checked=False, fname=None): |
-
QFileDialog.getOpenFileName返回的是元组(路径, 选中的过滤器),所以取路径要[0];用户按取消时是空串,必须先判空。 -
这两个函数都留了
fname=参数,是为了让自检能绕过对话框直接指定路径——对话框是模态的,实测会阻塞代码:1
2QMessageBox.information(...) 返回 = 1024(StandardButton.Ok) 阻塞了 0.32 秒
(用 QTimer.singleShot(300, ...) 去关模态窗口,代码才能继续)
撤销与重做:一个真实的坑
1 | before = w.editor.toPlainText() |
1 | 光标插入后首行 = '第一行被改写' |
- 关键结论:
setPlainText()会清空撤销/重做栈,insertText()之类的「编辑操作」才会留下历史。所以「整段替换文本再指望用户 Ctrl+Z」是无效的,实测isUndoAvailable()直接变False。 - 想自己控制历史,就
document().clearUndoRedoStacks()(打开文件时清一次,避免撤销到上一个文件的内容),或者自己存历史栈(Kivy 那篇就是这么做的,因为TextInput根本没有撤销)。
查找替换对话框
1 | class FindDialog(QtWidgets.QDialog): |
1 | 查找命中 = True 全部替换处数 = 2 |
-
QTextEdit.find()是 Qt 自带的查找,会移动光标并选中,返回布尔。实测在「明月」出现 3 次的文本上连续调用:1
2find 第一次 = True 第二次 = True 第三次 = True 第四次 = False
结束后光标位置 = 8- 也就是说它不会自动绕回头部(找不到就 False 结束),做「查找下一个」时要在返回 False 后把光标移回开头再找一遍。
-
批量替换用
setPlainText最直观,代价就是前面说的:撤销栈被清空。想保留撤销能力就改成find()+textCursor().insertText()循环。
关闭时自动存盘
1 | def closeEvent(self, e): |
1 | autosave.txt 存在 = True 内容 = '关闭前又改了' |
- 重写
closeEvent是拦截关闭的唯一正确位置(点标题栏叉、Alt+F4、close()都会走它),记得最后调super().closeEvent(e),否则窗口不会真的关。 - 真实的记事本在这里应该弹「是否保存」,本篇为了不阻塞自检直接落盘了。
自检跑完的总账
1 | === PySide6 记事本自检 === |
- PyQt6 版跑同一份逻辑的输出只有标题不同,总耗时 0.39 秒,逐项结果一致。
布局、信号槽与多线程
三种布局管理器
Qt 的布局是「交给 layout 对象,尺寸由它算」,和 Tkinter 的 pack/grid/place 思路一致,但没有绝对布局(move()/setGeometry() 就是绝对定位,实测可用但不推荐)。
1 | w = QtWidgets.QWidget() |
1 | 窗口 = 600 x 120 |
-
stretch=1:2:0的分配结果实测是 186/371/9:前两个按 1:2 分掉剩余空间,第三个只拿到自己的最小宽度 9(QLabel没内容时的空壳宽度)。 -
默认边距 11 像素、间距 6 像素是 Qt 的风格常量,不是 0,很多「为什么四周留了这么多白」的问题就是这个。
-
网格布局能跨行跨列,实测坐标:
1
2
3
4
5
6
7按钮0 x=11 y=11 149x26
按钮1 x=166 y=11 303x26 # addWidget(w, 0, 1, 1, 2) 跨两列
按钮2 x=11 y=43 149x26
按钮3 x=166 y=43 148x26
按钮4 x=320 y=43 149x26
按钮5 x=11 y=169 149x26 # addWidget(w, 2, 0, 3, 1) 跨三行
设了 setColumnStretch(1, 3) 并放大窗口后:跨列的按钮1 宽 = 491 -
表单用
QFormLayout,addRow("姓名:", QLineEdit())一行一条,addRow(QCheckBox(...))单控件会横跨两列,实测rowCount() = 4、verticalSpacing() = 6。 -
控件的伸缩由
sizePolicy决定,实测按钮默认是horizontalPolicy = Minimum, verticalPolicy = Fixed:1
btn.setSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Fixed)
信号槽的四种接法
1 | class Panel(QtCore.QObject): |
1 | 自定义信号 emit 后 = [(7, '七'), ('lambda', 7, '七')] |
disconnect只能断「连接时用的那个对象」,disconnect()不带参数则全断;lambda 因为没被保存引用,断不掉——需要能断开的连接就别用 lambda。- 内置信号带默认参数,槽的签名可以比信号少(实测
clicked带checked布尔,接一个无参def f():照样能连)。 - 统计连接数要用字符串签名,实测
p.receivers(p.valueChanged)直接TypeError,要写p.receivers("valueChanged(int,QString)")。
多线程:槽在哪个线程执行
Qt 的规矩是「槽在接收者所在线程执行」,跨线程的信号会自动排队(AutoConnection → QueuedConnection)。实测:
1 | class Worker(QtCore.QObject): |
1 | 槽执行线程 = 57984 与主线程不同 = True |
-
第二行是重点:
worker.done连的是 lambda,结果这个「回调」也跑在工作线程里(False表示不是主线程)。因为 lambda 没有绑定到主线程的 QObject,Qt 就把执行线程当成发射者所在线程。 -
想稳稳回到主线程,就连到主线程 QObject 的方法上:
1
worker.done.connect(main_window.on_done) # main_window 是主线程创建的对象
-
在工作线程里直接
QtWidgets.QLabel(...)建控件,实测不报错:1
子线程里建 QLabel -> 没抛异常(但官方文档明确说只能主线程操作 UI)
- 这一点和 Kivy 形成鲜明对比:Kivy 会直接
TypeError: Cannot create graphics instruction outside the main Kivy thread,Qt 则是「不拦你,但可能随机崩」。所以 Qt 里要自己守规矩:工作线程只算数据,界面更新一律走信号。
- 这一点和 Kivy 形成鲜明对比:Kivy 会直接
-
长任务不想卡界面的最小写法:
1
2
3
4
5
6
7
8
9class Task(QtCore.QThread):
progress = QtCore.Signal(int)
finished_ok = QtCore.Signal(str)
def run(self):
for i in range(100):
QtCore.QThread.msleep(10)
self.progress.emit(i)
self.finished_ok.emit("好了")
QSS:用类 CSS 的语法换外观
1 | box = QtWidgets.QFrame() |
1 | styleSheet 回读 = #card{background:#223;border:2px solid #f60;border-radius:8p ... |
- 选择器支持类名(
QPushButton)、#objectName、.className、后代与:hover/:pressed/:disabled伪状态,实测#card和QLabel两条规则分别命中。 font-size:18px落到 Qt 上就是pixelSize=18、pointSize=-1(-1 表示「没按磅数设」)。- QSS 不等于 CSS:没有布局属性(
margin之外基本无效,display、float、position都没有),布局仍然只能靠 layout。
字体与 DPI 的两个实测坑
1 | QFont("Microsoft YaHei", 12) -> pointSize=12 pixelSize=-1 控件 92x22 |
-
构造函数第二个参数永远是磅数,Qt C++ 里「负数表示像素」的约定在这里不生效(实测传 -12 被当成 9 磅,等价于默认磅数)。要像素字号必须显式:
1
2f = QtGui.QFont("Microsoft YaHei")
f.setPixelSize(18) # 而不是 QFont("...", -18) -
高分屏不需要手动乘缩放系数,
devicePixelRatio()实测 1.25,Qt 自己按 DPR 渲染;但自己画坐标(QPainter、move())时用的仍是逻辑像素,别混。
表格:QTableWidget 与 Model/View
1 | tw = QtWidgets.QTableWidget(3, 4) |
1 | row/col = 3 4 第0行第0列 = 陆彦旭 |
-
QTableWidget上手快但每格都要造QTableWidgetItem,数据量大就换 Model/View:1
2
3
4
5m = QtGui.QStandardItemModel(2, 3)
m.setHorizontalHeaderLabels(["a", "b", "c"])
m.setItem(0, 0, QtGui.QStandardItem("x"))
tv = QtWidgets.QTableView()
tv.setModel(m)1
2model 行列 = 2 3 (0,0) = x
view 的 model 是同一个吗 = True 选中行为 = SelectionBehavior.SelectItems -
和 Tkinter 的
ttk.Treeview对比:Qt 这边「表格」和「树」是两套控件(QTableView/QTreeView),共同点是都吃 model;Tkinter 用一个Treeview同时干两件事。
打包发布
- 用 PyInstaller 6.22.3 实测,同一个记事本分别打成
--onefile(单文件)和--onedir(目录),并且直接运行产物跑自检,确认打包出来的 exe 真的能用。
命令
1 | # 单文件 |
实测结果
1 | onefile 构建 44.3 秒 产物 45.6 MB |
-
--windowed只影响有没有控制台窗口,体积基本不变(45.6 MB vs 45.6 MB),别指望它减小包。 -
onedir 的目录反而比 onefile 大(115.4 MB vs 45.6 MB),因为 onefile 里的 DLL 是压缩存放的,运行时要先解压。
-
代价在启动速度上。四个产物我都直接跑了一遍 selftest(退出码都是 0,说明打包出来的程序真的能跑):
1
2PySide6 onefile 第1次 1.9 秒 第2次 1.9 秒 (程序内部自检耗时 0.43 / 0.39 秒)
PySide6 onedir 第1次 0.7 秒 第2次 0.6 秒 (内部 0.40 / 0.34 秒)- 也就是说 onefile 的启动成本差不多是 onedir 的 3 倍,而且第二次跑并不变快——每次都要把 45 MB 解到
%TEMP%\_MEIxxxxxx再删。 - 程序自己统计的耗时只有 0.4 秒左右,剩下的 1.2~1.5 秒全是解压开销。
- 这也是 LGPL 合规的理由之一:Qt 官方 FAQ 与社区通行做法是发 onedir,让用户能替换 Qt 的 DLL;onefile 把 DLL 封进单一 exe,严格讲不满足「可替换」。
- 也就是说 onefile 的启动成本差不多是 onedir 的 3 倍,而且第二次跑并不变快——每次都要把 45 MB 解到
想再小一点
1 | pip install PySide6-Essentials # 不要 Addons/WebEngine,实测省 430 MB |
- 体积大头是
PySide6_WebEngine(332.6 MB),不用内嵌浏览器就别装它;打包时用--exclude-module也能把误装进来的大模块踢掉。 --lto/UPX 这类压缩手段本篇没测,不评价。
打包后常见的「双击没反应」
- 十有八九是控制台被
--windowed关掉后异常看不见。先用 console 模式打一份,双击看报错,或者从命令行跑 exe。 - 另一个高频原因是打包用的环境和 import 成功的环境不是同一个:本篇的教训就是 3.14 里
import直接失败,如果在那个环境打包,PyInstaller 会因为找不到QtWidgets而打出一个缺 DLL 的包。
常见报错与调试
- 下面每条都是本篇实测里真的撞到的,不是抄文档。
ImportError: DLL load failed while importing QtWidgets: 找不到指定的程序
1 | 出现环境:Python 3.14(Anaconda base)+ PySide6 6.12.0 / PyQt6 6.12.0 |
- 这条的含义是「DLL 找到了但缺导出函数」,所以别去查路径,先查 Python 版本:换 3.12/3.13 建环境。
- 如果换版本仍不行,再按顺序查:VC 运行库(装最新版
vc_redist.x64.exe)、是否混装了两个不同小版本的 Qt(pip list | findstr -i "pyside pyqt qt")、杀毒软件拦了 DLL。 - 千万别 PySide6 和 PyQt6 装在同一个环境里:它们的 Qt DLL 会互相覆盖,症状就是这种随机
DLL load failed。
AttributeError: type object ‘Qt’ has no attribute ‘AlignLeft’
1 | PyQt6 专属:只认 Qt.AlignmentFlag.AlignLeft |
- 修法就是写全枚举类名;写全之后两边都兼容。
AttributeError: ‘QApplication’ object has no attribute ‘exec_’
1 | PyQt6 6.12: hasattr(QApplication, "exec_") = False |
- 老代码里的
app.exec_()在 PyQt6 上会炸,统一改app.exec()。
AttributeError: module ‘PySide6.QtWidgets’ has no attribute ‘QAction’
1 | hasattr(QtGui, "QAction") = True, hasattr(QtWidgets, "QAction") = False |
- Qt 6 把
QAction挪到了 QtGui,改 import 即可。
界面卡死、点了没反应
- 九成是在槽函数里做了耗时操作。Qt 的槽运行在主线程,里面
time.sleep(10)就等于冻结界面 10 秒。 - 正确做法是
QThread+ 信号回主线程(见「多线程」一节实测),或者用QTimer把任务切片。 - 调试时可以用
app.processEvents()手动泵事件,但不要在循环里长期调用它,那只是权宜之计。
对话框把脚本卡住
1 | QMessageBox.information(...) 返回 = 1024 阻塞了 0.32 秒 |
-
QMessageBox、QFileDialog都是模态阻塞的。自动化测试里要么给它们传死路径绕开(本篇的做法),要么用QTimer.singleShot(ms, ...)定时关掉模态窗口:1
QtCore.QTimer.singleShot(300, lambda: [wgt.close() for wgt in app.topLevelWidgets() if wgt.isModal()])
控件尺寸量出来是 0 或 100
- 和 Tkinter 一样,
width()/height()在没进事件循环、没show()之前是布局算完之前的值。实测:show()+app.processEvents()之后才拿到 186/371/9 这些真实宽度。 - 只想算尺寸不想弹窗,用
layout.sizeHint()/minimumSize(),实测它们不需要窗口显示就有值(60 x 38)。
程序退出后进程还在
- 常见于
QThread没停干净。收尾顺序是:requestInterruption()→quit()→wait(),实测wait(1500)之后isRunning()才变False。 - 另一个来源是
QWebEngineView(它有自己的子进程),退出前要先销毁。