目录
- [Qt、PySide6 与桌面 GUI 的技术选型](#Qt、PySide6 与桌面 GUI 的技术选型)
-
- Qt、PyQt、PySide是什么关系
- [对比WebView 类 GUI](#对比WebView 类 GUI)
- [PySide6 学习路线图](#PySide6 学习路线图)
-
- [阶段 1:安装与入门](#阶段 1:安装与入门)
- [阶段 2:教程与示例](#阶段 2:教程与示例)
- [阶段 3:核心 API](#阶段 3:核心 API)
- [阶段 4:工具链](#阶段 4:工具链)
- [阶段 5:部署与发布](#阶段 5:部署与发布)
- [阶段 6:按需阅读](#阶段 6:按需阅读)
- 构建与发布工具
- 安装与入门
- 教程与示例
-
- Qt、QML、Widgets......到底有什么区别?
-
- 路线一:Widgets
- [路线二:QML 和 Qt Quick](#路线二:QML 和 Qt Quick)
- 文件类型:不是每种文件都必须写
-
- `.py`:主战场
- [`.ui`:给 Designer 用的界面描述文件](#
.ui:给 Designer 用的界面描述文件) - `.qrc`:资源清单文件
- [`.qml`:QML 界面文件](#
.qml:QML 界面文件) - [`pyproject.toml`:现代 Python 项目配置](#
pyproject.toml:现代 Python 项目配置) - 小结:什么时候引入哪种文件
- 官方案例
-
- [第一个 QtWidgets 应用](#第一个 QtWidgets 应用)
- 使用一个简单的按钮
- 信号与槽
- 创建一个对话框应用
- 使用表格控件显示数据
- 使用树形控件显示数据
- [使用 Designer 或 QtCreator 的 .ui 文件:QUiLoader 与 pyside6-uic](#使用 Designer 或 QtCreator 的 .ui 文件:QUiLoader 与 pyside6-uic)
- [使用 .qrc 文件(pyside6-rcc)](#使用 .qrc 文件(pyside6-rcc))
- 翻译应用程序
- [为 Widgets 应用设置样式](#为 Widgets 应用设置样式)
- [你的第一个 QtQuick/QML 应用](#你的第一个 QtQuick/QML 应用)
- [Python 与 QML 集成](#Python 与 QML 集成)
- 响应式属性教程
- [QML 应用教程](#QML 应用教程)
- [QML、SQL 与 PySide 集成教程](#QML、SQL 与 PySide 集成教程)
- [核心 API](#核心 API)
-
- [核心 API 地图:哪些必学,哪些查手册](#核心 API 地图:哪些必学,哪些查手册)
- [QtCore:对象树与 QObject](#QtCore:对象树与 QObject)
- QtCore:信号槽深入
- [QtCore:事件循环、QTimer 与 QThread](#QtCore:事件循环、QTimer 与 QThread)
- QtCore:文件、路径、配置、进程
- QtGui:绘图、字体、颜色、图片
- QtWidgets:布局管理器
- QtWidgets:常用控件速览
- [QtWidgets:Model/View 架构](#QtWidgets:Model/View 架构)
- [QML 相关模块:只讲与 Python 交互的部分](#QML 相关模块:只讲与 Python 交互的部分)
- 其余模块索引
- 工具链
-
- 工具链全景:从设计到部署
- [pyside6-designer:可视化设计 Widgets 界面](#pyside6-designer:可视化设计 Widgets 界面)
- [pyside6-uic:.ui → Python](#pyside6-uic:.ui → Python)
- [pyside6-rcc:.qrc → Python](#pyside6-rcc:.qrc → Python)
- pyside6-project:项目级自动化
- [pyside6-qmllint:QML 质量检查](#pyside6-qmllint:QML 质量检查)
- pyside6-deploy:打包部署
- [Qt Creator 工作流](#Qt Creator 工作流)
- 部署与发布
-
- [选型:先用 pyside6-deploy,不够再往下挖](#选型:先用 pyside6-deploy,不够再往下挖)
- pysidedeploy.spec:部署的真正控制面
- 体积优化:钱要花在刀刃上
- [排错:打包后启动黑屏 / 闪退 / 缺插件](#排错:打包后启动黑屏 / 闪退 / 缺插件)
- 跨平台:没有交叉编译这回事
- 写在最后
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 时,加载的就是这些扩展模块。
大致流程是:
- API Extractor 解析 Qt 头文件;
- 生成 typesystem XML,描述哪些类、方法、信号要暴露给 Python;
- Shiboken6 根据这些信息生成 C++ 包装代码;
- 包装代码编译成 CPython 扩展模块;
- 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
重点模块顺序:
- QtCore :
QObject、Signal、Slot、QTimer、QThread、QSettings、QFile、QProcess - QtGui :
QIcon、QAction、QPainter、QFont、QColor - QtWidgets :
QApplication、QMainWindow、QWidget、QDialog、常用控件、布局、QMessageBox、QFileDialog - 进阶:
QtNetwork、QtSql、QtMultimedia、QtQml、QtQuick
学 Qt 的核心不是背 API,而是理解:
- 信号槽
- 对象树与父子关系
- 布局管理器
- 事件循环
- 模型 / 视图
- QML 与 Python 交互
阶段 4:工具链
pyside6-designer:拖拽做 UIpyside6-uic:.ui转 Pythonpyside6-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:当字典查
推荐小项目顺序:
- 计数器窗口
- 记事本
- 待办清单
- 图片查看器
- SQLite 增删改查
- 多线程下载器
- 用 Designer 重构 UI
- 打包成 exe
- 尝试 QML 仪表盘
Widgets 还是 QML?
- 传统桌面软件、工具、后台管理:先学 Qt Widgets;
- 现代 UI、动画、触摸、移动端:学 Qt Quick / QML;
- 不确定:先 Widgets,再 QML。
构建与发布工具
PySide6 打包发布,主要有三个选择。
-
pyside6-deploy:官方推荐这是 Qt 官方从 6.4 开始提供的部署工具,本质是对 Nuitka 的封装。
优点:
- 一条命令即可打包;
- 与 PySide6 兼容性最好;
- 官方主推;
- 生成结果经过优化。
用法:
bashpyside6-deploy main.py首次运行会生成
pysidedeploy.spec,后续可以改配置。 -
PyInstaller:社区老牌
优点:
- 上手极快;
- 文档和社区资源极多;
- 复杂依赖兼容性好。
缺点:
- 打包体积大;
- 启动速度相对慢;
- 代码保护一般。
常用命令:
bashpyinstaller -F -w main.py -
Nuitka:编译型方案
Nuitka 把 Python 编译成 C,再生成原生可执行文件。
优点:
- 体积更小;
- 启动速度极快;
- 内存占用低;
- 代码保护更强。
缺点:
- 打包过程慢;
- 某些复杂库需要调试;
- 社区规模小于 PyInstaller。
常用命令:
bashnuitka --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 包正常工作。
-
pyside6-project
从
pyproject.toml文件构建 Qt Widgets Designer 表单(.ui文件)、资源文件(.qrc)和 QML 类型文件(.qmltype)。 -
pyside6-designer
用于设计 Widget UI 的拖拽式工具(生成
.ui文件,参见《从 Designer 或 QtCreator 使用 .ui 文件:QUiLoader 与 pyside6-uic》)。 -
pyside6-uic
从
.ui表单文件生成 Python 代码。 -
pyside6-rcc
从
.qrc资源文件生成序列化数据。请记住,这些文件也可以用于其他非 Widget 项目。 -
pyside6-qmllint
验证 QML 文件的语法有效性。
-
pyside6-qmltyperegistrar
读取元类型文件,并生成包含必要代码的文件,以注册所有用相关宏标记的类型。
-
pyside6-qmlimportscanner
识别从项目/QML 文件导入的 QML 模块,并将结果输出为 JSON 数组。
-
pyside6-qmlcachegen
在编译时将 QML 编译为字节码,以便打包进二进制文件。
-
pyside6-qmlsc
取代
pyside6-qmlcachegen。该工具除了为它能彻底分析的函数生成字节码外,还会生成 C++ 代码。 -
pyside6-qml
用于快速进行 QML 文件原型开发。该工具通过直接调用
QQmlEngine/QQuickView,模拟了 Qt 的 QML 运行时实用工具的部分能力。 -
pyside6-svgtoqml
将 SVG 文件转换为 QML 文件。
-
pyside6-linguist
用于翻译应用程序中的文本。
-
pyside6-lrelease
为应用程序创建运行时翻译文件。
-
pyside6-lupdate
同步源代码和翻译。
-
pyside6-assistant
用于查看 Qt 帮助文件格式的在线文档。有关格式的更多信息,请参阅 QtHelp 框架页面。
-
pyside6-genpyi
为 Qt 模块生成 Python 存根(
.pyi文件)。 -
pyside6-metaobjectdump
一个以 JSON 格式打印元类型信息的工具,用作
qmltyperegistrar的输入。 -
Qt Python VSCode Extension
用于 PySide6 开发的 Visual Studio Code 扩展,包含项目模板、调试、构建任务等功能。
-
pyside6-deploy
将 PySide6 应用程序部署到桌面平台------Linux、Windows 和 macOS。
-
pyside6-android-deploy
将 PySide6 应用程序部署为 Android 应用,目标平台包括 aarch64、x86_64。
-
pyside6-qsb着色器工具
Qt Shader Tools 模块提供的命令行工具,用于生成和检查
.qsb文件。 -
pyside6-balsam
一个命令行工具,它接收在 Maya、3ds Max 或 Blender 等数字内容创建工具中创建的资产,并将其转换为高效运行时格式,以供 Qt Quick 3D 使用。
-
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 的官方封装,使用起来最省心,生成的程序体积小、启动也快。
这是最推荐的方式,整个过程非常自动化。
-
在项目文件夹中打开终端,确保你的虚拟环境已激活。
-
执行打包命令:
bashpyside6-deploy main.py如果你的主程序文件就叫
main.py,甚至可以直接运行pyside6-deploy,它会自动找到该文件。 -
等待完成 :工具会自动安装所需依赖并编译。首次运行时,它会在项目目录下生成一个
pysidedeploy.spec配置文件,后续打包会复用其中的设置。 -
找到成品 :打包完成后,可执行文件会生成在项目目录下的
deployment文件夹内。
备选方案:PyInstaller (社区主流),PyInstaller 是老牌的打包工具,社区资源丰富,适合处理依赖复杂的项目。
-
安装 PyInstaller:
bashpip install pyinstaller -
执行打包命令:
-
打包成单个文件 (推荐,方便分发):
bashpyinstaller -F -w main.py-F表示打包成单个可执行文件;-w表示运行时不显示黑色控制台窗口。 -
打包成文件夹 (启动速度稍快):
bashpyinstaller -D main.py
-
-
找到成品 :生成的程序会出现在项目目录下的
dist文件夹中。
如果你想绕过 pyside6-deploy 进行更精细的控制,也可以直接调用 Nuitka。它能将 Python 代码编译为 C 代码,性能优势明显。
-
安装 Nuitka:
bashpip install nuitka -
执行打包命令:
bashpython -m nuitka --standalone --onefile --enable-plugin=pyside6 --windows-disable-console main.py关键参数说明:
--standalone:生成独立的文件夹,包含所有依赖库。--onefile:将所有文件打包成一个单独的.exe。--enable-plugin=pyside6:必须添加,确保 Nuitka 能正确处理 PySide6 的插件和资源。--windows-disable-console:隐藏控制台窗口(Windows 系统)。
-
找到成品:可执行文件通常会生成在当前目录或指定的输出目录中。
⚠️ 常见问题与注意事项
- 虚拟环境 :打包时,不要 把你的虚拟环境文件夹(如
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。操作方法是:
-
打开项目文件夹中自动生成的
pysidedeploy.spec文件。 -
找到
mode这一行,将其值从standalone修改为onefile:ini# pysidedeploy.spec ... mode = onefile ... -
重新运行打包命令
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 更好地识别项目;
- 后续打包发布更顺;
那推荐加上。
小结:什么时候引入哪种文件
你可以按这个顺序逐步引入,不必一上来全用:
-
先只写
.py把窗口、布局、信号槽、事件循环搞清楚。
-
界面复杂了,引入
.ui用 Designer 拖界面,
pyside6-uic转 Python。 -
资源多了,引入
.qrc图片、字体、图标编进代码,
pyside6-rcc转 Python。 -
做 Qt Quick,引入
.qml这是另一条路线,和 Widgets 并列。
-
项目规范了,加
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()
运行后,你会看到一个简单的窗口:

-
导入
用 PySide6 写 Widgets 应用,第一步永远是从
PySide6.QtWidgets模块导入需要的类。pythonfrom PySide6.QtWidgets import QApplication, QLabelQApplication是应用对象,QLabel是一个能显示文本的控件。 -
创建 QApplication
pythonapp = QApplication(sys.argv)QApplication是整个程序的核心,每个 PySide6 Widgets 应用有且只有一个。Qt 可以接收命令行参数,所以你可以把
sys.argv传进去。通常你不需要传任何参数,也可以写成:pythonapp = QApplication([])两种写法都能跑。传
sys.argv的好处是 Qt 能处理它认识的一些命令行选项,比如-style、-platform;传空列表则完全忽略命令行。入门阶段用哪种都行。 -
创建 QLabel
pythonlabel = QLabel("Hello World!")QLabel是用于显示文本的控件。它不仅能显示纯文本,还能显示富文本(比如 HTML)和图片:python# HTML 写法也是合法的 label = QLabel("<font color=red size=40>Hello World!</font>")上面这行会显示一个红色、大号的"Hello World!"。
-
显示控件
pythonlabel.show()创建控件后,必须调用
show()它才会显示。不调用的话,控件存在,但不可见。 -
进入事件循环
pythonapp.exec()这一行进入 Qt 的主循环,开始执行 Qt 的事件处理。
一个容易被忽略的细节:实际上,
label是在进入app.exec()之后才真正显示出来的。 前面的show()只是标记"这个控件应该显示",真正的绘制发生在事件循环启动之后。入门阶段可以先忽略这个细节,记住"最后调用
app.exec()"就够了。后面讲事件循环时会再展开。
使用一个简单的按钮
这个教程演示如何在 PySide6 中处理信号和槽。信号槽是 Qt 的一项特性,让图形控件之间、或图形控件与 Python 代码之间能够通信。
我们的应用会创建一个按钮,每次点击时,在 Python 控制台打印一条消息:"Button clicked, Hello!"。
-
导入
pythonimport sys from PySide6.QtWidgets import QApplication, QPushButton from PySide6.QtCore import SlotQApplication是应用对象,QPushButton是按钮控件,Slot是槽装饰器。 -
定义槽函数
python# 问候函数 @Slot() def say_hello(): print("Button clicked, Hello!")@Slot()装饰器把函数标记为 Qt 的槽。说明
@Slot()的作用是告诉 Qt 元对象系统"这是一个槽"。它会影响跨线程调用时的连接类型、以及和 C++ 侧的交互方式。入门阶段不必深究原理,但建议养成习惯:连接到 Qt 信号的 Python 函数,都加上@Slot()装饰器,避免一些边缘情况下的意外行为。 -
创建应用
python# 创建 Qt 应用 app = QApplication(sys.argv)和之前的示例一样,每个 PySide6 程序都需要一个
QApplication实例。 -
创建按钮
python# 创建一个按钮 button = QPushButton("Click me")QPushButton是按钮控件。构造时传入一个 Python 字符串作为按钮文字。 -
连接信号和槽
python# 把按钮的 clicked 信号连接到 say_hello 函数 button.clicked.connect(say_hello)QPushButton自带一个预定义信号clicked,每次按钮被点击时触发。connect()把这个信号连接到say_hello函数。连接之后,信号一旦发射,say_hello就会被调用。连接信号槽有旧式和新式两种写法。PySide6 推荐新式写法,也就是上面这种
signal.connect(function)。旧式写法用QObject.connect(),可读性差,现在不推荐。 -
显示并进入事件循环
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?
把它理解为 "一块空白的画布" 或 "一个基础容器"。
适用场景:
- 自定义控件 :当你需要创建一个可复用的新控件(如一个特殊图表、一个复合卡片),希望它能被嵌入到任何其他窗口或布局中时,应继承
QWidget。因为QWidget是最基础的容器,不知道自己是独立窗口还是子部件,所以非常灵活。 - 非标准窗口:当你需要一个窗口,但不需要菜单栏、工具栏,也不希望它带有"对话框"的默认行为(如模态阻塞)时。它是一个纯粹的、用于展示内容的顶级窗口。
- 作为中间基类 :当你设计一套自己的控件体系时,通常会从
QWidget开始,而不是从更具体的QDialog或QMainWindow开始。
一句话总结 :当你需要一个基础部件 ,对它的角色(主窗口、对话框、嵌入控件)没有预设时,就继承 QWidget。
💬 什么时候继承 QDialog?
把它理解为 "一个为简短交互而生的专门窗口"。它生来就是为了完成"问用户一个问题并等待回答"这类任务。
适用场景:
- 模态交互 :这是
QDialog最核心的用途。当你需要阻塞 用户对其他窗口的操作,要求用户必须先处理完当前对话框(比如确认删除、输入登录信息)时,必须使用QDialog。QDialog的exec()方法会启动一个本地事件循环,实现这种阻塞效果。 - 需要返回值 :
QDialog内置了accept()和reject()槽,以及QDialogButtonBox等便利设施,专门用于处理"确定/取消"这类需要返回结果(Accepted/Rejected)的场景。 - 标准对话框 :Qt 中所有预制的对话框类(如
QMessageBox,QFileDialog,QColorDialog)都继承自QDialog,这本身就说明了它的定位。
一句话总结 :当你需要一个有明确"对话框"语义 的窗口(通常是模态的、用于临时交互、需要返回结果)时,就继承 QDialog。
使用表格控件显示数据
如果你想以表格形式展示数据,用 QTableWidget 就够了,不需要太多配置。
需要说明的是,QTableWidget 并不是展示表格数据的唯一方式。你也可以创建数据模型,用 QTableView 来展示,但那超出了本教程的范围。
说明
QTableWidget是一个开箱即用的表格控件。如果你想进一步定制,可以了解 Qt 的 Model/View 架构,参考官方文档。
-
导入
pythonimport sys from PySide6.QtGui import QColor from PySide6.QtWidgets import (QApplication, QTableWidget, QTableWidgetItem)QTableWidget是表格控件,QTableWidgetItem是表格里的单元格,QColor用来设置背景色。 -
准备数据
创建一个简单的数据集,包含颜色名称和对应的十六进制颜色码:
pythoncolors = [("Red", "#FF0000"), ("Green", "#00FF00"), ("Blue", "#0000FF"), ("Black", "#000000"), ("White", "#FFFFFF"), ("Electric Green", "#41CD52"), ("Dark Blue", "#222840"), ("Yellow", "#F9E56d")]每个元素是一个
(名称, 十六进制码)元组。 -
十六进制转 RGB
定义一个函数,把十六进制颜色码转成
QColor:pythondef 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。
-
创建应用和表格
pythonapp = 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?因为原始数据只有两列:名称和十六进制码。我们额外加一列,用来显示颜色本身。 -
填充数据
遍历数据,创建
QTableWidgetItem,按(行, 列)坐标放进表格:pythonfor 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 开始。 -
显示并运行
pythontable.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 架构,参考官方文档。
-
导入
pythonimport sys from PySide6.QtWidgets import QApplication, QTreeWidget, QTreeWidgetItemQTreeWidget是树形控件,QTreeWidgetItem是树里的节点。 -
准备数据
用一个字典描述项目结构:键是项目名,值是该项目下的文件列表。
pythondata = {"Project A": ["file_a.py", "file_a.txt", "something.xls"], "Project B": ["file_b.csv", "photo.jpg"], "Project C": []}这里三个项目,
Project C是空列表,用来演示"没有子节点的父节点"。 -
创建树形控件
pythonapp = QApplication() tree = QTreeWidget() tree.setColumnCount(2) tree.setHeaderLabels(["Name", "Type"])setColumnCount(2):两列,一列放名称,一列放文件类型;setHeaderLabels(["Name", "Type"]):设置表头。
注意这里用的是
setHeaderLabels(),不是表格那节的setHorizontalHeaderLabels()。树形控件的方法名略有不同。 -
填充数据
遍历字典,为每个项目创建一个父节点,为每个文件创建一个子节点:
pythonitems = [] 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)逐段看:
外层循环:创建父节点
pythonfor key, values in data.items(): item = QTreeWidgetItem([key])key是项目名,比如"Project A"。QTreeWidgetItem([key])创建一个只填了第一列的节点。内层循环:创建子节点
pythonfor 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)把子节点挂到父节点下。
收集顶层节点并插入
pythonitems.append(item) tree.insertTopLevelItems(0, items)先把所有顶层节点收集到
items列表,最后用insertTopLevelItems(0, items)一次性插入。参数0表示从第 0 个位置开始插入。这种"先收集、再批量插入"的写法,比每创建一个就
addTopLevelItem()一次效率更高,尤其在数据量大时。 -
显示并运行
pythontree.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()之前加上:
pythonview.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。
-
QML 界面
先看 QML 界面:

界面基于
GridLayout,里面包含两个ColumnLayout。里面有很多RadioButton、Button和一个Slider。 -
从 Python 加载 QML
pythonif __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 是否加载成功,失败就退出。
-
定义 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:接收字符串,返回布尔值。判断传入的文本是否等于对应样式名。
-
在 QML 中连接 Bridge
回到 QML 文件,把信号连接到
Bridge类里定义的槽:qmlBridge { id: bridge }在
ApplicationWindow内声明一个和 Python 类同名的组件,给它一个id。这个id用来引用从 Python 注册过来的元素。qmlRadioButton { 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,这样确保只有被选中的那个样式应用到文本上。 -
qmldir
把 QML 文件放进名为
QmlIntegration的目录,并在同目录下放一个qmldir:textmodule QmlIntegration Main 254.0 Main.qml -
槽函数的逻辑
每个槽函数检查选中的选项是否包含对应属性的文本:
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) -
设置应用外观
有两种方式改变应用外观。
方式一:命令行
运行 Python 文件时加上
--style选项:bashpython 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中导入:pythonimport 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 创建项目、管理文件、运行调试。
-
用 Qt Creator 创建项目
先安装 Qt Creator。Qt Creator 会自动处理 Python 环境,在需要的地方提示你安装 PySide6。
打开 Qt Creator,选择 File > New File or Project...:

在模板列表里选 Qt for Python - Empty ,点 Choose:

给项目起个名字,选好文件系统位置,点 Finish。Qt Creator 会创建两个文件:
textproject/ ├── main.py └── pyproject.toml
-
准备 QML 模块
下载
Main.qml、logo.png和qmldir三个文件,放进项目文件夹下的App子目录:textproject/ ├── 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工具看的。它根据这个列表收集项目文件,后续构建、打包时才知道要处理哪些文件。 -
从网络获取数据
打开
main.py,导入需要的模块,然后下载国家名称数据:pythonimport 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()取出所有国家名称,转成列表后排序。 -
创建应用和视图
用
QGuiApplication管理应用级设置,用QQuickView承载 QML 界面:python# 创建应用窗口 app = QGuiApplication(sys.argv) view = QQuickView() view.setResizeMode(QQuickView.ResizeMode.SizeRootObjectToView)说明
设置
SizeRootObjectToView很重要。它让 QML 根对象随窗口大小自动缩放。不设置的话,调整窗口大小时,QML 内容会保持原始尺寸,不会跟着变。QQuickView和前面用的QQmlApplicationEngine都是加载 QML 的引擎,区别在于:QQuickViewQQmlApplicationEngine本质 一个 QWindow子类纯引擎,不负责显示 适合 单个 QML 视图 完整应用,QML 根元素是 ApplicationWindow显示 自带 show()由 QML 的 visible: true控制这个教程用
QQuickView,因为它要把数据通过初始属性注入 QML,然后再显示视图。 -
把数据传给 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注册类型的方式更合适。 -
加载 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()显示视图。
-
运行并清理
python# 执行并清理 app.exec() del viewapp.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 应用中。
先导入需要的库,定义一个保存表名的全局变量,以及一个全局函数 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
这里用 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)
主函数里做了几件事:
- 声明
QGuiApplication:因为不用 QtWidgets 模块,所以用QGuiApplication而不是QApplication。 - 连接数据库。
- 声明
QQmlApplicationEngine:用于访问 QML 元素,把 Python 和 QML 里构建的对话模型连接起来。 - 加载定义 UI 的
.qml文件。 - 运行 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 也能连接,但:
- 每次连接时,方法会被动态加入
QMetaObject,有运行时开销; - 跨线程时,Qt 需要知道方法是不是槽,才能正确选择连接类型;
- 注册到 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 | 基本不用,跳过 |
使用建议:
- 先确认标准库能不能做。 比如 JSON 用
json,正则用re,文件用open(),不需要用 Qt 的对应类。 - 需要和 Qt 对象模型集成时才用 Qt 模块。 比如要把网络请求结果绑定到 UI,用
QNetworkAccessManager比requests更顺。 - 不确定就去官方 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 里的不兼容变更,能省掉很多"昨天还能跑,今天怎么报错了"的麻烦。