Qt 桌面开发入门:PySide6 学习路线、架构原理与技术选型

目录

Qt、PySide6 与桌面 GUI 的技术选型

如果你打算用 Python 写桌面应用,PySide6 是一个很稳的起点。

原因很简单:

  • 它是 Qt 官方 的 Python 绑定;
  • 对应 Qt 6,和 Qt 主线同步;
  • 许可证对闭源商用更友好:LGPLv3 / GPLv3 / 商业许可;
  • 工具链完整:Designer、uic、rcc、deploy 都有;
  • 文档、示例、教程可以直接看 Qt 官方。

Qt、PyQt、PySide是什么关系

先纠正一个常见写法:Qt 不是 QT,也不是缩写 。它通常读作 "cute"。名字来源没有特别正式的全称,一般解释是 Q 是当时字体里好看的字母,t 代表 toolkit,也就是"工具包"。

Qt 最初是 C++ 跨平台应用开发框架,由挪威公司 Trolltech 开发,1995 年发布 Qt 1.0。后来公司几经转手:Trolltech → Nokia → Digia → 现在的 The Qt Company。

所以:

Qt 原生是 C++ 框架,主要用 C++ 开发。

Python 想用 Qt,就需要一层绑定。于是有了:

项目 维护方 绑定工具 许可证 特点
PyQt6 Riverbank Computing SIP GPLv3 / 商业 第三方,老牌,生态大
PySide6 Qt 公司 Shiboken6 LGPLv3 / GPLv3 / 商业 官方,和 Qt6 同步

PyQt 从 1998 年左右开始发展,比 PySide 早很多。2009 年 Nokia 推出 PySide,主要是为了提供一个官方、LGPL、对闭源商用更友好的 Python 绑定。后来 PySide 一度停滞,存在感弱,所以很多人误以为它是山寨库。直到 PySide2、PySide6 之后,它才真正翻身。

一句话:

PyQt 是第三方老牌绑定,所以先出名。

PySide 是 Qt 官方绑定,早期拉胯,后来 PySide6 成为正统选择。

PySide6 的本质,不是把 Qt 用 Python 重写了一遍,而是:

用 Shiboken6 为 Qt 的 C++ 类自动生成 Python 包装器,再编译成 CPython 扩展模块。

也就是说,你 pip install PySide6 之后,安装的 wheel 里已经有编译好的 .pyd 或 .so 文件。Python 导入 PySide6.QtWidgets 时,加载的就是这些扩展模块。

大致流程是:

  1. API Extractor 解析 Qt 头文件;
  2. 生成 typesystem XML,描述哪些类、方法、信号要暴露给 Python;
  3. Shiboken6 根据这些信息生成 C++ 包装代码;
  4. 包装代码编译成 CPython 扩展模块;
  5. Python 调用时,Shiboken 把调用转发给底层 Qt C++ 对象。

比如:

python 复制代码
from PySide6.QtWidgets import QApplication, QPushButton

app = QApplication([])
button = QPushButton("OK")
button.show()
app.exec()

在 Python 里,button 是一个 Python 对象,但它内部持有一个 C++ QPushButton* 指针。它不是 C++ 对象的复制品,而是 代理。

调用 button.show() 时,实际发生的是:

Python 方法 → Shiboken 包装层 → C++ QWidget::show()

信号槽也一样:

python 复制代码
button.clicked.connect(on_click)

底层流程是:

C++ 信号 clicked → Qt 元对象系统 → Shiboken 桥接槽 → Python 函数 on_click

所以 PySide6 不是"薄薄一层转发",而是完整的双向桥接层。它还要处理:

  • 类型转换,比如 Python str ↔ C++ QString;
  • 对象所有权和生命周期;
  • 虚函数重写,比如 Python 子类重写 mousePressEvent;
  • 异常映射;
  • 信号槽连接和自动断开。

你可以把它类比成:你自己用 Go 或 C++ 写了一个库,Python 不能直接调,必须用 pybind11、SWIG、SIP、Shiboken 这类工具包一层。PySide6 就是 Qt 官方用 Shiboken 给整个 Qt 生成的 Python 入口。

Qt 底层如何对接操作系统?

Qt 并不是直接硬编码调用 Windows、macOS、Linux 的 API,而是通过一个抽象层:QPA,Qt Platform Abstraction。

QPA 定义统一接口,不同平台用不同插件实现:

  • Windows:qwindows
  • macOS:qcocoa
  • Linux X11:qxcb
  • Linux Wayland:qwayland

Qt 启动时动态加载对应平台的 QPA 插件,所以能做到"一次编写,处处运行"。

事件循环也是如此。Qt 有自己的 QEventLoop,但需要从操作系统获取事件。QPA 中的事件分发器会把 Windows 消息泵、macOS CFRunLoop、X11 socket 事件,统一封装成 Qt 的 QEvent,再分发给应用。

渲染方面:

  • Qt Widgets:默认使用光栅引擎,在 CPU 上软件绘制;
  • Qt Quick / QML:使用场景图和 RHI,对接 OpenGL、Vulkan、Direct3D、Metal,走 GPU 加速。

注意:Qt Widgets 的按钮、文本框等,大多不是操作系统的原生控件,而是 Qt 自己绘制的。它通过 QPA 和样式模拟原生外观,但不是真正的原生控件。

对比WebView 类 GUI

WebView 类 GUI 库,比如 Electron、Tauri、Wails,核心是用 HTML/CSS/JS 构建界面。它们和 Qt 是两条路线。

对比维度 Qt / PySide6 WebView 类库
渲染方式 原生绘制,Widgets 走 CPU 光栅,Quick 走 GPU 复用浏览器引擎,如 Chromium 或系统 WebView
内存占用 通常 50MB - 100MB 起步 常见 120MB - 300MB
启动速度 较快 较慢,需要初始化浏览器运行时
打包体积 约 20MB - 60MB 如果打包 Chromium,可达 85MB - 200MB
技术栈 Python / C++ / QML HTML / CSS / JavaScript / TypeScript
UI 风格 通过 QPA 模拟原生,但并非真正原生控件 完全由 Web 技术决定,跨平台一致,但与系统风格差异大
开发效率 C++ 门槛高,QML 效率高;PySide6 对 Python 用户友好 Web 团队上手极快,生态庞大
适用场景 高性能、低资源、长生命周期、嵌入式、工控 快速迭代、复杂 UI、Web 团队、跨平台应用

简单说:

  • 追求 性能、资源控制、长期维护,选 Qt / PySide6;
  • 追求 快速开发、Web 生态、UI 灵活,选 WebView 类方案;
  • 如果团队本来就是 Web 技术栈,Electron / Tauri / Wails 很自然;
  • 如果要做工控、嵌入式、桌面工具、低资源应用,Qt 更合适。

PySide6 学习路线图

官方文档目录不是线性教材。不要从 Modules API 开始通读,会劝退。推荐顺序是:

Package Details → Getting Started → Tutorials → Examples → QtCore / QtGui / QtWidgets API → Tools → Deployment,其余按需查。

具体阶段:

阶段 1:安装与入门

  • 建虚拟环境,pip install PySide6;
  • 看 Getting Started;
  • 搞懂 QApplication、QWidget、QMainWindow、事件循环、信号槽;
  • 目标:写出带按钮、输入框、布局的窗口。

阶段 2:教程与示例

  • 按顺序做官方 Tutorials;
  • 跑 Examples,先看 widgets,再看 qml;
  • 每个示例都改参数、改控件、改布局;
  • 目标:能模仿官方示例写小工具。

阶段 3:核心 API

重点模块顺序:

  1. QtCore :QObject、Signal、Slot、QTimer、QThread、QSettings、QFile、QProcess
  2. QtGui :QIcon、QAction、QPainter、QFont、QColor
  3. QtWidgets :QApplication、QMainWindow、QWidget、QDialog、常用控件、布局、QMessageBox、QFileDialog
  4. 进阶:QtNetwork、QtSql、QtMultimedia、QtQml、QtQuick

学 Qt 的核心不是背 API,而是理解:

  • 信号槽
  • 对象树与父子关系
  • 布局管理器
  • 事件循环
  • 模型 / 视图
  • QML 与 Python 交互

阶段 4:工具链

  • pyside6-designer:拖拽做 UI
  • pyside6-uic:.ui 转 Python
  • pyside6-rcc:资源文件
  • pyside6-lupdate / pyside6-lrelease:国际化
  • pyside6-deploy:部署

阶段 5:部署与发布

  • pyside6-deploy
  • PyInstaller / Nuitka
  • Windows、macOS、Linux 打包差异

阶段 6:按需阅读

  • Commercial Use:商用、闭源发布时看许可证
  • Security Considerations:网络、插件、不可信 QML
  • Considerations:跨平台注意事项
  • Release Notes:升级版本时看
  • Building from Source / Developer Notes:想贡献或特殊编译时看
  • Module Index:当字典查

推荐小项目顺序:

  1. 计数器窗口
  2. 记事本
  3. 待办清单
  4. 图片查看器
  5. SQLite 增删改查
  6. 多线程下载器
  7. 用 Designer 重构 UI
  8. 打包成 exe
  9. 尝试 QML 仪表盘

Widgets 还是 QML?

  • 传统桌面软件、工具、后台管理:先学 Qt Widgets;
  • 现代 UI、动画、触摸、移动端:学 Qt Quick / QML;
  • 不确定:先 Widgets,再 QML。

构建与发布工具

PySide6 打包发布,主要有三个选择。

  1. pyside6-deploy:官方推荐

    这是 Qt 官方从 6.4 开始提供的部署工具,本质是对 Nuitka 的封装。

    优点:

    • 一条命令即可打包;
    • 与 PySide6 兼容性最好;
    • 官方主推;
    • 生成结果经过优化。

    用法:

    bash 复制代码
    pyside6-deploy main.py

    首次运行会生成 pysidedeploy.spec,后续可以改配置。

  2. PyInstaller:社区老牌

    优点:

    • 上手极快;
    • 文档和社区资源极多;
    • 复杂依赖兼容性好。

    缺点:

    • 打包体积大;
    • 启动速度相对慢;
    • 代码保护一般。

    常用命令:

    bash 复制代码
    pyinstaller -F -w main.py
  3. Nuitka:编译型方案

    Nuitka 把 Python 编译成 C,再生成原生可执行文件。

    优点:

    • 体积更小;
    • 启动速度极快;
    • 内存占用低;
    • 代码保护更强。

    缺点:

    • 打包过程慢;
    • 某些复杂库需要调试;
    • 社区规模小于 PyInstaller。

    常用命令:

    bash 复制代码
    nuitka --onefile --plugin-enable=pyside6 --windows-disable-console main.py

安装与入门

环境准备

PySide6 官方要求 Python 3.7 及以上,但实际开发建议:

  • 最低:Python 3.9
  • 推荐:Python 3.11 或 3.12
  • 不建议:Python 3.13 刚发布时,部分第三方库可能还没跟上

你可以先确认版本:

bash 复制代码
python --version

或者:

bash 复制代码
python3 --version

强烈建议每个项目单独建虚拟环境。原因:

  • PySide6 体积不小,全局安装容易污染环境;
  • 不同项目可能依赖不同版本;
  • 打包时虚拟环境更干净,产物更小。

创建虚拟环境:

bash 复制代码
python -m venv venv

激活:

Windows:

bash 复制代码
venv\Scripts\activate

macOS / Linux:

bash 复制代码
source venv/bin/activate

激活后,命令行前面会出现 (venv)。

安装 PySide6

bash 复制代码
pip install PySide6

如果你想指定版本:

bash 复制代码
pip install PySide6==6.7.0

安装完成后,可以验证:

bash 复制代码
python -c "import PySide6; print(PySide6.__version__)"

正常会输出类似:

复制代码
6.7.0

pip install PySide6 会安装一组包:

包 作用
PySide6 元包,依赖下面这些
PySide6-Essentials 核心模块:QtCore、QtGui、QtWidgets 等
PySide6-Addons 附加模块:QtNetwork、QtSql、QtMultimedia 等
shiboken6 绑定运行时,Python ↔ C++ 桥接

如果你只做基础桌面开发,可以只装:

bash 复制代码
pip install PySide6-Essentials

但一般直接装 PySide6 更省事。

PySide6 自带一组命令行工具,安装后可以直接用:

bash 复制代码
pyside6-designer
pyside6-uic
pyside6-rcc
pyside6-deploy

后面章节会讲。现在可以先确认:

bash 复制代码
pyside6-designer --version

第一个窗口

新建一个文件 main.py:

python 复制代码
import sys
from PySide6.QtWidgets import QApplication, QWidget

app = QApplication(sys.argv)

window = QWidget()
window.setWindowTitle("第一个 PySide6 应用")
window.resize(400, 300)
window.show()

sys.exit(app.exec())

运行:

bash 复制代码
python main.py

你会看到一个空白窗口。

代码解释

import sys

sys.argv 要传给 QApplication,让 Qt 能处理命令行参数。

from PySide6.QtWidgets import QApplication, QWidget

QApplication 是应用对象,QWidget 是基础窗口。

app = QApplication(sys.argv)

每个 PySide6 程序有且只有一个 QApplication。它负责:

  • 初始化 Qt;
  • 加载平台插件,比如 Windows 的 qwindows;
  • 管理事件循环;
  • 处理全局设置。

必须在创建任何窗口之前创建它。

window = QWidget()

创建一个顶层窗口。QWidget 是所有可视控件的基类。

window.show()

让窗口可见。不调用它,窗口不会显示。

sys.exit(app.exec())

进入事件循环,并把退出码传给系统。

app.exec() 是 Qt 程序的核心。

它做一件事:

不断从操作系统获取事件,分发给对应的窗口和控件。

事件包括:

  • 鼠标点击
  • 键盘输入
  • 窗口重绘
  • 定时器
  • 网络事件
  • 信号槽调用

你可以把它想成一个 while 循环:

python 复制代码
while True:
    event = get_event_from_os()
    dispatch(event)

但这个循环是 Qt 在 C++ 层实现的,你不需要自己写。

如果没有 app.exec(),程序创建完窗口就会立刻退出。窗口一闪而过,就是因为没进事件循环。

为什么用 sys.exit(app.exec()) ?

app.exec() 返回一个整数退出码。sys.exit() 把它传给操作系统。

这是一种规范写法,也可以写成:

python 复制代码
app.exec()

但推荐前者。

加一个按钮:信号槽初体验

现在给窗口加一个按钮,点击后改变标签文字。

python 复制代码
import sys
from PySide6.QtWidgets import (
    QApplication, QWidget, QPushButton, QLabel, QVBoxLayout
)

app = QApplication(sys.argv)

window = QWidget()
window.setWindowTitle("信号槽示例")
window.resize(400, 300)

layout = QVBoxLayout()

label = QLabel("等待点击")
button = QPushButton("点我")

def on_click():
    label.setText("按钮被点击了!")

button.clicked.connect(on_click)

layout.addWidget(label)
layout.addWidget(button)
window.setLayout(layout)

window.show()
sys.exit(app.exec())

运行后,点击按钮,标签文字会变化,

代码解释:

QPushButton("点我")

创建一个按钮,文字是"点我"。

button.clicked

这是按钮的信号。clicked 是 QPushButton 从 QAbstractButton 继承来的信号,点击时发射。

.connect(on_click)

把信号连接到 Python 函数 on_click。

on_click

这是槽函数。信号发射时,Qt 调用它。

信号槽是 Qt 的核心机制:

一个对象状态变化时,发射信号;连接过的槽函数自动被调用。

它和回调函数类似,但更强大:

  • 一个信号可以连接多个槽;
  • 一个槽可以连接多个信号;
  • 信号可以连接信号;
  • 跨线程连接时,Qt 自动处理线程安全。

在 PySide6 里,信号槽底层通过 Shiboken 桥接:

C++ 信号 → Qt 元对象系统 → Shiboken 桥接槽 → Python 函数

所以 button.clicked.connect(on_click) 不只是在 Python 里存了个回调,而是真正把 Python 函数注册到了 Qt 的信号槽系统里。

布局管理器

上面的代码用了 QVBoxLayout。布局管理器负责自动排列控件。

不要用 move() 和 resize() 手动定位。原因:

  • 窗口大小变化时,手动定位不会自适应;
  • 不同平台字体、DPI 不同,手动定位容易错位;
  • 多语言文本长度不同,手动定位更麻烦。

常用布局:

布局 作用
QVBoxLayout 垂直排列
QHBoxLayout 水平排列
QGridLayout 网格排列
QFormLayout 表单:左标签右输入
QStackedLayout 堆叠,一次显示一个

布局可以嵌套:

python 复制代码
from PySide6.QtWidgets import (
    QApplication, QWidget, QPushButton,
    QVBoxLayout, QHBoxLayout
)
import sys

app = QApplication(sys.argv)

window = QWidget()
window.setWindowTitle("嵌套布局")

main_layout = QVBoxLayout()

top_layout = QHBoxLayout()
top_layout.addWidget(QPushButton("左"))
top_layout.addWidget(QPushButton("右"))

bottom_layout = QHBoxLayout()
bottom_layout.addWidget(QPushButton("确定"))
bottom_layout.addWidget(QPushButton("取消"))

main_layout.addLayout(top_layout)
main_layout.addLayout(bottom_layout)

window.setLayout(main_layout)
window.show()

sys.exit(app.exec())

除了 layout.addWidget(),也可以直接给控件指定父对象:

python 复制代码
button = QPushButton("OK", parent=window)

但更推荐用布局管理。布局会自动设置父子关系。

包含的工具

所有工具都必须通过 PySide 包装器来使用,而不是直接使用。例如,如果你在安装目录中浏览 site-packages/ 目录,发现了一个 uic.exe(在 Windows 上),你不应该直接点击它,而应该使用 pyside6-uic.exe。原因是:包装器会正确设置 PATH、插件等,以便与已安装的 Python 包正常工作。

  1. pyside6-project

    从 pyproject.toml 文件构建 Qt Widgets Designer 表单(.ui 文件)、资源文件(.qrc)和 QML 类型文件(.qmltype)。

  2. pyside6-designer

    用于设计 Widget UI 的拖拽式工具(生成 .ui 文件,参见《从 Designer 或 QtCreator 使用 .ui 文件:QUiLoader 与 pyside6-uic》)。

  3. pyside6-uic

    从 .ui 表单文件生成 Python 代码。

  4. pyside6-rcc

    从 .qrc 资源文件生成序列化数据。请记住,这些文件也可以用于其他非 Widget 项目。

  5. pyside6-qmllint

    验证 QML 文件的语法有效性。

  6. pyside6-qmltyperegistrar

    读取元类型文件,并生成包含必要代码的文件,以注册所有用相关宏标记的类型。

  7. pyside6-qmlimportscanner

    识别从项目/QML 文件导入的 QML 模块,并将结果输出为 JSON 数组。

  8. pyside6-qmlcachegen

    在编译时将 QML 编译为字节码,以便打包进二进制文件。

  9. pyside6-qmlsc

    取代 pyside6-qmlcachegen。该工具除了为它能彻底分析的函数生成字节码外,还会生成 C++ 代码。

  10. pyside6-qml

    用于快速进行 QML 文件原型开发。该工具通过直接调用 QQmlEngine/QQuickView,模拟了 Qt 的 QML 运行时实用工具的部分能力。

  11. pyside6-svgtoqml

    将 SVG 文件转换为 QML 文件。

  12. pyside6-linguist

    用于翻译应用程序中的文本。

  13. pyside6-lrelease

    为应用程序创建运行时翻译文件。

  14. pyside6-lupdate

    同步源代码和翻译。

  15. pyside6-assistant

    用于查看 Qt 帮助文件格式的在线文档。有关格式的更多信息,请参阅 QtHelp 框架页面。

  16. pyside6-genpyi

    为 Qt 模块生成 Python 存根(.pyi 文件)。

  17. pyside6-metaobjectdump

    一个以 JSON 格式打印元类型信息的工具,用作 qmltyperegistrar 的输入。

  18. Qt Python VSCode Extension

    用于 PySide6 开发的 Visual Studio Code 扩展,包含项目模板、调试、构建任务等功能。

  19. pyside6-deploy

    将 PySide6 应用程序部署到桌面平台------Linux、Windows 和 macOS。

  20. pyside6-android-deploy

    将 PySide6 应用程序部署为 Android 应用,目标平台包括 aarch64、x86_64。

  21. pyside6-qsb着色器工具

    Qt Shader Tools 模块提供的命令行工具,用于生成和检查 .qsb 文件。

  22. pyside6-balsam

    一个命令行工具,它接收在 Maya、3ds Max 或 Blender 等数字内容创建工具中创建的资产,并将其转换为高效运行时格式,以供 Qt Quick 3D 使用。

  23. pyside6-balsamui

    pyside6-balsam 工具的图形用户界面。

一个稍微完整的小例子:计数器

把前面学的串起来:

python 复制代码
import sys
from PySide6.QtWidgets import (
    QApplication, QWidget, QPushButton, QLabel,
    QVBoxLayout, QHBoxLayout
)
from PySide6.QtCore import Qt


class CounterWindow(QWidget):
    def __init__(self):
        super().__init__()
        self.count = 0
        self.setWindowTitle("计数器")
        self.resize(300, 200)

        self.label = QLabel("0")
        self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        font = self.label.font()
        font.setPointSize(32)
        self.label.setFont(font)

        self.btn_inc = QPushButton("+1")
        self.btn_dec = QPushButton("-1")
        self.btn_reset = QPushButton("归零")

        self.btn_inc.clicked.connect(self.increment)
        self.btn_dec.clicked.connect(self.decrement)
        self.btn_reset.clicked.connect(self.reset)

        btn_layout = QHBoxLayout()
        btn_layout.addWidget(self.btn_dec)
        btn_layout.addWidget(self.btn_reset)
        btn_layout.addWidget(self.btn_inc)

        main_layout = QVBoxLayout()
        main_layout.addWidget(self.label)
        main_layout.addLayout(btn_layout)
        self.setLayout(main_layout)

    def increment(self):
        self.count += 1
        self.label.setText(str(self.count))

    def decrement(self):
        self.count -= 1
        self.label.setText(str(self.count))

    def reset(self):
        self.count = 0
        self.label.setText(str(self.count))


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

这个例子包含了:

  • 继承 QWidget 自定义窗口;
  • 使用 QLabel、QPushButton;
  • 使用 QVBoxLayout 和 QHBoxLayout;
  • 信号槽连接;
  • 修改控件属性。

关于类式写法

前面用函数式写法,适合演示。实际项目建议用类式~

  • 状态存在 self 上;
  • 槽函数是方法,方便访问控件;
  • 便于扩展和维护。

把计算器案例打包成可执行文件,首选的工具是 Qt 官方提供的 pyside6-deploy 。它本质上是高性能编译工具 Nuitka 的官方封装,使用起来最省心,生成的程序体积小、启动也快。

这是最推荐的方式,整个过程非常自动化。

  1. 在项目文件夹中打开终端,确保你的虚拟环境已激活。

  2. 执行打包命令:

    bash 复制代码
    pyside6-deploy main.py

    如果你的主程序文件就叫 main.py,甚至可以直接运行 pyside6-deploy,它会自动找到该文件。

  3. 等待完成 :工具会自动安装所需依赖并编译。首次运行时,它会在项目目录下生成一个 pysidedeploy.spec 配置文件,后续打包会复用其中的设置。

  4. 找到成品 :打包完成后,可执行文件会生成在项目目录下的 deployment 文件夹内。

备选方案:PyInstaller (社区主流),PyInstaller 是老牌的打包工具,社区资源丰富,适合处理依赖复杂的项目。

  1. 安装 PyInstaller:

    bash 复制代码
    pip install pyinstaller
  2. 执行打包命令:

    • 打包成单个文件 (推荐,方便分发):

      bash 复制代码
      pyinstaller -F -w main.py

      -F 表示打包成单个可执行文件;-w 表示运行时不显示黑色控制台窗口。

    • 打包成文件夹 (启动速度稍快):

      bash 复制代码
      pyinstaller -D main.py
  3. 找到成品 :生成的程序会出现在项目目录下的 dist 文件夹中。

如果你想绕过 pyside6-deploy 进行更精细的控制,也可以直接调用 Nuitka。它能将 Python 代码编译为 C 代码,性能优势明显。

  1. 安装 Nuitka:

    bash 复制代码
    pip install nuitka
  2. 执行打包命令:

    bash 复制代码
    python -m nuitka --standalone --onefile --enable-plugin=pyside6 --windows-disable-console main.py

    关键参数说明:

    • --standalone:生成独立的文件夹,包含所有依赖库。
    • --onefile:将所有文件打包成一个单独的 .exe。
    • --enable-plugin=pyside6:必须添加,确保 Nuitka 能正确处理 PySide6 的插件和资源。
    • --windows-disable-console:隐藏控制台窗口(Windows 系统)。
  3. 找到成品:可执行文件通常会生成在当前目录或指定的输出目录中。

⚠️ 常见问题与注意事项

  • 虚拟环境 :打包时,不要 把你的虚拟环境文件夹(如 venv)放在项目目录内部。pyside6-deploy 等工具可能会尝试打包整个 venv 文件夹,导致最终文件异常巨大或打包失败。
  • 自定义图标 :如果想给程序换个图标,对于 pyside6-deploy,可以在生成的 pysidedeploy.spec 文件中找到 nuitka 部分,添加 --windows-icon-from-ico=你的图标.ico 参数。使用 PyInstaller 时,可以直接在命令中加入 -i "你的图标.ico"。
  • 文件体积 :如果觉得打包后的程序太大,可以在使用 PyInstaller 时配合 UPX 工具进行压缩。UPX 能显著减小最终可执行文件的体积。
  • 平台限制 :这些打包工具都不支持交叉编译 。例如,你无法在 Linux 系统上打包出 Windows 的 .exe 文件。你需要在哪个平台上运行程序,就在哪个平台上进行打包。

安装完后,我们会得到一个dist的目录和一个spec的规格文件:

首先,打包后出现一堆 DLL 和一个控制台窗口,这两个都是 pyside6-deploy(底层是 Nuitka)的正常行为,分别修改配置就能解决。

📁 为什么会有很多 DLL 文件?

这些 DLL 是 Qt 框架和 PySide6 运行所必需的动态链接库,不是打包出错了。

pyside6-deploy 默认采用 standalone(独立文件夹)模式 。它会将你的 main.exe 和所有依赖的 DLL、插件等,完整地放进一个文件夹里。这样程序才能独立运行,不依赖你电脑上已安装的 Python 或 PySide6。

如果你希望只生成一个 .exe 文件 ,可以将打包模式改为 onefile。操作方法是:

  1. 打开项目文件夹中自动生成的 pysidedeploy.spec 文件。

  2. 找到 mode 这一行,将其值从 standalone 修改为 onefile :

    ini 复制代码
    # pysidedeploy.spec
    ...
    mode = onefile
    ...
  3. 重新运行打包命令 pyside6-deploy main.py。

之后,所有依赖都会被封装进单个 .exe 文件中。

🖥️ 如何隐藏弹出的终端窗口?

因为 Nuitka 默认将程序视为控制台应用,所以启动时会弹出一个终端窗口来显示输出信息。

隐藏它的方法同样是在 pysidedeploy.spec 文件中配置 Nuitka 的参数。找到 [nuitka] 部分下的 extra_args,添加 --windows-console-mode=disable 参数:

ini 复制代码
# pysidedeploy.spec
[nuitka]
...
extra_args = --windows-console-mode=disable
...

保存后重新打包,终端窗口就不会再出现了。

补充 :在 Nuitka 的旧版本中,这个参数可能写作 --windows-disable-console,但新版本推荐使用 --windows-console-mode=disable。

修改配置后,重新运行 pyside6-deploy main.py,就能得到一个干净、无终端窗口的单个可执行文件了。

常见问题

窗口一闪而过

原因:没有调用 app.exec(),或者 window 是局部变量被回收。

解决:

python 复制代码
window.show()
sys.exit(app.exec())

并确保 window 在事件循环期间一直存在。

中文乱码

PySide6 对 Unicode 支持很好,一般不会乱码。

如果 Windows 控制台输出乱码,是终端编码问题,不是 Qt 问题。可以设置:

python 复制代码
import sys
sys.stdout.reconfigure(encoding="utf-8")

高 DPI 缩放

Qt 6 默认支持高 DPI。一般不需要额外设置。

如果发现界面模糊,可以检查:

python 复制代码
QApplication.setHighDpiScaleFactorRoundingPolicy(
    Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)

但多数情况下,默认行为已经够好。

找不到平台插件

报错类似:

复制代码
qt.qpa.plugin: Could not load the Qt platform plugin "windows"

常见原因:

  • 虚拟环境不完整;
  • 打包时没带上 platforms 插件;
  • 环境变量 QT_QPA_PLATFORM_PLUGIN_PATH 被错误设置。

解决:

  • 重新安装 PySide6;
  • 打包时确保包含 PySide6/plugins/platforms;
  • 不要手动设置插件路径,除非你很清楚在做什么。

教程与示例

Qt、QML、Widgets......到底有什么区别?

刚接触 Qt 的时候,最容易晕的就是这几个词:Qt、QtCore、QtWidgets、QML、Qt Quick。它们长得像,关系又绕。

这一节先把概念理清楚。理解这张地图之后,后面看教程和示例就不会迷路。

Qt 可以指两样东西:

  • Qt 项目:一个开放协作组织,负责协调 Qt 框架的开发;
  • Qt 框架:这个组织产出的开发框架本身。

日常说"学 Qt",一般指后者。

Qt 框架不是一个巨大的单体库,而是按组件(component) 和 模块(module) 拆分的。

最基础的组件叫 qtbase,它包含很多模块:

模块 作用
QtCore 核心非 GUI 功能:文件、时间、字节数组、线程、信号槽基类
QtGui 图形基础:字体、颜色、图像、绘图
QtWidgets 传统桌面控件:按钮、标签、菜单、布局
QtNetwork 网络通信

每个模块里有很多可以直接用的类。比如 QtCore 里有:

  • QFile:文件操作
  • QTime:时间
  • QByteArray:字节数组

所以 Qt 不只是"做界面的"。你可以完全不用界面,只用 QtCore 写命令行工具、处理文件、连网络、跑正则、做文本编码。

两条 GUI 路线:Widgets 和 QML

Qt 做图形界面有两条并行的路线,这是新手最容易混淆的地方。

QtWidgets QML / Qt Quick
界面怎么写 Python 代码或 .ui 文件 .qml 声明式文件
风格 贴近系统原生 更动态、更现代
起源 传统桌面 移动应用
适合 桌面工具、后台管理、工控 动画、触摸、移动端、现代 UI

它们是两套东西,不是新旧替代关系。选哪条,取决于项目需求。

路线一:Widgets

QtWidgets 模块提供一整套预定义控件,你直接拿来往窗口里放:

  • 按钮:QPushButton
  • 标签:QLabel
  • 输入框:QLineEdit
  • 菜单:QMenu
  • 布局:QVBoxLayout、QHBoxLayout

基于 Widgets 的应用,目标是看起来像原生应用。它在 Windows 上像 Windows 程序,在 macOS 上像 macOS 程序,尽量不破坏用户的使用习惯。

注意

你可以给 Widgets 应用换自制样式,但 Widgets 的设计目标是尊重系统风格。改颜色、改主题时要谨慎,否则容易做出"四不像"的界面。

Widgets 是传统桌面开发的主力。

路线二:QML 和 Qt Quick

QML 是另一条创建用户界面的路线,最初是为了移动应用开发而设计的。

它和 Qt Quick 模块配合使用,提供这些能力:

  • 点击、拖放
  • 动画
  • 状态和过渡
  • 抽屉菜单
  • 触摸交互

QML 的元素设计更偏向动态界面:根据某些行为改变属性,做出流畅的交互效果。

虽然 QML 的初衷是移动端,但桌面应用也完全可以用。很多现代风格的桌面软件就是用 QML 做的。

QML 还可以直接用标准 JavaScript 写逻辑。JavaScript + QML + Python/C++,可以搭出很灵活的应用架构。

QML 和 Widgets 的区别,可以简单记成:

Widgets 是"摆控件",QML 是"描述界面状态"。

PySide6 的目标是让你不需要懂 C++ 也能用 Qt。但在某些场景下,两种语言可以混合使用。

官方列了四种典型场景:

1. 把 Qt/C++ 应用改写成 Qt/Python 应用

如果你有一个现成的 Qt/C++ 应用,可以用 Python 重写用户层代码。Python 在这里是 C++ 用户代码的完整替代品。

2. 给 C++ 自定义 Widget 生成 Python 绑定

你写了一个 C++ 的自定义控件,想让 Python 也能直接用,可以为它生成 Python 绑定。

3. 给 C++ 库生成 Python 绑定

你有一个 C++ 库,负责高性能计算之类的任务,想从 Python 调用它,也可以生成绑定。

4. 给 Qt/C++ 应用加 Python 插件系统

你有一个 Qt/C++ 主程序,想把 QApplication 单例暴露给 Python 解释器,让 Python 脚本扩展它。这相当于给你的 C++ 应用加了一套"Python 插件系统"。

第 2、3、4 种场景都需要 Shiboken。它就是前面讲过的绑定生成工具,PySide6 本身就是用它生成的。

不过对大多数 Python 开发者来说,这四种场景暂时都用不到。知道"可以混,但不必混"就够了。

文件类型:不是每种文件都必须写

很多人刚接触 PySide6 时会有一种预期:我不是就写 Python 吗?为什么项目里还会冒出 .ui、.qrc、.qml、pyproject.toml?

先说结论:

只写 .py 完全可以做出完整的 PySide6 应用。

其他文件不是必需品,而是"可选工具"。它们存在的意义是:把某些重复、繁琐、适合可视化或声明式处理的东西,从 Python 代码里拆出去。

你可以先把这几种文件理解成下面这张表:

文件 是什么 不用行不行 什么时候用
.py Python 源码 不行,必须有 所有项目
.ui Designer 画出来的界面描述 行,可以纯代码写界面 界面复杂、想拖拽设计时
.qrc 资源清单:图片、字体、图标等 行,可以运行时读文件 想把资源打进程序里时
.qml QML 声明式界面 行,但用 QML 就必须有 做 Qt Quick / QML 界面时
pyproject.toml Python 项目配置 行,但推荐有 现代 Python 项目、工具链配置
.pyproject 旧版 Qt Creator 项目文件 行,已弃用 老项目兼容

下面逐个说。

.py:主战场

Python 文件是你开发 PySide6 项目时主要处理的格式。

一个最小可运行项目,可以只有一个 .py:

python 复制代码
class MyWidget(QWidget):
    def __init__(self):
        QWidget.__init__(self)

        self.hello = ["Hallo Welt", "你好,世界", "Hei maailma",
            "Hola Mundo", "Привет мир"]

        self.button = QPushButton("Click me!")
        self.text = QLabel("Hello World")
        self.text.setAlignment(Qt.AlignCenter)
        # ...

你可以用纯 Python 创建窗口、布局、按钮、信号槽,不需要任何其他文件。

所以如果你现在只想入门,先把 .py 写好就行。下面这些文件等你有具体需求再引入。

.ui:给 Designer 用的界面描述文件

.ui 是 Qt Widgets Designer 保存的界面文件。Designer 是一个拖拽式界面编辑器,你拖按钮、拉布局,它把结果存成 .ui。

.ui 本质是 XML,描述一棵控件树:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
 <class>MainWindow</class>
 <widget class="QMainWindow" name="MainWindow">
  <property name="geometry">
   <rect>
    <x>0</x>
    <y>0</y>
    <width>400</width>
    <height>300</height>
   </rect>
  </property>
  <property name="windowTitle">
   <string>MainWindow</string>
  </property>
  <widget class="QWidget" name="centralWidget">

它解决什么问题?

界面特别复杂时,纯 Python 写几十个控件和布局会很啰嗦。Designer 可以可视化拖拽,所见即所得。

怎么变成 Python?

用 pyside6-uic:

bash 复制代码
pyside6-uic mainwindow.ui -o ui_mainwindow.py

生成的 ui_mainwindow.py 就是普通 Python 代码,你在主程序里导入即可。

部署时要带 .ui 吗?

不需要。因为 .ui 已经在开发阶段被 pyside6-uic 转成 .py 了,运行时只认 Python 文件。

不用行不行?

完全行。界面不复杂,或者你更喜欢手写布局,就纯 Python 写,不需要 .ui。

.qrc:资源清单文件

.qrc 用来列出一组二进制资源,比如图片、字体、图标、音频:

xml 复制代码
<!DOCTYPE RCC><RCC version="1.0">
<qresource>
    <file>images/quit.png</file>
    <file>font/myfont.ttf</file>
</qresource>
</RCC>

它解决什么问题?

如果你在代码里直接写:

python 复制代码
icon = QIcon("images/quit.png")

那部署时就得把 images/ 目录一起带上,路径还不能错。.qrc 的思路是:把这些资源编译进 Python 代码里,运行时不再依赖外部文件路径。

怎么变成 Python?

用 pyside6-rcc:

bash 复制代码
pyside6-rcc resources.qrc -o resources_rc.py

生成的 resources_rc.py 里包含资源的二进制数据。导入它之后,可以用 :/images/quit.png 这种资源路径访问:

python 复制代码
import resources_rc  # noqa: F401
icon = QIcon(":/images/quit.png")

部署时要带原始图片吗?

不需要。资源已经编进 resources_rc.py 了。

不用行不行?

行。资源少、路径固定,直接读文件也行。但资源一多,或者你想打包成单文件,.qrc 会省很多事。

.qml:QML 界面文件

.qml 属于另一条技术路线:Qt Quick / QML,不是 Qt Widgets。

QML 是一种声明式语言,语法类似 JSON + CSS + JavaScript:

qml 复制代码
import QtQuick 2.0

Rectangle {
    id: page
    width: 320;
    height: 480
    color: "lightgray"

    Text {
        id: helloText
        text: "Hello world!"
        y: 30
        anchors.horizontalCenter: page.horizontalCenter
        font.pointSize: 24;
        font.bold: true
    }
}

这段代码会显示一个浅灰色矩形,上面有"Hello World!"。

它和 .ui 有什么区别?

  • .ui 是 Qt Widgets 的界面描述,最终转成 Python;
  • .qml 是 Qt Quick 的界面描述,运行时由 QML 引擎解释执行。

两者不是同一套东西。QML 项目通常的结构是:

text 复制代码
main.py      # 加载 QML
Main.qml     # 界面

Python 负责逻辑,QML 负责界面,两者可以通过注册类型、属性、信号槽交互。

不用行不行?

如果你做 Widgets 应用,不需要 .qml。如果你做 Qt Quick 应用,.qml 是核心,必须有。

pyproject.toml:现代 Python 项目配置

pyproject.toml 是 Python 项目的现代标准配置文件。

Qt Creator 16.0 开始支持它,PySide6 6.9.0 也引入了支持,官方推荐使用。

它通常放这些内容:

  • 项目元信息:名称、版本、描述;
  • 依赖列表;
  • 构建工具配置;
  • 工具链配置,比如 pyside6-project 读取它来构建 .ui、.qrc、.qmltype。

一个简单例子:

toml 复制代码
[project]
name = "my-app"
version = "0.1.0"
dependencies = ["PySide6"]

不用行不行?

行。你不配 pyproject.toml,直接用 pip install 和 python main.py 也能开发。

但如果你想要:

  • 规范管理依赖;
  • 用 pyside6-project 统一构建资源;
  • 让 Qt Creator 更好地识别项目;
  • 后续打包发布更顺;

那推荐加上。

小结:什么时候引入哪种文件

你可以按这个顺序逐步引入,不必一上来全用:

  1. 先只写 .py

    把窗口、布局、信号槽、事件循环搞清楚。

  2. 界面复杂了,引入 .ui

    用 Designer 拖界面,pyside6-uic 转 Python。

  3. 资源多了,引入 .qrc

    图片、字体、图标编进代码,pyside6-rcc 转 Python。

  4. 做 Qt Quick,引入 .qml

    这是另一条路线,和 Widgets 并列。

  5. 项目规范了,加 pyproject.toml

    管理依赖、构建、工具链配置。

核心心态:

.py 是必选项,其他都是可选项。

每种文件都是为了解决某类具体问题才出现的,不需要为了"完整"而全部引入。

QML 和 Widgets 能做出相同的东西吗?

理论上能,实际上不建议硬做。 两者都是图灵完备的 UI 技术,任何界面原则上都能用另一条路线复刻出来。但开发体验、性能表现、代码量、可维护性差别很大。选错路线,能做出来,但会做得很累。

  • 想用 Widgets 做动画?可以,QPropertyAnimation、QGraphicsView 都能实现。
  • 想用 QML 做复杂表格?可以,TableView、自定义委托、甚至嵌入 QWidget 都能实现。
  • 想用 Widgets 做触摸滑动菜单?可以,自己处理鼠标/触摸事件。
  • 想用 QML 做传统桌面表单?可以,Qt Quick Controls 提供了按钮、输入框、下拉框等。

所以问题不是"能不能",而是"顺不顺"。实际上:各有强项。

Widgets 更顺手的场景

场景 原因
复杂数据表格 QTableView + Model/View 框架成熟,排序、过滤、编辑、委托开箱即用
传统桌面表单 QFormLayout、QDialogButtonBox 等控件现成
菜单栏、工具栏、状态栏 QMainWindow 原生支持
多文档界面 QMdiArea 现成
需要贴近系统原生外观 Widgets 默认跟随系统风格
低资源环境 软件渲染,CPU 占用可控,不依赖 GPU

QML 更顺手的场景

场景 原因
流畅动画 属性动画、状态过渡、粒子效果是 QML 的一等公民
触摸交互 手势、拖拽、滑动、多点触控原生支持
现代扁平化 UI 声明式布局,改样式方便
响应式布局 锚点、绑定、状态机,窗口缩放自适应
嵌入式 / 移动端 硬件加速渲染,适合 GPU 环境
设计师参与 QML 语法接近 JSON/CSS,设计师能看懂

例子 1:一个带动画的侧边栏

Widgets 做法:

  • 用 QWidget 做侧边栏;
  • 用 QPropertyAnimation 动画 geometry;
  • 自己处理展开/收起状态;
  • 动画曲线、缓动函数要手动配。

能做,但代码量不小,动画效果也偏"机械"。

QML 做法:

qml 复制代码
Rectangle {
    id: sidebar
    width: expanded ? 240 : 60
    Behavior on width { NumberAnimation { duration: 250 } }
}

一行 Behavior 就搞定过渡动画。这就是声明式的优势。

例子 2:一个数据表格

QML 做法:

  • 用 TableView;
  • 自己写 delegate 渲染每个单元格;
  • 排序、过滤、编辑逻辑要自己接;
  • 复杂表格性能调优需要经验。

能做,但比 Widgets 费劲。

Widgets 做法:

  • QTableView + QAbstractTableModel;
  • 排序用 QSortFilterProxyModel;
  • 编辑用 QStyledItemDelegate;
  • 大量现成方案。

同样是表格,Widgets 的 Model/View 框架成熟得多。

能不能混用?

可以,而且很常见。

  • Widgets 里嵌 QML :用 QQuickWidget,把 QML 界面嵌进 Widgets 窗口。
  • QML 里嵌 Widgets :用 QWidget::createWindowContainer(),把 Widgets 控件塞进 QML。
  • 混合项目:主界面用 Widgets,某个动画面板用 QML;或者反过来。

所以不是二选一,可以按模块选。

一句话:

看你的界面"像什么",而不是"能不能"。

  • 界面像传统桌面软件:表格多、表单多、菜单多、工具栏多 → Widgets。
  • 界面像现代应用:动画多、触摸多、卡片式布局、响应式 → QML。
  • 两者都有:主框架 Widgets,局部动画面板 QML;或者主框架 QML,复杂表格嵌 Widgets。

对刚入门的人,先用 Widgets 打基础是合理的。信号槽、对象树、布局、事件循环这些核心概念在 Widgets 里更直观。等你有动画、触摸、现代 UI 的需求时,再学 QML,会发现很多概念是相通的。

官方案例

第一个 QtWidgets 应用

这是一个 PySide6 的 Hello World 最小示例:

python 复制代码
import sys
from PySide6.QtWidgets import QApplication, QLabel

app = QApplication(sys.argv)
label = QLabel("Hello World!")
label.show()
app.exec()

运行后,你会看到一个简单的窗口:

  1. 导入

    用 PySide6 写 Widgets 应用,第一步永远是从 PySide6.QtWidgets 模块导入需要的类。

    python 复制代码
    from PySide6.QtWidgets import QApplication, QLabel

    QApplication 是应用对象,QLabel 是一个能显示文本的控件。

  2. 创建 QApplication

    python 复制代码
    app = QApplication(sys.argv)

    QApplication 是整个程序的核心,每个 PySide6 Widgets 应用有且只有一个。

    Qt 可以接收命令行参数,所以你可以把 sys.argv 传进去。通常你不需要传任何参数,也可以写成:

    python 复制代码
    app = QApplication([])

    两种写法都能跑。传 sys.argv 的好处是 Qt 能处理它认识的一些命令行选项,比如 -style、-platform;传空列表则完全忽略命令行。入门阶段用哪种都行。

  3. 创建 QLabel

    python 复制代码
    label = QLabel("Hello World!")

    QLabel 是用于显示文本的控件。它不仅能显示纯文本,还能显示富文本(比如 HTML)和图片:

    python 复制代码
    # HTML 写法也是合法的
    label = QLabel("<font color=red size=40>Hello World!</font>")

    上面这行会显示一个红色、大号的"Hello World!"。

  4. 显示控件

    python 复制代码
    label.show()

    创建控件后,必须调用 show() 它才会显示。不调用的话,控件存在,但不可见。

  5. 进入事件循环

    python 复制代码
    app.exec()

    这一行进入 Qt 的主循环,开始执行 Qt 的事件处理。

    一个容易被忽略的细节:实际上,label 是在进入 app.exec() 之后才真正显示出来的。 前面的 show() 只是标记"这个控件应该显示",真正的绘制发生在事件循环启动之后。

    入门阶段可以先忽略这个细节,记住"最后调用 app.exec()"就够了。后面讲事件循环时会再展开。

使用一个简单的按钮

这个教程演示如何在 PySide6 中处理信号和槽。信号槽是 Qt 的一项特性,让图形控件之间、或图形控件与 Python 代码之间能够通信。

我们的应用会创建一个按钮,每次点击时,在 Python 控制台打印一条消息:"Button clicked, Hello!"。

  1. 导入

    python 复制代码
    import sys
    from PySide6.QtWidgets import QApplication, QPushButton
    from PySide6.QtCore import Slot

    QApplication 是应用对象,QPushButton 是按钮控件,Slot 是槽装饰器。

  2. 定义槽函数

    python 复制代码
    # 问候函数
    @Slot()
    def say_hello():
        print("Button clicked, Hello!")

    @Slot() 装饰器把函数标记为 Qt 的槽。

    说明

    @Slot() 的作用是告诉 Qt 元对象系统"这是一个槽"。它会影响跨线程调用时的连接类型、以及和 C++ 侧的交互方式。入门阶段不必深究原理,但建议养成习惯:连接到 Qt 信号的 Python 函数,都加上 @Slot() 装饰器,避免一些边缘情况下的意外行为。

  3. 创建应用

    python 复制代码
    # 创建 Qt 应用
    app = QApplication(sys.argv)

    和之前的示例一样,每个 PySide6 程序都需要一个 QApplication 实例。

  4. 创建按钮

    python 复制代码
    # 创建一个按钮
    button = QPushButton("Click me")

    QPushButton 是按钮控件。构造时传入一个 Python 字符串作为按钮文字。

  5. 连接信号和槽

    python 复制代码
    # 把按钮的 clicked 信号连接到 say_hello 函数
    button.clicked.connect(say_hello)

    QPushButton 自带一个预定义信号 clicked,每次按钮被点击时触发。

    connect() 把这个信号连接到 say_hello 函数。连接之后,信号一旦发射,say_hello 就会被调用。

    连接信号槽有旧式和新式两种写法。PySide6 推荐新式写法,也就是上面这种 signal.connect(function)。旧式写法用 QObject.connect(),可读性差,现在不推荐。

  6. 显示并进入事件循环

    python 复制代码
    # 显示按钮
    button.show()
    # 运行 Qt 主循环
    app.exec()

    show() 让按钮可见,app.exec() 进入 Qt 主循环,开始处理事件。

完整代码

python 复制代码
import sys
from PySide6.QtWidgets import QApplication, QPushButton
from PySide6.QtCore import Slot


@Slot()
def say_hello():
    print("Button clicked, Hello!")


# 创建 Qt 应用
app = QApplication(sys.argv)

# 创建按钮、连接信号槽、显示
button = QPushButton("Click me")
button.clicked.connect(say_hello)
button.show()

# 运行 Qt 主循环
app.exec()

运行后,每点击一次按钮,终端就会输出:

text 复制代码
Button clicked, Hello!

这个示例把信号槽的最小闭环走了一遍:

步骤 代码 作用
1 @Slot() 装饰函数 把 Python 函数标记为槽
2 button.clicked 获取按钮的 clicked 信号
3 .connect(say_hello) 把信号连接到槽函数
4 button.show() + app.exec() 显示控件并进入事件循环

核心就一句话:

信号是"发生了什么",槽是"发生之后做什么",connect() 把两者接起来。

clicked 只是 QPushButton 众多信号中的一个。后面你会遇到 textChanged、valueChanged、triggered 等各种信号,用法都一样:拿到信号对象,调用 connect(),传入槽函数。

另外,@Slot() 虽然不是强制要求(不写也能连接),但建议养成习惯加上。它在跨线程、C++ 交互、以及信号重载等场景下能避免一些隐蔽问题。

信号与槽

由于 Qt 的设计特性,QObject 之间需要一种通信方式,这就是信号槽成为 Qt 核心特性的原因。

简单来说,你可以把信号槽理解成家里的电灯开关。你拨动开关(信号),结果就是灯泡亮起或熄灭(槽)。

在界面开发中,一个真实的例子就是点击按钮:点击这个动作是信号,按钮被点击后发生的事情是槽,比如关闭窗口、保存文档等。

说明

如果你用过其他框架或工具包,可能见过"回调"(callback)这个概念。抛开实现细节不谈,回调是一种通知机制:把一个函数指针传进去,程序里发生事件时就调用它。这个思路听起来相似,但有本质区别,比如回调参数的类型安全难以保证,等等。

所有继承自 QObject 或其子类(比如 QWidget)的类,都可以包含信号和槽。对象在状态发生变化、且这个变化可能对其他对象有意义时,会发射信号。对象只负责发射,它不知道也不关心有没有接收者。这是真正的信息封装,也保证了对象可以作为独立的软件组件使用。

槽可以用来接收信号,但它本身也是普通的成员函数。就像对象不知道有没有人接收它的信号一样,槽也不知道有没有信号连接到它。这保证了用 Qt 可以创建真正独立的组件。

你可以把任意多个信号连接到同一个槽,也可以把一个信号连接到任意多个槽。甚至可以把一个信号直接连接到另一个信号:第一个信号发射时,第二个信号会立即跟着发射。

Qt 的控件自带大量预定义信号和槽。比如 QAbstractButton(Qt 中按钮的基类)有 clicked() 信号,QLineEdit(单行输入框)有一个叫 clear() 的槽。所以,一个"带清除按钮的输入框"可以这样实现:在 QLineEdit 右边放一个 QToolButton,把它的 clicked() 信号连接到 clear() 槽:

python 复制代码
button = QToolButton()
line_edit = QLineEdit()
button.clicked.connect(line_edit.clear)

connect() 返回一个 Connection 对象,可以用 disconnect() 断开连接。

信号也可以连接到普通函数:

python 复制代码
import sys
from PySide6.QtWidgets import QApplication, QPushButton


def function():
    print("The 'function' has been called!")

app = QApplication()
button = QPushButton("Call function")
button.clicked.connect(function)
button.show()
sys.exit(app.exec())

连接可以在代码里写,也可以在 Qt Widgets Designer 的 Signal-Slot Editor 里可视化设计。

connect() 有一个可选参数 ConnectionType,用来指定线程和事件循环相关的连接行为。

说明

不建议用私有方法(__ 开头)作为槽接收函数,因为它在运行时会带来额外的查找开销。

在 Python 里写类时,信号声明为类级别的 Signal 变量。一个基于 QWidget、能发射 clicked() 信号的按钮可以这样写:

python 复制代码
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QWidget

class Button(QWidget):

    clicked = Signal(Qt.MouseButton)

    ...

    def mousePressEvent(self, event):
        self.clicked.emit(event.button())

Signal 的构造函数接受一个元组或列表,里面是 Python 类型和 C 类型:

python 复制代码
signal1 = Signal(int)  # Python 类型
signal2 = Signal(QUrl)  # Qt 类型
signal3 = Signal(int, str, int)  # 多个类型
signal4 = Signal((float,), (QDate,))  # 可选类型

此外,它还可以接受一个命名参数 name,用来定义信号名。不传的话,新信号的名字和它赋值的变量名相同。

python 复制代码
signal5 = Signal(int, name='rangeChanged')
# ...
rangeChanged.emit(...)

Signal 还有一个有用的选项 arguments,用于 QML 应用按名字引用发射的值:

python 复制代码
sumResult = Signal(int, arguments=['sum'])
qml 复制代码
Connections {
    target: ...
    function onSumResult(sum) {
        // 对 'sum' 做点什么
    }
}

QObject 派生类中的槽应该用 @Slot 装饰器标记。和 Signal 一样,传入类型来定义签名。

python 复制代码
@Slot(str)
def slot_function(self, s):
    ...

Slot() 也接受 name 和 result 关键字。result 定义返回值类型,可以是 C 类型或 Python 类型。name 的行为和 Signal() 一样,不传的话,新槽的名字和被装饰的函数名相同。

建议把所有用于信号连接的方法都加上 @Slot 装饰器。不加的话,在建立连接时该方法会被加入 QMetaObject,带来运行时开销。对注册到 QML 的 QObject 类尤其重要,缺少装饰器可能引入 bug。

缺少装饰器的问题可以通过启用 qt.pyside.libpyside 日志类别的警告来诊断,比如设置环境变量:

bash 复制代码
export QT_LOGGING_RULES="qt.pyside.libpyside.warning=true"

在多线程应用中,信号可能由与接收者属于不同线程的发送者发射。对于非 Slot 类型的接收者,代码会在发送者所在线程的上下文中执行。

但对于用 @Slot 装饰的 QObject 派生类方法,情况通常不同,因为它们和线程关联(参见 moveToThread())。这取决于 connect() 的最后一个参数,类型是 PySide6.QtCore.Qt.ConnectionType。

当传入 Qt.ConnectionType.AutoConnection(默认)或 Qt.ConnectionType.QueuedConnection 时,接收者代码会在接收者对象所属线程的上下文中执行。这对把后台线程的结果传回 GUI 类非常有用,因为 GUI 类必须使用主线程。

同名但参数类型列表不同的信号和槽是可以用的。这是 Qt 5 遗留的写法,新代码不推荐。在 Qt 6 中,不同类型应该用不同的信号名。

下面的例子用两个处理函数演示了 Signal 和 Slot 的不同功能:

python 复制代码
import sys
from PySide6.QtWidgets import QApplication, QPushButton
from PySide6.QtCore import QObject, Signal, Slot


class Communicate(QObject):
    # 动态创建两个新信号:一个处理 int 类型,另一个处理字符串
    speak = Signal((int,), (str,))

    def __init__(self, parent=None):
        super().__init__(parent)

        self.speak[int].connect(self.say_something)
        self.speak[str].connect(self.say_something)

    # 定义一个新槽,接收 C 的 'int' 或 'str',
    # 名字是 'say_something'
    @Slot(int)
    @Slot(str)
    def say_something(self, arg):
        if isinstance(arg, int):
            print("This is a number:", arg)
        elif isinstance(arg, str):
            print("This is a string:", arg)

if __name__ == "__main__":
    app = QApplication(sys.argv)
    someone = Communicate()

    # 用不同参数发射 'speak' 信号。
    # 必须指定 str,因为 int 是默认的
    someone.speak.emit(10)
    someone.speak[str].emit("Hello everybody!")

信号和槽也可以通过 SIGNAL() 和 SLOT() 函数,用 C++ 方法签名字符串来指定:

python 复制代码
from PySide6.QtCore import SIGNAL, SLOT

button.connect(SIGNAL("clicked(Qt::MouseButton)"),
               action_handler, SLOT("action1(Qt::MouseButton)"))

通常不推荐这种写法,只在少数信号只能通过 QMetaObject 访问的情况下才需要,比如 QAxObject、QAxWidget、QDBusInterface 或 QWizardPage::registerField():

python 复制代码
wizard.registerField("text", line_edit, "text",
                     SIGNAL("textChanged(QString)"))

签名字符串可以在内省 QMetaObject 时通过 QMetaMethod.methodSignature() 查询:

python 复制代码
mo = widget.metaObject()
for m in range(mo.methodOffset(), mo.methodCount()):
    print(mo.method(m).methodSignature())

槽应该用 @Slot 装饰。

创建一个对话框应用

这个教程演示如何用几个基础控件搭一个简单的对话框。用户在一个 QLineEdit 里输入名字,点击 QPushButton,对话框在控制台打印问候语。

先创建一个对话框并显示出来:

python 复制代码
import sys
from PySide6.QtWidgets import QApplication, QDialog, QLineEdit, QPushButton

class Form(QDialog):

    def __init__(self, parent=None):
        super(Form, self).__init__(parent)
        self.setWindowTitle("My Form")


if __name__ == '__main__':
    # 创建 Qt 应用
    app = QApplication(sys.argv)
    # 创建并显示表单
    form = Form()
    form.show()
    # 运行 Qt 主循环
    sys.exit(app.exec())

导入、QApplication、app.exec() 这些前面都见过了。这里的新东西是类定义。

你可以继承任何 PySide6 控件来创建自定义类。这里继承 QDialog 定义了一个自定义对话框,命名为 Form。__init__() 里调用了父类 QDialog 的初始化方法,并传入可选的 parent。setWindowTitle() 设置对话框窗口标题。

main() 里创建 Form 对象并调用 show() 显示它。

我们需要两个控件:

  • QLineEdit:让用户输入名字;
  • QPushButton:点击后打印输入框里的内容。

把下面代码加到 Form 的 __init__() 里:

python 复制代码
# 创建控件
self.edit = QLineEdit("Write my name here..")
self.button = QPushButton("Show Greetings")

两个控件构造时传入的字符串就是它们显示的文字。

Qt 自带布局支持,帮你自动排列控件。这里用 QVBoxLayout 把控件垂直排列。

在创建控件之后,把下面代码加到 __init__() 里:

python 复制代码
# 创建布局并添加控件
layout = QVBoxLayout(self)
layout.addWidget(self.edit)
layout.addWidget(self.button)

创建布局,用 addWidget() 把控件加进去。QVBoxLayout(self) 直接把布局设置给当前对话框。

完整代码里用的是另一种写法:先 layout = QVBoxLayout(),最后再 self.setLayout(layout)。两种写法效果一样,前者更简洁,后者更显式。

最后,给 Form 加一个问候函数,并把按钮连接到它。这个函数是 Form 的成员,放在 __init__() 后面:

python 复制代码
# 向用户问候
def greetings(self):
    print(f"Hello {self.edit.text()}")

这个函数把 QLineEdit 里的内容打印到 Python 控制台。通过 QLineEdit.text() 方法获取文本。

现在把 QPushButton 连接到 Form.greetings() 方法。在 __init__() 里加一行:

python 复制代码
# 把按钮信号连接到 greetings 槽
self.button.clicked.connect(self.greetings)

运行后,在输入框里输入名字,点击按钮,控制台就会输出问候语。

完整代码:

python 复制代码
import sys
from PySide6.QtWidgets import (QLineEdit, QPushButton, QApplication,
    QVBoxLayout, QDialog)


class Form(QDialog):

    def __init__(self, parent=None):
        super(Form, self).__init__(parent)
        # 创建控件
        self.edit = QLineEdit("Write my name here")
        self.button = QPushButton("Show Greetings")
        # 创建布局并添加控件
        layout = QVBoxLayout()
        layout.addWidget(self.edit)
        layout.addWidget(self.button)
        # 设置对话框布局
        self.setLayout(layout)
        # 把按钮信号连接到 greetings 槽
        self.button.clicked.connect(self.greetings)

    # 向用户问候
    def greetings(self):
        print(f"Hello {self.edit.text()}")


if __name__ == '__main__':
    # 创建 Qt 应用
    app = QApplication(sys.argv)
    # 创建并显示表单
    form = Form()
    form.show()
    # 运行 Qt 主循环
    sys.exit(app.exec())

运行后输入名字,点击按钮,终端会输出:

text 复制代码
Hello 你的名字

这也是 Qt 开发的一个基本规律:窗口类负责组织控件和连接信号,槽函数负责响应事件。 后面更复杂的应用,也是在这个骨架上不断加控件、加布局、加信号槽。

关于继承基类是QWidget还是QDialog,这两个选择背后,是 Qt 窗口体系里一条清晰的继承链。理解这条链,就能明白什么时候该用哪个。

QDialog 并不是一个独立于 QWidget 的类,它本身就是 QWidget 的子类。

继承关系是这样的:

QObject -> QPaintDevice -> QWidget -> QDialog (以及 QMainWindow)

所以,QWidget 是所有用户界面对象的终极基类 。QDialog 继承了 QWidget 的全部基础能力,并在此之上,专门为"对话框"这种特定的交互场景增加了额外的功能。

🎯 什么时候继承 QWidget?

把它理解为 "一块空白的画布" 或 "一个基础容器"。

适用场景:

  1. 自定义控件 :当你需要创建一个可复用的新控件(如一个特殊图表、一个复合卡片),希望它能被嵌入到任何其他窗口或布局中时,应继承 QWidget。因为 QWidget 是最基础的容器,不知道自己是独立窗口还是子部件,所以非常灵活。
  2. 非标准窗口:当你需要一个窗口,但不需要菜单栏、工具栏,也不希望它带有"对话框"的默认行为(如模态阻塞)时。它是一个纯粹的、用于展示内容的顶级窗口。
  3. 作为中间基类 :当你设计一套自己的控件体系时,通常会从 QWidget 开始,而不是从更具体的 QDialog 或 QMainWindow 开始。

一句话总结 :当你需要一个基础部件 ,对它的角色(主窗口、对话框、嵌入控件)没有预设时,就继承 QWidget。

💬 什么时候继承 QDialog?

把它理解为 "一个为简短交互而生的专门窗口"。它生来就是为了完成"问用户一个问题并等待回答"这类任务。

适用场景:

  1. 模态交互 :这是 QDialog 最核心的用途。当你需要阻塞 用户对其他窗口的操作,要求用户必须先处理完当前对话框(比如确认删除、输入登录信息)时,必须使用 QDialog。QDialog 的 exec() 方法会启动一个本地事件循环,实现这种阻塞效果。
  2. 需要返回值 :QDialog 内置了 accept() 和 reject() 槽,以及 QDialogButtonBox 等便利设施,专门用于处理"确定/取消"这类需要返回结果(Accepted/Rejected)的场景。
  3. 标准对话框 :Qt 中所有预制的对话框类(如 QMessageBox, QFileDialog, QColorDialog)都继承自 QDialog,这本身就说明了它的定位。

一句话总结 :当你需要一个有明确"对话框"语义 的窗口(通常是模态的、用于临时交互、需要返回结果)时,就继承 QDialog。

使用表格控件显示数据

如果你想以表格形式展示数据,用 QTableWidget 就够了,不需要太多配置。

需要说明的是,QTableWidget 并不是展示表格数据的唯一方式。你也可以创建数据模型,用 QTableView 来展示,但那超出了本教程的范围。

说明

QTableWidget 是一个开箱即用的表格控件。如果你想进一步定制,可以了解 Qt 的 Model/View 架构,参考官方文档。

  1. 导入

    python 复制代码
    import sys
    from PySide6.QtGui import QColor
    from PySide6.QtWidgets import (QApplication, QTableWidget,
                                   QTableWidgetItem)

    QTableWidget 是表格控件,QTableWidgetItem 是表格里的单元格,QColor 用来设置背景色。

  2. 准备数据

    创建一个简单的数据集,包含颜色名称和对应的十六进制颜色码:

    python 复制代码
    colors = [("Red", "#FF0000"),
              ("Green", "#00FF00"),
              ("Blue", "#0000FF"),
              ("Black", "#000000"),
              ("White", "#FFFFFF"),
              ("Electric Green", "#41CD52"),
              ("Dark Blue", "#222840"),
              ("Yellow", "#F9E56d")]

    每个元素是一个 (名称, 十六进制码) 元组。

  3. 十六进制转 RGB

    定义一个函数,把十六进制颜色码转成 QColor:

    python 复制代码
    def get_rgb_from_hex(code):
        code_hex = code.replace("#", "")
        rgb = tuple(int(code_hex[i:i+2], 16) for i in (0, 2, 4))
        return QColor.fromRgb(rgb[0], rgb[1], rgb[2])

    拆解一下:

    • code.replace("#", ""):去掉 #,#FF0000 变成 FF0000;
    • code_hex[i:i+2]:每两位切一刀,得到 FF、00、00;
    • int(..., 16):按十六进制解析成整数;
    • QColor.fromRgb(...):用 RGB 三个分量构造 QColor。
  4. 创建应用和表格

    python 复制代码
    app = QApplication()
    
    table = QTableWidget()
    table.setRowCount(len(colors))
    table.setColumnCount(len(colors[0]) + 1)
    table.setHorizontalHeaderLabels(["Name", "Hex Code", "Color"])
    • setRowCount(len(colors)):行数等于颜色数量;
    • setColumnCount(len(colors[0]) + 1):列数等于元组长度(2)加 1;
    • setHorizontalHeaderLabels([...]):设置表头文字。

    说明

    为什么要 + 1?因为原始数据只有两列:名称和十六进制码。我们额外加一列,用来显示颜色本身。

  5. 填充数据

    遍历数据,创建 QTableWidgetItem,按 (行, 列) 坐标放进表格:

    python 复制代码
    for i, (name, code) in enumerate(colors):
        item_name = QTableWidgetItem(name)
        item_code = QTableWidgetItem(code)
        item_color = QTableWidgetItem()
        item_color.setBackground(get_rgb_from_hex(code))
        table.setItem(i, 0, item_name)
        table.setItem(i, 1, item_code)
        table.setItem(i, 2, item_color)

    逐行看:

    • enumerate(colors) 给出 i(行号)和 (name, code);
    • 第 0 列放名称;
    • 第 1 列放十六进制码;
    • 第 2 列是一个空单元格,用 setBackground() 设置背景色,颜色由 get_rgb_from_hex() 算出来。

    setItem(row, column, item) 的坐标是 (行, 列),行列都从 0 开始。

  6. 显示并运行

    python 复制代码
    table.show()
    sys.exit(app.exec())

    运行后,你会看到一个表格,三列分别是名称、十六进制码、颜色块。

完整代码

python 复制代码
import sys
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (QApplication, QTableWidget,
                               QTableWidgetItem)

colors = [("Red", "#FF0000"),
          ("Green", "#00FF00"),
          ("Blue", "#0000FF"),
          ("Black", "#000000"),
          ("White", "#FFFFFF"),
          ("Electric Green", "#41CD52"),
          ("Dark Blue", "#222840"),
          ("Yellow", "#F9E56d")]


def get_rgb_from_hex(code):
    code_hex = code.replace("#", "")
    rgb = tuple(int(code_hex[i:i+2], 16) for i in (0, 2, 4))
    return QColor.fromRgb(rgb[0], rgb[1], rgb[2])


app = QApplication()

table = QTableWidget()
table.setRowCount(len(colors))
table.setColumnCount(len(colors[0]) + 1)
table.setHorizontalHeaderLabels(["Name", "Hex Code", "Color"])

for i, (name, code) in enumerate(colors):
    item_name = QTableWidgetItem(name)
    item_code = QTableWidgetItem(code)
    item_color = QTableWidgetItem()
    item_color.setBackground(get_rgb_from_hex(code))
    table.setItem(i, 0, item_name)
    table.setItem(i, 1, item_code)
    table.setItem(i, 2, item_color)

table.show()
sys.exit(app.exec())

几个要点:

  • QTableWidget 是开箱即用的表格控件,适合数据量不大、不需要复杂模型逻辑的场景。
  • 数据量大、需要排序过滤、需要和数据库联动时,应该用 QTableView + 自定义 Model,也就是 Model/View 架构。QTableWidget 内部其实也是 Model/View,只是帮你把模型部分封装好了。
  • QTableWidgetItem 不只能放文字,还能设置背景色、前景色、字体、图标、对齐方式等。它本质上是"一个单元格的完整描述"。
  • setItem() 的坐标是 (行, 列),从 0 开始,别和 (x, y) 搞混。

使用树形控件显示数据

如果你想以树形结构展示数据,用 QTreeWidget 就够了。

需要说明的是,QTreeWidget 并不是展示树形数据的唯一方式。你也可以创建数据模型,用 QTreeView 来展示,但那超出了本教程的范围。

说明

QTreeWidget 是一个开箱即用的树形控件。如果你想进一步定制,可以了解 Qt 的 Model/View 架构,参考官方文档。

  1. 导入

    python 复制代码
    import sys
    from PySide6.QtWidgets import QApplication, QTreeWidget, QTreeWidgetItem

    QTreeWidget 是树形控件,QTreeWidgetItem 是树里的节点。

  2. 准备数据

    用一个字典描述项目结构:键是项目名,值是该项目下的文件列表。

    python 复制代码
    data = {"Project A": ["file_a.py", "file_a.txt", "something.xls"],
            "Project B": ["file_b.csv", "photo.jpg"],
            "Project C": []}

    这里三个项目,Project C 是空列表,用来演示"没有子节点的父节点"。

  3. 创建树形控件

    python 复制代码
    app = QApplication()
    
    tree = QTreeWidget()
    tree.setColumnCount(2)
    tree.setHeaderLabels(["Name", "Type"])
    • setColumnCount(2):两列,一列放名称,一列放文件类型;
    • setHeaderLabels(["Name", "Type"]):设置表头。

    注意这里用的是 setHeaderLabels(),不是表格那节的 setHorizontalHeaderLabels()。树形控件的方法名略有不同。

  4. 填充数据

    遍历字典,为每个项目创建一个父节点,为每个文件创建一个子节点:

    python 复制代码
    items = []
    for key, values in data.items():
        item = QTreeWidgetItem([key])
        for value in values:
            ext = value.split(".")[-1].upper()
            child = QTreeWidgetItem([value, ext])
            item.addChild(child)
        items.append(item)
    
    tree.insertTopLevelItems(0, items)

    逐段看:

    外层循环:创建父节点

    python 复制代码
    for key, values in data.items():
        item = QTreeWidgetItem([key])

    key 是项目名,比如 "Project A"。QTreeWidgetItem([key]) 创建一个只填了第一列的节点。

    内层循环:创建子节点

    python 复制代码
        for value in values:
            ext = value.split(".")[-1].upper()
            child = QTreeWidgetItem([value, ext])
            item.addChild(child)
    • value 是文件名,比如 "file_a.py";
    • value.split(".")[-1] 按点分割取最后一段,得到 "py";
    • .upper() 转成大写,得到 "PY";
    • QTreeWidgetItem([value, ext]) 创建填了两列的子节点;
    • item.addChild(child) 把子节点挂到父节点下。

    收集顶层节点并插入

    python 复制代码
        items.append(item)
    
    tree.insertTopLevelItems(0, items)

    先把所有顶层节点收集到 items 列表,最后用 insertTopLevelItems(0, items) 一次性插入。参数 0 表示从第 0 个位置开始插入。

    这种"先收集、再批量插入"的写法,比每创建一个就 addTopLevelItem() 一次效率更高,尤其在数据量大时。

  5. 显示并运行

    python 复制代码
    tree.show()
    sys.exit(app.exec())

    运行后,你会看到一个可展开/折叠的树:顶层是三个项目,展开后能看到各自的文件,第二列显示文件扩展名。

完整代码

python 复制代码
import sys
from PySide6.QtWidgets import QApplication, QTreeWidget, QTreeWidgetItem

data = {"Project A": ["file_a.py", "file_a.txt", "something.xls"],
        "Project B": ["file_b.csv", "photo.jpg"],
        "Project C": []}

app = QApplication()

tree = QTreeWidget()
tree.setColumnCount(2)
tree.setHeaderLabels(["Name", "Type"])

items = []
for key, values in data.items():
    item = QTreeWidgetItem([key])
    for value in values:
        ext = value.split(".")[-1].upper()
        child = QTreeWidgetItem([value, ext])
        item.addChild(child)
    items.append(item)

tree.insertTopLevelItems(0, items)

tree.show()
sys.exit(app.exec())

几个要点:

  • 树形控件和表格控件的区别在于层级。 表格是二维的(行、列),树是三维的(行、列、层级)。QTreeWidgetItem 通过 addChild() 建立父子关系。
  • 父节点和子节点用同一个类。 QTreeWidgetItem 既是父节点也是子节点,层级关系由 addChild() 决定,不是由类型决定。
  • 顶层节点需要单独插入。 子节点通过 addChild() 挂到父节点上,但顶层节点不在任何父节点之下,必须用 insertTopLevelItems() 或 addTopLevelItem() 显式插入。
  • QTreeWidget 和 QTableWidget 一样,都是"便捷版"。 数据量大、需要复杂模型逻辑时,应该用 QTreeView + 自定义 Model。QTreeWidget 适合快速展示层级不深、数据量不大的场景。

对比一下:

QTableWidget QTreeWidget
数据形态 二维表格 树形层级
单元格类 QTableWidgetItem QTreeWidgetItem
表头方法 setHorizontalHeaderLabels() setHeaderLabels()
添加数据 setItem(row, col, item) addChild() / insertTopLevelItems()
适用场景 平面数据、配置表、结果列表 文件树、目录结构、分类层级

使用 Designer 或 QtCreator 的 .ui 文件:QUiLoader 与 pyside6-uic

Qt Widgets Designer 是一个图形化 UI 设计工具,可以作为独立程序(pyside6-designer)使用,也可以嵌入 Qt Creator IDE。在 Qt Creator 中的用法参见《使用 Qt Widgets Designer》。

设计稿保存在 .ui 文件中,这是基于 XML 的格式。在项目构建时,pyside6-uic 工具会把它转换成 Python 或 C++ 代码,用来填充一个控件实例。

在 Qt Creator 中新建 Qt Design Form,选择 File/New File Or Project,模板选"Main Window",保存为 mainwindow.ui。往 centralwidget 中央加一个 QPushButton。

你的 mainwindow.ui 大致长这样:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
 <class>MainWindow</class>
 <widget class="QMainWindow" name="MainWindow">
  <property name="geometry">
   <rect>
    <x>0</x>
    <y>0</y>
    <width>400</width>
    <height>300</height>
   </rect>
  </property>
  <property name="windowTitle">
   <string>MainWindow</string>
  </property>
  <widget class="QWidget" name="centralWidget">
   <widget class="QPushButton" name="pushButton">
    <property name="geometry">
     <rect>
      <x>110</x>
      <y>80</y>
      <width>201</width>
      <height>81</height>
     </rect>
    </property>
    <property name="text">
     <string>PushButton</string>
    </property>
   </widget>
  </widget>
  <widget class="QMenuBar" name="menuBar">
   <property name="geometry">
    <rect>
     <x>0</x>
     <y>0</y>
     <width>400</width>
     <height>20</height>
    </rect>
   </property>
  </widget>
  <widget class="QToolBar" name="mainToolBar">
   <attribute name="toolBarArea">
    <enum>TopToolBarArea</enum>
   </attribute>
   <attribute name="toolBarBreak">
    <bool>false</bool>
   </attribute>
  </widget>
  <widget class="QStatusBar" name="statusBar"/>
 </widget>
 <layoutdefault spacing="6" margin="11"/>
 <resources/>
 <connections/>
</ui>

接下来要决定:在 Python 里怎么用这个 UI 文件。有两种方式。

方式 A:生成 Python 类

这是使用 UI 文件的标准方式:用 pyside6-uic 工具从 .ui 文件生成一个 Python 类。

在控制台运行:

bash 复制代码
pyside6-uic mainwindow.ui -o ui_mainwindow.py

命令的输出被重定向到 ui_mainwindow.py,之后直接导入即可:

python 复制代码
from ui_mainwindow import Ui_MainWindow

然后创建一个自定义类,用来承载这个生成的设计:

python 复制代码
import sys
from PySide6.QtWidgets import QApplication, QMainWindow
from PySide6.QtCore import QFile
from ui_mainwindow import Ui_MainWindow

class MainWindow(QMainWindow):
    def __init__(self):
        super(MainWindow, self).__init__()
        self.ui = Ui_MainWindow()
        self.ui.setupUi(self)

if __name__ == "__main__":
    app = QApplication(sys.argv)

    window = MainWindow()
    window.show()

    sys.exit(app.exec())

if 语句里的内容前面已经熟悉了。新的基础类只有两行,负责加载从 UI 文件生成的 Python 类:

python 复制代码
self.ui = Ui_MainWindow()
self.ui.setupUi(self)

说明

每次修改 UI 文件后,都必须重新运行 pyside6-uic。

为了避免图标里图片文件的路径解析问题,可以把它们嵌入 Qt 资源文件(参见 Qt Resource System、Editing Resources with Qt Widgets Designer 和《使用 .qrc 文件(pyside6-rcc)》)。

构建项目时,pyside6-rcc 工具会从 .qrc 文件生成一个加载资源的 Python 文件,通常命名为 rc_<name>.py,由 pyside6-uic 生成的 Python 文件导入。要让这条导入正常工作,资源文件和 .ui 文件应该放在同一目录。

如果不在同一目录,可以用 pyside6-uic 的几个命令行选项来调整生成的导入语句(参见 pyside6-uic --help):

选项 说明
--from-imports Python:生成相对于 . 的导入
--absolute-imports Python:生成绝对导入
--python-paths <pathlist> Python:为 --absolute-imports 指定路径列表

通常会把 --absolute-imports 和 --python-paths 组合使用,把项目目录传给后者。

方式 B:直接加载 UI 文件

不生成 Python 代码,直接在运行时加载 .ui 文件。这需要 QtUiTools 模块里的一个类:

python 复制代码
from PySide6.QtUiTools import QUiLoader

QUiLoader 可以动态加载 UI 文件并立即使用:

python 复制代码
ui_file = QFile("mainwindow.ui")
ui_file.open(QFile.ReadOnly)

loader = QUiLoader()
window = loader.load(ui_file)
window.show()

完整代码如下:

python 复制代码
# 文件: main.py
import sys
from PySide6.QtUiTools import QUiLoader
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import QFile, QIODevice

if __name__ == "__main__":
    app = QApplication(sys.argv)

    ui_file_name = "mainwindow.ui"
    ui_file = QFile(ui_file_name)
    if not ui_file.open(QIODevice.ReadOnly):
        print(f"Cannot open {ui_file_name}: {ui_file.errorString()}")
        sys.exit(-1)
    loader = QUiLoader()
    window = loader.load(ui_file)
    ui_file.close()
    if not window:
        print(loader.errorString())
        sys.exit(-1)
    window.show()

    sys.exit(app.exec())

运行:

bash 复制代码
python main.py

说明

QUiLoader 在建立信号槽连接时,用的是以函数签名字符串为参数的 connect() 调用。因此它无法处理 Python 自定义控件里像 str、list 这样的 Python 类型,因为这些类型在内部会映射成不同的 C++ 类型。

Qt Widgets Designer 可以使用用户提供的自定义控件。它们会出现在控件箱里,可以像 Qt 自带控件一样拖到表单上(参见《在 Qt Widgets Designer 中使用自定义控件》)。通常,这需要把控件实现成用 C++ 编写的 Designer 插件,实现 QDesignerCustomWidgetInterface。

Qt for Python 为此提供了一个简单的接口,类似于 registerCustomWidget()。

控件需要以 Python 模块的形式提供,参考 WigglyWidget 示例(文件 wigglywidget.py)或 Task Menu Extension 示例(文件 tictactoe.py)。

注册方式是提供一个名为 register*.py 的注册脚本,并把路径类型的环境变量 PYSIDE_DESIGNER_PLUGINS 指向该目录。

注册脚本的代码如下:

python 复制代码
# 文件: registerwigglywidget.py
from wigglywidget import WigglyWidget

import QtDesigner


TOOLTIP = "A cool wiggly widget (Python)"
DOM_XML = """
<ui language='c++'>
    <widget class='WigglyWidget' name='wigglyWidget'>
        <property name='geometry'>
            <rect>
                <x>0</x>
                <y>0</y>
                <width>400</width>
                <height>200</height>
            </rect>
        </property>
        <property name='text'>
            <string>Hello, world</string>
        </property>
    </widget>
</ui>
"""

QPyDesignerCustomWidgetCollection.registerCustomWidget(WigglyWidget, module="wigglywidget",
                                                       tool_tip=TOOLTIP, xml=DOM_XML)

QPyDesignerCustomWidgetCollection 提供了 QDesignerCustomWidgetCollectionInterface 的实现,把自定义控件暴露给 Qt Widgets Designer,并带有静态便捷函数,用于注册类型或添加 QDesignerCustomWidgetInterface 实例。

registerCustomWidget() 函数用于向 Designer 注册一个控件类型。简单情况下,用法类似 QUiLoader.registerCustomWidget()。它接收自定义控件类型和一些可选关键字参数,对应 QDesignerCustomWidgetInterface 的各个 getter。

通过启动器 pyside6-designer 启动 Designer 时,自定义控件应该会出现在控件箱里。

进阶用法中,还可以给 addCustomWidget() 传入 QDesignerCustomWidgetInterface 的实现,而不是类型本身。Task Menu Extension 示例演示了这一点,它给自定义控件注册了一个右键菜单。该示例是 C++ 版 Task Menu Extension 示例的移植版。

要让属性在 Qt Widgets Designer 中可见,必须用 PySide6.QtCore.Property 声明它们。

枚举和标志类型需要出现在类内部,并分别用 @PySide6.QtCore.QEnum 或 @PySide6.QtCore.QFlag 装饰。这要求为它们提取一个基类,否则在指定 PySide6.QtCore.Property 时枚举类型还不可知:

python 复制代码
class CustomWidgetBase(QObject):
    @QEnum
    class TestEnum(Enum):
        EnumValue0 = 0
        EnumValue1 = 1


class CustomWidget(CustomWidgetBase):
    def __init__(self, parent=None):
        super().__init__(parent)
        self._testEnum = CustomWidget.TestEnum.EnumValue1

    def testEnum(self):
        return self._testEnum

    def setTestEnum(self, new_val):
        self._testEnum = new_val

    testEnum = Property(CustomWidgetBase.TestEnum, testEnum, setTestEnum)

排查 Designer 插件问题

  • 必须使用启动器 pyside6-designer。独立的 Qt Widgets Designer 不会加载插件。
  • 菜单项 Help/About Plugin 会弹出对话框,显示找到的插件和可能的加载错误信息。
  • 检查控制台或 Windows Debug 视图,查看更详细的错误信息。
  • 由于 Python 的输出缓冲,错误信息可能只在 Designer 退出后才显示。
  • 构建 Qt for Python 时,确保设置 --standalone 选项,插件才能正确安装。

两种方式怎么选

方式 A:pyside6-uic 方式 B:QUiLoader
转换时机 构建时,生成 .py 运行时,直接读 .ui
部署是否带 .ui 不需要 需要
运行性能 更好,无需解析 XML 稍差,启动时要解析
修改 UI 后 必须重新运行 pyside6-uic 直接生效,无需重新生成
信号槽与 Python 类型 无限制 有局限,无法处理 Python 自定义类型
适合场景 正式项目、部署发布 原型开发、快速迭代

入门阶段建议用方式 A。它更接近实际项目的工作流:Designer 画界面 → pyside6-uic 转 Python → 在主程序里继承 QMainWindow 或 QWidget,把生成的 Ui_* 类挂进去。后面打包部署时,也只需要带上生成的 .py 文件,不用带 .ui 源文件。

使用 .qrc 文件(pyside6-rcc)

Qt 资源系统是一种把二进制文件存进应用程序的机制。

这些文件会被嵌入到应用程序中,QFile 类以及 QIcon、QPixmap 的构造函数都能访问它们,只要使用以 :/ 开头的特殊文件名即可。

最常见的用途是自定义图片、图标、字体等。

本教程演示如何加载自定义图片作为按钮图标。

为了有个参照,我们尝试改造 Qt 自带的多媒体播放器示例。

如下图所示,用于媒体操作(播放、暂停、停止等)的 QPushButton 使用的是这些操作的默认图标。

你可以自己设计图标让应用更好看,但如果你不想设计,也可以下载现成的图标来用。

关于 rcc 命令、.qrc 文件格式以及资源系统的更多信息,可以查阅 Qt Resource System 页面。

在运行任何命令之前,先把资源信息写进 .qrc 文件。下面的例子里,资源列在 icons.qrc 中:

xml 复制代码
<!DOCTYPE RCC><RCC version="1.0">
<qresource>
    <file>icons/play.png</file>
    <file>icons/pause.png</file>
    <file>icons/stop.png</file>
    <file>icons/previous.png</file>
    <file>icons/forward.png</file>
</qresource>
</RCC>

icons.qrc 准备好之后,用 pyside6-rcc 工具生成一个包含资源二进制信息的 Python 类。

运行:

bash 复制代码
pyside6-rcc icons.qrc -o rc_icons.py

-o 选项指定输出文件名,这里是 rc_icons.py。

要使用生成的文件,在主 Python 文件顶部加上:

python 复制代码
import rc_icons

说明

该工具使用 Zstandard 作为压缩算法,默认压缩级别(由实现决定)生成的结果可能无法在其他平台上使用。要确保文件在所有平台都能用,应该降低压缩级别,或者改用 zlib 作为压缩算法(参见 pyside6-rcc --help)。

因为是在改造一个现成的示例,需要修改 player.py 文件。在顶部,把:

python 复制代码
from PySide6.QtGui import QIcon, QKeySequence

改成:

python 复制代码
from PySide6.QtGui import QIcon, QKeySequence, QPixmap

在导入语句下方加上:

python 复制代码
import rc_icons

在 MainWindow.__init__() 函数里,把从主题加载图标的代码替换成你的自定义图标:

python 复制代码
playIcon = QIcon(QPixmap(":/icons/play.png"))
self._play_action = tool_bar.addAction(playIcon, "Play")

所有图标都这样处理后,类的构造函数大致如下:

python 复制代码
def __init__(self):
    super().__init__()

    self._playlist = []
    self._playlist_index = -1
    self._audio_output = QAudioOutput()
    self._player = QMediaPlayer()
    self._player.setAudioOutput(self._audio_output)

    self._player.errorOccurred.connect(self._player_error)

    tool_bar = QToolBar()
    self.addToolBar(tool_bar)

    file_menu = self.menuBar().addMenu("&File")
    icon = QIcon.fromTheme(QIcon.ThemeIcon.DocumentOpen)
    open_action = QAction(icon, "&Open...", self,
                          shortcut=QKeySequence.Open, triggered=self.open)
    file_menu.addAction(open_action)
    tool_bar.addAction(open_action)
    icon = QIcon.fromTheme(QIcon.ThemeIcon.ApplicationExit)
    exit_action = QAction(icon, "E&xit", self,
                          shortcut="Ctrl+Q", triggered=self.close)
    file_menu.addAction(exit_action)

    play_menu = self.menuBar().addMenu("&Play")
    playIcon = QIcon(QPixmap(":/icons/play.png"))
    self._play_action = tool_bar.addAction(playIcon, "Play")
    self._play_action.triggered.connect(self._player.play)
    play_menu.addAction(self._play_action)

    previousIcon = QIcon(QPixmap(":/icons/previous.png"))
    self._previous_action = tool_bar.addAction(previousIcon, "Previous")
    self._previous_action.triggered.connect(self.previous_clicked)
    play_menu.addAction(self._previous_action)

    pauseIcon = QIcon(QPixmap(":/icons/pause.png"))
    self._pause_action = tool_bar.addAction(pauseIcon, "Pause")
    self._pause_action.triggered.connect(self._player.pause)
    play_menu.addAction(self._pause_action)

    nextIcon = QIcon(QPixmap(":/icons/forward.png"))
    self._next_action = tool_bar.addAction(nextIcon, "Next")
    self._next_action.triggered.connect(self.next_clicked)
    play_menu.addAction(self._next_action)

    stopIcon = QIcon(QPixmap(":/icons/stop.png"))
    self._stop_action = tool_bar.addAction(stopIcon, "Stop")
    self._stop_action.triggered.connect(self._ensure_stopped)
    play_menu.addAction(self._stop_action)

    # 此处省略多行

运行 python main.py,查看新的图标效果:

几个要点:

  • 资源路径以 :/ 开头。 这个前缀告诉 Qt"去资源系统里找",而不是去文件系统里找。:/icons/play.png 对应 .qrc 里写的 icons/play.png。
  • 生成的文件是普通 Python 文件。 rc_icons.py 里是二进制数据的字节串,导入即注册,没有额外步骤。
  • 部署时不需要带原始文件。 图片、字体、图标都编进 rc_icons.py 了,打包时只带这个 .py 文件即可。
  • 和 .ui 文件一样,资源变了要重新生成。 每次修改 .qrc 或增删资源文件后,都要重新运行 pyside6-rcc。
  • 跨平台压缩要注意。 默认压缩算法在某些平台可能不兼容,需要时可以用 --compress-algo zlib 或降低压缩级别。

.qrc 和 .ui 的处理思路是一样的:

源文件(.ui / .qrc)→ 工具转换(pyside6-uic / pyside6-rcc)→ 生成的 Python 文件 → 主程序导入。

区别只在于:.ui 生成的是界面类,.qrc 生成的是资源数据。部署时都不需要带上源文件。

翻译应用程序

Qt Linguist 及其相关工具可以用来为应用程序提供翻译。这个示例很简单,有一个菜单,显示一个可多选的编程语言列表。

翻译的工作原理是:把消息字符串传给查找翻译的函数调用。每个 QObject 实例都提供一个 tr() 函数用于此目的。对于非 QObject 类,可以用 QCoreApplication.translate() 来添加翻译文本。

Qt 自带翻译文件,包含错误消息和标准对话框标题。

Linguist 示例中有若干消息用 self.tr() 包裹。选择变化时显示的状态栏消息,会根据数量使用复数形式:

python 复制代码
count = len(self._list_widget.selectionModel().selectedRows())
message = self.tr("%n language(s) selected", "", count)

这个示例的翻译工作流如下。

用 lupdate 工具提取待翻译消息,生成基于 XML 的 .ts 文件:

bash 复制代码
pyside6-lupdate main.py -ts example_de.ts

如果 example_de.ts 已存在,它会用代码中新增的消息更新。

如果项目里有表单文件(.ui)和/或 QML 文件(.qml),也应该传给 pyside6-lupdate:

bash 复制代码
pyside6-lupdate main.py main.qml form.ui -ts example_de.ts

由 pyside6-uic 从表单文件生成的源文件不应该传进去。

也可以用 pyside6-project 的 lupdate 模式。它收集所有源文件,当 .pyproject 文件中指定了 .ts 文件时,运行 pyside6-lupdate:

bash 复制代码
pyside6-project lupdate .

用 Qt Linguist 翻译 .ts 文件。完成后,把文件转换成二进制形式(.qm 文件):

bash 复制代码
pyside6-lrelease example_de.ts -qm example_de.qm

当 .pyproject 文件中指定了 .ts 文件时,pyside6-project 会自动构建 .qm 文件:

bash 复制代码
pyside6-project build .

为了避免单独分发 .qm 文件,推荐把它们和图标等应用资源一起放进 Qt 资源文件(参见《使用 .qrc 文件(pyside6-rcc)》)。资源文件 linguist.qrc 把 example_de.qm 放在 :/translations 下:

xml 复制代码
<!DOCTYPE RCC><RCC version="1.0">
<qresource prefix="translations">
    <file>example_de.qm</file>
</qresource>
</RCC>

运行时,需要用 QTranslator 类加载翻译:

python 复制代码
path = QLibraryInfo.location(QLibraryInfo.TranslationsPath)
translator = QTranslator(app)
if translator.load(QLocale.system(), 'qtbase', '_', path):
    app.installTranslator(translator)
translator = QTranslator(app)
path = ':/translations'
if translator.load(QLocale.system(), 'example', '_', path):
    app.installTranslator(translator)

这段代码先加载 Qt 自带的翻译,再加载从资源中加载的应用程序翻译。

之后就可以用德语运行示例:

bash 复制代码
LANG=de python main.py

GNU gettext 模块也可以用来为应用程序提供翻译。

GNU gettext 示例演示了这一点。这个示例同样很简单,有一个菜单,显示一个可多选的编程语言列表。

翻译的工作原理是:把消息字符串传给查找翻译的函数调用。通常把主翻译函数别名成 _。对于包含复数形式的句子("{0} items(s) selected"),有一个特殊的翻译函数,通常别名成 ngettext。

这两个函数在文件顶部定义:

python 复制代码
import gettext
# ...
_ = None
ngettext = None

然后在后面赋值:

python 复制代码
src_dir = Path(__file__).resolve().parent
try:
    translation = gettext.translation('example', localedir=src_dir / 'locales')
    if translation:
        translation.install()
        _ = translation.gettext
        ngettext = translation.ngettext
except FileNotFoundError:
    pass
if not _:
    _ = gettext.gettext
    ngettext = gettext.ngettext

这段代码指定翻译文件的基本名是 example,在源码树下的 locales 目录中查找。代码会尝试加载与当前语言匹配的翻译。

待翻译的消息写起来像这样:

python 复制代码
file_menu = self.menuBar().addMenu(_("&File"))

选择变化时显示的状态栏消息,会根据数量使用复数形式:

python 复制代码
count = len(self._list_widget.selectionModel().selectedRows())
message = ngettext("{0} language selected",
                   "{0} languages selected", count).format(count)

ngettext() 接收单数形式、复数形式和数量。返回的字符串仍然包含格式化占位符,所以需要再经过 format()。

要把消息翻译成德语,先创建模板文件(.pot):

bash 复制代码
mkdir -p locales/de_DE/LC_MESSAGES
xgettext -L Python -o locales/example.pot main.py

这个文件有一些通用占位符,可以替换成合适的值。然后复制到 de_DE/LC_MESSAGES 目录:

bash 复制代码
cd locales/de_DE/LC_MESSAGES/
cp ../../example.pot .

还需要针对德语的复数形式和编码做进一步调整:

text 复制代码
"Project-Id-Version: PySide6 gettext example\n"
"POT-Creation-Date: 2021-07-05 14:16+0200\n"
"Language: de_DE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"

接下来填入翻译后的消息:

text 复制代码
#: main.py:57
msgid "&File"
msgstr "&Datei"

最后,把 .pot 转换成二进制形式(机器对象文件,.mo),这个文件需要部署:

bash 复制代码
msgfmt -o example.mo example.pot

之后就可以用德语运行示例:

bash 复制代码
LANG=de_DE.UTF-8 python main.py

两种方案对比

Qt Linguist GNU gettext
源文件格式 .ts(XML) .pot(文本模板)
翻译工具 pyside6-linguist 任意文本编辑器 / Poedit
编译工具 pyside6-lrelease msgfmt
运行时格式 .qm(二进制) .mo(二进制)
代码标记方式 self.tr() / QCoreApplication.translate() _() / ngettext()
运行时加载 QTranslator + installTranslator() gettext.translation()
与 Qt 集成 原生,Qt 自带翻译也用它 通用 Python 方案,与 Qt 无关
适合场景 Qt 项目、需要和 Qt 标准对话框翻译统一 纯 Python 逻辑多、或已有 gettext 工作流

几个要点:

  • 翻译的核心是"把消息字符串包进函数调用"。 tr()、_() 都是标记,告诉工具"这个字符串需要翻译"。
  • .ts 和 .pot 都是中间格式,不是最终产物。 最终部署的是 .qm 或 .mo 二进制文件。
  • .qm 可以放进 .qrc 资源文件。 这样打包时不用单独带翻译文件,和图标、图片一样处理。
  • 复数形式要特殊处理。 不同语言的复数规则不同,Qt 用 %n 占位,gettext 用 ngettext() 分别传单数和复数形式。
  • Qt Linguist 是 Qt 生态的一部分,gettext 是通用方案。 如果项目以 Qt 为主,Linguist 更顺;如果项目有大量非 Qt 的 Python 代码,gettext 更通用。

为 Widgets 应用设置样式

Qt Widgets 应用会根据平台使用默认主题。某些情况下,系统级配置会修改 Qt 主题,导致应用显示效果不同。

不过,你可以为自己的控件单独设置样式,给每个组件提供自定义外观。先看一个最简单的例子:

python 复制代码
import sys
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QLabel

if __name__ == "__main__":
    app = QApplication()
    w = QLabel("This is a placeholder text")
    w.setAlignment(Qt.AlignCenter)
    w.show()
    sys.exit(app.exec())

运行后,你会看到一个居中的 QLabel,显示占位文本。

Qt Widgets 有多种样式化方式。这里重点讲 样式表(Style Sheets),它提供类似 CSS 的语法。

QLabel 可以通过设置 CSS 属性来改变外观,比如 background-color 和 font-family:

python 复制代码
import sys
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QLabel

if __name__ == "__main__":
    app = QApplication()
    w = QLabel("This is a placeholder text")
    w.setAlignment(Qt.AlignCenter)
    w.setStyleSheet("""
        background-color: #262626;
        color: #FFFFFF;
        font-family: Titillium;
        font-size: 18px;
        """)
    w.show()
    sys.exit(app.exec())

运行后,QLabel 会变成深色背景、白色文字、自定义字体和字号。

说明

如果你没有安装 Titillium 字体,可以换成任意你喜欢的字体。可以用 QFontDatabase 的 families() 方法列出已安装的字体。

像上面那样给每个控件单独设置样式表,工作量大且分散。更好的做法是用 Qt Style Sheets ,也就是一个或多个 .qss 文件,集中定义应用中所有 UI 元素的样式。

警告

开始修改应用样式之前要记住:一旦自定义样式,你就需要负责应用的所有图形细节。修改边距、尺寸可能导致界面看起来奇怪甚至错乱,改样式时要小心。建议创建一套完整的新 Qt 样式,覆盖所有可能的边界情况。

.qss 文件语法和 CSS 很像,但需要指定控件类型,可选地指定对象名:

css 复制代码
QLabel {
    background-color: red;
}

QLabel#title {
    font-size: 20px;
}
  • 第一条规则给应用中所有 QLabel 设置红色背景;
  • 第二条规则只给对象名为 title 的控件设置字号。

说明

可以用 setObjectName(str) 给任意 Qt 对象设置对象名。比如 label = QLabel("Test"),然后 label.setObjectName("title")。

有了 .qss 文件后,读取文件内容,用 QApplication.setStyleSheet(str) 应用:

python 复制代码
if __name__ == "__main__":
    app = QApplication()

    w = Widget()
    w.show()

    with open("style.qss", "r") as f:
        _style = f.read()
        app.setStyleSheet(_style)

    sys.exit(app.exec())

把样式放在单独的 .qss 文件里,可以把样式和功能代码解耦,不会混在业务逻辑中间,而且可以随时启用或禁用。

看一个包含多个控件的例子:

python 复制代码
class Widget(QWidget):
    def __init__(self, parent=None):
        super(Widget, self).__init__(parent)

        menu_widget = QListWidget()
        for i in range(10):
            item = QListWidgetItem(f"Item {i}")
            item.setTextAlignment(Qt.AlignCenter)
            menu_widget.addItem(item)

        text_widget = QLabel(_placeholder)
        button = QPushButton("Something")

        content_layout = QVBoxLayout()
        content_layout.addWidget(text_widget)
        content_layout.addWidget(button)
        main_widget = QWidget()
        main_widget.setLayout(content_layout)

        layout = QHBoxLayout()
        layout.addWidget(menu_widget, 1)
        layout.addWidget(main_widget, 4)

这显示一个两栏界面:左边是 QListWidget,右边是 QLabel 和 QPushButton。不加样式时,它看起来像这样:

往 style.qss 里加上以下内容,可以改变外观:

css 复制代码
QListWidget {
    color: #FFFFFF;
    background-color: #33373B;
}

QListWidget::item {
    height: 50px;
}

QListWidget::item:selected {
    background-color: #2ABf9E;
}

QLabel {
    background-color: #FFFFFF;
    qproperty-alignment: AlignCenter;
}

QPushButton {
    background-color: #2ABf9E;
    padding: 20px;
    font-size: 18px;
}

这段样式主要做了几件事:

  • 修改各控件的颜色;
  • 调整对齐方式;
  • 增加间距;
  • 用 ::item 和 :selected 对 QListWidget 的列表项做状态样式:选中和未选中显示不同。

应用这些样式后,界面会变成:

几个要点:

  • 样式表是 CSS 的超集,但不是完全兼容 CSS。 它支持 qproperty- 前缀直接设置 Qt 属性,也支持 ::子控件 和 :状态 这类选择器。
  • 给控件设置对象名,才能精确选中。 setObjectName("title") 配合 QLabel#title 是常用的组合。
  • 全局样式用 app.setStyleSheet(),局部样式用 widget.setStyleSheet()。 全局样式会影响所有匹配的控件,局部样式只影响该控件及其子控件。
  • 自定义样式意味着接管所有图形细节。 一旦改了颜色、边距,就要确保各种状态下界面都正常,建议用一套完整的 .qss 覆盖所有控件和状态。
  • .qss 文件不是 Python 文件,不需要转换。 运行时读取、应用即可。打包时把它作为普通资源文件带上,或者用 .qrc 嵌入。

你的第一个 QtQuick/QML 应用

QML 是一种声明式语言,开发应用比传统语言更快。因为它是声明式的,特别适合设计应用界面。在 QML 里,用户界面被描述成一棵带属性的对象树。

一个 PySide6/QML 应用主要由两个文件组成:

  • 一个描述用户界面的 QML 文件;
  • 一个加载该 QML 文件的 Python 文件。

QML 文件:Main.qml

qml 复制代码
import QtQuick

Rectangle {
    id: main
    width: 200
    height: 200
    color: "green"

    Text {
        text: "Hello World"
        anchors.centerIn: main
    }
}

逐段看:

import QtQuick

导入 QtQuick 这个 QML 模块。QML 文件必须导入至少一个模块才能使用其中的类型。

Rectangle { ... }

创建一个矩形,这是根元素。它有三个属性:

  • id: main:给这个对象一个标识,后面可以引用;
  • width: 200、height: 200:尺寸;
  • color: "green":颜色。

Text { ... }

在矩形内部嵌套一个文本元素:

  • text: "Hello World":显示的文字;
  • anchors.centerIn: main:把文本锚定在 id 为 main 的对象中央。

如果你用过 HTML 或 XML,这套嵌套结构应该很眼熟。QML 的对象树就是这样一层层嵌套出来的。

模块声明:qmldir

把 Main.qml 放进一个名为 App 的目录,并在同目录下放一个 qmldir 文件,用来描述这个 QML 模块:

text 复制代码
module App
Main 254.0 Main.qml

两行含义:

  • module App:模块名是 App;
  • Main 254.0 Main.qml:模块里有一个类型叫 Main,版本 254.0,对应文件 Main.qml。

关于版本号 254.0

这个数字看起来奇怪,但它不是笔误。PySide6 生成的 QML 模块使用一个特殊的高版本号,用来避免和用户自定义版本冲突。你不需要记住这个数字,按官方示例写即可。

Python 文件:main.py

python 复制代码
import sys
from PySide6.QtGui import QGuiApplication
from PySide6.QtQuick import QQuickView

if __name__ == "__main__":
    app = QGuiApplication()
    view = QQuickView()
    view.engine().addImportPath(sys.path[0])
    view.loadFromModule("App", "Main")
    view.show()
    ex = app.exec()
    del view
    sys.exit(ex)

逐行说明:

  • QGuiApplication :QML 应用不需要 Widgets,所以用 QGuiApplication 而不是 QApplication。它负责事件循环和基础 GUI 环境。
  • QQuickView:用来加载和显示 QML 界面的视图类。
  • view.engine().addImportPath(sys.path[0]) :把当前脚本所在目录加入 QML 引擎的导入路径。这样引擎才能找到 App/ 目录。
  • view.loadFromModule("App", "Main") :从 App 模块加载 Main 类型,对应 App/Main.qml。
  • view.show():显示视图。
  • app.exec():进入事件循环。
  • del view:在退出前显式删除视图,确保 QML 引擎和资源被正确释放。

说明

如果你做的是桌面程序,建议在 view.show() 之前加上:

python 复制代码
view.setResizeMode(QQuickView.SizeRootObjectToView)

这会让 QML 根对象随窗口大小自动缩放。不加的话,调整窗口大小时 QML 内容不会跟着变。

目录结构:

text 复制代码
project/
├── main.py
└── App/
    ├── Main.qml
    └── qmldir

运行:

bash 复制代码
python main.py

你会看到一个 200×200 的绿色方块,中间显示"Hello World"。

这个示例和前面 Widgets 教程的结构有相似之处,也有明显区别:

Widgets 应用 QML 应用
应用对象 QApplication QGuiApplication
界面定义 Python 代码或 .ui 文件 .qml 文件
加载方式 直接创建控件 QQuickView.loadFromModule()
界面语言 Python(命令式) QML(声明式)
模块声明 不需要 需要 qmldir
导入路径 不需要 需要 addImportPath()

QML 应用多了一个"模块"的概念。App/ 目录 + qmldir 构成一个 QML 模块,Python 侧通过 loadFromModule("App", "Main") 按模块名和类型名加载,而不是直接给文件路径。

一个最小的 PySide6/QML 应用包含:

文件 作用
Main.qml 声明式描述界面
qmldir 声明 QML 模块和类型
main.py 加载 QML 并启动事件循环

Python 侧的骨架只有四步:

python 复制代码
app = QGuiApplication()
view = QQuickView()
view.engine().addImportPath(...)
view.loadFromModule("App", "Main")
view.show()
app.exec()

关键概念:

  • QML 是声明式的。 你描述界面"长什么样",不描述"怎么一步步画出来"。
  • QML 文件不需要转换成 Python。 它由 QML 引擎在运行时解析执行,和 .ui 文件需要 pyside6-uic 转换是两回事。
  • qmldir 是模块清单。 它告诉引擎这个目录是一个模块,里面有哪些类型。
  • addImportPath() 让引擎能找到模块。 不调用它,loadFromModule("App", "Main") 会找不到 App 目录。
  • 桌面应用记得设 SizeRootObjectToView。 否则窗口缩放时界面不会跟着变。

这是 QML 路线的起点。后面更复杂的 QML 应用,也是在这个骨架上扩展:加更多 QML 文件、加 Python 与 QML 的交互、加自定义类型注册。

Python 与 QML 集成

这个教程快速演示一个 Python 应用如何加载 QML 文件并与它交互。QML 是一种声明式语言,设计 UI 比传统语言(比如 C++)更快。QtQml 和 QtQuick 模块为基于 QML 的 UI 提供了必要的基础设施。

本教程中,你会学到如何把 Python 和 QML 应用集成起来。这套机制能帮你理解:如何用 Python 作为后端,响应 QML 界面中 UI 元素发出的某些信号。此外,你还会学到如何用 Qt Quick Controls 2 的某个特性,给 QML 应用一个现代外观。

教程基于一个应用,它可以设置多种文本属性:增大字号、改变颜色、改变样式等。开始之前,请先安装 PySide6。

  1. QML 界面

    先看 QML 界面:

    界面基于 GridLayout,里面包含两个 ColumnLayout。里面有很多 RadioButton、Button 和一个 Slider。

  2. 从 Python 加载 QML

    python 复制代码
    if __name__ == '__main__':
        app = QGuiApplication(sys.argv)
        QQuickStyle.setStyle("Material")
        engine = QQmlApplicationEngine()
        # 把当前目录加入导入路径,加载主模块
        engine.addImportPath(sys.path[0])
        engine.loadFromModule("QmlIntegration", "Main")
    
        if not engine.rootObjects():
            sys.exit(-1)
    
        exit_code = app.exec()
        del engine
        sys.exit(exit_code)

    和前面的 QML 示例相比,这里用的是 QQmlApplicationEngine,不是 QQuickView。

    • QQuickStyle.setStyle("Material"):设置 Qt Quick Controls 的样式为 Material;
    • QQmlApplicationEngine:加载整个 QML 应用,适合以 ApplicationWindow 为根的 QML 文件;
    • loadFromModule("QmlIntegration", "Main"):从 QmlIntegration 模块加载 Main 类型;
    • if not engine.rootObjects():检查 QML 是否加载成功,失败就退出。
  3. 定义 Bridge 类

    Bridge 类包含所有要在 QML 中注册的元素的逻辑:

    python 复制代码
    # 用于 @QmlElement 装饰器
    # (QML_IMPORT_MINOR_VERSION 是可选的)
    QML_IMPORT_NAME = "io.qt.textproperties"
    QML_IMPORT_MAJOR_VERSION = 1
    
    
    @QmlElement
    class Bridge(QObject):
    
        @Slot(str, result=str)
        def getColor(self, s):
            if s.lower() == "red":
                return "#ef9a9a"
            if s.lower() == "green":
                return "#a5d6a7"
            if s.lower() == "blue":
                return "#90caf9"
            return "white"
    
        @Slot(float, result=int)
        def getSize(self, s):
            size = int(s * 34)
            return max(1, size)
    
        @Slot(str, result=bool)
        def getItalic(self, s):
            return s.lower() == "italic"
    
        @Slot(str, result=bool)
        def getBold(self, s):
            return s.lower() == "bold"
    
        @Slot(str, result=bool)
        def getUnderline(self, s):
            return s.lower() == "underline"

    要点:

    • @QmlElement :把 Bridge 类注册为 QML 类型。底层使用 Bridge 类引用以及 QML_IMPORT_NAME、QML_IMPORT_MAJOR_VERSION 两个变量完成注册。
    • @Slot(参数类型, result=返回类型) :声明槽函数的签名。result= 指定返回类型,这个返回类型会告诉 QML 侧该方法的返回值是什么类型。
    • getColor:接收字符串,返回十六进制颜色码。
    • getSize :接收浮点数,返回整数。int(s * 34) 把滑块值换算成字号,max(1, size) 保证字号至少为 1。
    • getItalic / getBold / getUnderline:接收字符串,返回布尔值。判断传入的文本是否等于对应样式名。
  4. 在 QML 中连接 Bridge

    回到 QML 文件,把信号连接到 Bridge 类里定义的槽:

    qml 复制代码
    Bridge {
       id: bridge
    }

    在 ApplicationWindow 内声明一个和 Python 类同名的组件,给它一个 id。这个 id 用来引用从 Python 注册过来的元素。

    qml 复制代码
    RadioButton {
        id: italic
        Layout.alignment: Qt.AlignLeft
        text: "Italic"
        onToggled: {
            leftlabel.font.italic = bridge.getItalic(italic.text)
            leftlabel.font.bold = bridge.getBold(italic.text)
            leftlabel.font.underline = bridge.getUnderline(italic.text)
        }
    }

    Italic、Bold、Underline 三个属性互斥,同一时间只能有一个生效。做法是:每次选中其中一个,就通过 QML 元素的属性把三个属性都检查一遍。三个中只有一个返回 True,另外两个返回 False,这样确保只有被选中的那个样式应用到文本上。

  5. qmldir

    把 QML 文件放进名为 QmlIntegration 的目录,并在同目录下放一个 qmldir:

    text 复制代码
    module QmlIntegration
    Main 254.0 Main.qml
  6. 槽函数的逻辑

    每个槽函数检查选中的选项是否包含对应属性的文本:

    python 复制代码
    @Slot(str, result=bool)
    def getBold(self, s):
        return s.lower() == "bold"

    返回 True 或 False,用来激活或取消 QML UI 元素的属性。

    也可以返回非布尔值,比如负责返回字号的槽:

    python 复制代码
    @Slot(float, result=int)
    def getSize(self, s):
        size = int(s * 34)
        return max(1, size)
  7. 设置应用外观

    有两种方式改变应用外观。

    方式一:命令行

    运行 Python 文件时加上 --style 选项:

    bash 复制代码
    python main.py --style material

    方式二:qtquickcontrols2.conf 文件

    ini 复制代码
    [Controls]
    Style=Material
    
    [Universal]
    Theme=System
    Accent=Red
    
    [Material]
    Theme=Dark
    Accent=Red

    然后把它加进 .qrc 文件:

    xml 复制代码
    <!DOCTYPE RCC><RCC version="1.0">
    <qresource prefix="/">
        <file>qtquickcontrols2.conf</file>
    </qresource>
    </RCC>

    运行 pyside6-rcc style.qrc -o rc_style.py 生成 rc 文件,最后在 main.py 中导入:

    python 复制代码
    import sys
    
    from PySide6.QtCore import QObject, Slot
    from PySide6.QtGui import QGuiApplication
    from PySide6.QtQml import QQmlApplicationEngine, QmlElement
    from PySide6.QtQuickControls2 import QQuickStyle
    
    import rc_style  # noqa F401

    最终效果:

关键概念:

  • Python 作为 QML 的后端。 QML 负责界面和用户交互,Python 负责逻辑计算和数据处理。两者通过注册的类型和槽函数通信。
  • @Slot 的 result= 参数很重要。 它告诉 QML 引擎返回值类型,QML 才能正确处理返回值。没有 result=,QML 侧可能拿不到正确的类型。
  • @QmlElement 是注册的入口。 导入包含 Bridge 类的 Python 模块时,注册自动发生。所以 main.py 里必须导入 Bridge 所在的模块。
  • 样式配置两种方式各有用途。 命令行 --style 适合临时测试;qtquickcontrols2.conf 适合正式项目,可以精细配置主题、强调色等。
  • QQmlApplicationEngine 和 QQuickView 的区别。 QQmlApplicationEngine 适合加载以 ApplicationWindow 为根的完整应用;QQuickView 适合加载单个 QML 组件作为视图。

这个模式在实际项目中非常常见:QML 写界面,Python 写逻辑,通过注册类型和信号槽把两边接起来。 后面的 QML 示例基本都是这个骨架的变体。

响应式属性教程

本教程将教你使用 PySide6.QtQmlFeatures 模块中的装饰器------auto_properties()、computed()、watch() 和 effect()------用极少的代码让一个 QObject 具备响应式能力,并能被 QML 绑定。

我们从一个传统的购物车类开始,它用 Property 和 Signal 编写,然后一步步重写,让每个装饰器消除一部分样板代码。

说明

PySide6.QtQmlFeatures 模块目前处于技术预览阶段。

示例场景:

购物车有两个输入和一个派生值:

  • price 和 quantity:普通整数输入。
  • total:始终等于 price * quantity。

除此之外,我们希望在发生变化时触发两种反应:

  • 当价格涨幅超过 50% 时发出警告(需要旧值和新值来判断);
  • 任何变化后记录购物车状态(只需要新状态)。

这三种需求正好对应三个装饰器:total 变成 computed(),价格跳变警告变成 watch(),日志记录变成 effect()。

第 1 步:传统写法

不使用新装饰器时,每个属性都要用 Property 装饰器声明,并且需要后备属性、getter、setter 和 notify 信号。派生的 total 必须手动重算,两种反应也必须从每个 setter 内部显式调用。

python 复制代码
from PySide6.QtCore import QObject, Property, Signal


class Cart(QObject):
    # 每个属性一个 notify 信号,手动声明。
    priceChanged = Signal()
    quantityChanged = Signal()
    totalChanged = Signal()

    def __init__(self, parent=None):
        super().__init__(parent)
        self._price = 10
        self._quantity = 2
        self._total = self._price * self._quantity

        # 手动保持派生值同步:输入变化时重新计算 total。
        self.priceChanged.connect(self._recompute_total)
        self.quantityChanged.connect(self._recompute_total)

    # price:后备字段 + getter + setter,用 @Property 声明。
    @Property(int, notify=priceChanged)
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if self._price == value:
            return
        old = self._price
        self._price = value
        self.priceChanged.emit()
        # "watch" 行为:手动对比旧值和新值。
        if value > old * 1.5:
            print(f"warning: price jumped {old} -> {value}")
        # "effect" 行为:手动记录新状态。
        self._log_state()

    # quantity:同样的样板代码,再来一遍。
    @Property(int, notify=quantityChanged)
    def quantity(self):
        return self._quantity

    @quantity.setter
    def quantity(self, value):
        if self._quantity == value:
            return
        self._quantity = value
        self.quantityChanged.emit()
        self._log_state()

    # total:只读派生属性,手动保持同步。没有 setter,QML 无法写入。
    @Property(int, notify=totalChanged)
    def total(self):
        return self._total

    def _recompute_total(self):
        new_total = self._price * self._quantity
        if new_total != self._total:
            self._total = new_total
            self.totalChanged.emit()

    def _log_state(self):
        print(f"cart: {self._price} x {self._quantity}")


if __name__ == "__main__":
    cart = Cart()
    print(f"initial total: {cart.total}")
    cart.price = 16      # 16 > 10 * 1.5,会触发跳变警告
    cart.quantity = 3
    print(f"final total: {cart.total}")

这段代码能跑,但注意你手动写了多少东西:

  • 三个 Signal 声明;
  • price 和 quantity 各自的后备字段、getter 和 setter;
  • 只读的 total 属性,外加一个连接到两个输入的 _recompute_total 槽;
  • 价格跳变警告的旧值/新值比较,内联在 setter 里;
  • 每个 setter 里都内联了 _log_state() 调用。

后面的步骤会一组一组地消除这些代码。

第 2 步:用 @auto_properties 和 @computed 生成属性

auto_properties() 是一个类装饰器。它把 __init__ 中每个普通的 self.<name> = <value> 赋值变成一个真正的 Q_PROPERTY,并带上 <name>Changed notify 信号;同时把每个 computed() 方法变成缓存、只读的属性。

python 复制代码
from PySide6.QtCore import QObject
from PySide6.QtQmlFeatures import auto_properties, computed


# @auto_properties 把普通的 "self.price = ..." / "self.quantity = ..."
# 赋值变成真正的 Q_PROPERTY,并带上 priceChanged/quantityChanged notify
# 信号;同时把 @computed 方法变成缓存、只读的属性。
@auto_properties
class Cart(QObject):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.price = 10
        self.quantity = 2

    # 'total' 只在 price 或 quantity 变化时重新计算。没有后备字段,
    # 没有 notify 信号,没有手动重算的接线。
    @computed("price", "quantity")
    def total(self) -> int:
        return self.price * self.quantity


if __name__ == "__main__":
    cart = Cart()
    print(f"initial total: {cart.total}")
    cart.price = 16
    cart.quantity = 3
    print(f"final total: {cart.total}")

@computed("price", "quantity") 装饰器声明 total 依赖 price 和 quantity。值会被缓存,只在这些依赖之一变化时重新计算;它的 totalChanged 信号自动发射,所以任何读取 total 的 QML 绑定都会自行刷新。返回注解(-> int)让 @auto_properties 推断属性的 Qt 类型。

和第 1 步相比,后备字段、getter、setter、三个显式 Signal 对象,以及整个 _recompute_total 接线都没了。

第 3 步:用 @watch 响应变化

watch() 方法观察单个属性。该属性变化后,方法会被调用,并收到一个描述变化的 Change 对象:它携带属性名、旧值、新值和所有者。

python 复制代码
from PySide6.QtCore import QObject
from PySide6.QtQmlFeatures import auto_properties, computed, watch, Change


@auto_properties
class Cart(QObject):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.price = 10
        self.quantity = 2

    @computed("price", "quantity")
    def total(self) -> int:
        return self.price * self.quantity

    # 在 'price' 变化后运行。Change 携带旧值和新值,所以可以比较。
    # 这替代了原本手动 setter 里的手动跟踪。
    @watch("price")
    def on_price_changed(self, change: Change):
        if change.new > change.old * 1.5:
            print(f"warning: price jumped {change.old} -> {change.new}")


if __name__ == "__main__":
    cart = Cart()
    print(f"initial total: {cart.total}")
    cart.price = 16      # 16 > 10 * 1.5,所以 @watch 回调会警告
    cart.quantity = 3
    print(f"final total: {cart.total}")

第 1 步里藏在手动 setter 中的价格跳变警告,现在是一个独立的方法。因为 Change 同时给出 change.old 和 change.new,可以直接比较。

@watch 回调只在值真正变化时运行。把属性设成当前值不会触发。

说明

要观察多个属性,可以在同一个方法上叠加多个 @watch 装饰器,每个属性一个。

第 4 步:用 @effect 执行副作用

effect() 在它列出的任意属性变化时运行。和 @watch 不同,它不接收 Change;它只是带着 self 被调用,以便对新状态做出反应。

python 复制代码
from PySide6.QtCore import QObject
from PySide6.QtQmlFeatures import auto_properties, computed, watch, effect, Change


@auto_properties
class Cart(QObject):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.price = 10
        self.quantity = 2

    @computed("price", "quantity")
    def total(self) -> int:
        return self.price * self.quantity

    @watch("price")
    def on_price_changed(self, change: Change):
        if change.new > change.old * 1.5:
            print(f"warning: price jumped {change.old} -> {change.new}")

    # 在 price 或 quantity 变化后运行。和 @watch 不同,它不接收 Change;
    # 它只是对新状态做出反应。这替代了手动 setter 里必须手动调用的
    # _log_state() 调用。
    @effect("price", "quantity")
    def log_state(self):
        print(f"cart: {self.price} x {self.quantity}")


if __name__ == "__main__":
    cart = Cart()
    print(f"initial total: {cart.total}")
    cart.price = 16
    cart.quantity = 3
    print(f"final total: {cart.total}")

第 1 步里必须在两个 setter 中分别塞入的 _log_state 调用,现在是一个 @effect("price", "quantity") 方法。

需要前后值时用 @watch,只需要对新状态做出反应时用 @effect。

直接运行任何一步都能在控制台看到反应:

text 复制代码
$ python steps/04-effect.py
initial total: 20
warning: price jumped 10 -> 16
cart: 16 x 2
cart: 16 x 3
final total: 48

第 5 步:从 QML 绑定全部

到目前为止,购物车都是从 Python 驱动的。真正的收益在于:生成的属性是普通的 Q_PROPERTY 对象,所以 QML 既能读也能写,而且从 QML 写入的变化也会触发观察者。

在 @auto_properties 之上加 @QmlElement 装饰器,并定义 QML_IMPORT_NAME / QML_IMPORT_MAJOR_VERSION 变量。装饰器要叠放,让 @auto_properties 先运行(最内层):它必须先给 QMetaObject 加上生成的属性,然后 @QmlElement 才能把类型注册给 QML 引擎。

python 复制代码
import sys
from pathlib import Path

from PySide6.QtCore import QObject
from PySide6.QtGui import QGuiApplication
from PySide6.QtQml import QQmlApplicationEngine, QmlElement
from PySide6.QtQmlFeatures import auto_properties, computed, watch, effect, Change

# 用于 @QmlElement 装饰器。
QML_IMPORT_NAME = "Shop"
QML_IMPORT_MAJOR_VERSION = 1


# 装饰器要叠放,让 @auto_properties 先运行(最内层):它必须先给
# QMetaObject 加上生成的 Q_PROPERTY,然后 @QmlElement 才能把类型注册
# 给 QML 引擎。
@QmlElement
@auto_properties
class Cart(QObject):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.price = 10
        self.quantity = 2

    @computed("price", "quantity")
    def total(self) -> int:
        return self.price * self.quantity

    @watch("price")
    def on_price_changed(self, change: Change):
        if change.new > change.old * 1.5:
            print(f"warning: price jumped {change.old} -> {change.new}")

    @effect("price", "quantity")
    def log_state(self):
        print(f"cart: {self.price} x {self.quantity}")


if __name__ == "__main__":
    app = QGuiApplication(sys.argv)
    engine = QQmlApplicationEngine()
    engine.addImportPath(sys.path[0])
    qml_file = Path(__file__).parent / "cart.qml"
    engine.load(qml_file)
    if not engine.rootObjects():
        sys.exit(-1)
    exit_code = app.exec()
    del engine
    sys.exit(exit_code)

QML 文件读取 total,写入 price 和 quantity:

qml 复制代码
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Shop

ApplicationWindow {
    visible: true
    width: 320
    height: 220
    title: "Reactive Cart"

    Cart { id: cart }

    ColumnLayout {
        anchors.centerIn: parent
        spacing: 12

        // 读取 @computed 的 'total'。每当 price 或 quantity 变化时,
        // 它会自动重新求值,因为两者都会发射 notify 信号。
        Label {
            text: `Total: ${cart.total}`
            font.pixelSize: 24
            Layout.alignment: Qt.AlignHCenter
        }

        RowLayout {
            Label { text: "Price:" }
            // 写入 cart.price 会运行生成的 setter,触发 @watch 和
            // @effect 回调,并使 'total' 失效。
            SpinBox {
                from: 1
                to: 999
                value: cart.price
                onValueModified: cart.price = value
            }
        }

        RowLayout {
            Label { text: "Quantity:" }
            SpinBox {
                from: 1
                to: 99
                value: cart.quantity
                onValueModified: cart.quantity = value
            }
        }
    }
}

当你拖动 SpinBox 时,QML 写入 cart.price(或 cart.quantity)。这会运行生成的 setter,触发 Python 侧的 @watch 和 @effect 回调,使 total 失效,并发射 notify 信号,让 Total 标签重新求值。全程没有一条手写的信号连接。

第 6 步:用 load_qml_component 从 Python 加载视图

第 5 步让 QML 拥有对象:Cart { id: cart } 从 QML 实例化 Python 类型,QQmlApplicationEngine.load() 把整个 UI 带起来。

最后一步展示镜像方向。响应式 Cart 留在 Python,我们用 load_qml_component 把一个 QML 视图拉进 Python,再把响应式值喂给它。

这次 Cart 上没有 @QmlElement:QML 从不创建它。模型是普通 Python 对象,一个小的、只负责显示的 QML 组件被加载并从 Python 驱动。

python 复制代码
import sys

from PySide6.QtCore import QObject, QTimer
from PySide6.QtGui import QGuiApplication
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtQmlFeatures import (auto_properties, computed, watch, effect,
                                   Change, load_qml_component)


# 响应式模型和第 4 步一样:这次没有 @QmlElement,
# 因为模型留在 Python,QML 从不实例化它。
@auto_properties
class Cart(QObject):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.price = 10
        self.quantity = 2

    @computed("price", "quantity")
    def total(self) -> int:
        return self.price * self.quantity

    @watch("price")
    def on_price_changed(self, change: Change):
        if change.new > change.old * 1.5:
            print(f"warning: price jumped {change.old} -> {change.new}")

    @effect("price", "quantity")
    def log_state(self):
        print(f"cart: {self.price} x {self.quantity}")


if __name__ == "__main__":
    app = QGuiApplication(sys.argv)
    engine = QQmlApplicationEngine()

    # 这次响应式模型在 Python 里,不在 QML 里。
    cart = Cart()

    # 用 load_qml_component() 把 QML 视图拉进 Python,并创建实例。
    # 'view' 是一个 Pythonic 包装器,它的 QML 属性就是普通属性,
    # 所以可以直接给 view.total、view.price 等赋值。
    view_factory = load_qml_component(engine, "cartview.qml")
    view = view_factory.create()

    # 把响应式值推给加载的 QML 视图。连接到输入 notify 信号就够了:
    # 读取 cart.total 时会按需重新计算 @computed 值。
    def sync():
        view.price = cart.price
        view.quantity = cart.quantity
        view.total = cart.total

    cart.priceChanged.connect(sync)
    cart.quantityChanged.connect(sync)
    sync()

    # 从 Python 驱动响应式模型。每次赋值都会运行 @watch 和 @effect
    # 回调(控制台输出),并通过 sync() 更新从 Python 加载的 QML 视图。
    def tick():
        cart.price += 2
        if cart.price > 30:
            cart.price = 10
            cart.quantity = cart.quantity % 5 + 1

    timer = QTimer()
    timer.timeout.connect(tick)
    timer.start(500)

    exit_code = app.exec()
    del engine
    sys.exit(exit_code)

load_qml_component 返回一个工厂;对它调用 create() 会实例化组件,返回一个 Pythonic 包装器,其 QML 属性就是普通属性。这就是为什么 sync() 可以直接给 view.total 和 view.price 赋值。

QML 视图本身没有逻辑,只有三个 Python 写入的属性:

qml 复制代码
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

// 一个纯显示组件。它没有自己的逻辑:Python 用
// load_qml_component() 加载它、创建它,并把响应式购物车的值
// 推给这三个普通属性。
ApplicationWindow {
    property int price: 0
    property int quantity: 0
    property int total: 0

    visible: true
    width: 320
    height: 220
    title: "Reactive Cart (loaded from Python)"

    ColumnLayout {
        anchors.centerIn: parent
        spacing: 12

        Label {
            text: `Total: ${total}`
            font.pixelSize: 24
            Layout.alignment: Qt.AlignHCenter
        }

        Label {
            text: `price = ${price},  quantity = ${quantity}`
            Layout.alignment: Qt.AlignHCenter
        }
    }
}

一个 QTimer 从 Python 推动 cart.price。每次变化都会像之前一样运行 @watch 和 @effect 回调(打印到控制台),sync() 读取重新计算后的 total,并推给从 Python 加载的 QML 视图。

响应式装饰器和 load_qml_component 协同工作:装饰器保持模型一致,load_qml_component 让 Python 拥有并驱动视图。

说明

我们连接的是 priceChanged 和 quantityChanged(输入 notify 信号),而不是 totalChanged。@computed 值是惰性的:total 在被读取时才重新计算,所以 sync() 在任一输入变化后按需读取 cart.total。
说明

load_qml_component 是 PySide6.QtQmlFeatures 技术预览的一部分。完整说明参见 PySide6.QtQmlFeatures 模块参考。

这篇教程展示了如何用四个装饰器消除响应式属性的样板代码:

装饰器 作用 替代了什么
@auto_properties 类装饰器,把 self.x = value 变成 Q_PROPERTY 手动声明 Signal、后备字段、getter、setter
@computed("a", "b") 声明依赖 a、b 的缓存只读属性 手动重算派生值 + 手动发射 notify 信号
@watch("x") 属性 x 变化后调用,收到 Change(含旧值、新值) setter 里内联的旧值/新值比较
@effect("x", "y") x 或 y 变化后调用,只对新状态做出反应 setter 里手动调用的副作用

几个要点:

  • 这是技术预览模块。 PySide6.QtQmlFeatures 目前还在 preview 阶段,API 可能变化,生产环境慎用。
  • 装饰器顺序很重要。 @QmlElement 必须在 @auto_properties 外层,确保属性先加到 QMetaObject,再注册类型。
  • @computed 是惰性的。 值在被读取时才重算,所以监听输入信号而不是 totalChanged 是更稳妥的做法。
  • @watch 要前后值,@effect 只要新状态。 按需求选。
  • 两个方向都支持。 QML 可以实例化 Python 类型(第 5 步),Python 也可以加载 QML 视图并驱动它(第 6 步)。

对博客读者来说,这篇属于进阶内容。建议先掌握 Property、Signal、@Slot、@QmlElement 的基本用法,再来看这些装饰器如何简化样板代码。

QML 应用教程

这个教程带你走一遍一个 Python 应用的开发过程:从网络获取数据,用 QML 构建界面,再把 Python 数据传给 QML 的 ListView 显示。

和前面手写文件的方式不同,这个教程走的是 Qt Creator 工作流:用 IDE 创建项目、管理文件、运行调试。

  1. 用 Qt Creator 创建项目

    先安装 Qt Creator。Qt Creator 会自动处理 Python 环境,在需要的地方提示你安装 PySide6。

    打开 Qt Creator,选择 File > New File or Project...:

    在模板列表里选 Qt for Python - Empty ,点 Choose:

    给项目起个名字,选好文件系统位置,点 Finish。Qt Creator 会创建两个文件:

    text 复制代码
    project/
    ├── main.py
    └── pyproject.toml
  2. 准备 QML 模块

    下载 Main.qml、logo.png 和 qmldir 三个文件,放进项目文件夹下的 App 子目录:

    text 复制代码
    project/
    ├── main.py
    ├── pyproject.toml
    └── App/
        ├── Main.qml
        ├── logo.png
        └── qmldir

    这样 App/ 就构成了一个基本的 QML 模块。

    双击打开 pyproject.toml,把这三个文件加进 files 列表:

    toml 复制代码
    [project]
    name = "qml-application"
    
    [tool.pyside6-project]
    files = ["main.py", "App/Main.qml", "App/logo.png", "App/qmldir"]

    这一步是给 pyside6-project 工具看的。它根据这个列表收集项目文件,后续构建、打包时才知道要处理哪些文件。

  3. 从网络获取数据

    打开 main.py,导入需要的模块,然后下载国家名称数据:

    python 复制代码
    import sys
    import urllib.request
    import json
    
    from PySide6.QtQuick import QQuickView
    from PySide6.QtCore import QStringListModel
    from PySide6.QtGui import QGuiApplication
    
    
    if __name__ == '__main__':
    
        # 获取数据
        url = "https://country.io/names.json"
        response = urllib.request.urlopen(url)
        data = json.loads(response.read().decode('utf-8'))
    
        # 格式化并排序
        data_list = list(data.values())
        data_list.sort()

    urllib.request.urlopen() 请求 URL,json.loads() 把返回的 JSON 解析成 Python 字典。data.values() 取出所有国家名称,转成列表后排序。

  4. 创建应用和视图

    用 QGuiApplication 管理应用级设置,用 QQuickView 承载 QML 界面:

    python 复制代码
        # 创建应用窗口
        app = QGuiApplication(sys.argv)
        view = QQuickView()
        view.setResizeMode(QQuickView.ResizeMode.SizeRootObjectToView)

    说明

    设置 SizeRootObjectToView 很重要。它让 QML 根对象随窗口大小自动缩放。不设置的话,调整窗口大小时,QML 内容会保持原始尺寸,不会跟着变。

    QQuickView 和前面用的 QQmlApplicationEngine 都是加载 QML 的引擎,区别在于:

    QQuickView QQmlApplicationEngine
    本质 一个 QWindow 子类 纯引擎,不负责显示
    适合 单个 QML 视图 完整应用,QML 根元素是 ApplicationWindow
    显示 自带 show() 由 QML 的 visible: true 控制

    这个教程用 QQuickView,因为它要把数据通过初始属性注入 QML,然后再显示视图。

  5. 把数据传给 QML

    QStringListModel 把 Python 列表包装成 Qt 的模型,然后通过 setInitialProperties() 注入 QML:

    python 复制代码
        # 把列表暴露给 QML
        my_model = QStringListModel()
        my_model.setStringList(data_list)
        view.setInitialProperties({"myModel": my_model})

    setInitialProperties() 接收一个字典,键是 QML 侧的属性名,值是 Python 对象。QML 文件里会声明一个 property var myModel,加载时自动收到这个模型。

    这是 Python 向 QML 传数据的一种方式,适合初始化时一次性注入 。需要双向绑定或动态更新时,用第 5 步那种 @QmlElement 注册类型的方式更合适。

  6. 加载 QML 模块

    python 复制代码
        # 加载 QML 文件
        # 把当前目录加入导入路径,加载主模块
        view.engine().addImportPath(sys.path[0])
        view.loadFromModule("App", "Main")
    
        # 显示窗口
        if view.status() == QQuickView.Status.Error:
            sys.exit(-1)
        view.show()
    • addImportPath(sys.path[0]):把脚本所在目录加入 QML 导入路径,引擎才能找到 App/ 目录;
    • loadFromModule("App", "Main"):从 App 模块加载 Main 类型,对应 App/Main.qml;
    • view.status() 检查加载状态,出错就退出;
    • view.show() 显示视图。
  7. 运行并清理

    python 复制代码
        # 执行并清理
        app.exec()
        del view

    app.exec() 进入事件循环,del view 在退出前显式释放视图。

    补充

    官方示例这里写的是 app.exec(),没传 sys.exit()。正式项目更规范的写法是 sys.exit(app.exec()),把退出码传给操作系统。

完整代码

python 复制代码
import sys
import urllib.request
import json

from PySide6.QtQuick import QQuickView
from PySide6.QtCore import QStringListModel
from PySide6.QtGui import QGuiApplication


if __name__ == '__main__':

    # 获取数据
    url = "https://country.io/names.json"
    response = urllib.request.urlopen(url)
    data = json.loads(response.read().decode('utf-8'))

    # 格式化并排序
    data_list = list(data.values())
    data_list.sort()

    # 创建应用窗口
    app = QGuiApplication(sys.argv)
    view = QQuickView()
    view.setResizeMode(QQuickView.ResizeMode.SizeRootObjectToView)

    # 把列表暴露给 QML
    my_model = QStringListModel()
    my_model.setStringList(data_list)
    view.setInitialProperties({"myModel": my_model})

    # 加载 QML 文件
    # 把当前目录加入导入路径,加载主模块
    view.engine().addImportPath(sys.path[0])
    view.loadFromModule("App", "Main")

    # 显示窗口
    if view.status() == QQuickView.Status.Error:
        sys.exit(-1)
    view.show()

    # 执行并清理
    app.exec()
    del view

在左侧 Projects 模式里选择要用的 Python 版本,然后按 Ctrl+R 运行。

运行后,你会看到一个窗口,里面是国家名称列表,每项旁边带 Logo:

这个教程把 QML 应用的几个关键环节串了起来:

环节 做法 作用
项目结构 Qt Creator 创建 main.py + pyproject.toml 规范的项目骨架
QML 模块 App/ 目录 + qmldir 让 QML 类型可被 loadFromModule 加载
数据获取 urllib + json 从网络拿到国家名称
数据传递 QStringListModel + setInitialProperties() 把 Python 数据注入 QML
视图加载 QQuickView + loadFromModule() 加载并显示 QML
窗口缩放 setResizeMode(SizeRootObjectToView) 根对象随窗口缩放

几个要点:

  • setInitialProperties() 是单向注入。 它在 QML 加载时把 Python 对象传进去,适合初始化数据。需要持续更新时,用信号槽或 @QmlElement 注册的类型。
  • QStringListModel 是 Qt 的模型类。 QML 的 ListView 认这个类型,所以 Python 列表要先包装成它。
  • QQuickView 和 QQmlApplicationEngine 各有分工。 前者是一个可显示的窗口,适合简单视图;后者是纯引擎,适合 ApplicationWindow 为根的完整应用。
  • pyproject.toml 的 files 列表要维护好。 pyside6-project 靠它收集文件,漏了文件后续构建会出问题。
  • Qt Creator 工作流和手写文件不冲突。 IDE 只是帮你管理项目,底层还是 main.py + QML 模块那一套。

QML、SQL 与 PySide 集成教程

这个教程和 Qt Chat 教程非常相似,但重点在于讲解如何把一个 SQL 数据库集成到用 QML 做界面的 PySide6 应用中。

sqlDialog.py

先导入需要的库,定义一个保存表名的全局变量,以及一个全局函数 createTable(),用于在表不存在时创建新表。数据库里会插入一条记录,模拟对话的开头。

python 复制代码
import datetime
import logging

from PySide6.QtCore import Qt, Slot
from PySide6.QtSql import QSqlDatabase, QSqlQuery, QSqlRecord, QSqlTableModel
from PySide6.QtQml import QmlElement

table_name = "Conversations"
QML_IMPORT_NAME = "ChatModel"
QML_IMPORT_MAJOR_VERSION = 1


def createTable():
    if table_name in QSqlDatabase.database().tables():
        return

    query = QSqlQuery()
    if not query.exec_(
        """
        CREATE TABLE IF NOT EXISTS 'Conversations' (
            'author' TEXT NOT NULL,
            'recipient' TEXT NOT NULL,
            'timestamp' TEXT NOT NULL,
            'message' TEXT NOT NULL,
        FOREIGN KEY('author') REFERENCES Contacts ( name ),
        FOREIGN KEY('recipient') REFERENCES Contacts ( name )
        )
        """
    ):
        logging.error("Failed to query database")

    # 插入机器人发的第一条消息。
    # 要让它具备交互能力,还需要进一步开发。
    query.exec_(
        """
        INSERT INTO Conversations VALUES(
            'machine', 'Me', '2019-01-07T14:36:06', 'Hello!'
        )
        """
    )

SqlConversationModel 类提供聊天记录列表所需的只读数据模型。它继承自 QSqlTableModel,对这个场景来说是合理的选择。然后创建表,用 setTable() 把表名设为前面定义的 table_name,再配置几个属性,让程序符合聊天应用的行为。

python 复制代码
@QmlElement
class SqlConversationModel(QSqlTableModel):
    def __init__(self, parent=None):
        super(SqlConversationModel, self).__init__(parent)

        createTable()
        self.setTable(table_name)
        self.setSort(2, Qt.DescendingOrder)
        self.setEditStrategy(QSqlTableModel.OnManualSubmit)
        self.recipient = ""

        self.select()
        logging.debug("Table was loaded successfully.")

data() 函数在角色不是自定义用户角色时,回退到 QSqlTableModel 的默认实现。如果收到的是用户角色,就用它减去 Qt.UserRole 得到字段索引,再用这个索引取出要返回的值。

python 复制代码
    def data(self, index, role):
        if role < Qt.UserRole:
            return QSqlTableModel.data(self, index, role)

        sql_record = QSqlRecord()
        sql_record = self.record(index.row())

        return sql_record.value(role - Qt.UserRole)

roleNames() 返回一个 Python 字典,键是自定义角色值,值是角色名,这样 QML 里就能用这些角色名。也可以用一个枚举来统一管理所有角色值。

python 复制代码
    def roleNames(self):
        """把字典转成 hash,因为 QSqlTableModel 期望的就是这种形式"""

        return {int(Qt.UserRole): b"author",
                Qt.UserRole + 1: b"recipient",
                Qt.UserRole + 2: b"timestamp",
                Qt.UserRole + 3: b"message"}

send_message() 函数用给定的接收者、消息和发送者往数据库里插入一条新记录。因为用的是 OnManualSubmit 策略,插入后必须调用 submitAll(),否则改动只缓存在模型里,不会写入数据库。

python 复制代码
    # 这是一个变通做法:PySide 没有提供 Q_INVOKABLE,
    # 所以把方法声明为 Slot,才能从 QML 调用。
    @Slot(str, str, str)
    def send_message(self, recipient, message, author):
        timestamp = datetime.datetime.now()

        new_record = self.record()
        new_record.setValue("author", author)
        new_record.setValue("recipient", recipient)
        new_record.setValue("timestamp", str(timestamp))
        new_record.setValue("message", message)

        logging.debug(f'Message: "{message}" \n Received by: "{recipient}"')

        if not self.insertRecord(self.rowCount(), new_record):
            logging.error("Failed to send message: {self.lastError().text()}")
            return

        self.submitAll()
        self.select()

Main.qml

来看 Main.qml 文件。

qml 复制代码
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls

先导入 QtQuick 模块。它提供 Item、Rectangle、Text 等图形基础类型,完整列表见 Qt Quick QML Types 文档。

接着导入 QtQuick.Layouts,后面会用到。再导入 QtQuick.Controls,它提供 ApplicationWindow 等控件,ApplicationWindow 在这里替代了原来的根类型 Window。

逐步看 App/Main.qml:

qml 复制代码
ApplicationWindow {
    id: window
    title: qsTr("Chat")
    width: 640
    height: 960
    visible: true

ApplicationWindow 本质上是一个 Window,额外提供了创建头部和底部的便利,也是弹出窗口的基础,还支持一些基础样式,比如背景色。

使用 ApplicationWindow 时,几乎总要设置三个属性:width、height 和 visible。设置好之后,就得到一个尺寸合适、等待填充内容的空窗口。

因为 SqlConversationModel 类已经暴露给 QML,这里声明一个组件来使用它:

qml 复制代码
    SqlConversationModel {
        id: chat_model
    }

QML 里排列元素有两种方式:Item Positioners (定位器)和 Qt Quick Layouts(布局)。

定位器(Row、Column 等)适合元素尺寸已知或固定的场景,只需要按某种形态整齐排列。

Qt Quick Layouts 里的布局既能定位也能调整元素大小,更适合可缩放的界面。下面用 ColumnLayout 把 ListView 和 Pane 垂直排列:

qml 复制代码
    ColumnLayout {
        anchors.fill: parent

        ListView {
        Pane {
            id: pane
            Layout.fillWidth: true

Pane 基本就是一个矩形,颜色来自应用样式。它类似 Frame,但边框没有描边。

布局的直接子元素可以使用各种附加属性。这里给 ListView 设置 Layout.fillWidth 和 Layout.fillHeight,让它在 ColumnLayout 里尽可能占满空间,Pane 也做同样处理。因为 ColumnLayout 是垂直布局,子元素左右没有其他元素,所以 Layout.fillWidth 会让每个子元素占满整个宽度。

而 ListView 上的 Layout.fillHeight 让它在容纳 Pane 之后占据剩余空间。

详细看 ListView:

qml 复制代码
        ListView {
            id: listView
            Layout.fillWidth: true
            Layout.fillHeight: true
            Layout.margins: pane.leftPadding + messageField.leftPadding
            displayMarginBeginning: 40
            displayMarginEnd: 40
            verticalLayoutDirection: ListView.BottomToTop
            spacing: 12
            model: chat_model
            delegate: Column {
                anchors.right: sentByMe ? listView.contentItem.right : undefined
                spacing: 6

                readonly property bool sentByMe: model.recipient !== "Me"
                Row {
                    id: messageRow
                    spacing: 6
                    anchors.right: sentByMe ? parent.right : undefined

                    Rectangle {
                        width: Math.min(messageText.implicitWidth + 24,
                            listView.width - (!sentByMe ? messageRow.spacing : 0))
                        height: messageText.implicitHeight + 24
                        radius: 15
                        color: sentByMe ? "lightgrey" : "steelblue"

                        Label {
                            id: messageText
                            text: model.message
                            color: sentByMe ? "black" : "white"
                            anchors.fill: parent
                            anchors.margins: 12
                            wrapMode: Label.Wrap
                        }
                    }
                }

                Label {
                    id: timestampText
                    text: Qt.formatDateTime(model.timestamp, "d MMM hh:mm")
                    color: "lightgrey"
                    anchors.right: sentByMe ? parent.right : undefined
                }
            }

            ScrollBar.vertical: ScrollBar {}
        }

填满父元素宽高后,还给视图设置了一些边距。

接着设置 displayMarginBeginning 和 displayMarginEnd。这两个属性保证滚动到边缘时,视图外的委托项不会消失。想更直观地理解,可以把这两行注释掉再运行,观察滚动时的效果。

然后把视图的垂直方向翻转,让第一条消息出现在底部。

另外,自己发的消息和对方发的消息要区分开。这里用 sentByMe 属性来区分,通过两种方式体现:

  • 自己发的消息通过 anchors.right: parent.right 对齐到右侧;
  • 矩形颜色根据发送者切换。为了避免深色背景配深色文字,文字颜色也一并切换。

屏幕底部放一个 TextArea 支持多行输入,再加一个发送按钮。用 Pane 包住这两个控件:

qml 复制代码
        Pane {
            id: pane
            Layout.fillWidth: true

            RowLayout {
                width: parent.width

                TextArea {
                    id: messageField
                    Layout.fillWidth: true
                    placeholderText: qsTr("Compose message")
                    wrapMode: TextArea.Wrap
                }

                Button {
                    id: sendButton
                    text: qsTr("Send")
                    enabled: messageField.length > 0
                    onClicked: {
                        listView.model.send_message("machine", messageField.text, "Me");
                        messageField.text = "";
                    }
                }
            }
        }

TextArea 占满可用宽度,设置占位文本提示用户在哪里输入。输入内容会自动换行,不会超出屏幕。

按钮点击时调用 sqlDialog.py 里定义的 send_message 方法。这里只是模拟示例,对话中只有一个接收者、一个发送者,所以直接传字符串。

Main.qml 要放进名为 App 的目录,并在同目录下放一个 qmldir 描述基本 QML 模块:

text 复制代码
module App
Main 254.0 Main.qml

main.py

这里用 logging 而不是 Python 的 print(),因为它能更好地控制应用产生的消息级别(错误、警告、信息)。

python 复制代码
import sys
import logging

from PySide6.QtCore import QCoreApplication, QDir, QFile, QStandardPaths
from PySide6.QtGui import QGuiApplication
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtSql import QSqlDatabase

# 导入这个文件,触发 QmlElement 类型注册。
import sqlDialog  # noqa E703

logging.basicConfig(filename="chat.log", level=logging.DEBUG)
logger = logging.getLogger("logger")

connectToDatabase() 建立与 SQLite 数据库的连接,如果文件不存在就创建它。

python 复制代码
def connectToDatabase():
    database = QSqlDatabase.database()
    if not database.isValid():
        database = QSqlDatabase.addDatabase("QSQLITE")
        if not database.isValid():
            logger.error("Cannot add database")

    app_data = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
    write_dir = QDir(app_data)
    if not write_dir.mkpath("."):
        logger.error(f"Failed to create writable directory {app_data}")

    # 确保在所有设备上都有可写位置。
    abs_path = write_dir.absolutePath()
    filename = f"{abs_path}/chat-database.sqlite3"

    # 使用 SQLite 驱动时,open() 会在数据库不存在时创建它。
    database.setDatabaseName(filename)
    if not database.open():
        logger.error("Cannot open database")
        QFile.remove(filename)

主函数里做了几件事:

  1. 声明 QGuiApplication :因为不用 QtWidgets 模块,所以用 QGuiApplication 而不是 QApplication。
  2. 连接数据库。
  3. 声明 QQmlApplicationEngine:用于访问 QML 元素,把 Python 和 QML 里构建的对话模型连接起来。
  4. 加载定义 UI 的 .qml 文件。
  5. 运行 Qt 应用,程序启动。
python 复制代码
    app = QGuiApplication()
    QCoreApplication.setOrganizationName("QtProject")
    QCoreApplication.setApplicationName("Chat Tutorial")

    connectToDatabase()

    engine = QQmlApplicationEngine()
    engine.addImportPath(sys.path[0])
    engine.loadFromModule("App", "Main")

    if not engine.rootObjects():
        sys.exit(-1)

    exit_code = app.exec()
    del engine

运行后,界面效果:

这个教程把 QML、SQL 和 PySide 三部分串了起来:

部分 文件 职责
数据模型 sqlDialog.py 建表、查询、插入消息、向 QML 暴露角色
界面 App/Main.qml 聊天窗口、消息列表、输入框、发送按钮
启动入口 main.py 初始化应用、连接数据库、加载 QML

关键概念:

  • QSqlTableModel 是 Qt 的数据库模型。 它直接映射一张表,配合 setTable()、select()、insertRecord() 使用。
  • 自定义角色让 QML 能读字段。 Qt.UserRole 是自定义角色的起点,roleNames() 把角色值映射成 QML 里能用的名字,data() 负责按角色返回对应字段的值。
  • @Slot 替代 Q_INVOKABLE。 PySide6 没有 Q_INVOKABLE,所以想让 QML 调用 Python 方法,就用 @Slot 装饰。
  • OnManualSubmit 要手动提交。 插入记录后必须调用 submitAll(),否则改动不会写入数据库。
  • QStandardPaths.AppDataLocation 是可写目录。 数据库文件放在这里,跨平台都能保证有写权限。
  • import sqlDialog 只是为了触发注册。 # noqa E703 告诉 linter 这个导入看起来没用,但它是注册 @QmlElement 类型所必需的。
  • QML 布局用 Layout.fillWidth / Layout.fillHeight。 这是 Qt Quick Layouts 的附加属性,不是普通 QML 属性,只对布局的子元素有效。

这个示例比较完整,涉及数据库、模型、自定义角色、QML 委托、输入控件和信号槽。可以作为"Python 后端 + SQL 存储 + QML 前端"这套架构的参考模板。

核心 API

阶段 2 跟着官方示例走了一遍,我们已经能写出带按钮、对话框、表格、QML 界面的小程序。

但示例是别人搭好的骨架。真正自己写项目时,会遇到这些问题:

  • 这个控件该去哪个模块找?
  • 对象什么时候销毁?要不要手动删?
  • 信号槽跨线程为什么行为不一样?
  • 布局为什么调不动?
  • QTableWidget 和 QTableView 到底选哪个?

阶段 3 回答这些。它不按官方 Modules API 逐模块翻译,而是按核心概念组织:讲透少数几个地基概念,其余模块给索引,用到再查。

核心 API 地图:哪些必学,哪些查手册

官方 Modules API 列了几十个模块,但真正需要深入的只有三个。

模块 职责 为什么深入
QtCore 非 GUI 基础:对象树、信号槽、事件循环、定时器、线程、文件、配置 所有模块的地基
QtGui 图形基础:绘图、字体、颜色、图片、图标 Widgets 和 QML 都依赖
QtWidgets 传统桌面控件:按钮、布局、对话框、表格、菜单 Widgets 路线的主战场

这三个讲透,传统桌面开发就能走通。

QML 相关:只讲与 Python 交互的部分

模块 只讲什么
QtQml QQmlApplicationEngine、QmlElement、Property、Slot、Signal 在 QML 注册中的用法
QtQuick QQuickView、QQuickWidget 如何嵌入 Python 应用
Qt Quick Controls QML 侧控件库,样式用 qtquickcontrols2.conf 配置

QML 语法、Rectangle、ListView、anchors 这些,阶段 2 的示例已经带过,这里不重复。

其余模块:索引式介绍

模块 一句话定位 入口类
QtNetwork 网络请求、TCP/UDP QNetworkAccessManager、QTcpSocket
QtSql 数据库 QSqlDatabase、QSqlQuery、QSqlTableModel
QtMultimedia 音视频播放 QMediaPlayer、QAudioOutput
QtCharts 图表 QChart、QChartView
QtWebEngine 内嵌浏览器 QWebEngineView
QtSerialPort 串口通信 QSerialPort
QtBluetooth 蓝牙 QBluetoothDeviceDiscoveryAgent
QtPrintSupport 打印 QPrinter、QPrintDialog
QtTest 单元测试 QTest
QtStateMachine 状态机 QStateMachine
QtConcurrent 并行任务 QtConcurrent.run()
QtSvg SVG 渲染 QSvgWidget、QSvgRenderer
QtXml XML 解析 QDomDocument、QXmlStreamReader
QtUiTools QUiLoader 阶段 2 提过

遇到具体需求时回来扫一眼,知道该去哪个模块找就够了。

学习顺序

节 主题 核心问题
3.2 QObject 与对象树 对象什么时候销毁?父子关系怎么影响生命周期?
3.3 信号槽深入 自定义信号怎么写?跨线程为什么行为不同?
3.4 事件循环、QTimer 与 QThread 程序怎么动起来?后台任务怎么做?
3.5 文件、路径、配置、进程 读写文件、存配置、启动外部程序怎么做?
3.6 QtGui:绘图、字体、颜色、图片 怎么画东西?怎么处理图片和图标?
3.7 布局管理器 控件怎么随窗口缩放?为什么布局调不动?
3.8 常用控件速览 有什么控件?各自最常用的方法是什么?
3.9 Model/View 架构 数据量大时怎么展示?什么时候不用便捷版控件?
3.10 QML 相关模块 Python 和 QML 怎么双向交互?
3.11 其余模块索引 遇到具体需求该去哪个模块找?

QtCore:对象树与 QObject

QObject 是 Qt 里几乎所有类的祖先。理解它,就理解了 Qt 的对象生命周期。

创建 QObject 时,可以传入 parent:

python 复制代码
from PySide6.QtCore import QObject

parent = QObject()
child1 = QObject(parent)
child2 = QObject(parent)

print(parent.children())  # [<PySide6.QtCore.QObject object at ...>, ...]

children() 返回所有直接子对象。

也可以用 setParent() 事后指定:

python 复制代码
child = QObject()
child.setParent(parent)

生命周期:父对象销毁,子对象自动销毁

这是对象树最重要的作用:

python 复制代码
parent = QObject()
child = QObject(parent)

del parent  # 父对象被销毁
# child 也会被自动销毁

反过来,子对象销毁不影响父对象。

这解决了什么? 在 C++ 里手动管理内存很容易漏删或重复删。Qt 用对象树把这件事自动化了:只要保证父对象被正确销毁,整棵子树都会跟着销毁。

和 Python GC 的关系

Python 有自己的垃圾回收。QObject 的父子关系与 Python 引用计数是两套机制,但 PySide6 做了协调:

  • Python 侧引用消失,且 C++ 侧没有父对象,C++ 对象会被销毁;
  • Python 侧引用消失,但 C++ 侧有父对象,C++ 对象继续存在,由父对象管理;
  • 父对象在 C++ 侧被销毁时,子对象的 Python 包装器会变成"失效"状态,再访问会报错。

实际开发中记住一条:

给控件指定父对象,或者加进布局,就不需要手动管理它的生命周期。

为什么 QWidget 加进布局后不用手动删

python 复制代码
layout = QVBoxLayout()
button = QPushButton("OK")
layout.addWidget(button)

addWidget() 内部会把 button 的父对象设为布局所属的窗口。窗口销毁时,button 自动销毁。

deleteLater()

有时候需要显式销毁一个对象,但不能立即删,因为可能正在处理它的事件。这时用 deleteLater():

python 复制代码
button.deleteLater()

它把删除请求排进事件循环,等当前事件处理完再删。不要直接 del 一个还有父对象的控件,也不要在槽函数里直接销毁发信号的控件。

QtCore:信号槽深入

阶段 2 已经用过信号槽。这里往深走一层。

自定义信号

在类里声明 Signal:

python 复制代码
from PySide6.QtCore import QObject, Signal

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

    def run(self):
        for i in range(100):
            self.progress.emit(i)
        self.finished.emit()

Signal(int) 表示信号携带一个 int 参数。可以有多个参数,也可以无参数。

自定义槽

用 @Slot 装饰:

python 复制代码
from PySide6.QtCore import Slot

class Receiver(QObject):
    @Slot(int)
    def on_progress(self, value):
        print(f"进度:{value}%")

    @Slot()
    def on_finished(self):
        print("完成")

@Slot 的参数类型要和信号匹配。不写 @Slot 也能连接,但建议总是加,原因见后文。

连接与断开

python 复制代码
worker = Worker()
receiver = Receiver()

worker.progress.connect(receiver.on_progress)
worker.finished.connect(receiver.on_finished)

# 断开
worker.progress.disconnect(receiver.on_progress)

一个信号可以连接多个槽,一个槽也可以连接多个信号。

信号连接信号

python 复制代码
worker.finished.connect(receiver.on_finished)
worker.finished.connect(another_signal)

第一个信号发射时,第二个信号会立即跟着发射。

连接类型

connect() 可以指定连接类型,决定槽函数在哪个线程执行:

类型 行为
Qt.AutoConnection(默认) 同线程直接调用,跨线程排队调用
Qt.DirectConnection 始终在发射信号的线程直接调用
Qt.QueuedConnection 始终排队到接收者线程的事件循环
Qt.BlockingQueuedConnection 排队并阻塞发送者,直到槽执行完
python 复制代码
worker.progress.connect(
    receiver.on_progress,
    Qt.QueuedConnection
)

默认的 AutoConnection 在跨线程时自动变成排队调用,这就是为什么后台线程发信号给 GUI 是安全的:槽函数会在主线程执行,不会直接操作 GUI。

为什么建议总是加 @Slot

不加 @Slot 也能连接,但:

  1. 每次连接时,方法会被动态加入 QMetaObject,有运行时开销;
  2. 跨线程时,Qt 需要知道方法是不是槽,才能正确选择连接类型;
  3. 注册到 QML 的类,缺少 @Slot 可能导致 QML 调用不到方法。

所以:所有用于信号连接的方法,都加上 @Slot。

QtCore:事件循环、QTimer 与 QThread

事件循环

app.exec() 进入事件循环,不断从操作系统取事件、分发处理。

事件包括:鼠标、键盘、重绘、定时器、网络、信号槽调用。

没有事件循环,窗口创建后立即退出。

事件处理

重写控件的事件方法来响应:

python 复制代码
from PySide6.QtWidgets import QWidget

class MyWidget(QWidget):
    def mousePressEvent(self, event):
        print(f"点击位置:{event.position()}")

    def keyPressEvent(self, event):
        print(f"按键:{event.key()}")

    def paintEvent(self, event):
        # 自定义绘制
        pass

常用事件方法:

方法 触发时机
mousePressEvent 鼠标按下
mouseReleaseEvent 鼠标释放
mouseMoveEvent 鼠标移动
keyPressEvent 键盘按下
keyReleaseEvent 键盘释放
paintEvent 需要重绘
resizeEvent 尺寸变化
closeEvent 关闭窗口
event 所有事件的入口

QTimer

定时器:

python 复制代码
from PySide6.QtCore import QTimer

timer = QTimer()
timer.timeout.connect(on_timeout)
timer.start(1000)  # 每 1000 毫秒触发一次

单次定时:

python 复制代码
QTimer.singleShot(500, on_timeout)  # 500 毫秒后执行一次

QThread

后台任务不能直接操作 GUI。标准做法是用 QThread + 信号槽:

python 复制代码
from PySide6.QtCore import QThread, Signal

class Worker(QThread):
    result = Signal(str)

    def run(self):
        # 耗时操作
        import time
        time.sleep(2)
        self.result.emit("完成")

worker = Worker()
worker.result.connect(on_result)
worker.start()

run() 在新线程执行。通过信号把结果传回主线程,槽函数在主线程执行。

不要 在 run() 里直接操作 GUI 控件。所有 GUI 操作必须在主线程。

QThreadPool 和 QRunnable

适合短小的并行任务:

python 复制代码
from PySide6.QtCore import QRunnable, QThreadPool

class Task(QRunnable):
    def run(self):
        print("后台任务")

pool = QThreadPool.globalInstance()
pool.start(Task())

QtCore:文件、路径、配置、进程

文件

QFile 是 Qt 的文件类,但 Python 里通常直接用内置 open()。需要和 Qt 资源系统(:/ 路径)交互时,才用 QFile。

python 复制代码
from PySide6.QtCore import QFile, QIODevice

f = QFile(":/data/config.json")
if f.open(QIODevice.ReadOnly):
    data = f.readAll()
    f.close()

路径

QDir 和 QFileInfo:

python 复制代码
from PySide6.QtCore import QDir, QFileInfo, QStandardPaths

home = QDir.homePath()
temp = QDir.tempPath()

info = QFileInfo("/path/to/file.txt")
print(info.exists(), info.suffix(), info.absolutePath())

QStandardPaths 提供跨平台的标准目录:

python 复制代码
from PySide6.QtCore import QStandardPaths

data_dir = QStandardPaths.writableLocation(
    QStandardPaths.StandardLocation.AppDataLocation
)
位置 用途
AppDataLocation 应用数据目录
DocumentsLocation 文档目录
TempLocation 临时目录
HomeLocation 用户主目录

配置

QSettings 跨平台保存配置:

python 复制代码
from PySide6.QtCore import QSettings

settings = QSettings("MyCompany", "MyApp")
settings.setValue("window/size", [800, 600])
size = settings.value("window/size", [640, 480])

Windows 写注册表,macOS 写 plist,Linux 写 ini 文件。不用关心底层差异。

进程

QProcess 启动外部程序:

python 复制代码
from PySide6.QtCore import QProcess

proc = QProcess()
proc.start("notepad.exe")
proc.waitForFinished()
print(proc.readAllStandardOutput())

异步方式:

python 复制代码
proc.finished.connect(on_finished)
proc.start("python", ["script.py"])

QtGui:绘图、字体、颜色、图片

图片和图标

python 复制代码
from PySide6.QtGui import QPixmap, QIcon, QImage

pixmap = QPixmap("logo.png")
label.setPixmap(pixmap)

icon = QIcon("icon.png")
button.setIcon(icon)
类 用途
QPixmap 显示用的图像,优化过
QImage 像素级操作的图像
QIcon 图标,可有多尺寸状态

字体和颜色

python 复制代码
from PySide6.QtGui import QFont, QColor

font = QFont("Arial", 14, QFont.Bold)
label.setFont(font)

color = QColor("#FF0000")
palette = label.palette()
palette.setColor(label.foregroundRole(), color)

QPainter 绘图

重写 paintEvent() 用 QPainter 绘制:

python 复制代码
from PySide6.QtGui import QPainter, QPen, QBrush
from PySide6.QtCore import Qt

class MyWidget(QWidget):
    def paintEvent(self, event):
        painter = QPainter(self)
        painter.setPen(QPen(Qt.black, 2))
        painter.setBrush(QBrush(Qt.blue))
        painter.drawRect(10, 10, 100, 50)
        painter.drawText(20, 80, "Hello")
        painter.drawLine(0, 0, 200, 200)

常用绘制方法:

方法 绘制
drawRect() 矩形
drawEllipse() 椭圆
drawLine() 直线
drawText() 文字
drawPixmap() 图片
drawPath() 路径

QAction

QAction 是菜单、工具栏、快捷键的统一抽象:

python 复制代码
from PySide6.QtGui import QAction, QKeySequence

action = QAction("打开", self)
action.setShortcut(QKeySequence.Open)
action.triggered.connect(self.open_file)

menu.addAction(action)
toolbar.addAction(action)

一个 QAction 可以同时出现在菜单和工具栏,共用同一个回调。

QtWidgets:布局管理器

为什么不要手动定位

python 复制代码
button.move(10, 20)
button.resize(100, 30)

窗口缩放时不会自适应,不同平台字体和 DPI 不同,容易错位。

四种基本布局:

python 复制代码
from PySide6.QtWidgets import (
    QVBoxLayout, QHBoxLayout, QGridLayout, QFormLayout
)

# 垂直
vbox = QVBoxLayout()
vbox.addWidget(widget1)
vbox.addWidget(widget2)

# 水平
hbox = QHBoxLayout()
hbox.addWidget(widget1)
hbox.addWidget(widget2)

# 网格
grid = QGridLayout()
grid.addWidget(widget, 0, 0)  # 第 0 行第 0 列
grid.addWidget(widget, 0, 1)

# 表单
form = QFormLayout()
form.addRow("姓名:", name_edit)
form.addRow("年龄:", age_spin)

嵌套布局

python 复制代码
main = QVBoxLayout()
top = QHBoxLayout()
top.addWidget(QLabel("搜索:"))
top.addWidget(search_edit)

main.addLayout(top)
main.addWidget(result_list)

伸缩和间距

python 复制代码
layout.addStretch()  # 弹性空间
layout.setSpacing(10)  # 控件间距
layout.setContentsMargins(10, 10, 10, 10)  # 外边距

尺寸策略

控件通过 sizeHint() 和 sizePolicy() 告诉布局它想要多大:

python 复制代码
widget.setSizePolicy(
    QSizePolicy.Expanding,
    QSizePolicy.Fixed
)
策略 含义
Fixed 固定
Minimum 可放大,不可小于 sizeHint
Maximum 可缩小,不可大于 sizeHint
Preferred 首选 sizeHint,可伸缩
Expanding 尽可能占满

QtWidgets:常用控件速览

按功能分组,只列最常用的方法和信号。

按钮

控件 常用方法 / 信号
QPushButton setText()、clicked
QCheckBox setChecked()、toggled
QRadioButton setChecked()、toggled
QToolButton setIcon()、clicked

输入

控件 常用方法 / 信号
QLineEdit text()、setText()、textChanged
QTextEdit toPlainText()、setPlainText()
QSpinBox value()、setValue()、valueChanged
QDoubleSpinBox 同上,浮点数
QComboBox currentText()、addItems()、currentIndexChanged
QSlider value()、setValue()、valueChanged

显示

控件 常用方法
QLabel setText()、setPixmap()、setAlignment()
QProgressBar setValue()、setRange()
QTextBrowser setHtml()、setSource()

列表 / 表格 / 树(便捷版)

控件 常用方法 / 信号
QListWidget addItem()、currentItem()、itemClicked
QTableWidget setItem()、item()、cellClicked
QTreeWidget addTopLevelItem()、currentItem()、itemClicked

容器

控件 用途
QGroupBox 带标题分组
QTabWidget 标签页
QSplitter 可拖拽分割
QScrollArea 滚动区域
QStackedWidget 多页面切换

对话框

python 复制代码
QMessageBox.information(self, "提示", "操作完成")
QMessageBox.warning(self, "警告", "出错了")
QMessageBox.question(self, "确认", "确定删除?")

path, _ = QFileDialog.getOpenFileName(self, "打开文件")
text, ok = QInputDialog.getText(self, "输入", "名字:")
color = QColorDialog.getColor()

菜单 / 工具栏

python 复制代码
action = QAction("打开", self)
action.triggered.connect(self.open_file)

menu = self.menuBar().addMenu("文件")
menu.addAction(action)

toolbar = self.addToolBar("主工具栏")
toolbar.addAction(action)

self.statusBar().showMessage("就绪")

QtWidgets:Model/View 架构

为什么需要 Model/View

QTableWidget 把数据和显示混在一起,适合小数据。数据量大、需要排序过滤、需要和数据库联动时,用 Model/View:

  • Model:管数据
  • View:管显示
  • Delegate:管单个单元格怎么画

用现成模型

QStringListModel:

python 复制代码
from PySide6.QtCore import QStringListModel
from PySide6.QtWidgets import QListView

model = QStringListModel(["A", "B", "C"])
view = QListView()
view.setModel(model)

QStandardItemModel:

python 复制代码
from PySide6.QtGui import QStandardItemModel, QStandardItem
from PySide6.QtWidgets import QTableView

model = QStandardItemModel()
model.setHorizontalHeaderLabels(["名称", "数量"])

for name, count in [("A", 1), ("B", 2)]:
    model.appendRow([
        QStandardItem(name),
        QStandardItem(str(count))
    ])

view = QTableView()
view.setModel(model)

自定义模型

继承 QAbstractItemModel,至少实现:

python 复制代码
from PySide6.QtCore import QAbstractTableModel, Qt

class MyModel(QAbstractTableModel):
    def __init__(self, data):
        super().__init__()
        self._data = data

    def rowCount(self, parent=None):
        return len(self._data)

    def columnCount(self, parent=None):
        return len(self._data[0]) if self._data else 0

    def data(self, index, role=Qt.DisplayRole):
        if role == Qt.DisplayRole:
            return self._data[index.row()][index.column()]
        return None

    def headerData(self, section, orientation, role=Qt.DisplayRole):
        if role == Qt.DisplayRole:
            if orientation == Qt.Horizontal:
                return f"列 {section}"
            return f"行 {section}"
        return None

排序和过滤

QSortFilterProxyModel:

python 复制代码
from PySide6.QtCore import QSortFilterProxyModel

proxy = QSortFilterProxyModel()
proxy.setSourceModel(model)
proxy.setFilterKeyColumn(0)
proxy.setFilterRegularExpression("A")

view.setModel(proxy)

自定义委托

QStyledItemDelegate 控制单元格怎么画:

python 复制代码
from PySide6.QtWidgets import QStyledItemDelegate

class MyDelegate(QStyledItemDelegate):
    def paint(self, painter, option, index):
        # 自定义绘制
        super().paint(painter, option, index)

什么时候用哪种

场景 选择
数据量小、逻辑简单 QTableWidget / QListWidget / QTreeWidget
数据量大、需要排序过滤 QTableView + QStandardItemModel + QSortFilterProxyModel
数据来自数据库 QSqlTableModel
数据来自自定义结构 继承 QAbstractTableModel
需要自定义绘制 加 QStyledItemDelegate

QML 相关模块:只讲与 Python 交互的部分

加载 QML

python 复制代码
from PySide6.QtQml import QQmlApplicationEngine

engine = QQmlApplicationEngine()
engine.addImportPath(sys.path[0])
engine.loadFromModule("App", "Main")

注册 Python 类型到 QML

python 复制代码
from PySide6.QtCore import QObject, Property, Slot
from PySide6.QtQml import QmlElement

QML_IMPORT_NAME = "MyModule"
QML_IMPORT_MAJOR_VERSION = 1

@QmlElement
class Backend(QObject):
    def __init__(self, parent=None):
        super().__init__(parent)
        self._name = ""

    @Property(str)
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        self._name = value

    @Slot(str, result=str)
    def greet(self, text):
        return f"Hello, {text}"

QML 侧:

qml 复制代码
import MyModule

Backend {
    id: backend
}

Text {
    text: backend.greet("QML")
}

数据传递的两种方式

方式 适用场景
setInitialProperties() 初始化时一次性注入
@QmlElement 注册类型 持续交互、属性绑定、信号槽

QQuickWidget:在 Widgets 里嵌 QML

python 复制代码
from PySide6.QtQuickWidgets import QQuickWidget

quick = QQuickWidget()
quick.setSource(QUrl.fromLocalFile("view.qml"))
layout.addWidget(quick)

反过来,QML 里嵌 Widgets 用 QWidget::createWindowContainer(),但较少用。

其余模块索引

遇到具体需求时回来查这张表。

需求 模块 入口类
HTTP 请求 QtNetwork QNetworkAccessManager
TCP / UDP QtNetwork QTcpSocket、QUdpSocket
数据库 QtSql QSqlDatabase、QSqlQuery、QSqlTableModel
播放音频视频 QtMultimedia QMediaPlayer、QAudioOutput
画图表 QtCharts QChart、QChartView
内嵌浏览器 QtWebEngine QWebEngineView
串口 QtSerialPort QSerialPort
蓝牙 QtBluetooth QBluetoothDeviceDiscoveryAgent
打印 QtPrintSupport QPrinter、QPrintDialog
单元测试 QtTest QTest
状态机 QtStateMachine QStateMachine
并行任务 QtConcurrent QtConcurrent.run()
SVG QtSvg QSvgWidget、QSvgRenderer
XML QtXml QDomDocument、QXmlStreamReader
加载 .ui 文件 QtUiTools QUiLoader
3D Qt3D 基本不用,跳过

使用建议:

  1. 先确认标准库能不能做。 比如 JSON 用 json,正则用 re,文件用 open(),不需要用 Qt 的对应类。
  2. 需要和 Qt 对象模型集成时才用 Qt 模块。 比如要把网络请求结果绑定到 UI,用 QNetworkAccessManager 比 requests 更顺。
  3. 不确定就去官方 Modules API 页面搜。 阶段 3 这张表只是索引,不是替代文档。

工具链

阶段 2 的示例里已经零散用过几个工具:pyside6-uic 转 .ui、pyside6-rcc 转 .qrc。阶段 3 讲 API 时也提过 pyside6-project 和 pyside6-deploy。

这一章把它们系统化,回答一个问题:

从设计稿到可执行文件,中间到底经过了哪些工具?

答案是一条链路,而不是一个孤立的命令。

工具链全景:从设计到部署

PySide6 自带一整套工具,覆盖开发的不同阶段:

阶段 工具 作用
设计 pyside6-designer 拖拽设计 Widgets 界面,保存为 .ui
转换 pyside6-uic .ui → .py
资源 pyside6-rcc .qrc → .py
项目 pyside6-project 统一管理 pyproject.toml,自动调用 uic、rcc 等
检查 pyside6-qmllint 检查 QML 语法和反模式
部署 pyside6-deploy 打包成可执行文件(底层是 Nuitka)

核心链路可以画成这样:

text 复制代码
.ui ──pyside6-uic──► .py
.qrc ──pyside6-rcc──► .py
.qml ──pyside6-qmllint──► 检查
      │
      ▼
pyproject.toml ──pyside6-project──► 自动调用 uic/rcc/qmllint
      │
      ▼
main.py ──pyside6-deploy──► 可执行文件

理解这条链路之后,每个工具的定位就清楚了。下面逐个展开。

pyside6-designer:可视化设计 Widgets 界面

pyside6-designer 封装了 Qt Widgets Designer,让你用拖拽的方式设计界面,结果保存为 .ui 文件。

启动

bash 复制代码
pyside6-designer

启动后会让你选择基础窗口类型:QWidget、QMainWindow、QDialog 等。

界面分为四个区域:

区域 作用
左侧控件箱 所有可拖拽的控件
中间画布 设计区域
右侧对象树 当前界面的控件层级
右侧属性编辑器 选中控件的属性

什么时候用

  • 界面控件多、布局复杂,手写代码啰嗦;
  • 想快速调整尺寸、间距、对齐;
  • 和设计师协作,对方可以只碰 .ui 文件。

界面简单,或者你更喜欢手写布局,可以跳过 Designer,直接用阶段 3 讲的布局管理器写。

输出

Designer 保存的是 .ui 文件,本质是 XML,描述控件树、属性、布局。它不能直接运行,下一步是交给 pyside6-uic。

pyside6-uic:.ui → Python

pyside6-uic 把 .ui 文件转换成 Python 类。

基本用法

bash 复制代码
pyside6-uic mainwindow.ui -o ui_mainwindow.py

-o 必须写。不写的话,转换结果会直接打印到终端,而不是生成文件。

生成的文件长什么样

生成的 Python 文件里有一个类,命名规则是 Ui_ 加上设计名,比如 Ui_MainWindow。这个类负责把 Designer 里摆好的控件和布局还原出来。

在代码里这样用:

python 复制代码
from ui_mainwindow import Ui_MainWindow

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.ui = Ui_MainWindow()
        self.ui.setupUi(self)

这和阶段 2 的 .ui 教程是同一套流程。

关键原则

不要手改 pyside6-uic 生成的文件。 下次重新生成时,手改的内容会全部丢失。

需要加逻辑,就在外部类里操作 self.ui,或者继承生成的类。生成文件只读,逻辑写在外面。

常用选项

选项 用途
-o 指定输出文件
--from-imports 生成相对导入
--absolute-imports 生成绝对导入
--python-paths 配合绝对导入指定路径

资源文件不在同一目录时,用后三个选项调整导入路径。

pyside6-rcc:.qrc → Python

pyside6-rcc 把 .qrc 资源文件编译成 Python 文件,资源数据直接嵌入代码。

基本用法

bash 复制代码
pyside6-rcc icons.qrc -o rc_icons.py

之后在代码里导入:

python 复制代码
import rc_icons

然后用 :/ 前缀访问资源:

python 复制代码
icon = QIcon(":/icons/play.png")

和 pyside6-uic 的关系

如果 .ui 文件里引用了 .qrc 里的资源,pyside6-uic 生成的代码会自动导入 rc_*.py。两个文件需要在同一目录,或者用 --from-imports / --absolute-imports 调整路径。

压缩的跨平台问题

pyside6-rcc 默认用 Zstandard 压缩,默认压缩级别生成的结果可能无法在其他平台使用。

跨平台发布时,改用 zlib:

bash 复制代码
pyside6-rcc --compress-algo zlib icons.qrc -o rc_icons.py

pyside6-project:项目级自动化

前面三个工具都是单文件操作。pyside6-project 在项目级别统一管理它们。

它解决什么问题

手动管理时,每改一次 .ui,就要记得跑一次 pyside6-uic;每改一次 .qrc,就要跑一次 pyside6-rcc。文件一多,容易漏。

pyside6-project 读取 pyproject.toml 里的文件列表,自动调用对应的工具。

pyproject.toml 配置

toml 复制代码
[project]
name = "myproject"

[tool.pyside6-project]
files = [
    "main.py",
    "App/Main.qml",
    "App/logo.png",
    "App/qmldir"
]

files 列出项目的源文件。生成文件(比如 ui_*.py、rc_*.py)不需要列进去。

还可以给 pyside6-rcc 和 pyside6-uic 指定选项:

toml 复制代码
[tool.pyside6-rcc]
options = ["--compress-algo", "zlib"]

[tool.pyside6-uic]
options = ["--star-imports"]

常用子命令

子命令 作用
pyside6-project new-ui 新建带 Designer 界面的 Widgets 项目
pyside6-project new-widget 新建 Widgets 项目
pyside6-project new-quick 新建 QtQuick 项目
pyside6-project build 构建项目,生成 uic/rcc 产物
pyside6-project lupdate 提取翻译字符串
pyside6-project qmllint 检查 QML 文件

和 Qt Creator 的关系

pyside6-project 操作的 pyproject.toml 也被 Qt Creator 使用。在 Qt Creator 里运行项目时,它会自动处理 pyside6-uic 和 pyside6-rcc 步骤,不需要手动跑命令。

pyside6-qmllint:QML 质量检查

pyside6-qmllint 检查 QML 文件的语法有效性,并对一些 QML 反模式发出警告。

什么时候用

  • QML 文件多,想批量检查;
  • 遇到运行时才暴露的 QML 错误,想提前发现;
  • CI 里加一道检查。

用法

通常不直接调用,因为需要一系列导入路径和类型信息。更常见的做法是通过 pyside6-project 触发:

bash 复制代码
pyside6-project qmllint .

如果直接检查单个文件:

bash 复制代码
pyside6-qmllint Main.qml

它会提示未使用的导入等问题。

和 Widgets 的关系

这是 QML 专用工具。纯 Widgets 项目用不到。

pyside6-deploy:打包部署

pyside6-deploy 把 PySide6 应用打包成可执行文件。它底层是 Nuitka,把 Python 编译成 C,再链接成可执行文件。

基本用法

最简形式:

bash 复制代码
pyside6-deploy main.py

如果入口文件就叫 main.py,甚至不需要参数:

bash 复制代码
pyside6-deploy

首次运行会安装依赖、生成 pysidedeploy.spec 配置文件,然后开始编译。

输出产物

平台 后缀
Windows .exe
Linux .bin
macOS .app

pysidedeploy.spec

首次运行后生成,控制后续部署行为。可以修改的常见项:

ini 复制代码
[nuitka]
# 隐藏控制台窗口
extra_args = --windows-console-mode=disable

# 设置图标
# extra_args = --windows-icon-from-ico=icons/icon.ico

也可以在里面指定 mode(standalone 或 onefile),以及 Nuitka 版本等。

Qt Creator 工作流

前面所有工具都可以在命令行使用。Qt Creator 把它们集成到了 IDE 里。

项目创建

用向导创建 Qt for Python 项目时,Qt Creator 生成 pyproject.toml 和模板代码。基于控件的向导会额外生成 .ui 文件,Qt Quick 向导会生成 .qml 文件。

自动构建

在 Qt Creator 里运行项目时,它会自动调用 pyside6-uic 和 pyside6-rcc 处理 .ui 和 .qrc 文件。不需要手动跑命令,改完 .ui 直接运行即可。

Python 环境

Qt Creator 首次打开 .py 文件时,会提示为项目安装 PySide6 和语言服务器。每个项目可以有独立的 Python 环境,在 "Projects > Build & Run" 里选择套件。

什么时候用命令行,什么时候用 IDE

场景 推荐
日常开发、调试 Qt Creator
CI/CD 流水线 命令行
快速转换单个文件 命令行
管理复杂项目 pyside6-project + pyproject.toml
打包发布 pyside6-deploy,命令行或 IDE 均可

部署与发布

阶段 4 讲了怎么把项目跑起来。这一章回答最后一个问题:怎么把程序交给别人用。

pyside6-deploy 能让你一条命令打出可执行文件,但打出来的东西往往体积偏大、偶尔报缺 DLL、换台电脑就闪退。这一章处理的就是这些"打出来了但不太对"的问题。

选型:先用 pyside6-deploy,不够再往下挖

pyside6-deploy 是 Qt 官方从 6.4 开始提供的部署工具,本质是 Nuitka 的封装。产物后缀:

平台 后缀
Windows .exe
Linux .bin
macOS .app

对大多数项目,直接用它是正确的起点。官方文档也明确推荐它,原因是"更容易用,且能得到最优化后的可执行文件"。

但如果遇到体积下不去、DLL 带多了、启动黑屏,就需要知道它背后在做什么,以及替代方案各有什么代价。

三个方案的真实差异

pyside6-deploy PyInstaller 直接 Nuitka
本质 Nuitka 封装 打包解释器 + 字节码 Python → C → 原生二进制
打包速度 中等 快 慢,需要编译
产物体积 小(接近 Nuitka) 大 小
启动速度 快 慢,需要解压初始化 最快
代码保护 好(机器码) 一般(保留字节码) 好
上手成本 最低 低 需要理解 Nuitka 选项

有一组实测数据可以参考:一个中等复杂度的 PySide6 应用,PyInstaller 打包后 85MB、启动 2.3 秒 ;Nuitka 打包后 52MB、启动 0.8 秒 。另一组数据中,Nuitka 产物甚至可以压到 21MB 左右,启动比 PyInstaller 单文件快约 28%。

所以如果你的第一反应是"PyInstaller 更熟,先用它",要知道代价主要体现在体积和启动速度 上。pyside6-deploy 底层就是 Nuitka,没有特殊理由时,从它开始更划算。

PyInstaller 仍然有价值: 打包速度极快、社区方案多、复杂依赖的兼容性好。如果 pyside6-deploy 在某个依赖上卡住了,PyInstaller 可以作为兜底,但不建议作为首选。

pysidedeploy.spec:部署的真正控制面

第一次运行 pyside6-deploy 时,会在项目目录生成 pysidedeploy.spec。后续所有部署参数都从这里读,不需要每次在命令行重复指定。

这个文件的价值在于可版本控制、可复现。你可以把排除规则、模式、Nuitka 额外参数都固化进去,换一台机器、换一个人,跑出来的结果一致。

最常用的几个配置

ini 复制代码
[app]
input_file = main.py
title = "My App"
icon = "icon.ico"

[nuitka]
mode = onefile
extra_args = --windows-console-mode=disable

[qt]
excluded_qml_plugins = QtWebEngine, QtCharts
配置 作用
mode onefile(单文件)或 standalone(文件夹)。默认 onefile
excluded_qml_plugins 排除用不到的 QML 插件
modules 显式指定要包含的 Qt 模块(通常自动推断,一般不用手写)
plugins 显式指定要包含的 Qt 插件
extra_args 直接传给 Nuitka 的额外参数,这是体积优化的主入口

mode 的取舍:

  • onefile :分发方便,但每次启动要把内容解压到临时目录,启动会慢一些;
  • standalone:产物是一个文件夹,启动快,但分发时需要把整个目录交给用户。

如果用户对启动速度敏感,或者程序本身不小,standalone 往往更实际。onefile 适合"就发一个 exe 过去"的轻量场景。

体积优化:钱要花在刀刃上

打包出来的体积,大头通常是 Qt 的 DLL 和插件 。优化思路只有一条:把你用不到的东西明确排除掉。

方法一:排除 QML 插件

如果项目里用到了 QML,Nuitka 默认会把所有 QML 插件都带上,包括像 QtWebEngine 这种体积巨大的模块------哪怕你根本没用它。

pysidedeploy.spec 的 excluded_qml_plugins 就是用来处理这个的。pyside6-deploy 本身会自动检查并排除 QtQuick、QtQuick3D、QtCharts、QtWebEngine、QtTest、QtSensors 这六个"体积大户"中没有用到的模块,但如果你明确知道自己不需要更多,可以继续追加。

方法二:排除不需要的 DLL

直接在 Nuitka 层面排除。在 extra_args 里加:

ini 复制代码
extra_args = --noinclude-dlls=PySide6/Qt6WebEngineCore.* --noinclude-dlls=PySide6/Qt6Pdf.*

--noinclude-dlls 按目标文件名 匹配,不是源路径。论坛中有人用这条命令成功去掉了 qt6webenginecore.dll、qt6webchannel.dll 等一整批不需要的 DLL。

代价是:你需要知道自己排除了什么,并确认这些 DLL 确实不会被间接加载。排除过度会导致运行时缺 DLL 崩溃。

方法三:用 PySide6-Essentials 替代完整包

如果项目不需要 QtNetwork、QtSql、QtMultimedia、QtCharts 这类 Addons 模块,安装依赖时用 PySide6-Essentials 而不是完整的 PySide6。从源头减少进入打包范围的二进制文件。

关于 UPX

UPX 能压缩可执行文件,但它是运行时解压 ,会把启动速度的收益吃掉一部分。而且 PyInstaller + UPX 对某些 DLL 可能造成损坏。如果已经用了 Nuitka 路线,不建议再把 UPX 当作主要优化手段,优先做模块和 DLL 的排除,收益更直接。

排错:打包后启动黑屏 / 闪退 / 缺插件

这是 PySide6 打包最常见的故障,根因几乎永远是同一个:

Qt 的平台插件没有被正确收集或找到。

典型报错:

text 复制代码
qt.qpa.plugin: Could not find the Qt platform plugin "windows"

窗口一个都不出来,或者直接闪退。

先确认插件在不在,PySide6 的 Qt 插件位于:

text 复制代码
<site-packages>/PySide6/plugins/

Windows 平台插件是 plugins/platforms/qwindows.dll。打包后,检查产物目录中是否存在类似路径:

text 复制代码
_internal/PySide6/plugins/platforms/qwindows.dll

如果这个文件不存在,说明打包工具没有收集到它。

让插件被正确收集

如果用的是 PyInstaller: 简单的 excludes 对 PySide6 的框架文件往往不起作用,需要在 .spec 文件中显式收集插件目录 ,或者在 Analysis 之后过滤 a.binaries / a.datas。也可以在命令中加 --add-binary 把插件目录复制进去。

如果用的是 pyside6-deploy / Nuitka: 正常情况下 Nuitka 的 PySide6 插件会处理插件收集,但如果用了 --noinclude-dlls 排除过度,或者在非标准环境中打包,仍可能缺失。检查 pysidedeploy.spec 中的 plugins 配置,必要时显式加入 platforms。

环境变量作为临时验证

如果插件文件存在但仍然报错,可以临时设置环境变量指向插件目录,用来判断是不是路径问题:

text 复制代码
QT_QPA_PLATFORM_PLUGIN_PATH=<产物目录>/PySide6/plugins/platforms

这不是发布方案,只是诊断手段。正式分发的程序不应该依赖用户手动设置环境变量。

保留控制台来定位崩溃点

如果窗口版"能装不能跑",打包时临时保留控制台窗口,让启动错误直接打在黑框里。很多 Qt 插件相关的报错,只有在控制台模式下才看得到完整信息。定位到问题后再关掉控制台。

跨平台:没有交叉编译这回事

pyside6-deploy 支持 Windows、Linux、macOS 三个桌面平台。但"支持"不等于"在任意一个平台上就能打出所有平台的包"。

你不能在 Windows 上直接打出 macOS 的 .app,也不能在 Linux 上直接打出 Windows 的 .exe。 每个平台的产物需要在对应平台上构建。

实际做法通常是:

目标平台 构建方式
Windows 在 Windows 上打包
macOS 在 macOS 上打包(.app 还需要签名,如果对外分发)
Linux 在 Linux 上打包(不同发行版可能需要分别构建)

如果项目需要多平台发布,用 CI(GitHub Actions 等)在三个平台的 runner 上分别跑 pyside6-deploy,是目前最可控的方式。各自平台上的 Python 版本、PySide6 版本、系统库版本都会影响产物,本地"在 Windows 上试了一下能用"不能推导出 macOS 和 Linux 也正常。

Android 是另一条线。 pyside6-android-deploy 目前只能在 Linux 主机上使用,不在桌面部署的范畴内。

写在最后

从"PySide 是不是野鸡库"开始,到这个系列结束,其实只回答了一个问题:

用 Python 写桌面应用,Qt 这条路线值不值得走,以及怎么走。

答案是值得,而且路径比想象中清晰。

回头看一下整条线:

阶段 解决的问题 留下什么
技术选型 Qt、PyQt、PySide 是什么关系 知道该选哪个、为什么
安装与入门 环境怎么搭、第一个窗口怎么写 能跑起来
教程与示例 官方示例怎么读、怎么改 能照着写
核心 API 对象树、信号槽、事件、布局、模型/视图 能自己设计
工具链 Designer、uic、rcc、project、deploy 能组织项目
部署与发布 打包、体积、插件、跨平台 能交付使用
按需阅读 许可证、安全、跨平台差异 知道遇到问题去哪查

这七块拼起来,就是一条从"完全不懂"到"能独立做完一个桌面应用"的完整路径。

几个值得记住的判断

写这个系列的过程中,有几件事反复出现,值得单独拎出来:

PySide6 不是野鸡库。 它是 Qt 官方的 Python 绑定,只是出道比 PyQt 晚,早期存在感低。现在新项目优先选它,许可证对闭源商用也更友好。

Qt 不需要你懂 C++。 但理解它的底层机制有用:对象树决定生命周期,QPA 对接操作系统,Shiboken 把 Python 调用桥接到 C++。知道这些,遇到"为什么对象没了""为什么插件加载失败"这类问题时,才有排查方向。

Widgets 和 QML 是两条路,不是新旧替代。 传统桌面工具走 Widgets,现代动态界面走 QML,两者可以混用。入门先 Widgets,理解核心概念之后再学 QML,会顺很多。

打包工具的选择,本质是体积、速度和省心程度的权衡。 pyside6-deploy 是官方封装、底层 Nuitka,默认选它;PyInstaller 作为兜底;直接 Nuitka 适合追求极致。没有哪个"最好",只有哪个适合当前项目。

官方文档不是线性教材。 Modules API 不要通读,Tutorials 和 Examples 才是入口。遇到具体问题,回到对应页面查,比从头读一遍有效得多。

这个系列结束,不代表学习结束。接下来最值得做的三件事:

第一,做一个自己的小项目。 不要停在示例上。记事本、待办清单、图片查看器、简单的数据库工具,任选一个,从设计到打包完整走一遍。只有在自己的项目里,才会遇到示例里不会出现的问题:布局调不动、信号连接了没反应、打包后缺 DLL。这些问题解决一次,比读十篇教程记得牢。

第二,遇到问题先查官方文档,再搜社区。 PySide6 的文档质量不差,很多问题在对应的类页面里就有答案。社区资源里,Stack Overflow 和 Qt 官方论坛的答案通常更可靠,中文博客的质量参差不齐,参考时注意版本。

第三,保持对版本的敏感。 Qt 6 和 PySide6 还在持续演进,API 会有变化。升级前扫一眼 Release Notes 里的不兼容变更,能省掉很多"昨天还能跑,今天怎么报错了"的麻烦。

相关推荐
Mr.D学长1 小时前
毕业设计项目 基于机器视觉的车牌识别系统
python·毕业设计·毕设
沫璃染墨1 小时前
《Qt从零入门系列(十四):QThread多线程详解——从线程创建到客户端阻塞处理》
开发语言·c++·笔记·qt·ui·交互·线程
xiayutian_c1 小时前
BTP Event Mesh
笔记
云和数据.ChenGuang1 小时前
AI 工程师相关就业岗位(细分,偏工程落地,由底层到上层)
服务器·开发语言·人工智能·python
Web3&Basketball2 小时前
litellm 做 Agent 成本监控:3 个账单维度实战
python·大模型·agent·可观测性·litellm·成本控制·llm运维
Mr.D学长2 小时前
毕业设计项目 基于python的答题卡识别评分系统
python·毕业设计·毕设
云和数据.ChenGuang2 小时前
LangChain 实战指南:从入门到构建智能应用
开发语言·人工智能·python·langchain
青少儿编程课堂2 小时前
蓝桥杯·全国青少年信息素养大赛·电子学会C/C++考级:拓扑排序课程表排课解析
c++·python·算法·bfs·信息学竞赛
const_qiu2 小时前
第三章 04:3.6 基础调用链的构建
开发语言·python