Python 基础体系 · 第 103/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python PySide 与 Qt:信号槽、Model/View、线程、资源和发布

Qt 是一套跨平台应用程序框架,提供窗口、控件、布局、事件循环、绘图、网络、线程、资源和发布工具等能力。PySide6 是 Qt for Python 的官方 Python 绑定,使 Python 程序能够调用 Qt 6 API;Shiboken6 则负责绑定生成和相关运行时支持。通过 pip 安装的 PySide6 包通常已经包含所需的 Qt 二进制库,不要求系统另外安装一套 Qt。(doc.qt.io)

本文使用 Python 3.14 语法和运行环境,重点解释五条相互连接的主线:

  1. Qt 对象、事件循环和信号槽如何组织 GUI 程序;
  2. Model/View 如何把数据结构与显示控件分离;
  3. QThread、工作对象和 Python 线程模型如何避免界面冻结;
  4. Qt 资源系统如何处理图标、图片、翻译文件等静态资源;
  5. 如何把开发目录中的 PySide 应用发布为可交付程序,并验证失败路径。

一、先建立 Qt GUI 的运行模型

1. QApplication、事件循环和窗口生命周期

一个 Qt Widgets 应用至少需要:

  • 一个 QApplication 对象;
  • 一个或多个 QWidget 或其子类对象;
  • 调用 app.exec() 进入事件循环;
  • 在事件循环结束后释放应用程序资源并退出进程。

最小程序如下:

from PySide6.QtWidgets import QApplication, QLabel
import sys

app = QApplication(sys.argv)

label = QLabel("Hello, PySide6")
label.resize(320, 120)
label.show()

sys.exit(app.exec())

这里的关键不是 QLabel,而是 app.exec()

在调用 exec() 之前,Python 代码按照普通顺序执行:

创建 QApplication
创建 QLabel
设置文本和大小
显示窗口
进入事件循环

进入事件循环后,程序不再按照一个从上到下的业务流程持续运行,而是反复处理事件:

等待事件
  ├── 鼠标事件
  ├── 键盘事件
  ├── 窗口重绘事件
  ├── 定时器事件
  ├── 信号槽投递事件
  └── 其他系统事件
处理事件
回到等待状态

因此,Qt GUI 程序的主线程通常同时承担两件事:

  1. 运行窗口系统和控件;
  2. 执行主线程事件循环。

如果在主线程中执行耗时工作,例如:

def on_button_clicked():
    result = very_slow_function()
    label.setText(str(result))

那么事件循环会被 very_slow_function() 占住。窗口无法及时重绘,鼠标点击和窗口移动也无法被处理,用户看到的结果通常是“窗口卡死”。

这不是 Qt 特有的问题。Tkinter 也依赖主线程事件循环,因此同样不能在按钮回调中直接执行长时间任务。Qt 的差异在于,它提供了更完整的对象线程归属、队列连接和线程生命周期管理机制。

2. GUI 对象与线程归属

Qt 中的 QObject 有线程归属,称为 thread affinity。一个对象通常由创建它的线程拥有;也可以使用 moveToThread() 将对象移动到另一个线程,但必须满足对象没有父对象等约束。

QWidget 及其子类属于 GUI 对象。工程上应当把它们保留在主线程中,不要在工作线程中创建或修改窗口控件。工作线程只负责计算、文件读写或网络访问,通过信号把结果交回主线程。

一个可靠的数据流应当类似:

用户点击按钮
    │
    ▼
主线程槽函数
    │ 启动工作线程
    ▼
工作对象在后台线程执行任务
    │
    ├── result(int, str)
    ├── progress(int)
    └── error(str)
    ▼
主线程槽函数更新控件

主线程和工作线程之间不要直接共享可变的 GUI 状态。共享数据越多,越容易出现竞态条件、对象已销毁、重复更新或关闭窗口时线程仍在运行等问题。


二、信号槽:Qt 中的类型化事件通信

1. 信号和槽分别是什么

信号(signal) 是对象声明的一种事件出口,表示“某件事发生了”。

槽(slot) 是接收信号后执行的函数,表示“收到该事件后如何处理”。

例如,按钮的 clicked 是预定义信号:

from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QVBoxLayout, QWidget
import sys

class Window(QWidget):
    def __init__(self):
        super().__init__()

        self.label = QLabel("尚未点击")
        self.button = QPushButton("点击")

        layout = QVBoxLayout(self)
        layout.addWidget(self.label)
        layout.addWidget(self.button)

        self.button.clicked.connect(self.handle_click)

    def handle_click(self):
        self.label.setText("已经点击")

app = QApplication(sys.argv)
window = Window()
window.show()
sys.exit(app.exec())

clicked.connect(self.handle_click) 建立了如下关系:

QPushButton.clicked  ─────► Window.handle_click

按钮不需要知道 Window 如何处理点击;窗口也不需要轮询按钮状态。两者通过信号槽进行松耦合通信。

Qt 文档将信号描述为对象状态发生有意义变化时发出的事件,将槽描述为响应信号的函数。一个信号可以连接多个槽,一个槽也可以接收多个信号。直接连接时,槽通常在信号发出过程中立即执行;队列连接则会把调用安排到目标线程的事件循环中稍后执行。(doc.qt.io)

2. PySide6 中声明自定义信号和槽

PySide6 使用 Signal 声明信号,使用 Slot 装饰槽函数:

from PySide6.QtCore import QObject, Signal, Slot

class Downloader(QObject):
    progress_changed = Signal(int)
    completed = Signal(bytes)
    failed = Signal(str)

    @Slot(str)
    def download(self, url: str):
        try:
            data = fetch(url)
        except Exception as exc:
            self.failed.emit(str(exc))
            return

        self.completed.emit(data)

Signal(int) 表示该信号携带一个整数参数;Signal(bytes) 表示携带字节串;Signal(str) 表示携带字符串。

@Slot 并不是 Python 函数能够被调用的必要条件:

def handle_value(value):
    print(value)

signal.connect(handle_value)

这种连接通常也能工作。但显式声明 @Slot 可以让函数进入 Qt 元对象系统,明确参数签名,并在跨线程通信、动态连接和性能诊断中减少歧义。PySide6 的 Slot 支持声明参数类型,也支持声明名称和返回类型。(doc.qt.io)

3. 信号参数必须与槽兼容

假设信号为:

value_changed = Signal(int, str)

可以连接到接收两个参数的槽:

@Slot(int, str)
def on_value_changed(number, text):
    print(number, text)

也可以连接到只接收前一个参数的槽:

@Slot(int)
def on_number_changed(number):
    print(number)

但不能把参数顺序或类型含义弄错:

@Slot(str, int)
def wrong_slot(text, number):
    ...

Python 本身不会像 C++ 编译器那样在编译阶段检查所有连接关系,因此工程中应当让信号名称、参数类型和槽函数意图保持一致。不要用一个携带大量无关参数的“万能信号”代替多个语义明确的信号。

例如,与其定义:

status = Signal(object)

再让接收者猜测 object 可能是字典、异常还是结果,不如定义:

started = Signal()
progress = Signal(int)
completed = Signal(list)
failed = Signal(str)

信号的参数越接近业务事件本身,调用关系越容易测试和诊断。

4. 直接连接与队列连接

连接类型可以概括为:

连接方式 槽执行位置 适用场景
Direct 发出信号的线程 同线程、短小同步处理
Queued 接收对象所属线程的事件循环 跨线程通信
Auto Qt 根据线程关系自动选择 通常使用默认值

对同一线程中的对象:

emit()
  └── 立即调用槽

对不同线程中的对象:

工作线程 emit()
  └── 投递事件
        └── 主线程事件循环稍后执行槽

跨线程信号槽的核心不是“信号自动创建线程”,而是 Qt 将调用转换成目标线程可以处理的事件。目标线程必须有正在运行的事件循环,否则队列中的槽可能无法按预期执行。QThread 默认的 run() 会启动该线程的 Qt 事件循环,因此采用工作对象模式时通常需要让线程进入事件循环。(doc.qt.io)

5. lambda 的捕获边界

lambda 可以给槽附加固定参数:

for index in range(3):
    button.clicked.connect(
        lambda checked=False, index=index: print(index)
    )

这里显式写 index=index 是为了在连接时捕获当前值。

如果写成:

for index in range(3):
    button.clicked.connect(lambda: print(index))

三个 lambda 都会读取循环结束后的同一个 index,通常全部输出 2。这是 Python 闭包的晚绑定问题,不是 Qt 信号槽的问题。

另外,按钮的 clicked 信号可能携带 checked 参数,因此无参数 lambda 在某些连接场景下会出现参数不匹配。稳妥写法是:

lambda checked=False: self.handle_action()

三、布局和控件:不要用固定坐标描述界面

Qt Widgets 中,控件负责交互,布局负责计算控件位置和尺寸。

常用布局包括:

  • QVBoxLayout:垂直排列;
  • QHBoxLayout:水平排列;
  • QGridLayout:网格排列;
  • QFormLayout:标签—字段表单布局;
  • QSplitter:可拖动分隔的区域布局。

例如:

from PySide6.QtWidgets import (
    QApplication,
    QLabel,
    QLineEdit,
    QPushButton,
    QFormLayout,
    QWidget,
)

class Form(QWidget):
    def __init__(self):
        super().__init__()

        self.name_edit = QLineEdit()
        self.email_edit = QLineEdit()
        self.submit_button = QPushButton("提交")
        self.message = QLabel()

        form = QFormLayout(self)
        form.addRow("姓名", self.name_edit)
        form.addRow("邮箱", self.email_edit)
        form.addRow(self.submit_button)
        form.addRow(self.message)

        self.submit_button.clicked.connect(self.submit)

    def submit(self):
        name = self.name_edit.text().strip()
        email = self.email_edit.text().strip()

        if not name or "@" not in email:
            self.message.setText("输入无效")
            return

        self.message.setText(f"已提交:{name}")

app = QApplication([])
window = Form()
window.show()
app.exec()

固定坐标:

label.move(20, 20)
button.move(20, 60)

只在尺寸、字体、平台都固定的临时界面中勉强可用。窗口缩放、系统字体变化、不同 DPI 和不同平台样式都会使固定坐标失效。

布局计算的因果关系是:

控件 sizeHint()
    + 最小尺寸
    + stretch factor
    + margins
    + spacing
    └── 布局管理器计算最终几何尺寸

如果一个控件应当占用多余空间,可以设置 stretch:

layout.addWidget(self.editor, stretch=1)
layout.addWidget(self.preview, stretch=2)

在可用空间中,两个控件大致按 1:2 分配伸缩空间,但实际结果还会受到最小尺寸、最大尺寸和 size policy 影响。因此 stretch 不是绝对像素比例。


四、Model/View:把数据与显示分成两个对象

1. 为什么不用 QTableWidget

QTableWidget 是方便的项目型控件:每个单元格通常由一个 QTableWidgetItem 表示,适合小型、简单、一次性编辑的表格。

但当数据来自数据库、文件、网络或大型内存结构时,直接把所有数据复制为控件项目会带来三个问题:

  1. 数据和界面耦合;
  2. 数据变化时需要手工同步大量项目;
  3. 大数据量下对象和刷新成本增加。

Qt 的 Model/View 模式将职责拆成:

数据源 ───► Model ───► View
                    ▲
                    └── Delegate
  • Model:以统一接口提供行、列、单元格数据;
  • View:显示数据、处理选择和滚动;
  • Delegate:负责单元格绘制和编辑器创建;
  • Proxy Model:在模型和视图之间提供排序、过滤或映射。

QTableView 不直接知道数据存储在列表、数据库还是远程服务中,它只通过 QAbstractItemModel 的接口读取数据。

2. QAbstractTableModel 的最小协议

二维表模型至少需要回答三个问题:

rowCount(parent)    有多少行?
columnCount(parent) 有多少列?
data(index, role)   该索引在指定角色下的数据是什么?

一个只读模型如下:

from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt

class UserModel(QAbstractTableModel):
    HEADERS = ["姓名", "年龄", "城市"]

    def __init__(self, rows=None, parent=None):
        super().__init__(parent)
        self._rows = rows or []

    def rowCount(self, parent=QModelIndex()):
        if parent.isValid():
            return 0
        return len(self._rows)

    def columnCount(self, parent=QModelIndex()):
        if parent.isValid():
            return 0
        return len(self.HEADERS)

    def data(self, index, role=Qt.ItemDataRole.DisplayRole):
        if not index.isValid():
            return None

        if role == Qt.ItemDataRole.DisplayRole:
            return self._rows[index.row()][index.column()]

        return None

    def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
        if role != Qt.ItemDataRole.DisplayRole:
            return None

        if orientation == Qt.Orientation.Horizontal:
            return self.HEADERS[section]

        return str(section + 1)

绑定到视图:

from PySide6.QtWidgets import QApplication, QTableView

app = QApplication([])

model = UserModel([
    ["张三", 28, "杭州"],
    ["李四", 31, "宁波"],
])

view = QTableView()
view.setModel(model)
view.resize(480, 240)
view.show()

app.exec()

data()role 很重要。相同单元格可以为不同角色提供不同值:

if role == Qt.ItemDataRole.DisplayRole:
    return value

if role == Qt.ItemDataRole.ToolTipRole:
    return f"原始值:{value}"

if role == Qt.ItemDataRole.TextAlignmentRole:
    return Qt.AlignmentFlag.AlignCenter

View 会根据角色请求数据,而不是只调用一个“获取单元格文本”的函数。Qt 的模型接口还支持 dataChanged()headerDataChanged()layoutChanged() 等变化通知;如果模型结构发生变化,还必须使用对应的 begin/end 通知配对。(doc.qt.io)

3. 编辑模型:setData()flags()

要允许用户编辑,需要实现:

def flags(self, index):
    if not index.isValid():
        return Qt.ItemFlag.NoItemFlags

    return (
        Qt.ItemFlag.ItemIsEnabled
        | Qt.ItemFlag.ItemIsSelectable
        | Qt.ItemFlag.ItemIsEditable
    )

def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
    if not index.isValid() or role != Qt.ItemDataRole.EditRole:
        return False

    self._rows[index.row()][index.column()] = value
    self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole])
    return True

状态变化顺序是:

View 请求编辑
    │
    ▼
Model.setData(index, value, EditRole)
    │
    ├── 校验失败:返回 False
    │
    └── 写入数据
          │
          └── 发出 dataChanged
                │
                └── View 重新请求 DisplayRole

反例是直接修改 _rows

model._rows[0][1] = 29

这会改变底层数据,但 View 不知道发生了变化,因此界面可能仍显示旧值。Model/View 的原则是:数据变化必须伴随正确的模型通知

4. 行插入和删除的 begin/end 配对

假设当前模型有 2 行,要在尾部追加一行。新行索引是 2:

def append_row(self, row):
    new_row = self.rowCount()
    self.beginInsertRows(QModelIndex(), new_row, new_row)
    self._rows.append(row)
    self.endInsertRows()

顺序不能写反:

self._rows.append(row)
self.beginInsertRows(...)
self.endInsertRows()

View 需要在数据结构变化前获知将要插入的范围,以便修正选择、索引和滚动状态。Qt 明确要求 beginInsertRows() 在修改底层数据前调用,endInsertRows() 在修改后立即调用;删除操作同样需要 beginRemoveRows()endRemoveRows()。(doc.qt.io)

5. 排序和过滤应该放在哪里

排序和过滤可以直接写进模型,也可以使用代理模型:

from PySide6.QtCore import QSortFilterProxyModel

proxy = QSortFilterProxyModel()
proxy.setSourceModel(model)
proxy.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)

view.setModel(proxy)

此时索引有两层:

View index
   │
   └── proxy.mapToSource()
          │
          └── source model index

如果双击代理模型中的一行后要修改原始数据,不能把代理行号直接当成源模型行号:

source_index = proxy.mapToSource(proxy_index)
model.setData(source_index, value)

这是一类常见错误:界面显示经过过滤后的第 0 行,但源模型中的对应行可能是第 17 行。


五、线程:让耗时任务离开 GUI 主线程

1. Python 线程与 Qt 线程不是同一个抽象层

Python 的 threading.Thread 和 Qt 的 QThread 都能创建操作系统线程,但它们解决的问题不同:

  • threading.Thread 主要提供 Python 层线程同步和共享内存;
  • QThread 还与 Qt 对象归属、事件循环和信号槽投递结合;
  • QObject.moveToThread() 让对象的槽函数可以在目标线程的事件循环中执行。

CPython 3.14 的普通构建仍然受到 GIL 影响:同一进程中,同一时刻通常只有一个线程执行 Python 字节码,因此 Python 线程更适合 I/O 密集型工作。Python 3.13 起还提供可选的 free-threaded 构建,但并非默认构建,部分扩展模块可能重新启用 GIL,不能把它当作所有环境的默认性能模型。(docs.python.org)

任务类型与选择关系可以写成:

等待网络、文件、数据库
    └── QThread / threading.Thread / asyncio

大量纯 Python CPU 计算
    └── multiprocessing / ProcessPoolExecutor

调用会释放 GIL 的 C、NumPy 或其他原生库
    └── 线程可能有效,但必须实测

需要修改 QWidget
    └── 只在 GUI 主线程执行

Python 3.14 在 POSIX 平台将 forkserver 设为 multiprocessing 的默认启动方式,以减少多线程进程中直接 fork 带来的兼容性问题。使用进程时仍应显式设计入口和启动方式,不要假设所有平台的进程行为相同。(docs.python.org)

2. 推荐的 QThread + Worker QObject 模式

下面是一个完整的端到端示例。它模拟一个分批执行的任务,展示:

  • 主线程创建窗口;
  • Worker 移动到后台线程;
  • 主线程通过信号启动工作;
  • 工作线程发出进度和结果;
  • 主线程更新进度条;
  • 任务完成或失败后退出线程;
  • 窗口关闭时等待线程退出。
from __future__ import annotations

import sys
import time

from PySide6.QtCore import QObject, QThread, Signal, Slot
from PySide6.QtWidgets import (
    QApplication,
    QLabel,
    QMainWindow,
    QPushButton,
    QProgressBar,
    QVBoxLayout,
    QWidget,
)


class Worker(QObject):
    progress = Signal(int)
    result = Signal(str)
    error = Signal(str)
    finished = Signal()

    @Slot()
    def run(self):
        try:
            total = 10

            for step in range(1, total + 1):
                # 真实代码中这里可以是网络请求、文件读取或数据库查询。
                time.sleep(0.2)

                percent = step * 100 // total
                self.progress.emit(percent)

            self.result.emit("任务完成")
        except Exception as exc:
            self.error.emit(f"{type(exc).__name__}: {exc}")
        finally:
            self.finished.emit()


class MainWindow(QMainWindow):
    start_work = Signal()

    def __init__(self):
        super().__init__()
        self.setWindowTitle("PySide6 Worker 示例")

        self.status_label = QLabel("等待开始")
        self.progress_bar = QProgressBar()
        self.progress_bar.setRange(0, 100)

        self.start_button = QPushButton("开始")
        self.start_button.clicked.connect(self.start_task)

        layout = QVBoxLayout()
        layout.addWidget(self.status_label)
        layout.addWidget(self.progress_bar)
        layout.addWidget(self.start_button)

        container = QWidget()
        container.setLayout(layout)
        self.setCentralWidget(container)

        self.thread: QThread | None = None
        self.worker: Worker | None = None

    @Slot()
    def start_task(self):
        if self.thread is not None and self.thread.isRunning():
            return

        self.start_button.setEnabled(False)
        self.progress_bar.setValue(0)
        self.status_label.setText("正在执行")

        thread = QThread(self)
        worker = Worker()

        self.thread = thread
        self.worker = worker

        worker.moveToThread(thread)

        self.start_work.connect(worker.run)
        worker.progress.connect(self.progress_bar.setValue)
        worker.result.connect(self.on_result)
        worker.error.connect(self.on_error)

        worker.finished.connect(thread.quit)
        worker.finished.connect(worker.deleteLater)
        thread.finished.connect(thread.deleteLater)
        thread.finished.connect(self.on_thread_finished)

        thread.start()

        # 线程启动后,通过队列把 run() 投递到 worker 所在线程。
        self.start_work.emit()

    @Slot(str)
    def on_result(self, message: str):
        self.status_label.setText(message)

    @Slot(str)
    def on_error(self, message: str):
        self.status_label.setText(f"任务失败:{message}")

    @Slot()
    def on_thread_finished(self):
        self.start_work.disconnect()
        self.thread = None
        self.worker = None
        self.start_button.setEnabled(True)

    def closeEvent(self, event):
        if self.thread is not None and self.thread.isRunning():
            self.thread.quit()
            self.thread.wait(3000)

        event.accept()


def main():
    app = QApplication(sys.argv)
    window = MainWindow()
    window.resize(360, 180)
    window.show()
    return app.exec()


if __name__ == "__main__":
    sys.exit(main())

安装和运行:

python -m venv .venv

Linux/macOS:

source .venv/bin/activate
python -m pip install pyside6
python main.py

Windows:

.venv\Scripts\activate
python -m pip install pyside6
python main.py

使用 python -m pip 而不是直接使用 pip,可以减少把包装入错误 Python 环境的风险。PySide 官方文档也建议使用虚拟环境,并通过 PySide 自带的包装工具处理 Qt 工具链。(doc.qt.io)

3. 上例的线程时序

关键路径如下:

sequenceDiagram
    participant UI as GUI主线程
    participant T as QThread
    participant W as Worker

    UI->>T: thread.start()
    T->>T: 启动线程事件循环
    UI->>W: start_work.emit()
    Note over W: Worker 已 moveToThread(T)
    T->>W: 队列调用 run()
    loop 每个步骤
        W-->>UI: progress.emit(percent)
        UI->>UI: 更新 QProgressBar
    end
    W-->>UI: result 或 error
    W-->>T: finished.emit()
    T->>T: quit()
    T-->>UI: finished
    UI->>UI: 清理引用并恢复按钮

这里有三个容易混淆的对象:

  • QThread 对象本身通常仍由创建它的线程,即 GUI 线程拥有;
  • Worker 对象被移动到后台线程;
  • run()Worker 的槽,因此由后台线程的事件循环调用。

这也是为什么不应把耗时逻辑直接写进 QThread 子类的 __init__()QThread 对象和它管理的执行线程不是同一个概念。

4. 不能直接调用工作槽

下面的代码看起来相似,但语义不同:

worker.run()

这是普通 Python 函数调用,调用者所在的线程会直接执行 run()。如果调用者是 GUI 主线程,界面仍然会冻结。

而下面的代码通过信号槽连接:

start_work.connect(worker.run)
start_work.emit()

由于 start_work 的发送者在主线程,worker 属于工作线程,Qt 会根据线程关系采用队列投递,让 run() 在工作线程事件循环中执行。

5. 停止、取消和异常

QThread.quit() 只能请求线程事件循环退出。它不能安全地中断正在执行的普通 Python 函数。

因此,长任务应当设计取消标志:

class Worker(QObject):
    finished = Signal()
    cancelled = Signal()

    def __init__(self):
        super().__init__()
        self._cancel_requested = False

    @Slot()
    def cancel(self):
        self._cancel_requested = True

    @Slot()
    def run(self):
        for step in range(1000):
            if self._cancel_requested:
                self.cancelled.emit()
                self.finished.emit()
                return

            do_one_step()

取消流程是:

用户点击取消
    │
    ▼
主线程发出 cancel 信号
    │
    ▼
Worker 在下一次事件循环机会中收到请求
    │
    ├── 任务可取消:尽快退出
    └── 当前阻塞调用不可取消:只能等待返回

如果工作函数正在一个无法设置超时的阻塞系统调用中,取消信号不会立即生效。解决方法通常是给 I/O 设置超时、拆分任务、使用可取消 API,或把真正无法控制的工作放入独立进程。

异常必须在工作线程边界内捕获并通过信号传递:

try:
    value = do_work()
except Exception as exc:
    self.error.emit(f"{type(exc).__name__}: {exc}")
else:
    self.result.emit(value)
finally:
    self.finished.emit()

不要让未捕获异常穿过线程边界后期待 GUI 自动显示错误。未捕获异常可能导致线程结束、控制台输出堆栈,或者在发布环境中只表现为“按钮点击后没有结果”。

6. 窗口关闭时的故障路径

错误的关闭流程是:

def closeEvent(self, event):
    event.accept()

如果后台线程仍运行,进程可能继续存活;如果线程引用或底层对象被错误销毁,可能出现退出警告或崩溃。

更完整的关闭状态应是:

窗口收到关闭事件
    │
    ├── 没有运行中的线程 ──► 立即接受关闭
    │
    └── 有运行中的线程
          │
          ├── 请求取消
          ├── 等待有限时间
          ├── 线程退出 ──► 接受关闭
          └── 超时 ──► 记录错误,不应无条件 terminate()

terminate() 会强制终止线程,可能让锁、文件写入、数据库事务或 Python 对象处于不一致状态。只有在明确接受资源泄漏和数据损坏风险时,才考虑使用强制终止。


六、Model/View 与线程结合

1. 模型也有线程边界

即使模型不是 QWidget,也不能随意从任意线程修改已绑定到 View 的模型。

常见错误:

# Worker 线程中直接修改主线程使用的 model
model._rows.append(new_row)
model.layoutChanged.emit()

这同时违反两个假设:

  1. 底层数据结构被跨线程直接修改;
  2. 模型信号可能从错误的线程发出。

更安全的结构是:

后台线程读取、解析、计算
    │
    ▼
发出 list[Record] 或增量结果
    │
    ▼
主线程槽函数调用 model.replace_all()
    │
    ▼
模型在主线程中发出正确通知

例如批量替换:

class UserModel(QAbstractTableModel):
    # rowCount、columnCount、data 等略

    @Slot(list)
    def replace_all(self, rows: list[list[object]]):
        self.beginResetModel()
        self._rows = rows
        self.endResetModel()

beginResetModel() / endResetModel() 会告诉所有观察者模型索引整体失效。它适合一次性替换整个数据集,但不应被当成每次单行更新的通用通知,因为重置会丢失选择、滚动位置和部分视图状态。

如果只是追加几行,应使用 beginInsertRows();如果只是单元格值改变,应使用 dataChanged()

2. 大数据量时使用增量更新

假设后台线程每次解析 1000 条记录,主线程可以批量发送:

batch_ready = Signal(list)

主线程槽函数:

@Slot(list)
def append_batch(self, batch):
    first = self.model.rowCount()
    last = first + len(batch) - 1

    self.model.beginInsertRows(QModelIndex(), first, last)
    self.model._rows.extend(batch)
    self.model.endInsertRows()

实际项目中应把 append_batch() 封装成模型自身的方法,不让窗口直接访问 _rows

class UserModel(QAbstractTableModel):
    @Slot(list)
    def append_rows(self, rows):
        if not rows:
            return

        first = len(self._rows)
        last = first + len(rows) - 1

        self.beginInsertRows(QModelIndex(), first, last)
        self._rows.extend(rows)
        self.endInsertRows()

这样模型能够统一维护索引、通知和数据校验。


七、资源系统:从相对路径到 :/ URL

1. 为什么发布后相对路径会失效

开发环境中常见:

icon = QIcon("assets/icon.png")

这段代码依赖当前工作目录,而不是依赖 main.py 所在目录。以下命令可能得到不同结果:

python main.py
python /opt/myapp/main.py

因为当前工作目录可能分别是项目目录、用户目录或启动器指定的目录。

即使改成:

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent
icon = QIcon(str(BASE_DIR / "assets" / "icon.png"))

发布为单文件或目录后,__file__、临时解包目录、安装目录和用户可写目录之间仍可能存在差异。配置、缓存、日志和数据库文件也不应与只读程序资源混在一起。

2. Qt Resource System 的基本原理

Qt Resource System 是跨平台的资源机制,可以把图标、图片、字体、翻译文件等资源编译进应用或库,也可以生成外部 .rcc 文件,在运行时加载。资源由 .qrc 文件描述,并由 rccpyside6-rcc 编译。(doc.qt.io)

目录结构:

project/
├── main.py
├── resources/
│   ├── app.qrc
│   └── icons/
│       └── app.png
└── generated/

resources/app.qrc

<!DOCTYPE RCC>
<RCC version="1.0">
    <qresource prefix="/">
        <file>icons/app.png</file>
    </qresource>
</RCC>

生成 Python 资源模块:

pyside6-rcc resources/app.qrc -o generated/rc_app.py

PySide 官方建议使用与当前 PySide 安装匹配的 pyside6-rcc,而不是随意调用系统中的 Qt rcc,以减少生成代码和运行时版本不一致的问题。(doc.qt.io)

代码中导入生成模块:

from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QApplication, QMainWindow

import generated.rc_app  # 只需导入,使资源注册生效

app = QApplication([])
window = QMainWindow()
window.setWindowIcon(QIcon(":/icons/app.png"))
window.show()
app.exec()

:/icons/app.png 不是操作系统文件系统路径,而是 Qt 资源树中的路径。使用资源系统后,程序不再依赖用户启动程序时的当前目录。

3. 资源系统的边界

Qt 资源系统适合:

  • 应用图标;
  • 内置图片;
  • 默认字体;
  • 内置 QML 文件;
  • 随程序版本固定的翻译文件;
  • 不应由用户直接修改的静态模板。

它不适合:

  • 用户上传的文件;
  • 日志;
  • 用户配置;
  • 下载缓存;
  • 运行时生成的大文件;
  • 需要被外部程序频繁编辑的数据。

用户数据应放在系统认可的用户目录中,例如通过 QStandardPaths 获取:

from PySide6.QtCore import QStandardPaths

config_dir = QStandardPaths.writableLocation(
    QStandardPaths.StandardLocation.AppConfigLocation
)

程序资源和用户数据有不同的生命周期:

程序资源:随版本发布,通常只读
用户配置:跨版本保留,可迁移
缓存:可删除,可重建
日志:用于诊断,需轮转

把它们全部写进程序安装目录,常见后果是 Linux/macOS 权限错误、Windows 安装目录不可写,或者升级时用户配置被覆盖。

4. 使用 pyside6-project 管理资源

PySide6 提供 pyside6-project,可以从项目文件处理 Python、QML、.qrc.ui 和翻译文件。较新的 PySide6 文档中,pyproject.toml 是推荐的项目描述格式,旧的 *.pyproject 格式已被标记为迁移方向。(doc.qt.io)

示例:

[project]
name = "qt-demo"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
    "PySide6",
]

[tool.pyside6-project]
files = [
    "main.py",
    "resources/app.qrc",
]

具体工具行为可能随 PySide6 版本变化,因此项目中应固定 PySide6 版本,并在 CI 中执行资源编译和启动测试,而不是只依赖开发机上的全局工具。


八、错误处理、日志和可诊断性

GUI 程序的错误不能只打印到终端,因为发布后的用户可能看不到控制台。

Python 标准库 logging 支持按级别记录事件,包括 debuginfowarningerrorcritical;日志记录还可以包含线程信息和进程信息。(docs.python.org)

最小配置:

import logging
import sys

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s "
           "process=%(process)d thread=%(threadName)s %(message)s",
    stream=sys.stderr,
)

logger = logging.getLogger(__name__)

工作线程中:

logger.info("worker started")

try:
    result = load_records()
except Exception:
    logger.exception("load records failed")
    self.error.emit("加载失败,请查看日志")
else:
    self.result.emit(result)

logger.exception() 应在 except 块中调用,它会记录当前异常堆栈。用户界面展示可以是简短提示,而日志中保留完整上下文:

界面:加载失败,请查看日志
日志:异常类型、堆栈、文件路径、线程名、版本号、配置摘要

不要把密码、访问令牌和完整个人信息写入日志。日志是诊断数据,也属于需要保护的数据。


九、发布:从 Python 项目到用户可运行程序

1. 发布不等于复制一个 .py 文件

桌面应用通常包含:

Python 代码
PySide6 Python 绑定
Qt 动态库
平台插件
图片、图标、翻译和 QML 资源
第三方 Python 依赖
配置和数据目录约定

因此发布需要解决两个问题:

  1. 代码发现:冻结工具能否找到 Python 模块;
  2. 运行时发现:Qt 能否找到平台插件和资源。

Qt for Python 的发布选项包括普通 ZIP、Python 包、冻结为单文件或目录,以及原生安装器。官方还提供 pyside6-deploy,面向 Windows、Linux 和 macOS 的 PySide6 应用部署。(doc.qt.io)

2. 先做目录模式,再考虑单文件模式

目录模式通常类似:

dist/
├── MyApp.exe
├── PySide6/
├── Qt6Core.dll
├── Qt6Gui.dll
├── Qt6Widgets.dll
├── platforms/
└── resources/

它的优点是:

  • 启动时不需要先解包;
  • Qt 插件路径更容易诊断;
  • 崩溃后容易检查实际文件;
  • 大资源不必每次启动重新解压。

单文件模式将内容压缩到一个启动文件中,用户体验可能更简单,但启动时通常需要解包,临时目录、杀毒软件、权限和资源定位问题也更复杂。正确做法不是默认追求“单文件”,而是先让目录发布在目标平台稳定运行。

3. 使用 pyside6-deploy

一个常见流程是:

python -m pip install pyside6
pyside6-deploy main.py

首次使用时应检查生成的部署配置,确认:

  • 入口文件;
  • 应用名称;
  • 图标;
  • 需要包含的资源;
  • 构建模式;
  • 目标平台;
  • 输出目录。

pyside6-deploy 是 Qt for Python 提供的部署工具;PySide 项目文档也说明 pyside6-project 可以参与项目构建、资源和 UI 文件处理。(doc.qt.io)

如果使用 PyInstaller,应特别注意环境一致性:

python -m pip install pyinstaller
python -m PyInstaller --name QtDemo --windowed main.py

PyInstaller 的基本输出通常包含 build/dist/.spec 文件。目录模式的结果在 dist/QtDemo/ 下。Qt for Python 文档提醒,使用 PyInstaller 时应确认它使用的是当前虚拟环境中的 PySide6 和 Shiboken6,而不是系统路径中另一套版本;否则可能出现本地运行正常、发布结果却加载了旧库的情况。(doc.qt.io)

4. Qt 平台插件错误如何诊断

典型错误:

This application failed to start because no Qt platform plugin could be initialized.
Available platform plugins are: ...

这通常不是业务代码错误,而是运行时找不到或无法加载 platforms 目录中的插件,例如 Windows 下的 qwindows.dll、Linux 下的 libqxcb.so 或 macOS 下对应插件。

诊断步骤:

1. 确认目标目录存在 Qt 动态库;
2. 确认 platforms 子目录存在;
3. 确认插件架构与程序架构一致;
4. 确认目标系统缺少的系统库;
5. 检查环境变量是否覆盖了 Qt 插件搜索路径;
6. 在干净虚拟机或容器中重新验证。

在 Windows 环境中,Qt SDK 提供的 windeployqt 也常用于补齐 Qt 库和插件;但如果应用通过 PySide6 的 Python 环境发布,应先确认冻结工具和 PySide6 版本组合是否被当前部署流程支持。(doc.qt.io)

5. 资源发布验证

资源文件最容易出现“开发环境正常、发布后丢失”。验证应同时覆盖:

from PySide6.QtCore import QFile

path = ":/icons/app.png"

if not QFile.exists(path):
    raise RuntimeError(f"missing Qt resource: {path}")

启动自检可以记录:

import logging
from PySide6.QtCore import QFile

logger = logging.getLogger(__name__)

def verify_resources():
    required = [
        ":/icons/app.png",
    ]

    missing = [path for path in required if not QFile.exists(path)]
    if missing:
        raise RuntimeError(f"missing resources: {missing}")

    logger.info("resource check passed: %d files", len(required))

这里的验证目标不是测试图片是否美观,而是验证发布产物中资源是否可通过 Qt 资源树访问。


十、版本、平台和授权边界

1. Python 3.14 不是唯一版本变量

一个 PySide 应用至少受以下版本共同影响:

Python 版本
PySide6 版本
Qt 版本
操作系统版本
编译器和架构
冻结工具版本
第三方原生依赖版本

因此,不能只在 requirements.txt 中写:

PySide6

更可控的做法是固定经过测试的版本:

PySide6==6.x.y

具体版本号应由项目实际验证后确定,而不是照抄开发机当前版本。升级时至少测试:

  • 信号槽连接;
  • Model/View 编辑和刷新;
  • 线程启动、异常、取消和关闭;
  • Qt 资源;
  • Windows、Linux、macOS 目标环境;
  • 安装、卸载和升级;
  • 无 Python 开发环境的干净机器。

2. PySide6 与 PyQt 不应混用

两者都提供 Qt 的 Python 绑定,但信号、槽、打包工具和授权模型并不完全相同。例如:

# PySide6
from PySide6.QtCore import Signal, Slot

# PyQt
from PyQt6.QtCore import pyqtSignal, pyqtSlot

同一个项目中不要混用两套绑定。混用会导致类型对象、元对象、插件加载和发布依赖难以预测。

3. 发布前确认 Qt 许可义务

Qt for Python 项目同时涉及 LGPLv3/GPLv3 和 Qt 商业许可。选择许可模式、是否动态链接、是否修改 Qt、是否分发相关许可证文本等问题,应根据实际发行方式审查,而不能仅因为“代码是 Python”就认为没有 Qt 许可义务。(doc.qt.io)


十一、常见误解与失败表现

误解一:信号槽本身会自动创建后台线程

不会。

button.clicked.connect(expensive_function)

如果按钮和槽函数都在 GUI 线程,expensive_function() 仍然在 GUI 线程执行。信号槽解决的是通信和调用调度,不是自动并发。

误解二:给函数加 @Slot 就能避免卡顿

不会。

@Slot()
def expensive_function():
    do_expensive_work()

@Slot 改变的是元对象声明和连接语义,不会把函数自动搬到后台线程。必须结合 QThread、线程池或进程。

误解三:QThread 子类中的 __init__() 在新线程执行

通常不是。QThread 对象由创建它的线程拥有,构造函数也在调用它的线程执行。真正在线程中执行的是 run();而推荐的工作对象模式则把业务对象移动到线程,再通过队列调用其槽。

误解四:更新列表后调用 layoutChanged 就够了

不一定。

  • 单元格值改变:dataChanged
  • 插入行:beginInsertRows / endInsertRows
  • 删除行:beginRemoveRows / endRemoveRows
  • 整体重置:beginResetModel / endResetModel
  • 排序或布局映射改变:视情况使用布局变化通知。

通知必须描述真实的变化类型。通知错误会表现为视图显示旧值、选择错位、滚动位置跳动或代理模型索引失效。

误解五:发布后用当前目录读取资源最简单

开发阶段最简单,生产环境最脆弱。启动器、快捷方式、文件关联、服务包装器和测试框架都可能改变当前工作目录。固定内置资源应使用 Qt Resource System;用户数据应使用专门的数据目录。


十二、一个可执行的开发到发布闭环

可以把一个 PySide6 桌面程序的交付流程表示为:

flowchart TD
    A[创建 Python 3.14 虚拟环境] --> B[固定 PySide6 版本]
    B --> C[实现 QApplication 和事件循环]
    C --> D[用信号槽连接界面行为]
    D --> E[用 Model/View 管理数据]
    E --> F[把耗时任务放入 Worker/QThread]
    F --> G[用 qrc 和 pyside6-rcc 管理静态资源]
    G --> H[运行单元测试与启动测试]
    H --> I[构建目录发布产物]
    I --> J[在干净目标系统验证]
    J --> K{资源、插件、线程关闭是否正常}
    K -- 否 --> L[收集日志并修复构建配置]
    L --> I
    K -- 是 --> M[生成安装包和版本说明]

每一步都对应不同的故障类型:

阶段 主要故障
环境创建 Python、PySide6、架构不匹配
GUI 实现 事件循环被阻塞
Model/View 模型通知错误、代理索引错位
线程实现 跨线程访问控件、线程无法退出
资源编译 .qrc 未编译或资源路径错误
冻结构建 隐式导入、Qt 插件缺失
目标机验证 系统库、权限、DPI、字体和平台差异
升级交付 配置迁移失败、用户数据被覆盖

PySide6 GUI 的核心不是“把控件摆在窗口上”,而是建立清晰的所有权和数据流:

事件循环负责调度
信号槽负责通信
Model/View 负责数据呈现
QThread 负责隔离阻塞任务
资源系统负责内置文件
发布工具负责收集运行时依赖

当这些边界保持清楚时,界面、数据、后台任务和发布产物可以分别测试、分别替换,也更容易定位“卡顿来自哪里”“数据为何没刷新”“发布后插件为何丢失”这类真实问题。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。