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
2
pip install PySide6
pip install PyQt6
1
2
PySide6 6.12.0  依赖 shiboken6-6.12.0、PySide6_Essentials、PySide6_Addons、PySide6_WebEngine、PySide6_Pdf
PyQt6 6.12.0 依赖 PyQt6-sip-13.13.0、PyQt6-Qt6-6.12.0
  • 两个包的版本号都跟着 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
    8
    PySide6(元包)        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 一个量级)。
  • 装完自带一堆命令行工具(pyside6-designer.exe、pyside6-uic.exe、pyside6-lupdate.exe 等 20 个),实测装在 venv 的 Scripts 目录里:

    1
    2
    3
    4
    pyside6-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
2
3
4
5
6
7
8
9
10
在 Python 3.14.10(Anaconda base)里:
pip install PySide6 -> Successfully installed PySide6-6.12.0 ...(装得上)
import PySide6.QtWidgets
-> ImportError: DLL load failed while importing QtWidgets: 找不到指定的程序。

pip install PyQt6 -> Successfully installed PyQt6-6.12.0 PyQt6-Qt6-6.12.0 PyQt6-sip-13.13.0
from PyQt6 import QtWidgets
-> ImportError: DLL load failed while importing QtCore: 找不到指定的程序。

在 Python 3.12.16 里:同样的 wheel,两个库都正常,窗口能弹、控件能建。
  • 报错原文是「找不到指定的程序」(ERROR_PROCEDURE_NOT_FOUND),意思是 DLL 找到了、但里面缺它要的导出函数,属于版本对不上,不是「没装」也不是「路径没找到」。

  • 我按最常见的三种猜测逐一排除过,实测都无效:

    1
    2
    3
    1) 把 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
2
3
4
5
6
7
8
from PySide6 import QtCore, QtWidgets

app = QtWidgets.QApplication([])
print(QtCore.qVersion()) # Qt 版本
print(QtCore.__version__) # PySide6 版本
print(app.platformName()) # 平台插件
print(app.style().objectName()) # 当前风格
print(app.primaryScreen().devicePixelRatio()) # 缩放
1
2
3
4
5
6.12.0
6.12.0
windows
windows11
1.25
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import sys
from PySide6 import QtWidgets


class MainWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Hello")
self.resize(420, 300)
label = QtWidgets.QLabel("天下无双", self)
label.move(20, 20)


app = QtWidgets.QApplication(sys.argv)
win = MainWindow()
win.show()
sys.exit(app.exec())
  • 四步和 Tkinter 一样对得上:建 QApplication → 建控件(指定父)→ show() → 进事件循环。差别在于 Qt 必须有且只有一个 QApplication,它管着剪贴板、字体、拖放这些全局设施。

  • 实测数据(E:\Desktop\Test\kivy-env,Python 3.12.16):

    1
    2
    3
    4
    QApplication 建好耗时 = 0.077 秒
    show 之后 isVisible = True size = 420 x 300
    平台插件 = windows 风格 = windows11
    屏幕 = 2048 x 1152 DPR = 1.25

事件循环:exec、exec_ 和 processEvents

1
2
3
app = QtWidgets.QApplication(sys.argv)
win.show()
app.exec() # 阻塞,直到最后一个窗口关闭或调用 quit()
  • exec() 会一直阻塞,这就是为什么「在 exec() 后面 print」永远看不到输出——新手最常见的「代码不执行」都是这个原因。

  • 实测两个绑定的方法名不一样:

    1
    2
    PySide6:  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
    2
    QtCore.QTimer.singleShot(600, app.quit)   # 0.6 秒后自己退出事件循环
    app.exec()

窗口相关的常用调用

1
2
3
4
5
6
win.setWindowTitle("标题")
win.resize(640, 480) # 客户区大小
win.move(200, 200) # 位置(屏幕坐标)
win.setGeometry(200, 200, 640, 480)
win.setFixedSize(320, 200) # 锁死大小
win.showMaximized(); win.showMinimized(); win.showFullScreen()
1
2
实测:resize(640,480) 之后 width()/height() = 640 x 480,isVisible() = True
实测:setStyleSheet 与 setWindowTitle 都能生效,windowTitle() 读回原样
  • resize() 设的是客户区,不含标题栏和边框,所以 x()/y() 与屏幕坐标会有边框宽度的偏差(Tkinter 那篇里 winfo_rootx 也一样)。

  • 想「窗口居中」别自己算,用屏幕几何:

    1
    2
    screen = 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
    6
    combo = 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
2
PySide6:  from PySide6.QtCore import Signal, Slot          (没有 pyqtSignal)
PyQt6: from PyQt6.QtCore import pyqtSignal, pyqtSlot (hasattr(QtCore,'Signal') = False)
  • 两边写自定义信号的语法形状一样,只是名字不同:

    1
    2
    3
    4
    5
    6
    class Panel(QtCore.QObject):
    valueChanged = QtCore.Signal(int, str) # PyQt6 里改成 pyqtSignal(int, str)

    @QtCore.Slot(int, str) # PyQt6 里改成 @pyqtSlot(int, str)
    def on_value(self, n, s):
    print(n, s)
  • 版本常量差异(实测):

    1
    2
    PySide6: 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
2
hasattr(QtGui, "QAction")      = True
hasattr(QtWidgets, "QAction") = False
  • 老代码里的 QtWidgets.QAction 会直接 AttributeError,改成 QtGui.QAction 即可。

  • 顺带两个「这个方法在 6.12 里其实没有」的实测:

    1
    2
    3
    4
    5
    6
    QStatusBar.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
2
3
4
5
6
7
8
9
10
-from PySide6 import QtCore, QtGui, QtWidgets
-from PySide6.QtCore import Qt
-APP_NAME = "PySide6 记事本"
+from PyQt6 import QtCore, QtGui, QtWidgets
+from PyQt6.QtCore import Qt
+APP_NAME = "PyQt6 记事本"
- print("Qt = %s, PySide6 = %s, 平台 = %s, 风格 = %s" % (
- QtCore.qVersion(), QtCore.__version__, app.platformName(), app.style().objectName()))
+ print("Qt = %s, PyQt6 = %s, 平台 = %s, 风格 = %s" % (
+ QtCore.qVersion(), QtCore.PYQT_VERSION_STR, app.platformName(), app.style().objectName()))
  • 除了 import 名字、程序标题和「取自身版本号」的常量名,控件、布局、信号槽、样式表、对话框一行都不用动。

  • 两边跑出来的自检结果完全一致(同一台机器,同一份逻辑):

    1
    2
    PySide6: 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import sys
from PySide6 import QtCore, QtGui, QtWidgets
from PySide6.QtCore import Qt


class MainWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("PySide6 记事本")
self.resize(640, 480)
self.path = None

self.editor = QtWidgets.QTextEdit(self)
self.editor.setAcceptRichText(False) # 只要纯文本
self.editor.setPlaceholderText("在这里输入")
self.setCentralWidget(self.editor)

m_file = self.menuBar().addMenu("文件(&F)")
self.act_new = QtGui.QAction("新建", self, shortcut="Ctrl+N")
self.act_open = QtGui.QAction("打开…", self, shortcut="Ctrl+O")
self.act_save = QtGui.QAction("保存", self, shortcut="Ctrl+S")
self.act_saveas = QtGui.QAction("另存为…", self, shortcut="Ctrl+Shift+S")
self.act_quit = QtGui.QAction("退出", self, shortcut="Ctrl+Q")
for a in (self.act_new, self.act_open, None, self.act_save, self.act_saveas, None, self.act_quit):
m_file.addSeparator() if a is None else m_file.addAction(a)

tb = self.addToolBar("主工具栏")
tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly)
tb.addAction(self.act_save)
tb.addAction(self.act_find)

self.status = self.statusBar()
self.status.showMessage("就绪")
self.lbl_count = QtWidgets.QLabel("0 字", self)
self.status.addPermanentWidget(self.lbl_count)
  • 结构是 Qt 主窗口的标准四件套:中心控件(setCentralWidget)+ 菜单栏 + 工具栏 + 状态栏,QMainWindow 帮你排好了,不用自己算位置。

  • 实测出来的结构数字:

    1
    2
    3
    4
    窗口尺寸 = 640 x 480  可见 = True
    菜单数 = 2 文件菜单项数 = 7(含 2 条分隔线)
    工具栏按钮数 = 2
    状态栏里的 QLabel 数 = 1,permanentWidgets() 存在吗 = False

把信号接到功能上

1
2
3
4
5
6
7
8
9
10
11
12
    self.act_new.triggered.connect(self.new_file)
self.act_open.triggered.connect(self.open_file)
self.act_save.triggered.connect(self.save_file)
self.act_saveas.triggered.connect(lambda: self.save_file(ask=True))
self.act_quit.triggered.connect(self.close)
self.act_undo.triggered.connect(self.editor.undo)
self.act_redo.triggered.connect(self.editor.redo)
self.editor.textChanged.connect(self.on_changed)

def on_changed(self):
self.lbl_count.setText("%d 字" % len(self.editor.toPlainText()))
self.setWindowTitle(("*" if self.editor.document().isModified() else "") + "PySide6 记事本")
  • textChanged 是 QTextEdit 的信号,不需要参数也能接(它其实带一个 text 参数,槽签名少写就行,实测可行)。

  • 实测的两个「脏标记」脾气,跟直觉不一样:

    1
    2
    setPlainText("四行诗") 之后:isModified = False,但标题已经是 '*PySide6 记事本'
    save_file() 之后:isModified = False
    • QTextEdit.setPlainText() 会重置文档的修改标志(Qt 认为这是「装载内容」而不是「用户编辑」),所以用 setPlainText 灌进去的内容不会触发「未保存」提示。
    • 而标题里的 * 来自更早一次 textChanged 时的判断,两者不是同一时刻读的值——别拿 isModified() 当「文本变过」用,要「变过」就用 textChanged 自己记标志位。

打开与保存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def open_file(self, checked=False, fname=None):
fname = fname or QtWidgets.QFileDialog.getOpenFileName(
self, "打开", WORK, "文本文件 (*.txt)")[0]
if not fname:
return False
with open(fname, encoding="utf-8") as f:
self.editor.setPlainText(f.read())
self.path = fname
self.editor.document().clearUndoRedoStacks()
self.status.showMessage("已打开 " + os.path.basename(fname))
return True

def save_file(self, checked=False, ask=False, fname=None):
fname = fname or (QtWidgets.QFileDialog.getSaveFileName(self, "另存为", WORK, "文本文件 (*.txt)")[0]
if (ask or not self.path) else self.path)
if not fname:
return False
with open(fname, "w", encoding="utf-8") as f:
f.write(self.editor.toPlainText())
self.path = fname
self.editor.document().setModified(False)
self.status.showMessage("已保存 " + os.path.basename(fname))
return True
  • QFileDialog.getOpenFileName 返回的是元组 (路径, 选中的过滤器),所以取路径要 [0];用户按取消时是空串,必须先判空。

  • 这两个函数都留了 fname= 参数,是为了让自检能绕过对话框直接指定路径——对话框是模态的,实测会阻塞代码:

    1
    2
    QMessageBox.information(...) 返回 = 1024(StandardButton.Ok)  阻塞了 0.32 秒
    (用 QTimer.singleShot(300, ...) 去关模态窗口,代码才能继续)

撤销与重做:一个真实的坑

1
2
3
4
5
6
before = w.editor.toPlainText()
cur = w.editor.textCursor()
cur.movePosition(QtGui.QTextCursor.MoveOperation.Start)
cur.insertText("第一行被改写\n") # 用光标插入,才会进撤销栈
w.act_undo.trigger()
w.act_redo.trigger()
1
2
3
4
光标插入后首行 = '第一行被改写'
撤销后与原文一致 = True 重做可用 = True
重做后首行 = '第一行被改写'
setPlainText 之后 isUndoAvailable = False (False 说明它清空了撤销栈)
  • 关键结论:setPlainText() 会清空撤销/重做栈,insertText() 之类的「编辑操作」才会留下历史。所以「整段替换文本再指望用户 Ctrl+Z」是无效的,实测 isUndoAvailable() 直接变 False。
  • 想自己控制历史,就 document().clearUndoRedoStacks()(打开文件时清一次,避免撤销到上一个文件的内容),或者自己存历史栈(Kivy 那篇就是这么做的,因为 TextInput 根本没有撤销)。

查找替换对话框

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class FindDialog(QtWidgets.QDialog):
def __init__(self, parent):
super().__init__(parent)
self.main = parent
self.setWindowTitle("查找替换")
lay = QtWidgets.QFormLayout(self)
self.ed_find = QtWidgets.QLineEdit()
self.ed_repl = QtWidgets.QLineEdit()
lay.addRow("查找:", self.ed_find)
lay.addRow("替换:", self.ed_repl)
btns = QtWidgets.QDialogButtonBox()
Role = QtWidgets.QDialogButtonBox.ButtonRole
self.b_find = btns.addButton("查找", Role.ActionRole)
self.b_repl = btns.addButton("替换", Role.ActionRole)
self.b_replall = btns.addButton("全部替换", Role.ActionRole)
btns.addButton(QtWidgets.QDialogButtonBox.StandardButton.Close)
lay.addRow(btns)
self.b_find.clicked.connect(self.find)
self.b_replall.clicked.connect(self.replace_all)

def find(self):
ok = self.main.editor.find(self.ed_find.text())
self.main.status.showMessage("找到" if ok else "没找到")
return ok

def replace_all(self):
text = self.main.editor.toPlainText()
n = text.count(self.ed_find.text())
self.main.editor.setPlainText(text.replace(self.ed_find.text(), self.ed_repl.text()))
self.status_message = "替换 %d 处" % n
return n
1
2
查找命中 = True   全部替换处数 = 2
替换后正文 = '床前太阳光\n疑是地上霜\n举头望太阳\n低头思故乡'
  • QTextEdit.find() 是 Qt 自带的查找,会移动光标并选中,返回布尔。实测在「明月」出现 3 次的文本上连续调用:

    1
    2
    find 第一次 = True 第二次 = True 第三次 = True 第四次 = False
    结束后光标位置 = 8
    • 也就是说它不会自动绕回头部(找不到就 False 结束),做「查找下一个」时要在返回 False 后把光标移回开头再找一遍。
  • 批量替换用 setPlainText 最直观,代价就是前面说的:撤销栈被清空。想保留撤销能力就改成 find() + textCursor().insertText() 循环。

关闭时自动存盘

1
2
3
4
def closeEvent(self, e):
if self.editor.document().isModified():
self.save_file(fname=os.path.join(WORK, "autosave.txt"))
super().closeEvent(e)
1
autosave.txt 存在 = True 内容 = '关闭前又改了'
  • 重写 closeEvent 是拦截关闭的唯一正确位置(点标题栏叉、Alt+F4、close() 都会走它),记得最后调 super().closeEvent(e),否则窗口不会真的关。
  • 真实的记事本在这里应该弹「是否保存」,本篇为了不阻塞自检直接落盘了。

自检跑完的总账

1
2
3
4
5
6
7
8
=== PySide6 记事本自检 ===
QApplication 耗时 = 0.076 秒
Qt = 6.12.0, PySide6 = 6.12.0, 平台 = windows, 风格 = windows11
DPR = 1.25, 屏幕 = (0, 0, 2048, 1152)
输入后字数标签 = 23 字
保存返回 = True 磁盘内容行数 = 4
重新打开后第一行 = '床前明月光' 状态栏 = 已打开 pyside_note.txt
从 import 到退出总耗时 = 0.44 秒
  • PyQt6 版跑同一份逻辑的输出只有标题不同,总耗时 0.39 秒,逐项结果一致。

布局、信号槽与多线程

三种布局管理器

Qt 的布局是「交给 layout 对象,尺寸由它算」,和 Tkinter 的 pack/grid/place 思路一致,但没有绝对布局(move()/setGeometry() 就是绝对定位,实测可用但不推荐)。

1
2
3
4
5
6
7
8
w = QtWidgets.QWidget()
lay = QtWidgets.QHBoxLayout(w)
a = QtWidgets.QLabel("A"); b = QtWidgets.QLabel("B"); c = QtWidgets.QLabel("C")
lay.addWidget(a, 1) # 第二个参数就是 stretch
lay.addWidget(b, 2)
lay.addWidget(c, 0)
w.resize(600, 120)
w.show()
1
2
3
4
5
6
窗口 = 600 x 120
三个标签宽 = 186 371 9 高 = 98
sizeHint = 60 x 38 minimumSize = 60
contentsMargins = QMargins(11, 11, 11, 11)
spacing = 6 布局方向 = Direction.LeftToRight
窗口缩到 300 宽之后三个标签宽 = 86 171 9 (stretch 按比例分)
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Panel(QtCore.QObject):
valueChanged = QtCore.Signal(int, str) # PyQt6: pyqtSignal(int, str)

def __init__(self):
super().__init__()
self.got = []
self.valueChanged.connect(self.on_value) # 1. 接方法
self.valueChanged.connect(lambda n, s: self.got.append(("lambda", n, s))) # 2. 接 lambda

@QtCore.Slot(int, str) # 3. 装饰器(可选)
def on_value(self, n, s):
self.got.append((n, s))


p = Panel()
p.valueChanged.emit(7, "七")
p.valueChanged.disconnect(p.on_value) # 4. 断开
p.valueChanged.emit(8, "八")
1
2
自定义信号 emit 后 = [(7, '七'), ('lambda', 7, '七')]
disconnect 只断开槽方法,lambda 仍在 = [('lambda', 8, '八')]
  • disconnect 只能断「连接时用的那个对象」,disconnect() 不带参数则全断;lambda 因为没被保存引用,断不掉——需要能断开的连接就别用 lambda。
  • 内置信号带默认参数,槽的签名可以比信号少(实测 clicked 带 checked 布尔,接一个无参 def f(): 照样能连)。
  • 统计连接数要用字符串签名,实测 p.receivers(p.valueChanged) 直接 TypeError,要写 p.receivers("valueChanged(int,QString)")。

多线程:槽在哪个线程执行

Qt 的规矩是「槽在接收者所在线程执行」,跨线程的信号会自动排队(AutoConnection → QueuedConnection)。实测:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Worker(QtCore.QObject):
done = QtCore.Signal(str)

@QtCore.Slot()
def run(self):
res["slot_tid"] = threading.get_ident()
self.done.emit("来自工作线程")


thread = QtCore.QThread()
worker = Worker()
worker.moveToThread(thread)
emitter.go.connect(worker.run)
worker.done.connect(lambda msg: ...) # lambda 没有接收者上下文
thread.start()
emitter.go.emit()
1
2
3
槽执行线程 = 57984  与主线程不同 = True
回到主线程的槽 = ('来自工作线程', False)
线程退出 wait 返回 = False # quit() + wait() 之后 isRunning() 为 False
  • 第二行是重点: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 里要自己守规矩:工作线程只算数据,界面更新一律走信号。
  • 长任务不想卡界面的最小写法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class 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
2
3
4
box = QtWidgets.QFrame()
box.setObjectName("card")
box.setStyleSheet("#card{background:#223;border:2px solid #f60;border-radius:8px}"
"QLabel{color:#0f0;font-size:18px}")
1
2
3
styleSheet 回读 = #card{background:#223;border:2px solid #f60;border-radius:8p ...
计算后的窗口底色 = #222233 标签文字色 = #00ff00
fontPointSize = -1 pixelSize = 18
  • 选择器支持类名(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
2
3
QFont("Microsoft YaHei", 12)  -> pointSize=12  pixelSize=-1  控件 92x22
QFont("Microsoft YaHei", -12) -> pointSize=9 pixelSize=-1 控件 69x16
QFont("Microsoft YaHei", 9) -> pointSize=9 pixelSize=-1 控件 69x16
  • 构造函数第二个参数永远是磅数,Qt C++ 里「负数表示像素」的约定在这里不生效(实测传 -12 被当成 9 磅,等价于默认磅数)。要像素字号必须显式:

    1
    2
    f = QtGui.QFont("Microsoft YaHei")
    f.setPixelSize(18) # 而不是 QFont("...", -18)
  • 高分屏不需要手动乘缩放系数,devicePixelRatio() 实测 1.25,Qt 自己按 DPR 渲染;但自己画坐标(QPainter、move())时用的仍是逻辑像素,别混。

表格:QTableWidget 与 Model/View

1
2
3
4
tw = QtWidgets.QTableWidget(3, 4)
tw.setHorizontalHeaderLabels(["姓名", "年龄", "身高", "体重"])
tw.setItem(0, 0, QtWidgets.QTableWidgetItem("陆彦旭"))
tw.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.ResizeMode.Stretch)
1
2
3
row/col = 3 4   第0行第0列 = 陆彦旭
列宽 = [100, 100, 100, 100] 行高 = 30
表头 Stretch 之后列宽 = [125, 125, 125, 125]
  • QTableWidget 上手快但每格都要造 QTableWidgetItem,数据量大就换 Model/View:

    1
    2
    3
    4
    5
    m = QtGui.QStandardItemModel(2, 3)
    m.setHorizontalHeaderLabels(["a", "b", "c"])
    m.setItem(0, 0, QtGui.QStandardItem("x"))
    tv = QtWidgets.QTableView()
    tv.setModel(m)
    1
    2
    model 行列 = 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
2
3
4
5
6
# 单文件
python -m PyInstaller --noconfirm --onefile --name pk_pyside gui_note_pyside.py
# 目录(LGPL 推荐)
python -m PyInstaller --noconfirm --onedir --name pk_pyside gui_note_pyside.py
# 不要黑色控制台窗口
python -m PyInstaller --noconfirm --onefile --windowed --name pk_pyside gui_note_pyside.py

实测结果

1
2
3
onefile  构建 44.3 秒   产物 45.6 MB
onedir 构建 34.2 秒 目录 115.4 MB(213 个文件),主 exe 只有 1.7 MB
windowed 构建 48.8 秒 产物 45.6 MB(和 console 几乎一样大)
  • --windowed 只影响有没有控制台窗口,体积基本不变(45.6 MB vs 45.6 MB),别指望它减小包。

  • onedir 的目录反而比 onefile 大(115.4 MB vs 45.6 MB),因为 onefile 里的 DLL 是压缩存放的,运行时要先解压。

  • 代价在启动速度上。四个产物我都直接跑了一遍 selftest(退出码都是 0,说明打包出来的程序真的能跑):

    1
    2
    PySide6 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,严格讲不满足「可替换」。

想再小一点

1
2
pip install PySide6-Essentials          # 不要 Addons/WebEngine,实测省 430 MB
python -m PyInstaller --noconfirm --onedir --exclude-module PyQt5 --exclude-module tkinter pk_pyside.spec
  • 体积大头是 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
2
3
出现环境:Python 3.14(Anaconda base)+ PySide6 6.12.0 / PyQt6 6.12.0
排除过程:清 PATH 无效、预加载 System32 的 vcruntime140 无效、把 PATH 弄得更脏也无效
同版本 wheel 在 Python 3.12.16 上正常
  • 这条的含义是「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
2
PyQt6 专属:只认 Qt.AlignmentFlag.AlignLeft
PySide6 两种都能过
  • 修法就是写全枚举类名;写全之后两边都兼容。

AttributeError: ‘QApplication’ object has no attribute ‘exec_’

1
2
PyQt6 6.12: hasattr(QApplication, "exec_") = False
PySide6 6.12: hasattr(QApplication, "exec_") = True
  • 老代码里的 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(它有自己的子进程),退出前要先销毁。

参考

本文结束 感谢您的阅读
正在加载今日诗词....